// SPEC: PJ2026-010401 Web工作台 - Workbench 可见性与性能探针
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { promisify } from "node:util";

import {
  classifyGlobalChange,
  componentModelsForServices,
  createCiPlan,
  envReuseRecipeForLane,
  matchingPaths,
  packageRuntimeFieldsChanged
} from "./src/ci-plan-lib.mjs";
import { readStructuredFile, writeStructuredFile } from "./src/structured-config.mjs";

const execFileAsync = promisify(execFile);

const V02_RUNTIME_SERVICE_IDS = Object.freeze([
  "hwlab-cloud-api",
  "hwlab-cloud-web",
  "hwlab-gateway",
  "hwlab-edge-proxy",
  "hwlab-agent-skills"
]);

test("node CI planner maps bridge changes to cloud-api only", async () => {
  const repo = await createFixtureRepo();
  await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge v2');\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change bridge"]);

  const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]);
  assert.equal(plan.reusedServices.includes("hwlab-cloud-web"), true);
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.equal(cloudApi.affected, true);
  assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]);
});

test("node CI planner treats docs-only changes as no image build", async () => {
  const repo = await createFixtureRepo();
  await mkdir(path.join(repo, "docs/reference"), { recursive: true });
  await writeFile(path.join(repo, "docs/reference/node-gitops-cicd.md"), "docs v2\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change docs"]);

  const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
  assert.deepEqual(plan.affectedServices, []);
  assert.equal(plan.imageBuildRequired, false);
  assert.equal(plan.changedPathSummary.docsOnly, true);
});

test("v03 planner keeps affected code rollouts out of catalog reuse", async () => {
  const selectedServices = ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-gateway"];
  const repo = await createFixtureRepo({ serviceIds: selectedServices });
  const baseRef = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  await mkdir(path.join(repo, "internal/cloud"), { recursive: true });
  await mkdir(path.join(repo, "tools/src/hwlab-cli"), { recursive: true });
  await writeFile(path.join(repo, "internal/cloud/kafka-event-bridge.ts"), "export const bridge = 2;\n");
  await writeFile(path.join(repo, "tools/src/hwlab-cli/trace-renderer.ts"), "export const renderer = 2;\n");
  await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "<html>v2</html>\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change shared cloud and web source"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef,
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: selectedServices
  });

  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
  assert.deepEqual(plan.buildServices, []);
  assert.deepEqual(plan.rolloutWithoutImageBuildServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
  assert.deepEqual(plan.reusedServices, ["hwlab-gateway"]);
  assert.equal(plan.serviceReusedCount, 1);
  assert.deepEqual(plan.imageBuildSkippedServices, selectedServices);
  assert.equal(plan.buildSkippedCount, 3);
  assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout");
  assert.equal(plan.artifactCatalog.authority, "workspace-file");
  assert.equal(plan.artifactCatalog.serviceCount, 3);
});

test("v03 planner reports renderer changes as full rollout without image builds", async () => {
  const selectedServices = ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-gateway"];
  const repo = await createFixtureRepo({ serviceIds: selectedServices });
  await mkdir(path.join(repo, "scripts/src/gitops-render"), { recursive: true });
  await writeFile(path.join(repo, "scripts/src/gitops-render/runtime-manifests.mjs"), "export const renderer = 2;\n");
  await git(repo, ["add", "scripts/src/gitops-render/runtime-manifests.mjs"]);
  await git(repo, ["commit", "-m", "change runtime manifest renderer"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: selectedServices
  });

  assert.equal(plan.changedPathSummary.gitopsOnly, true);
  assert.deepEqual(plan.affectedServices, selectedServices);
  assert.deepEqual(plan.rolloutServices, selectedServices);
  assert.deepEqual(plan.buildServices, []);
  assert.deepEqual(plan.rolloutWithoutImageBuildServices, selectedServices);
  assert.deepEqual(plan.reusedServices, []);
  assert.equal(plan.imageBuildRequired, false);
  assert.equal(plan.services.every((service) => service.buildRequired === false), true);
  assert.equal(plan.services.every((service) => service.reason.includes("gitops-render-input-changed")), true);
  assert.equal(plan.ciCdPlan.noImageBuildReason, "gitops-render-only-change");
});

test("tracked PaC remote pipelines pass selected services to catalog restore", async () => {
  const targets = [
    {
      node: "NC01",
      pipelineName: "hwlab-nc01-v03-ci-image-publish",
      pipelinePath: "ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml",
      pacPath: ".tekton/hwlab-nc01-v03-pac.yaml"
    }
  ];

  for (const target of targets) {
    const pipeline = await readStructuredFile(process.cwd(), target.pipelinePath);
    assert.equal(pipeline.metadata?.name, target.pipelineName, `${target.node} tracked Pipeline name`);
    const planArtifacts = pipeline.spec?.tasks?.find((task) => task.name === "plan-artifacts");
    assert.ok(planArtifacts, `${target.node} tracked Pipeline plan-artifacts task`);
    assert.ok(planArtifacts.taskSpec?.params?.some((param) => param.name === "services"), `${target.node} plan-artifacts services param`);
    assert.ok(planArtifacts.params?.some((param) => param.name === "services" && param.value === "$(params.services)"), `${target.node} plan-artifacts services binding`);
    const script = planArtifacts.taskSpec?.steps?.[0]?.script ?? "";
    assert.match(script, /HWLAB_SERVICES="\$\(params\.services\)" node scripts\/ci\/restore-artifact-catalog\.mjs/u);
    assert.match(script, /rolloutWithoutImageBuildServices/u);
    assert.match(script, /artifactCatalog: plan\.artifactCatalog/u);

    const pac = await readStructuredFile(process.cwd(), target.pacPath);
    assert.equal(pac.metadata?.annotations?.["pipelinesascode.tekton.dev/pipeline"], target.pipelinePath, `${target.node} PaC remote Pipeline path`);
    assert.equal(pac.spec?.pipelineRef?.name, target.pipelineName, `${target.node} PaC PipelineRef`);
  }
});

test("artifact catalog restore refreshes GitOps authority even when a source file exists", async () => {
  const repo = await createFixtureRepo();
  const sourceBranch = (await git(repo, ["branch", "--show-current"])).stdout.trim();
  await git(repo, ["checkout", "-b", "fixture-gitops"]);
  const catalogPath = path.join(repo, "deploy/artifact-catalog.v03.json");
  const catalog = JSON.parse(await readFile(catalogPath, "utf8"));
  catalog.commitId = "gitops-authoritative";
  catalog.publish = { sourceCommitId: "a".repeat(40) };
  await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
  await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "refresh gitops catalog"]);
  const gitopsCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  await git(repo, ["checkout", sourceBranch]);

  const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], {
    cwd: repo,
    env: {
      ...process.env,
      HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json",
      HWLAB_GITOPS_BRANCH: "fixture-gitops",
      HWLAB_GIT_READ_URL: repo,
      HWLAB_SERVICES: "hwlab-cloud-api,hwlab-cloud-web"
    }
  });
  const restored = JSON.parse(await readFile(catalogPath, "utf8"));
  const authority = JSON.parse(await readFile(`${catalogPath}.authority.json`, "utf8"));
  assert.equal(restored.commitId, "gitops-authoritative");
  assert.equal(authority.authority, "gitops-branch");
  assert.equal(authority.gitopsCommitId, gitopsCommitId);
  assert.equal(authority.catalogSourceCommitId, "a".repeat(40));
  assert.equal(authority.coverage.status, "exact");
  assert.match(restore.stderr, /"status":"hydrated"/u);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });
  assert.equal(plan.artifactCatalog.status, "hydrated");
  assert.equal(plan.artifactCatalog.authority, "gitops-branch");
  assert.equal(plan.artifactCatalog.gitopsCommitId, gitopsCommitId);
  assert.equal(plan.artifactCatalog.coverage.status, "exact");
  assert.equal(plan.compatibility.artifactCatalogAuthority, "gitops-branch");

  const fastPlan = await runCiPlanCli(repo, [
    "--lane", "v03",
    "--base-ref", "HEAD",
    "--target-ref", "HEAD",
    "--artifact-catalog", "deploy/artifact-catalog.v03.json",
    "--services", "hwlab-cloud-api,hwlab-cloud-web"
  ]);
  assert.equal(fastPlan.compatibility.mode, "no-deps-global-change-fast-path");
  assert.deepEqual(sortedServiceIds(fastPlan.reusedServices), ["hwlab-cloud-api", "hwlab-cloud-web"]);
});

