1443 lines
66 KiB
JavaScript
1443 lines
66 KiB
JavaScript
// 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("node CI planner rolls service runtime config changes without rebuilding image", 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.buildServices, []);
|
|
assert.equal(plan.imageBuildRequired, false);
|
|
const cloudApi = plan.services.find((service) => service.serviceId === "hwlab-cloud-api");
|
|
assert.equal(cloudApi.runtimeConfigChanged, true);
|
|
assert.equal(cloudApi.envChanged, false);
|
|
assert.deepEqual(cloudApi.reason, ["runtime-config-changed"]);
|
|
});
|
|
|
|
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-git-mirror-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, []);
|
|
});
|
|
|
|
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, /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 gitopsRender = await readFile("scripts/gitops-render.mjs", "utf8");
|
|
assert.match(gitopsRender, /const componentInputHash = envReuse \? \(planned\.componentInputHash \|\| service\.componentInputHash \|\| ""\) : \(service\.componentInputHash \|\| ""\);/u);
|
|
assert.doesNotMatch(gitopsRender, /"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-git-mirror-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-git-mirror-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-git-mirror-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-git-mirror-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 script cleanup for runtime images", async () => {
|
|
const repo = await createFixtureRepo();
|
|
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", "package.json"]);
|
|
await git(repo, ["commit", "-m", "seed package scripts"]);
|
|
await refreshFixtureCatalogForHead(repo);
|
|
|
|
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.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-git-mirror-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-git-mirror-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-git-mirror-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);
|
|
}
|
|
});
|
|
|
|
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 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, "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
|
|
});
|
|
}
|
|
|
|
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 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]
|
|
} : {}),
|
|
...(V02_RUNTIME_SERVICE_IDS.includes(serviceId) ? {
|
|
runtimeMode: "env-reuse-git-mirror-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 git(cwd, args) {
|
|
return execFileAsync("git", ["-c", "user.name=HWLAB Test", "-c", "user.email=hwlab-test@example.invalid", ...args], { cwd });
|
|
}
|