test("artifact catalog restore hydrates partial GitOps coverage and builds missing services", async () => {
  const repo = await createFixtureRepo();
  const sourceBranch = (await git(repo, ["branch", "--show-current"])).stdout.trim();
  await git(repo, ["checkout", "-b", "fixture-gitops-mismatch"]);
  const catalogPath = path.join(repo, "deploy/artifact-catalog.v03.json");
  const catalog = JSON.parse(await readFile(catalogPath, "utf8"));
  catalog.services = catalog.services.filter((service) => service.serviceId === "hwlab-cloud-api");
  await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`);
  await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "remove a service from gitops catalog"]);
  await git(repo, ["checkout", sourceBranch]);

  const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], {
    cwd: repo,
    env: {
      ...process.env,
      HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json",
      HWLAB_GITOPS_BRANCH: "fixture-gitops-mismatch",
      HWLAB_GIT_READ_URL: repo,
      HWLAB_SERVICES: "hwlab-cloud-api,hwlab-cloud-web"
    }
  });
  assert.match(restore.stderr, /"status":"partial"/u);
  assert.match(restore.stderr, /"missingSelectedServices":\["hwlab-cloud-web"\]/u);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });
  assert.equal(plan.artifactCatalog.authority, "gitops-branch");
  assert.equal(plan.artifactCatalog.coverage.status, "partial");
  assert.deepEqual(plan.artifactCatalog.coverage.missingSelectedServices, ["hwlab-cloud-web"]);
  assert.deepEqual(sortedServiceIds(plan.buildServices), ["hwlab-cloud-web"]);

  const cliPlan = await runCiPlanCli(repo, [
    "--lane", "v03",
    "--base-ref", "HEAD",
    "--target-ref", "HEAD",
    "--artifact-catalog", "deploy/artifact-catalog.v03.json",
    "--services", "hwlab-cloud-api,hwlab-cloud-web"
  ]);
  assert.equal(cliPlan.changedPathSummary.summary, "no-source-diff");
  assert.equal(cliPlan.compatibility.mode, "advisory-read-only");
  assert.deepEqual(cliPlan.artifactCatalog.coverage.missingSelectedServices, ["hwlab-cloud-web"]);
  assert.deepEqual(sortedServiceIds(cliPlan.buildServices), ["hwlab-cloud-web"]);
});

test("v03 planner builds all services when the GitOps branch is absent", async () => {
  const repo = await createFixtureRepo({ catalog: false });
  const serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"];
  const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], {
    cwd: repo,
    env: {
      ...process.env,
      HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json",
      HWLAB_GITOPS_BRANCH: "fixture-gitops-missing",
      HWLAB_GIT_READ_URL: repo,
      HWLAB_SERVICES: serviceIds.join(",")
    }
  });
  assert.match(restore.stderr, /"status":"missing"/u);
  assert.match(restore.stderr, /"reason":"gitops-branch-missing"/u);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: serviceIds
  });
  assert.equal(plan.artifactCatalog.authority, "none");
  assert.deepEqual(sortedServiceIds(plan.buildServices), sortedServiceIds(serviceIds));
});

test("ci-plan CLI bypasses no-deps reuse when the v03 artifact catalog is missing", async () => {
  const repo = await createFixtureRepo({ catalog: false });
  const serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"];
  const commonArgs = [
    "--lane", "v03",
    "--target-ref", "HEAD",
    "--artifact-catalog", "deploy/artifact-catalog.v03.json",
    "--services", serviceIds.join(",")
  ];

  const initialPlan = await runCiPlanCli(repo, commonArgs);
  assert.equal(initialPlan.baseRef, "HEAD");
  assert.equal(initialPlan.changedPathSummary.summary, "no-source-diff");
  assert.equal(initialPlan.compatibility.mode, "advisory-read-only");
  assert.equal(initialPlan.artifactCatalog.authority, "none");
  assert.deepEqual(sortedServiceIds(initialPlan.buildServices), sortedServiceIds(serviceIds));
  assert.deepEqual(initialPlan.reusedServices, []);

  await mkdir(path.join(repo, "docs/reference"), { recursive: true });
  await writeFile(path.join(repo, "docs/reference/bootstrap.md"), "bootstrap docs\n");
  await git(repo, ["add", "docs/reference/bootstrap.md"]);
  await git(repo, ["commit", "-m", "add bootstrap docs"]);

  const docsPlan = await runCiPlanCli(repo, ["--base-ref", "HEAD~1", ...commonArgs]);
  assert.equal(docsPlan.changedPathSummary.docsOnly, true);
  assert.equal(docsPlan.compatibility.mode, "advisory-read-only");
  assert.deepEqual(sortedServiceIds(docsPlan.buildServices), sortedServiceIds(serviceIds));
  assert.deepEqual(docsPlan.reusedServices, []);
});

test("v03 planner builds all services when the GitOps branch has no catalog", async () => {
  const repo = await createFixtureRepo({ catalog: false });
  const sourceBranch = (await git(repo, ["branch", "--show-current"])).stdout.trim();
  const serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"];
  const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], {
    cwd: repo,
    env: {
      ...process.env,
      HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json",
      HWLAB_GITOPS_BRANCH: sourceBranch,
      HWLAB_GIT_READ_URL: repo,
      HWLAB_SERVICES: serviceIds.join(",")
    }
  });
  assert.match(restore.stderr, /"status":"missing"/u);
  assert.match(restore.stderr, /"reason":"gitops-catalog-missing"/u);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: serviceIds
  });
  assert.equal(plan.artifactCatalog.authority, "none");
  assert.deepEqual(sortedServiceIds(plan.buildServices), sortedServiceIds(serviceIds));
});

test("v02 planner accepts a current contract skeleton when the GitOps catalog is absent", async () => {
  const repo = await createFixtureRepo({ catalog: false });
  const sourceBranch = (await git(repo, ["branch", "--show-current"])).stdout.trim();
  const sourceCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const catalogPath = path.join(repo, "deploy/artifact-catalog.v02.json");
  const serviceIds = ["hwlab-cloud-api", "hwlab-cloud-web"];
  const skeleton = {
    catalogVersion: "v1",
    kind: "hwlab-artifact-catalog",
    environment: "v02",
    profile: "v02",
    namespace: "hwlab-v02",
    commitId: sourceCommitId,
    artifactState: "contract-skeleton",
    publish: { sourceCommitId },
    services: serviceIds.map((serviceId) => ({
      serviceId,
      sourceCommitId,
      digest: null,
      buildBackend: "contract-skeleton"
    }))
  };
  await writeFile(catalogPath, `${JSON.stringify(skeleton, null, 2)}\n`);

  const restore = await execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], {
    cwd: repo,
    env: {
      ...process.env,
      HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v02.json",
      HWLAB_GITOPS_BRANCH: sourceBranch,
      HWLAB_GIT_READ_URL: repo,
      HWLAB_SERVICES: serviceIds.join(",")
    }
  });
  assert.match(restore.stderr, /"status":"bootstrap"/u);
  assert.match(restore.stderr, /"authority":"source-bootstrap"/u);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: serviceIds
  });
  assert.equal(plan.artifactCatalog.status, "bootstrap");
  assert.equal(plan.artifactCatalog.authority, "source-bootstrap");
  assert.equal(plan.artifactCatalog.sourceCommitId, sourceCommitId);
  assert.equal(plan.artifactCatalog.coverage.status, "exact");
  assert.deepEqual(sortedServiceIds(plan.buildServices), sortedServiceIds(serviceIds));
});

test("artifact catalog restore rejects a non-bootstrap source catalog when GitOps is absent", async () => {
  const repo = await createFixtureRepo();
  await assert.rejects(
    execFileAsync(process.execPath, [path.resolve("scripts/ci/restore-artifact-catalog.mjs")], {
      cwd: repo,
      env: {
        ...process.env,
        HWLAB_CATALOG_PATH: "deploy/artifact-catalog.v03.json",
        HWLAB_GITOPS_BRANCH: "fixture-gitops-missing",
        HWLAB_GIT_READ_URL: repo,
        HWLAB_SERVICES: "hwlab-cloud-api,hwlab-cloud-web"
      }
    }),
    (error) => {
      assert.equal(error.code, 1);
      assert.match(error.stderr, /existing source catalog is not an allowed v02 contract skeleton/u);
      return true;
    }
  );
});

test("node CI planner scopes runtime config rendering to the changed service without image builds", async () => {
  const repo = await createFixtureRepo();
  const deployPath = path.join(repo, "deploy/deploy.yaml");
  const deploy = await readStructuredFile(repo, deployPath);
  deploy.lanes.v02.serviceDeclarations["hwlab-cloud-api"].env.HWLAB_METRICS_NAMESPACE = "hwlab-v02";
  await writeStructuredFile(repo, deployPath, deploy);
  await git(repo, ["add", "deploy/deploy.yaml"]);
  await git(repo, ["commit", "-m", "change cloud api runtime env"]);

  const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]);
  assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-api"]);
  assert.deepEqual(plan.buildServices, []);
  assert.deepEqual(plan.rolloutWithoutImageBuildServices, ["hwlab-cloud-api"]);
  assert.equal(plan.imageBuildRequired, false);
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web");
  assert.equal(cloudApi.runtimeConfigChanged, true);
  assert.equal(cloudApi.envChanged, false);
  assert.deepEqual(cloudApi.reason, ["runtime-config-changed"]);
  assert.equal(cloudWeb.affected, false);
  assert.equal(plan.ciCdPlan.noImageBuildReason, "runtime-config-only-change");
});

test("node CI planner keeps Tekton renderer changes out of runtime rollout scope", async () => {
  const repo = await createFixtureRepo();
  await mkdir(path.join(repo, "scripts/src/gitops-render"), { recursive: true });
  await writeFile(path.join(repo, "scripts/src/gitops-render/tekton-manifests.mjs"), "export const pipeline = 2;\n");
  await git(repo, ["add", "scripts/src/gitops-render/tekton-manifests.mjs"]);
  await git(repo, ["commit", "-m", "change Tekton renderer"]);

  const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
  assert.deepEqual(plan.affectedServices, []);
  assert.deepEqual(plan.rolloutServices, []);
  assert.deepEqual(plan.buildServices, []);
  assert.equal(plan.ciCdPlan.willRunGitopsPromote, true);
});

test("planner rebuilds when catalog digest is missing instead of blocking reuse", async () => {
  const repo = await createFixtureRepo({ catalog: "missing-digest" });
  await mkdir(path.join(repo, "docs/reference"), { recursive: true });
  await writeFile(path.join(repo, "docs/reference/node-gitops-cicd.md"), "docs v2\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change docs"]);

  const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
  assert.equal(plan.imageBuildRequired, true);
  assert.equal(plan.services.every((service) => service.envChanged === true), true);
  assert.equal(plan.services.every((service) => service.reuse.status === "not-reused"), true);
});

test("v02 planner rolls cloud-api service code changes without rebuilding service image", async () => {
  const repo = await createFixtureRepo();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });
  const initialCloudApi = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
    sourceCommitId: initialPlan.sourceCommitId,
    environmentInputHashes: { "hwlab-cloud-api": initialCloudApi.environmentInputHash },
    codeInputHashes: { "hwlab-cloud-api": initialCloudApi.codeInputHash },
    bootCommits: { "hwlab-cloud-api": initialPlan.sourceCommitId }
  }), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
  await git(repo, ["commit", "-m", "seed catalog with current cloud-api env reuse hashes"]);

  await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n");
  await git(repo, ["add", "cmd/hwlab-cloud-api/main.ts"]);
  await git(repo, ["commit", "-m", "change cloud-api service code"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api"]
  });
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.equal(cloudApi.affected, true);
  assert.equal(cloudApi.runtimeMode, "env-reuse-gitea-checkout");
  assert.equal(cloudApi.envChanged, false);
  assert.equal(cloudApi.codeChanged, true);
  assert.equal(cloudApi.buildRequired, false);
  assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]);
  assert.deepEqual(plan.buildServices, []);
  assert.deepEqual(plan.rolloutWithoutImageBuildServices, ["hwlab-cloud-api"]);
  assert.deepEqual(plan.reusedServices, []);
});

test("v02 planner rebuilds env image when an env reuse catalog digest is missing", async () => {
  const repo = await createFixtureRepo({ catalog: "missing-digest" });
  await mkdir(path.join(repo, "docs/reference"), { recursive: true });
  await writeFile(path.join(repo, "docs/reference/node-gitops-cicd.md"), "docs only with missing env digest\n");
  await git(repo, ["add", "docs/reference/node-gitops-cicd.md"]);
  await git(repo, ["commit", "-m", "docs after missing env digest"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api"]
  });
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.equal(cloudApi.affected, true);
  assert.equal(cloudApi.buildRequired, true);
  assert.equal(cloudApi.envChanged, true);
  assert.equal(cloudApi.reuse.status, "not-reused");
  assert.deepEqual(cloudApi.reason, ["environment-input-changed"]);
});

test("v03 planner rebuilds env image when catalog env input hash is stale and source commit is unavailable", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"]
  });
  const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: "0".repeat(40) }));
  catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input";
  await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "seed stale v03 env catalog"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"]
  });
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.equal(cloudApi.buildRequired, true);
  assert.equal(cloudApi.environmentInputChanged, true);
  assert.deepEqual(cloudApi.reason, ["environment-input-mismatch"]);
  assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]);
});

test("v03 planner reuses catalog env image when hash metadata drifts but env source tree is unchanged", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"]
  });
  const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha }));
  const catalogCloudApi = catalog.services.find((service) => service.serviceId === "hwlab-cloud-api");
  catalogCloudApi.environmentInputHash = "stale-environment-input";
  await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "seed drifted v03 env catalog"]);

  await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n");
  await git(repo, ["add", "cmd/hwlab-cloud-api/main.ts"]);
  await git(repo, ["commit", "-m", "change cloud-api code only"]);

  const requests = [];
  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"],
    verifyReuseRegistry: true,
    reuseRegistryProbe: async (request) => {
      requests.push(request);
      if (request.image === catalogCloudApi.environmentImage && request.digest === catalogCloudApi.environmentDigest) {
        return { status: "present", method: "HEAD", statusCode: 200, digest: catalogCloudApi.environmentDigest };
      }
      return { status: "missing", method: "HEAD", statusCode: 404 };
    }
  });
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.equal(cloudApi.environmentInputChanged, true);
  assert.equal(cloudApi.effectiveEnvironmentInputChanged, false);
  assert.equal(cloudApi.environmentMetadataHashDrift, true);
  assert.equal(cloudApi.environmentSourceUnchanged, true);
  assert.equal(cloudApi.envChanged, false);
  assert.equal(cloudApi.codeChanged, true);
  assert.equal(cloudApi.affected, true);
  assert.equal(cloudApi.buildRequired, false);
  assert.equal(cloudApi.environmentImage, catalogCloudApi.environmentImage);
  assert.equal(cloudApi.environmentDigest, catalogCloudApi.environmentDigest);
  assert.equal(cloudApi.reuseRegistry.reuseSource, "catalog-metadata-hash-drift");
  assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]);
  assert.deepEqual(plan.buildServices, []);
  assert.equal(requests.length, 2);
  assert.match(requests[0].image, /hwlab-cloud-api-env:env-/u);
  assert.equal(requests[1].image, catalogCloudApi.environmentImage);
});

test("v03 planner reuses current env hash tag when registry already has it", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"]
  });
  const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha }));
  catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input";
  await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "seed stale v03 env catalog"]);

  const digest = `sha256:${"2".repeat(64)}`;
  let probeRequest = null;
  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"],
    verifyReuseRegistry: true,
    reuseRegistryProbe: async (request) => {
      probeRequest = request;
      return { status: "present", method: "HEAD", statusCode: 200, digest };
    }
  });
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.equal(cloudApi.environmentInputChanged, true);
  assert.equal(cloudApi.envChanged, false);
  assert.equal(cloudApi.buildRequired, false);
  assert.equal(cloudApi.environmentDigest, digest);
  assert.equal(cloudApi.reuse.status, "ready");
  assert.equal(cloudApi.reuse.reusedFrom, cloudApi.environmentInputHash);
  assert.equal(probeRequest.digest, null);
  assert.equal(probeRequest.image, `127.0.0.1:5000/hwlab/hwlab-cloud-api-env:env-${cloudApi.environmentInputHash.slice(0, 12)}`);
  assert.deepEqual(plan.buildServices, []);
});

test("v03 planner rebuilds when hash metadata drifts and registry lacks reusable env image", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"]
  });
  const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha }));
  catalog.services.find((service) => service.serviceId === "hwlab-cloud-api").environmentInputHash = "stale-environment-input";
  await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "seed stale v03 env catalog"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"],
    verifyReuseRegistry: true,
    reuseRegistryProbe: async () => ({ status: "missing", method: "HEAD", statusCode: 404 })
  });
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.equal(cloudApi.environmentInputChanged, true);
  assert.equal(cloudApi.envChanged, true);
  assert.equal(cloudApi.buildRequired, true);
  assert.equal(cloudApi.reuseRegistry.status, "missing");
  assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]);
});

test("v03 planner rebuilds env image when target registry lacks reused digest", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"]
  });
  await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha })), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "seed fresh v03 catalog"]);

  await mkdir(path.join(repo, "deploy/gitops/node/d601/runtime-v03"), { recursive: true });
  await writeFile(path.join(repo, "deploy/gitops/node/d601/runtime-v03/workloads.yaml"), "kind: List\nitems: []\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change generated gitops after fresh catalog"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api"],
    verifyReuseRegistry: true,
    reuseRegistryProbe: async () => ({ status: "missing", method: "HEAD", statusCode: 404 })
  });
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.equal(cloudApi.buildRequired, true);
  assert.equal(cloudApi.reuseRegistry.status, "missing");
  assert.deepEqual(cloudApi.reason, ["reuse-registry-missing"]);
  assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]);
});

test("artifact reuse paths preserve catalog provenance instead of current planner provenance", async () => {
  const artifactPublish = await readFile("scripts/artifact-publish.mjs", "utf8");
  assert.match(artifactPublish, /downloadDependencyScript/u);
  assert.match(artifactPublish, /RUN \$\{downloadEnvPrefix\}\$\{downloadDependencyScript\}/u);
  assert.match(artifactPublish, /goLauncherPath = "deploy\/runtime\/launcher\/hwlab-go-env-reuse-launcher\.mjs"/u);
  assert.match(artifactPublish, /goCacheEnabled \? \[\] : \["COPY package\.json \.\/package\.json", "COPY package-lock\.json\* \.\/"\]/u);
  assert.match(artifactPublish, /goCacheEnabled \? \[\] : \[`RUN \$\{downloadEnvPrefix\}\$\{downloadDependencyScript\}`\]/u);
  assert.match(artifactPublish, /CMD \[\\"node\\", \\"\/usr\/local\/bin\/hwlab-go-env-reuse-launcher\.mjs\\"\]/u);
  assert.match(artifactPublish, /ENTRYPOINT \[\\"tini\\", \\"--\\"\]/u);
  assert.match(artifactPublish, /command -v tini >\/dev\/null/u);
  assert.match(artifactPublish, /\.\.\.recipe\.goOsPackages, "tini"/u);
  assert.match(artifactPublish, /aptInstallSection\("init", \["tini"\]\)/u);
  assert.match(artifactPublish, /ln -sf "\$\{runtimeRoot\}\/\$bun_bin" \/usr\/local\/bin\/bun/u);
  assert.doesNotMatch(artifactPublish, /RUN \$\{dependencyScript\}/u);
  assert.match(artifactPublish, /const catalogProvenance = serviceImageCatalogProvenance\(catalogService\);/u);
  assert.match(artifactPublish, /\.\.\.catalogProvenance,[\s\S]{0,80}buildBackend: "reused-catalog"/u);
  assert.match(artifactPublish, /envReuseRecipe: servicePlan\.envReuseRecipe \?\? null/u);
  assert.match(artifactPublish, /componentInputHash: serviceResultValue\(env, service\.serviceId, "COMPONENT_INPUT_HASH"\) \?\? \(reusedServiceImage \? null : service\.componentInputHash \?\? null\)/u);

  const perServicePublish = await readFile("scripts/src/gitops-render/templates/per-service-publish.sh", "utf8");
  assert.match(perServicePublish, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u);
  assert.doesNotMatch(perServicePublish, /"component-input-hash": planned\.componentInputHash \|\| service\.componentInputHash \|\| ""/u);
});

test("artifact publish ignores stale external reports for envreuse services that do not need current build artifacts", async () => {
  const currentCommit = (await git(process.cwd(), ["rev-parse", "HEAD"])).stdout.trim();
  const oldCommit = "0".repeat(40);
  const digest = `sha256:${"3".repeat(64)}`;
  const tmp = await mkdtemp(path.join(os.tmpdir(), "hwlab-artifact-stale-report-"));
  const reportDir = path.join(tmp, "service-results");
  await mkdir(reportDir, { recursive: true });
  const image = "127.0.0.1:5000/hwlab/hwlab-cloud-web-env:env-test";
  const deployPath = path.join(tmp, "deploy.yaml");
  const catalogPath = path.join(tmp, "artifact-catalog.v03.json");
  const ciPlanPath = path.join(tmp, "ci-plan.json");
  const deploy = createDeployFixture({ serviceIds: ["hwlab-cloud-web"] });
  deploy.lanes.v03.namespace = "hwlab-v03";
  deploy.lanes.v03.endpoint = "https://hwlab.pikapython.com";
  await writeStructuredFile(process.cwd(), deployPath, deploy);

  const catalog = createCatalogFixture("ready", {
    serviceIds: ["hwlab-cloud-web"],
    sourceCommitId: currentCommit,
    bootCommits: { "hwlab-cloud-web": currentCommit },
    environmentInputHashes: { "hwlab-cloud-web": "e".repeat(64) },
    codeInputHashes: { "hwlab-cloud-web": "c".repeat(64) }
  });
  catalog.environment = "v03";
  catalog.profile = "v03";
  catalog.namespace = "hwlab-v03";
  catalog.allowedProfiles = ["v03"];
  catalog.forbiddenProfiles = ["dev", "prod"];
  for (const service of catalog.services) {
    service.profile = "v03";
    service.namespace = "hwlab-v03";
  }
  await writeFile(catalogPath, JSON.stringify(catalog, null, 2));
  await writeFile(ciPlanPath, JSON.stringify({
    planVersion: "v1",
    sourceCommitId: currentCommit,
    shortCommitId: currentCommit.slice(0, 12),
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    compatibility: { lane: "v03" },
    changedPathSummary: {},
    imageBuildRequired: false,
    affectedServices: [],
    buildServices: ["hwlab-cloud-web"],
    rolloutServices: [],
    reusedServices: ["hwlab-cloud-web"],
    buildSkippedCount: 1,
    ciCdPlan: {},
    services: [{
      serviceId: "hwlab-cloud-web",
      runtimeMode: "env-reuse-gitea-checkout",
      envReuse: true,
      affected: false,
      buildRequired: false,
      envChanged: false,
      codeChanged: false,
      environmentImage: image,
      environmentDigest: digest,
      environmentInputHash: "e".repeat(64),
      codeInputHash: "c".repeat(64),
      bootRepo: "git@github.com:pikasTech/HWLAB.git",
      bootCommit: currentCommit,
      bootSh: "deploy/runtime/boot/hwlab-cloud-web.sh",
      reason: ["component-inputs-unchanged"],
      reuse: { status: "ready", image, digest, reusedFrom: "e".repeat(64) }
    }]
  }, null, 2));
  await writeFile(path.join(reportDir, "hwlab-cloud-web.json"), JSON.stringify({
    artifactPublish: {
      services: [{
        serviceId: "hwlab-cloud-web",
        status: "reused",
        commitId: oldCommit,
        sourceCommitId: oldCommit,
        image: "127.0.0.1:5000/hwlab/hwlab-cloud-web-env:env-old",
        imageTag: "env-old",
        digest,
        repositoryDigest: `127.0.0.1:5000/hwlab/hwlab-cloud-web-env@${digest}`,
        runtimeMode: "env-reuse-gitea-checkout",
        envReuse: true
      }]
    }
  }, null, 2));

  const { stdout } = await execFileAsync(process.execPath, [
    "scripts/artifact-publish.mjs",
    "--build",
    "--lane", "v03",
    "--services", "hwlab-cloud-web",
    "--deploy-config", deployPath,
    "--catalog-path", catalogPath,
    "--ci-plan-path", ciPlanPath,
    "--external-service-report-dir", reportDir,
    "--registry-prefix", "127.0.0.1:5000/hwlab",
    "--no-report"
  ], {
    cwd: process.cwd(),
    env: { ...process.env, HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY: "false" }
  });
  const result = JSON.parse(stdout);
  const blockerText = JSON.stringify(result.blockers ?? []);
  assert.equal(blockerText.includes(oldCommit), false);
  assert.equal(result.status, "built");
  assert.equal(result.services[0].serviceId, "hwlab-cloud-web");
  assert.equal(result.services[0].status, "reused");
  assert.equal(result.services[0].sourceCommitId, currentCommit);
});

test("artifact publish accepts stale reused envreuse external reports for unchanged services in buildServices", async () => {
  const currentCommit = (await git(process.cwd(), ["rev-parse", "HEAD"])).stdout.trim();
  const oldCommit = "1".repeat(40);
  const digest = `sha256:${"4".repeat(64)}`;
  const tmp = await mkdtemp(path.join(os.tmpdir(), "hwlab-artifact-reused-report-"));
  const reportDir = path.join(tmp, "service-results");
  await mkdir(reportDir, { recursive: true });
  const image = "127.0.0.1:5000/hwlab/hwlab-cloud-web-env:env-stable";
  const deployPath = path.join(tmp, "deploy.yaml");
  const catalogPath = path.join(tmp, "artifact-catalog.v03.json");
  const ciPlanPath = path.join(tmp, "ci-plan.json");
  const deploy = createDeployFixture({ serviceIds: ["hwlab-cloud-web"] });
  deploy.lanes.v03.namespace = "hwlab-v03";
  deploy.lanes.v03.endpoint = "https://hwlab.pikapython.com";
  await writeStructuredFile(process.cwd(), deployPath, deploy);

  const catalog = createCatalogFixture("ready", {
    serviceIds: ["hwlab-cloud-web"],
    sourceCommitId: oldCommit,
    bootCommits: { "hwlab-cloud-web": oldCommit },
    environmentInputHashes: { "hwlab-cloud-web": "e".repeat(64) },
    codeInputHashes: { "hwlab-cloud-web": "c".repeat(64) }
  });
  catalog.environment = "v03";
  catalog.profile = "v03";
  catalog.namespace = "hwlab-v03";
  catalog.allowedProfiles = ["v03"];
  catalog.forbiddenProfiles = ["dev", "prod"];
  for (const service of catalog.services) {
    service.profile = "v03";
    service.namespace = "hwlab-v03";
  }
  await writeFile(catalogPath, JSON.stringify(catalog, null, 2));
  await writeFile(ciPlanPath, JSON.stringify({
    planVersion: "v1",
    sourceCommitId: currentCommit,
    shortCommitId: currentCommit.slice(0, 12),
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    compatibility: { lane: "v03" },
    changedPathSummary: {},
    imageBuildRequired: true,
    affectedServices: [],
    buildServices: ["hwlab-cloud-web"],
    rolloutServices: [],
    reusedServices: [],
    buildSkippedCount: 0,
    ciCdPlan: {},
    services: [{
      serviceId: "hwlab-cloud-web",
      runtimeMode: "env-reuse-gitea-checkout",
      envReuse: true,
      affected: false,
      ciAffected: false,
      buildRequired: true,
      envChanged: false,
      codeChanged: false,
      environmentImage: image,
      environmentDigest: digest,
      environmentInputHash: "e".repeat(64),
      codeInputHash: "c".repeat(64),
      bootRepo: "git@github.com:pikasTech/HWLAB.git",
      bootCommit: oldCommit,
      bootSh: "deploy/runtime/boot/hwlab-cloud-web.sh",
      reason: ["component-inputs-unchanged"],
      reuse: { status: "ready", image, digest, reusedFrom: "e".repeat(64) }
    }]
  }, null, 2));
  await writeFile(path.join(reportDir, "hwlab-cloud-web.json"), JSON.stringify({
    artifactPublish: {
      services: [{
        serviceId: "hwlab-cloud-web",
        status: "reused",
        commitId: oldCommit,
        sourceCommitId: oldCommit,
        image,
        imageTag: "env-stable",
        digest,
        repositoryDigest: `${image}@${digest}`,
        runtimeMode: "env-reuse-gitea-checkout",
        envReuse: true,
        affected: false,
        ciAffected: false,
        codeChanged: false,
        envChanged: false
      }]
    }
  }, null, 2));

  const { stdout } = await execFileAsync(process.execPath, [
    "scripts/artifact-publish.mjs",
    "--build",
    "--lane", "v03",
    "--services", "hwlab-cloud-web",
    "--deploy-config", deployPath,
    "--catalog-path", catalogPath,
    "--ci-plan-path", ciPlanPath,
    "--external-service-report-dir", reportDir,
    "--registry-prefix", "127.0.0.1:5000/hwlab",
    "--no-report"
  ], {
    cwd: process.cwd(),
    env: { ...process.env, HWLAB_CI_PLAN_VERIFY_REUSE_REGISTRY: "false" }
  });
  const result = JSON.parse(stdout);
  const blockerText = JSON.stringify(result.blockers ?? []);
  assert.equal(blockerText.includes(oldCommit), false);
  assert.equal(result.status, "built");
  assert.equal(result.services[0].serviceId, "hwlab-cloud-web");
  assert.equal(result.services[0].status, "reused");
  assert.equal(result.services[0].sourceCommitId, oldCommit);
});

test("component model uses service paths declared in deploy config", () => {
  const deploy = createDeployFixture();
  const models = componentModelsForServices(["hwlab-cloud-api"], deploy.lanes.v02);
  assert.deepEqual(models[0].componentPaths, [
    "cmd/hwlab-cloud-api/",
    "cmd/hwlab-codex-api-responses-forwarder/",
    "cmd/hwlab-deepseek-responses-bridge/",
    "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh",
    "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh",
    "internal/agent/agentrun-dispatch.mjs",
    "internal/agent/prompts/",
    "internal/audit/",
    "internal/cloud/",
    "internal/db/",
    "skills/hwlab-agent-runtime/"
  ]);
  assert.deepEqual(models[0].sharedPaths, ["internal/build-metadata.mjs", "internal/protocol/"]);
  assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]);
});

test("v02 planner rejects missing service declarations instead of falling back to an internal model", async () => {
  const repo = await createFixtureRepo();
  const deployPath = path.join(repo, "deploy/deploy.yaml");
  const deploy = await readStructuredFile(repo, deployPath);
  delete deploy.lanes.v02.serviceDeclarations;
  await writeStructuredFile(repo, deployPath, deploy);
  await git(repo, ["add", "deploy/deploy.yaml"]);
  await git(repo, ["commit", "-m", "remove v02 declarations"]);

  await assert.rejects(
    () => createCiPlan({
      repoRoot: repo,
      lane: "v02",
      baseRef: "HEAD~1",
      targetRef: "HEAD",
      artifactCatalogPath: "deploy/artifact-catalog.v02.json",
      services: ["hwlab-cloud-api"]
    }),
    /deploy\.lanes\.v02\.serviceDeclarations is required/u
  );
});

test("v02 planner rejects missing boot script declarations instead of guessing paths", async () => {
  const repo = await createFixtureRepo();
  const deployPath = path.join(repo, "deploy/deploy.yaml");
  const deploy = await readStructuredFile(repo, deployPath);
  delete deploy.lanes.v02.bootScripts["hwlab-cloud-api"];
  await writeStructuredFile(repo, deployPath, deploy);
  await git(repo, ["add", "deploy/deploy.yaml"]);
  await git(repo, ["commit", "-m", "remove v02 cloud-api boot script"]);

  await assert.rejects(
    () => createCiPlan({
      repoRoot: repo,
      lane: "v02",
      baseRef: "HEAD~1",
      targetRef: "HEAD",
      artifactCatalogPath: "deploy/artifact-catalog.v02.json",
      services: ["hwlab-cloud-api"]
    }),
    /deploy\.lanes\.v02\.bootScripts\.hwlab-cloud-api is required/u
  );
});

test("v02 planner keeps hwpod-spec changes out of runtime service env reuse", async () => {
  const repo = await createFixtureRepo();
  await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n  name: changed-preinstalled\nspec:\n  nodeBinding:\n    nodeId: node-changed\n  workspace:\n    path: F:\\\\Work\\\\D601-HWLAB\n  targetDevice:\n    board: D601-F103-V2\n  debugProbe:\n    type: daplink\n  ioProbe:\n    uart:\n      port: COM9\n", "utf8");
  await git(repo, ["add", ".hwlab/hwpod-spec.yaml"]);
  await git(repo, ["commit", "-m", "change preinstalled hwpod spec"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });
  assert.deepEqual(plan.affectedServices, []);
  assert.deepEqual(plan.buildServices, []);
  assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false);
  assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").affected, false);
});

test("v02 planner treats AgentRun runtime prompt changes as cloud-api inputs", async () => {
  const repo = await createFixtureRepo();
  await mkdir(path.join(repo, "internal/agent/prompts"), { recursive: true });
  await writeFile(path.join(repo, "internal/agent/agentrun-dispatch.mjs"), "export const prompt = true;\n");
  await writeFile(path.join(repo, "internal/agent/prompts/hwlab-v02-runtime.md"), "prompt v1\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "seed agentrun prompt"]);
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });
  const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
  await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, {
    cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
    cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
    cloudWebBootCommit: initialPlan.sourceCommitId
  })), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
  await git(repo, ["commit", "-m", "seed v02 catalog with agentrun prompt"]);

  await writeFile(path.join(repo, "internal/agent/prompts/hwlab-v02-runtime.md"), "prompt v2\n");
  await git(repo, ["add", "internal/agent/prompts/hwlab-v02-runtime.md"]);
  await git(repo, ["commit", "-m", "change agentrun prompt"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });
  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api"]);
  assert.deepEqual(plan.buildServices, []);
  assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-api"]);
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  assert.deepEqual(cloudApi.changedPaths, ["internal/agent/prompts/hwlab-v02-runtime.md"]);
  assert.equal(cloudApi.envChanged, false);
  assert.equal(cloudApi.codeChanged, true);
  assert.deepEqual(cloudApi.reason, ["code-input-changed", "component-path-changed"]);
});

test("planner scopes cloud-web runtime changes to cloud-web", async () => {
  const repo = await createFixtureRepo();
  await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 2;\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change cloud web runtime"]);

  const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]);
  assert.equal(plan.services.some((service) => service.serviceId === "hwlab-agent-worker"), false);
  const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web");
  assert.deepEqual(cloudWeb.reason, ["code-input-changed", "component-path-changed"]);
});

test("v02 planner treats shared trace renderer changes as cloud-web code rollout inputs", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });
  const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
  await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, {
    cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
    cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
    cloudWebBootCommit: initialPlan.sourceCommitId
  })), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
  await git(repo, ["commit", "-m", "seed v02 catalog with trace renderer"]);

  await mkdir(path.join(repo, "tools/src/hwlab-cli"), { recursive: true });
  await writeFile(path.join(repo, "tools/src/hwlab-cli/trace-renderer.ts"), "export const traceRenderer = 2;\n");
  await git(repo, ["add", "tools/src/hwlab-cli/trace-renderer.ts"]);
  await git(repo, ["commit", "-m", "change shared trace renderer"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });
  const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
  const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web");
  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]);
  assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-web"]);
  assert.deepEqual(plan.buildServices, []);
  assert.equal(cloudApi.affected, false);
  assert.equal(cloudWeb.codeChanged, true);
  assert.deepEqual(cloudWeb.codeChangedPaths, ["tools/src/hwlab-cli/trace-renderer.ts"]);
  assert.deepEqual(cloudWeb.reason, ["code-input-changed", "component-path-changed"]);
});

test("planner ignores package scripts-only changes even when package is a component path", async () => {
  const repo = await createFixtureRepo();
  const deployPath = path.join(repo, "deploy/deploy.yaml");
  const deploy = await readStructuredFile(repo, deployPath);
  deploy.lanes.v02.serviceDeclarations["hwlab-cloud-api"].componentPaths.push("package.json");
  deploy.lanes.v02.serviceDeclarations["hwlab-cloud-web"].componentPaths.push("package.json");
  await writeStructuredFile(repo, deployPath, deploy);
  await writeFile(path.join(repo, "package.json"), JSON.stringify({
    type: "module",
    scripts: {
      validate: "node scripts/check-runner.mjs --profile validate"
    },
    dependencies: {
      "@openai/codex": "^0.128.0"
    }
  }, null, 2));
  await git(repo, ["add", "deploy/deploy.yaml", "package.json"]);
  await git(repo, ["commit", "-m", "seed package scripts"]);
  await refreshFixtureCatalogForHead(repo);
  const catalogPath = path.join(repo, "deploy/artifact-catalog.v02.json");
  const catalog = JSON.parse(await readFile(catalogPath, "utf8"));
  for (const service of catalog.services) service.codeInputHash = "legacy-package-script-hash";
  await writeFile(catalogPath, JSON.stringify(catalog, null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
  await git(repo, ["commit", "-m", "seed legacy code input hashes"]);

  await writeFile(path.join(repo, "package.json"), JSON.stringify({
    type: "module",
    scripts: {
      validate: "node scripts/check-runner.mjs --profile validate",
      check: "node scripts/check-runner.mjs --profile check"
    },
    dependencies: {
      "@openai/codex": "^0.128.0"
    }
  }, null, 2));
  await git(repo, ["add", "package.json"]);
  await git(repo, ["commit", "-m", "change package scripts"]);

  assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), false);
  const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
  assert.deepEqual(plan.affectedServices, []);
  assert.deepEqual(plan.buildServices, []);
  assert.equal(plan.services.every((service) => !service.changedPaths.includes("package.json")), true);
  assert.equal(plan.services.every((service) => service.codeMetadataHashDrift === true), true);
  assert.equal(plan.imageBuildRequired, false);
  assert.equal(plan.buildSkippedCount, 2);
});

test("planner treats dependency changes as runtime image inputs", async () => {
  const repo = await createFixtureRepo();
  await writeFile(path.join(repo, "package.json"), JSON.stringify({
    type: "module",
    dependencies: {
      "@openai/codex": "^0.128.0"
    }
  }, null, 2));
  await git(repo, ["add", "package.json"]);
  await git(repo, ["commit", "-m", "seed dependencies"]);

  await writeFile(path.join(repo, "package.json"), JSON.stringify({
    type: "module",
    dependencies: {
      "@openai/codex": "^0.129.0"
    }
  }, null, 2));
  await git(repo, ["add", "package.json"]);
  await git(repo, ["commit", "-m", "change dependencies"]);

  assert.equal(await packageRuntimeFieldsChanged(repo, "HEAD~1", "HEAD"), true);
  const plan = await planV02CoreServices(repo, { baseRef: "HEAD~1", targetRef: "HEAD" });
  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
  assert.equal(plan.imageBuildRequired, true);
});

test("global change classifier distinguishes gitops-only and test-only", () => {
  assert.equal(classifyGlobalChange(["deploy/gitops/node/runtime-dev/workloads.yaml"]).gitopsOnly, true);
  assert.equal(classifyGlobalChange(["cmd/hwlab-cloud-api/runtime-options.test.ts"]).testOnly, true);
});

test("v02 planner marks cloud-web code-only change as env reuse rollout", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-web"]
  });
  const initialCloudWeb = initialPlan.services.find((service) => service.serviceId === "hwlab-cloud-web");
  await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
    cloudWebEnvironmentInputHash: initialCloudWeb.environmentInputHash,
    cloudWebCodeInputHash: initialCloudWeb.codeInputHash,
    cloudWebBootCommit: initialPlan.sourceCommitId
  }), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
  await git(repo, ["commit", "-m", "seed v02 cloud web catalog"]);

  await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "<html>v2</html>\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change cloud web code"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-web"]
  });
  const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web");
  assert.equal(cloudWeb.runtimeMode, "env-reuse-gitea-checkout");
  assert.equal(cloudWeb.envChanged, false);
  assert.equal(cloudWeb.codeChanged, true);
  assert.equal(cloudWeb.buildRequired, false);
  assert.equal(cloudWeb.bootSh, "deploy/runtime/boot/hwlab-cloud-web.sh");
  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]);
  assert.deepEqual(plan.rolloutServices, ["hwlab-cloud-web"]);
  assert.deepEqual(plan.buildServices, []);
  assert.equal(plan.imageBuildRequired, false);
  assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout");
});

test("v02 planner enables env reuse for all retained runtime services", async () => {
  const repo = await createFixtureRepo({ serviceIds: V02_RUNTIME_SERVICE_IDS });
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: V02_RUNTIME_SERVICE_IDS
  });
  const environmentInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.environmentInputHash]));
  const codeInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.codeInputHash]));
  const bootCommits = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, initialPlan.sourceCommitId]));
  await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
    sourceCommitId: initialPlan.sourceCommitId,
    environmentInputHashes,
    codeInputHashes,
    bootCommits,
    serviceIds: V02_RUNTIME_SERVICE_IDS
  }), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
  await git(repo, ["commit", "-m", "seed all v02 env reuse catalogs"]);

  await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api v2');\n");
  await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "<html>v2</html>\n");
  await writeFile(path.join(repo, "cmd/hwlab-gateway/main.ts"), "console.log('gateway v2');\n");
  await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.mjs"), "console.log('edge v2');\n");
  await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill v2\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change all v02 service code"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: V02_RUNTIME_SERVICE_IDS
  });
  assert.deepEqual(plan.compatibility.v02EnvReuseServiceIds, V02_RUNTIME_SERVICE_IDS);
  assert.deepEqual(plan.affectedServices, sortedServiceIds(V02_RUNTIME_SERVICE_IDS));
  assert.deepEqual(plan.rolloutServices, sortedServiceIds(V02_RUNTIME_SERVICE_IDS));
  assert.deepEqual(plan.buildServices, []);
  assert.equal(plan.imageBuildRequired, false);
  for (const service of plan.services) {
    assert.equal(service.runtimeMode, "env-reuse-gitea-checkout", service.serviceId);
    assert.equal(service.envReuse, true, service.serviceId);
    assert.equal(service.envChanged, false, service.serviceId);
    assert.equal(service.codeChanged, true, service.serviceId);
    assert.equal(service.buildRequired, false, service.serviceId);
    assert.equal(service.bootSh, `deploy/runtime/boot/${service.serviceId}.sh`, service.serviceId);
  }
});

test("v02 planner rejects removed HWLAB-owned agent worker service selections", async () => {
  const repo = await createFixtureRepo();
  await assert.rejects(
    () => createCiPlan({
      repoRoot: repo,
      lane: "v02",
      baseRef: "HEAD",
      targetRef: "HEAD",
      artifactCatalogPath: "deploy/artifact-catalog.v02.json",
      services: ["hwlab-agent-worker"]
    }),
    /unknown service IDs for node CI plan: hwlab-agent-worker/u
  );
});

test("v02 planner keeps hwlab-cli-only changes out of runtime service builds", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"]
  });
  await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan)), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
  await git(repo, ["commit", "-m", "seed v02 catalog"]);

  await writeFile(path.join(repo, "tools/src/hwlab-cli-lib.ts"), "export const harness = true;\n");
  await writeFile(path.join(repo, "package.json"), JSON.stringify({
    type: "module",
    scripts: {
      validate: "node scripts/check-runner.mjs --profile validate",
      check: "node scripts/check-runner.mjs --profile check"
    }
  }, null, 2));
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change cli and host asset"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"]
  });
  assert.deepEqual(plan.buildServices, []);
  assert.deepEqual(plan.affectedServices, []);
  assert.equal(plan.buildSkippedCount, 3);
  assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false);
});

test("v02 planner scopes hwpod skill bundle changes to the skills service", async () => {
  const repo = await createFixtureRepo();
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"]
  });
  await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan)), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
  await git(repo, ["commit", "-m", "seed v02 catalog"]);

  await writeFile(path.join(repo, "tools/src/hwpod-harness-lib.ts"), "export const hwpod = true;\n");
  await writeFile(path.join(repo, "skills/hwpod-cli/SKILL.md"), "hwpod skill v2\n");
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "change hwpod runtime cli"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-skills"]
  });
  assert.deepEqual(plan.affectedServices, ["hwlab-agent-skills"]);
  assert.deepEqual(plan.buildServices, []);
  assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").affected, false);
  assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").affected, false);
  const skillsService = plan.services.find((service) => service.serviceId === "hwlab-agent-skills");
  assert.equal(skillsService.codeChanged, true);
  assert.equal(skillsService.envChanged, false);
  assert.deepEqual(skillsService.reason, ["code-input-changed", "component-path-changed"]);
});

test("v02 planner reads env reuse declarations and recipe from deploy config", async () => {
  const deploy = createDeployFixture();
  deploy.lanes.v02.serviceDeclarations["hwlab-cloud-web"].componentPaths.push("custom/web-plugin/");
  deploy.lanes.v02.envRecipe.bunVersion = "1.3.14";
  deploy.lanes.v02.envRecipe.additionalEnvPaths.push("custom/env-tool.mjs");

  const models = componentModelsForServices(["hwlab-cloud-web"], deploy.lanes.v02);
  assert.equal(models[0].runtimeKind, "cloud-web");
  assert.equal(models[0].entrypoint, "web/hwlab-cloud-web/index.html");
  assert.equal(models[0].componentPaths.includes("custom/web-plugin/"), true);
  assert.equal(models[0].buildSystemPaths.includes("custom/env-tool.mjs"), true);

  const recipe = envReuseRecipeForLane(deploy, "v02");
  assert.equal(recipe.bunVersion, "1.3.14");
  assert.equal(recipe.downloadStack.npmRegistry, "https://registry.npmjs.org/");
  assert.equal(recipe.downloadStack.bunPackages.x64, "@oven/bun-linux-x64");
  assert.equal(recipe.launcherInputPaths.includes("deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), true);
  assert.equal(recipe.launcherInputPaths.includes("custom/env-tool.mjs"), true);
});

test("planner keeps YAML download proxy authority under Tekton env", async () => {
  const repo = await createFixtureRepo();
  const previousEnv = {
    HWLAB_TEKTON_PIPELINERUN: process.env.HWLAB_TEKTON_PIPELINERUN,
    HTTP_PROXY: process.env.HTTP_PROXY,
    HTTPS_PROXY: process.env.HTTPS_PROXY,
    NO_PROXY: process.env.NO_PROXY,
    no_proxy: process.env.no_proxy
  };
  process.env.HWLAB_TEKTON_PIPELINERUN = "test-pipelinerun";
  process.env.HTTP_PROXY = "http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808";
  process.env.HTTPS_PROXY = "http://sub2api-egress-proxy.platform-infra.svc.cluster.local:10808";
  process.env.NO_PROXY = [
    "127.0.0.1",
    "localhost",
    "10.43.0.0/16",
    "git-mirror-http.devops-infra.svc.cluster.local",
    "deb.debian.org",
    ".debian.org",
    "goproxy.cn",
    ".goproxy.cn",
    "hyueapi.com",
    ".hyueapi.com"
  ].join(",");
  delete process.env.no_proxy;
  try {
    const plan = await createCiPlan({
      repoRoot: repo,
      lane: "v03",
      baseRef: "HEAD",
      targetRef: "HEAD",
      artifactCatalogPath: "deploy/artifact-catalog.v03.json",
      services: ["hwlab-cloud-api"]
    });
    const noProxy = plan.services[0].envReuseRecipe.downloadStack.noProxy;
    assert.equal(plan.services[0].envReuseRecipe.downloadStack.httpProxy, "http://127.0.0.1:10808");
    assert.equal(plan.services[0].envReuseRecipe.downloadStack.httpsProxy, "http://127.0.0.1:10808");
    assert.deepEqual(noProxy, [
      "127.0.0.1",
      "localhost",
      "::1",
      "hyueapi.com",
      ".hyueapi.com"
    ]);
  } finally {
    for (const [key, value] of Object.entries(previousEnv)) {
      if (value === undefined) delete process.env[key];
      else process.env[key] = value;
    }
  }
});

test("v03 planner reads env reuse declarations and catalog from v03 lane config", async () => {
  const repo = await createFixtureRepo();

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: "HEAD",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });

  assert.equal(plan.compatibility.lane, "v03");
  assert.equal(plan.compatibility.artifactCatalog, "deploy/artifact-catalog.v03.json");
  assert.equal(plan.compatibility.serviceIdSource, "cli-services");
  assert.equal(plan.compatibility.serviceDeclarationSource, "deploy.lanes.v03.serviceDeclarations");
  assert.equal(plan.compatibility.envRecipeSource, "deploy.lanes.v03.envRecipe");
  assert.deepEqual(plan.compatibility.envReuseServiceIds, ["hwlab-cloud-api", "hwlab-cloud-web"]);
  assert.equal(plan.services.every((service) => service.envReuseRecipe.downloadStack.npmFetchTimeoutMs === 300000), true);
  for (const service of plan.services) {
    assert.equal(service.runtimeMode, "env-reuse-gitea-checkout", service.serviceId);
    assert.equal(service.envReuse, true, service.serviceId);
    assert.equal(service.bootRepo, "git@github.com:pikasTech/HWLAB.git", service.serviceId);
    assert.equal(service.bootSh, `deploy/runtime/boot/${service.serviceId}.sh`, service.serviceId);
  }
});

test("v03 TaskTree API and worker share one TypeScript environment artifact", async () => {
  const reuse = await readStructuredFile(process.cwd(), "gitops/reuse.ymal");
  const group = reuse.spec?.envArtifactGroups?.["hwlab-ts-runtime-env"];
  const services = reuse.spec?.services ?? {};
  const tasktreeServiceIds = ["hwlab-tasktree-api", "hwlab-tasktree-worker"];
  const expectedEnvIdentity = [
    "package.json",
    "package-lock.json",
    "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts",
    "internal/dev-entrypoint/artifact-runtime.mjs"
  ];

  assert.ok(group, "shared TypeScript environment group");
  for (const serviceId of tasktreeServiceIds) {
    assert.equal(group.services?.includes(serviceId), true, `${serviceId} shared environment membership`);
    assert.deepEqual(services[serviceId]?.runtimeReuse?.envIdentity?.paths, expectedEnvIdentity, `${serviceId} runtime env identity`);
    assert.deepEqual(services[serviceId]?.envReuse?.envIdentityFiles, expectedEnvIdentity, `${serviceId} env reuse identity`);
    assert.equal(services[serviceId]?.envReuse?.mode, "env-reuse-gitea-checkout", `${serviceId} runtime mode`);
  }
  assert.deepEqual(services["hwlab-tasktree-api"].runtimeReuse.codeIdentity.paths, [
    "cmd/hwlab-tasktree-api",
    "internal/tasktree",
    "tools/src/tasktree-cli.ts",
    "deploy/runtime/boot/hwlab-tasktree-api.sh"
  ]);
  assert.deepEqual(services["hwlab-tasktree-worker"].runtimeReuse.codeIdentity.paths, [
    "cmd/hwlab-tasktree-worker",
    "internal/tasktree",
    "deploy/runtime/boot/hwlab-tasktree-worker.sh"
  ]);
});

test("v03 planner builds one shared Go env artifact for compatible Go services", async () => {
  const repo = await createFixtureRepo({ catalog: false });
  await addGoEnvReuseFixture(repo);
  await writeFile(path.join(repo, "go.mod"), "module github.com/pikasTech/HWLAB\n\ngo 1.22\n\nrequire example.com/direct v0.0.2\n");
  await git(repo, ["add", "go.mod"]);
  await git(repo, ["commit", "-m", "change go env dependency"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
    services: ["hwlab-user-billing", "hwlab-workbench-runtime"]
  });

  const group = plan.envArtifactGroups.find((item) => item.id === "hwlab-go-runtime-env");
  assert.equal(group.buildRequired, true);
  assert.deepEqual(group.buildServices, ["hwlab-user-billing"]);
  assert.deepEqual(group.consumerServices, ["hwlab-workbench-runtime"]);
  assert.deepEqual(plan.buildServices, ["hwlab-user-billing"]);
  const userBilling = plan.services.find((service) => service.serviceId === "hwlab-user-billing");
  const workbenchRuntime = plan.services.find((service) => service.serviceId === "hwlab-workbench-runtime");
  assert.equal(userBilling.envArtifactCacheRef, "127.0.0.1:5000/hwlab/cache/hwlab-go-runtime-env");
  assert.equal(workbenchRuntime.envArtifactCacheRef, "127.0.0.1:5000/hwlab/cache/hwlab-go-runtime-env");
  assert.equal(userBilling.envReuseRecipe.goBaseImage, "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim");
  assert.equal(workbenchRuntime.envReuseRecipe.goBaseImage, "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim");
  assert.equal(userBilling.environmentImage, `127.0.0.1:5000/hwlab/hwlab-go-runtime-env:env-${userBilling.environmentInputHash.slice(0, 12)}`);
  assert.equal(workbenchRuntime.environmentImage, userBilling.environmentImage);
  assert.equal(workbenchRuntime.sharedEnvBuildSkipped, true);
  assert.equal(workbenchRuntime.buildRequired, false);
});

test("v03 planner ignores Go launcher comment-only changes for env identity", async () => {
  const repo = await createFixtureRepo({ catalog: false });
  await addGoEnvReuseFixture(repo);
  const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const initialPlan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD",
    targetRef: initialSha,
    artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
    services: ["hwlab-user-billing", "hwlab-workbench-runtime"]
  });
  await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(createCatalogFixture("ready", catalogOptionsFromPlan(initialPlan, {
    sourceCommitId: initialSha,
    serviceIds: ["hwlab-user-billing", "hwlab-workbench-runtime"]
  })), null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "seed go env reuse catalog"]);

  await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"), "// changed explanatory comment\nexport const goEnvLauncher = true;\n");
  await git(repo, ["add", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"]);
  await git(repo, ["commit", "-m", "change go launcher comment only"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-user-billing", "hwlab-workbench-runtime"]
  });

  assert.deepEqual(plan.affectedServices, []);
  assert.deepEqual(plan.buildServices, []);
  assert.equal(plan.imageBuildRequired, false);
  for (const service of plan.services) {
    assert.equal(service.runtimeReuseDecision.envIdentityHit, true, service.serviceId);
    assert.equal(service.environmentInputChanged, false, service.serviceId);
    assert.equal(service.buildRequired, false, service.serviceId);
  }
});

test("v03 planner rebuilds env images when the env image generator changes", async () => {
  const repo = await createFixtureRepo();
  await writeFile(path.join(repo, "scripts/artifact-publish.mjs"), "export const envImageGenerator = 2;\n");
  await git(repo, ["add", "scripts/artifact-publish.mjs"]);
  await git(repo, ["commit", "-m", "change env image generator"]);

  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v03",
    baseRef: "HEAD~1",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/artifact-catalog.v03.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"]
  });

  assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
  assert.deepEqual(plan.buildServices, ["hwlab-cloud-api", "hwlab-cloud-web"]);
  for (const service of plan.services) {
    assert.equal(service.environmentInputChanged, true, service.serviceId);
    assert.equal(service.buildRequired, true, service.serviceId);
  }
});

test("Go env build path declares base image, stable timing, and late volatile labels", async () => {
  const deploy = await readStructuredFile(process.cwd(), path.join(process.cwd(), "deploy/deploy.yaml"));
  const recipe = envReuseRecipeForLane(deploy, "v03");
  assert.equal(
    deploy.lanes?.v03?.envRecipe?.goBaseImage,
    "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim"
  );
  assert.equal(recipe.publicRecipe.goBaseImage, "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim");

  const source = await readFile(path.join(process.cwd(), "scripts/artifact-publish.mjs"), "utf8");
  const envDockerfileSource = source.slice(
    source.indexOf("function envReuseDockerfile"),
    source.indexOf("async function copyRepoFileToContext")
  );
  assert.match(source, /const effectiveBaseImage = goBaseImageEnabled \? normalizedRecipe\.goBaseImage : baseImage/u);
  assert.match(envDockerfileSource, /timed_stage "go-mod-download"/u);
  assert.match(envDockerfileSource, /timed_stage "env-readiness"/u);
  assert.match(source, /goBaseImage: artifact\.goBaseImage \?\? null/u);
  assert.match(source, /"go-base-image-status": service\.goBaseImageStatus \?\? ""/u);

  const labelIndex = envDockerfileSource.indexOf("...labels.map(([name, value]) => `LABEL");
  const readinessIndex = envDockerfileSource.indexOf('timed_stage "env-readiness"');
  assert.ok(readinessIndex >= 0, "env readiness timing should be emitted from the Dockerfile");
  assert.ok(labelIndex > readinessIndex, "volatile labels should not invalidate expensive env build layers");
});

async function createFixtureRepo(options = {}) {
  const serviceIds = options.serviceIds ?? ["hwlab-cloud-api", "hwlab-cloud-web"];
  const laneServiceIds = options.laneServiceIds ?? V02_RUNTIME_SERVICE_IDS;
  const repo = await mkdtemp(path.join(os.tmpdir(), "hwlab-ci-plan-"));
  await mkdir(path.join(repo, "cmd/hwlab-cloud-api"), { recursive: true });
  await mkdir(path.join(repo, "cmd/hwlab-codex-api-responses-forwarder"), { recursive: true });
  await mkdir(path.join(repo, "cmd/hwlab-deepseek-responses-bridge"), { recursive: true });
  await mkdir(path.join(repo, "cmd/hwlab-gateway"), { recursive: true });
  await mkdir(path.join(repo, "cmd/hwlab-edge-proxy"), { recursive: true });
  await mkdir(path.join(repo, "web/hwlab-cloud-web"), { recursive: true });
  await mkdir(path.join(repo, "internal/dev-entrypoint"), { recursive: true });
  await mkdir(path.join(repo, "internal/sim"), { recursive: true });
  await mkdir(path.join(repo, "tools/hwlab-cli"), { recursive: true });
  await mkdir(path.join(repo, "tools/src"), { recursive: true });
  await mkdir(path.join(repo, "skills/hwpod-cli"), { recursive: true });
  await mkdir(path.join(repo, "skills/hwpod-ctl"), { recursive: true });
  await mkdir(path.join(repo, "skills/hwlab-agent-runtime"), { recursive: true });
  await mkdir(path.join(repo, ".hwlab"), { recursive: true });
  await mkdir(path.join(repo, "deploy/runtime/boot"), { recursive: true });
  await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true });
  await mkdir(path.join(repo, "deploy"), { recursive: true });
  await mkdir(path.join(repo, "scripts"), { recursive: true });
  await writeStructuredFile(repo, "deploy/deploy.yaml", createDeployFixture({ serviceIds: laneServiceIds }));
  const catalogMode = options.catalog ?? "ready";
  await writeFile(path.join(repo, "package.json"), JSON.stringify({ type: "module" }, null, 2));
  await writeFile(path.join(repo, "package-lock.json"), JSON.stringify({ lockfileVersion: 3 }, null, 2));
  await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api');\n");
  await writeFile(path.join(repo, "cmd/hwlab-codex-api-responses-forwarder/main.ts"), "console.log('forwarder');\n");
  await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge');\n");
  await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.ts"), "console.log('test');\n");
  await writeFile(path.join(repo, "cmd/hwlab-gateway/main.ts"), "console.log('gateway');\n");
  await writeFile(path.join(repo, "cmd/hwlab-edge-proxy/main.mjs"), "console.log('edge');\n");
  await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "<html></html>\n");
  await writeFile(path.join(repo, "internal/dev-entrypoint/artifact-runtime.mjs"), "export const artifactRuntime = 1;\n");
  await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 1;\n");
  await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-proxy.mjs"), "export const proxy = 1;\n");
  await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-routes.mjs"), "export const routes = 1;\n");
  await writeFile(path.join(repo, "internal/dev-entrypoint/http.mjs"), "export const http = 1;\n");
  await writeFile(path.join(repo, "internal/sim/http.mjs"), "export const simHttp = 1;\n");
  await writeFile(path.join(repo, "tools/hwpod-cli.ts"), "console.log('hwpod cli');\n");
  await writeFile(path.join(repo, "tools/hwpod-ctl.ts"), "console.log('hwpod ctl');\n");
  await writeFile(path.join(repo, "tools/hwpod-compiler-cli.ts"), "console.log('hwpod compiler');\n");
  await writeFile(path.join(repo, "tools/hwpod-node.ts"), "console.log('hwpod node');\n");
  await writeFile(path.join(repo, "tools/src/hwpod-harness-lib.ts"), "export const hwpod = false;\n");
  await writeFile(path.join(repo, "tools/src/hwpod-node-lib.ts"), "export const node = true;\n");
  await writeFile(path.join(repo, "tools/src/hwpod-node-ops-contract.ts"), "export const ops = true;\n");
  await writeFile(path.join(repo, "tools/src/runtime-endpoint-resolver.ts"), "export const endpoint = true;\n");
  await writeFile(path.join(repo, "skills/hwpod-cli/SKILL.md"), "hwpod cli skill\n");
  await writeFile(path.join(repo, "skills/hwpod-ctl/SKILL.md"), "hwpod ctl skill\n");
  await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n  name: d601-f103-v2\nspec:\n  nodeBinding:\n    nodeId: node-d601-f103-v2\n  workspace:\n    path: F:\\\\Work\\\\D601-HWLAB\n  targetDevice:\n    board: D601-F103-V2\n  debugProbe:\n    type: daplink\n  ioProbe:\n    uart:\n      port: COM9\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-cloud-web.sh"), "#!/bin/sh\nbun run --cwd web/hwlab-cloud-web build\nexec bun .hwlab-cloud-web-runtime.mjs\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-cloud-api.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-cloud-api/main.ts\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-codex-api-responses-forwarder/main.ts\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-deepseek-responses-bridge/main.ts\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-gateway.sh"), "#!/bin/sh\nexec bun run cmd/hwlab-gateway/main.ts\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-edge-proxy.sh"), "#!/bin/sh\nexec node cmd/hwlab-edge-proxy/main.mjs\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-agent-skills.sh"), "#!/bin/sh\nexec bun .hwlab-agent-skills-runtime.mjs\n");
  await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n");
  await writeFile(path.join(repo, "scripts/artifact-publish.mjs"), "export const envImageGenerator = 1;\n");
  await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n");
  await git(repo, ["init"]);
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "initial"]);
  if (catalogMode !== false) {
    const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
    const initialPlan = await createCiPlan({
      repoRoot: repo,
      lane: "v02",
      baseRef: "HEAD",
      targetRef: "HEAD",
      artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
      services: serviceIds
    });
    const catalog = createCatalogFixture(catalogMode, catalogOptionsFromPlan(initialPlan, { sourceCommitId: initialSha, serviceIds }));
    await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
    await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
    await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
    await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json", "deploy/artifact-catalog.v03.json"]);
    await git(repo, ["commit", "-m", "seed artifact catalogs"]);
  }
  return repo;
}

async function refreshFixtureCatalogForHead(repo) {
  const sourceCommitId = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
  const services = ["hwlab-cloud-api", "hwlab-cloud-web"];
  const plan = await createCiPlan({
    repoRoot: repo,
    lane: "v02",
    baseRef: "HEAD",
    targetRef: "HEAD",
    artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
    services
  });
  const catalog = createCatalogFixture("ready", catalogOptionsFromPlan(plan, { sourceCommitId, serviceIds: services }));
  await writeFile(path.join(repo, "deploy/artifact-catalog.dev.json"), JSON.stringify(catalog, null, 2));
  await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(catalog, null, 2));
  await writeFile(path.join(repo, "deploy/artifact-catalog.v03.json"), JSON.stringify(catalog, null, 2));
  await git(repo, ["add", "deploy/artifact-catalog.dev.json", "deploy/artifact-catalog.v02.json", "deploy/artifact-catalog.v03.json"]);
  await git(repo, ["commit", "-m", "refresh fixture artifact catalogs"]);
}

function planV02CoreServices(repo, options) {
  return createCiPlan({
    repoRoot: repo,
    lane: "v02",
    artifactCatalogPath: "deploy/artifact-catalog.v02.json",
    services: ["hwlab-cloud-api", "hwlab-cloud-web"],
    ...options
  });
}

async function addGoEnvReuseFixture(repo) {
  await mkdir(path.join(repo, "cmd/hwlab-user-billing"), { recursive: true });
  await mkdir(path.join(repo, "cmd/hwlab-workbench-runtime"), { recursive: true });
  await mkdir(path.join(repo, "internal/userbilling"), { recursive: true });
  await mkdir(path.join(repo, "internal/workbenchruntime"), { recursive: true });
  await mkdir(path.join(repo, "gitops"), { recursive: true });
  await writeFile(path.join(repo, "go.mod"), "module github.com/pikasTech/HWLAB\n\ngo 1.22\n\nrequire example.com/direct v0.0.1\n");
  await writeFile(path.join(repo, "go.sum"), "example.com/direct v0.0.1 h1:fixture\n");
  await writeFile(path.join(repo, "cmd/hwlab-user-billing/main.go"), "package main\n\nfunc main() {}\n");
  await writeFile(path.join(repo, "cmd/hwlab-workbench-runtime/main.go"), "package main\n\nfunc main() {}\n");
  await writeFile(path.join(repo, "internal/userbilling/billing.go"), "package userbilling\n");
  await writeFile(path.join(repo, "internal/workbenchruntime/runtime.go"), "package workbenchruntime\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-user-billing.sh"), "#!/bin/sh\nexec /app/hwlab-user-billing\n");
  await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-workbench-runtime.sh"), "#!/bin/sh\nexec /app/hwlab-workbench-runtime\n");
  await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"), "// initial explanatory comment\nexport const goEnvLauncher = true;\n");
  const deployPath = path.join(repo, "deploy/deploy.yaml");
  const deploy = await readStructuredFile(repo, deployPath);
  deploy.lanes.v03.envReuseServices = uniquePreserveOrder([
    ...deploy.lanes.v03.envReuseServices,
    "hwlab-user-billing",
    "hwlab-workbench-runtime"
  ]);
  deploy.lanes.v03.bootScripts["hwlab-user-billing"] = "deploy/runtime/boot/hwlab-user-billing.sh";
  deploy.lanes.v03.bootScripts["hwlab-workbench-runtime"] = "deploy/runtime/boot/hwlab-workbench-runtime.sh";
  deploy.lanes.v03.envRecipe.goOsPackages = ["golang-go"];
  deploy.lanes.v03.envRecipe.goBaseImage = "127.0.0.1:5000/hwlab/hwlab-go-env-base:go1-bookworm-slim";
  deploy.lanes.v03.envRecipe.runtimeGoModCachePath = "/opt/hwlab-env/go/pkg/mod";
  deploy.lanes.v03.envRecipe.runtimeGoBuildCachePath = "/opt/hwlab-env/go/cache";
  deploy.lanes.v03.envRecipe.goToolchain = "local";
  deploy.lanes.v03.serviceDeclarations["hwlab-user-billing"] = {
    runtimeKind: "go-service",
    entrypoint: "cmd/hwlab-user-billing/main.go",
    artifactKind: "go-service",
    healthPath: "/health/live",
    healthPort: 7101,
    componentPaths: ["cmd/hwlab-user-billing/", "internal/userbilling/"],
    env: {},
    observable: true
  };
  deploy.lanes.v03.serviceDeclarations["hwlab-workbench-runtime"] = {
    runtimeKind: "go-service",
    entrypoint: "cmd/hwlab-workbench-runtime/main.go",
    artifactKind: "go-service",
    healthPath: "/health/live",
    healthPort: 7102,
    componentPaths: ["cmd/hwlab-workbench-runtime/", "internal/workbenchruntime/"],
    env: {},
    observable: true
  };
  await writeStructuredFile(repo, deployPath, deploy);
  await writeStructuredFile(repo, "gitops/reuse.ymal", {
    apiVersion: "unidesk.pikapython.com/v1alpha1",
    kind: "RuntimeReuseConfig",
    metadata: { name: "hwlab-v03-reuse", ownerRepository: "pikasTech/HWLAB" },
    spec: {
      envArtifactGroups: {
        "hwlab-go-runtime-env": {
          enabled: true,
          mode: "env-reuse-gitea-checkout",
          buildService: "hwlab-user-billing",
          imageRepository: "hwlab-go-runtime-env",
          cacheRepository: "cache/hwlab-go-runtime-env",
          services: ["hwlab-user-billing", "hwlab-workbench-runtime"]
        }
      },
      services: Object.fromEntries(["hwlab-user-billing", "hwlab-workbench-runtime"].map((serviceId) => [serviceId, {
        runtimeReuse: {
          enabled: true,
          codeIdentity: { paths: [serviceId === "hwlab-user-billing" ? "cmd/hwlab-user-billing" : "cmd/hwlab-workbench-runtime"] },
          envIdentity: { paths: ["go.mod", "go.sum", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"] }
        },
        envReuse: {
          enabled: true,
          mode: "env-reuse-gitea-checkout",
          envIdentityFiles: ["go.mod", "go.sum", "deploy/runtime/launcher/hwlab-go-env-reuse-launcher.mjs"]
        }
      }]))
    }
  });
  await git(repo, ["add", "."]);
  await git(repo, ["commit", "-m", "add go env reuse fixture"]);
}

function createDeployFixture({ serviceIds = V02_RUNTIME_SERVICE_IDS } = {}) {
  const v02 = {
        sourceRepo: "git@github.com:pikasTech/HWLAB.git",
        artifactCatalog: "deploy/artifact-catalog.v02.json",
        envReuseServices: [...serviceIds],
        bootScripts: {
          "hwlab-cloud-api": "deploy/runtime/boot/hwlab-cloud-api.sh",
          "hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh",
          "hwlab-gateway": "deploy/runtime/boot/hwlab-gateway.sh",
          "hwlab-edge-proxy": "deploy/runtime/boot/hwlab-edge-proxy.sh",
          "hwlab-agent-skills": "deploy/runtime/boot/hwlab-agent-skills.sh"
        },
        serviceDeclarations: {
          "hwlab-cloud-api": {
            runtimeKind: "bun-command",
            entrypoint: "cmd/hwlab-cloud-api/main.ts",
            artifactKind: "bun-command",
            healthPath: "/health/live",
            healthPort: 6667,
            componentPaths: [
              "cmd/hwlab-cloud-api/",
              "cmd/hwlab-codex-api-responses-forwarder/",
              "cmd/hwlab-deepseek-responses-bridge/",
              "deploy/runtime/boot/hwlab-codex-api-responses-forwarder.sh",
              "deploy/runtime/boot/hwlab-deepseek-responses-bridge.sh",
              "internal/cloud/",
              "internal/db/",
              "internal/audit/",
              "internal/agent/agentrun-dispatch.mjs",
              "internal/agent/prompts/",
              "skills/hwlab-agent-runtime/"
            ],
            env: {},
            observable: true
          },
          "hwlab-cloud-web": {
            runtimeKind: "cloud-web",
            entrypoint: "web/hwlab-cloud-web/index.html",
            artifactKind: "cloud-web",
            healthPath: "/health/live",
            healthPort: 8080,
            componentPaths: [
              "web/hwlab-cloud-web/",
              "internal/dev-entrypoint/cloud-web-runtime.mjs",
              "internal/dev-entrypoint/cloud-web-proxy.mjs",
              "internal/dev-entrypoint/cloud-web-routes.mjs",
              "tools/src/hwlab-cli/trace-renderer.ts"
            ],
            env: {},
            observable: true
          },
          "hwlab-gateway": {
            runtimeKind: "bun-command",
            entrypoint: "cmd/hwlab-gateway/main.ts",
            artifactKind: "bun-command",
            healthPath: "/health/live",
            healthPort: 7001,
            componentPaths: ["cmd/hwlab-gateway/", "internal/sim/"],
            env: {},
            observable: false
          },
          "hwlab-edge-proxy": {
            runtimeKind: "node-command",
            entrypoint: "cmd/hwlab-edge-proxy/main.mjs",
            artifactKind: "node-command",
            healthPath: "/health",
            healthPort: 6667,
            componentPaths: ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
            env: {},
            observable: true
          },
          "hwlab-agent-skills": {
            runtimeKind: "skills-bundle",
            entrypoint: "skills/hwlab-agent-runtime/SKILL.md",
            artifactKind: "skills-bundle",
            healthPath: "/health/live",
            healthPort: 7430,
            componentPaths: ["skills/"],
            env: {},
            observable: true
          }
        },
        envRecipe: {
          osPackages: ["ca-certificates", "git"],
          bunVersion: "1.3.13",
          downloadStack: {
            httpProxy: "http://127.0.0.1:10808",
            httpsProxy: "http://127.0.0.1:10808",
            noProxy: ["127.0.0.1", "localhost", "::1", "hyueapi.com", ".hyueapi.com"],
            npmRegistry: "https://registry.npmjs.org/",
            npmFetchTimeoutMs: 300000,
            bunPackages: {
              x64: "@oven/bun-linux-x64",
              arm64: "@oven/bun-linux-aarch64"
            }
          },
          launcherPath: "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts",
          runtimeNodeModulesPath: "/opt/hwlab-env/node_modules",
          additionalEnvPaths: [
            "internal/dev-entrypoint/artifact-runtime.mjs"
          ],
          hwpodAliases: [
            { command: "hwpod", script: "tools/hwpod-cli.ts" },
            { command: "hwpod-ctl", script: "tools/hwpod-ctl.ts" },
            { command: "hwpod-compiler", script: "tools/hwpod-compiler-cli.ts" }
          ]
        },
        bootConfig: {
          template: "default",
          overrides: {}
        }
      };
  const v03 = JSON.parse(JSON.stringify(v02));
  v03.artifactCatalog = "deploy/artifact-catalog.v03.json";
  const deploy = {
    lanes: {
      v02,
      v03
    }
  };
  return deploy;
}

function createCatalogFixture(mode, options = {}) {
  const digest = mode === "ready" ? `sha256:${"1".repeat(64)}` : "not_published";
  const envReuseCatalogServiceIds = new Set([
    ...V02_RUNTIME_SERVICE_IDS,
    ...Object.keys(options.environmentInputHashes ?? {}),
    ...Object.keys(options.codeInputHashes ?? {})
  ]);
  const serviceIds = options.serviceIds ?? uniquePreserveOrder([
    "hwlab-cloud-api",
    "hwlab-cloud-web",
    ...Object.keys(options.componentInputHashes ?? {}),
    ...Object.keys(options.environmentInputHashes ?? {}),
    ...Object.keys(options.codeInputHashes ?? {})
  ]);
  return {
    services: serviceIds.map((serviceId) => ({
      serviceId,
      commitId: "abc1234",
      sourceCommitId: options.sourceCommitId ?? "abc1234",
      image: `127.0.0.1:5000/hwlab/${serviceId}:abc1234`,
      imageTag: "abc1234",
      digest,
      ...((options.componentInputHashes?.[serviceId] || (serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash)) ? {
        componentInputHash: options.cloudApiComponentInputHash ?? options.componentInputHashes?.[serviceId]
      } : {}),
      ...(envReuseCatalogServiceIds.has(serviceId) ? {
        runtimeMode: "env-reuse-gitea-checkout",
        envReuse: true,
        environmentImage: `127.0.0.1:5000/hwlab/${serviceId}-env:env-abc1234`,
        environmentDigest: digest,
        environmentInputHash: options.environmentInputHashes?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebEnvironmentInputHash : null) ?? "e".repeat(64),
        codeInputHash: options.codeInputHashes?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebCodeInputHash : null) ?? "c".repeat(64),
        bootRepo: "git@github.com:pikasTech/HWLAB.git",
        bootCommit: options.bootCommits?.[serviceId] ?? (serviceId === "hwlab-cloud-web" ? options.cloudWebBootCommit : null) ?? "abc1234",
        bootSh: `deploy/runtime/boot/${serviceId}.sh`
      } : {})
    }))
  };
}

function catalogOptionsFromPlan(plan, extra = {}) {
  const serviceIds = plan.services.map((service) => service.serviceId);
  return {
    sourceCommitId: plan.sourceCommitId,
    serviceIds,
    componentInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.componentInputHash])),
    environmentInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.environmentInputHash]).filter(([, value]) => value)),
    codeInputHashes: Object.fromEntries(plan.services.map((service) => [service.serviceId, service.codeInputHash]).filter(([, value]) => value)),
    bootCommits: Object.fromEntries(plan.services.map((service) => [service.serviceId, plan.sourceCommitId])),
    ...extra
  };
}

function sortedServiceIds(values) {
  return [...values].sort();
}

function uniquePreserveOrder(values) {
  const seen = new Set();
  const result = [];
  for (const value of values) {
    if (!value || seen.has(value)) continue;
    seen.add(value);
    result.push(value);
  }
  return result;
}

async function runCiPlanCli(repo, args) {
  const { stdout } = await execFileAsync(process.execPath, [path.resolve("scripts/ci-plan.mjs"), ...args], { cwd: repo });
  return JSON.parse(stdout);
}

async function git(cwd, args) {
  return execFileAsync("git", ["-c", "user.name=HWLAB Test", "-c", "user.email=hwlab-test@example.invalid", ...args], { cwd });
}
