fix(v02): remove legacy code agent control plane

This commit is contained in:
Codex Agent
2026-06-05 01:35:19 +08:00
parent 6422487444
commit fd1b8c0e5a
66 changed files with 222 additions and 5363 deletions
+1 -2
View File
@@ -75,8 +75,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥
- v0.2 CI/CD 加法 lane、`v0.2-gitops``hwlab-v02``19666/19667` 规格:[docs/reference/spec-v02-cicd.md](docs/reference/spec-v02-cicd.md)。
- v0.2 `hwlab-cloud-api` API 核心服务规格:[docs/reference/spec-v02-hwlab-cloud-api.md](docs/reference/spec-v02-hwlab-cloud-api.md)。
- v0.2 `hwlab-cloud-web` 浏览器工作台规格:[docs/reference/spec-v02-hwlab-cloud-web.md](docs/reference/spec-v02-hwlab-cloud-web.md)。
- v0.2 `hwlab-agent-mgr` agent 管理服务规格:[docs/reference/spec-v02-hwlab-agent-mgr.md](docs/reference/spec-v02-hwlab-agent-mgr.md)。
- v0.2 `hwlab-agent-worker` session-scoped Job 模板规格:[docs/reference/spec-v02-hwlab-agent-worker.md](docs/reference/spec-v02-hwlab-agent-worker.md)。
- v0.2 Code Agent 由 `hwlab-cloud-api` 接入 AgentRun v0.1 共享执行基础设施,不再保留 HWLAB 自有 agent manager/worker 控制面:[docs/reference/agentrun-code-agent-dispatch.md](docs/reference/agentrun-code-agent-dispatch.md)。
- v0.2 `hwlab-agent-skills` 技能包服务规格:[docs/reference/spec-v02-hwlab-agent-skills.md](docs/reference/spec-v02-hwlab-agent-skills.md)。
- v0.2 `hwlab-cli` 固定 repo 短连接 client 规格:[docs/reference/spec-v02-hwlab-cli.md](docs/reference/spec-v02-hwlab-cli.md)。
- v0.2 `hwlab-device-pod` 部署服务规格:[docs/reference/spec-v02-hwlab-device-pod-service.md](docs/reference/spec-v02-hwlab-device-pod-service.md)。
-121
View File
@@ -1,121 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun");
test("agent manager CLI reports degraded skills and manages local session lifecycle", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-mgr-"));
try {
const health = await runManagerJson(["health", "--state-dir", stateDir]);
assert.equal(health.serviceId, "hwlab-agent-mgr");
assert.equal(health.ok, true);
assert.equal(health.status, "degraded");
assert.deepEqual(health.health.skills.manager.missing, ["skillCommitId", "skillVersion"]);
const created = await runManagerJson([
"create",
"--agent-session-id", "ags_test",
"--project-id", "prj_test",
"--goal", "keep behavior",
"--prompt", "run dry",
"--skill-commit-id", "commit-a",
"--skill-version", "1.0.0",
"--state-dir", stateDir
]);
assert.equal(created.agentSessionId, "ags_test");
assert.equal(created.status, "ok");
assert.equal(created.task.status, "queued");
assert.equal(created.traceCount, 1);
const status = await runManagerJson(["status", "--agent-session-id", "ags_test", "--state-dir", stateDir]);
assert.equal(status.agentSession.metadata.skills.commitId, "commit-a");
assert.equal(status.workerSession.metadata.expectedSkills.version, "1.0.0");
const trace = await runManagerJson(["trace", "--agent-session-id", "ags_test", "--state-dir", stateDir]);
assert.equal(trace.traceEvents.length, 1);
assert.equal(trace.traceEvents[0].metadata.state, "create");
const evidence = await runManagerJson(["evidence", "--agent-session-id", "ags_test", "--state-dir", stateDir]);
assert.equal(evidence.evidenceRecords.length, 0);
const cleanup = await runManagerJson(["cleanup", "--agent-session-id", "ags_test", "--state-dir", stateDir]);
assert.equal(cleanup.serviceId, "hwlab-agent-mgr");
assert.equal(cleanup.cleanup.completed, true);
assert.equal(cleanup.workspace.cleaned, true);
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
test("agent manager server returns health JSON", async () => {
const port = await freePort();
const server = await startManagerServer(port);
try {
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
assert.equal(response.status, 200);
const health = await response.json();
assert.equal(health.serviceId, "hwlab-agent-mgr");
assert.equal(health.ok, true);
assert.equal(health.status, "degraded");
} finally {
await server.stop();
}
});
async function runManagerJson(args) {
const { stdout } = await execFileAsync(bunCommand, ["run", "cmd/hwlab-agent-mgr/main.ts", ...args], {
cwd: process.cwd(),
maxBuffer: 1024 * 1024
});
return JSON.parse(stdout);
}
async function startManagerServer(port) {
const child = execFile(bunCommand, ["run", "cmd/hwlab-agent-mgr/main.ts", "server"], {
cwd: process.cwd(),
env: {
...process.env,
PORT: String(port)
},
maxBuffer: 1024 * 1024
});
await new Promise((resolve, reject) => {
let stderr = "";
const timer = setTimeout(() => reject(new Error(`manager server did not start: ${stderr}`)), 5000);
child.stderr?.on("data", (chunk) => {
stderr += chunk;
});
child.stdout?.on("data", (chunk) => {
if (chunk.toString().includes("listening")) {
clearTimeout(timer);
resolve();
}
});
child.on("exit", (code) => {
clearTimeout(timer);
reject(new Error(`manager server exited early code=${code}: ${stderr}`));
});
});
return {
stop: async () => {
if (child.exitCode !== null) return;
child.kill("SIGTERM");
await new Promise((resolve) => child.once("exit", resolve));
}
};
}
async function freePort() {
const server = createServer();
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const port = server.address().port;
await new Promise((resolve) => server.close(resolve));
return port;
}
-234
View File
@@ -1,234 +0,0 @@
#!/usr/bin/env node
import { createServer } from "node:http";
import { buildMetadataFromEnv } from "../../internal/build-metadata.mjs";
import {
AGENT_MGR_SERVICE_ID,
createSkillsManifest
} from "../../internal/agent/index.mjs";
import {
assessSkillsInjection,
cleanupLocalAgentSession,
createLocalAgentSession,
createRuntimeHealth,
getLocalAgentEvidence,
getLocalAgentStatus,
getLocalAgentTrace,
resolveAgentRuntimeStateDir
} from "../../internal/agent/runtime.mjs";
const command = process.argv[2] || process.env.HWLAB_AGENT_MGR_DEFAULT_COMMAND || "server";
const options = parseOptions(process.argv.slice(3));
try {
const result = await dispatch(command, options);
printJSON(result);
} catch (error) {
printJSON(
{
serviceId: AGENT_MGR_SERVICE_ID,
ok: false,
error: {
message: error.message
}
},
{ stderr: true }
);
process.exit(1);
}
async function dispatch(name, opts) {
if (name === "help" || opts.help) {
return help();
}
if (name === "health") {
return createHealthResponse(opts);
}
if (name === "server") {
return serveHealth(opts);
}
if (name === "create") {
return {
ok: true,
...(await createLocalAgentSession({
stateDir: opts.stateDir,
agentSessionId: requireOption(opts, "agentSessionId", "--agent-session-id"),
projectId: requireOption(opts, "projectId", "--project-id"),
goal: opts.goal || "",
prompt: opts.prompt || "",
requestedBy: opts.requestedBy || "usr_local",
skillCommitId: opts.skillCommitId || null,
skillVersion: opts.skillVersion || null
}))
};
}
if (name === "status") {
return {
ok: true,
...(await getLocalAgentStatus({
stateDir: opts.stateDir,
agentSessionId: requireOption(opts, "agentSessionId", "--agent-session-id")
}))
};
}
if (name === "trace") {
return {
ok: true,
...(await getLocalAgentTrace({
stateDir: opts.stateDir,
agentSessionId: requireOption(opts, "agentSessionId", "--agent-session-id")
}))
};
}
if (name === "evidence") {
return {
ok: true,
...(await getLocalAgentEvidence({
stateDir: opts.stateDir,
agentSessionId: requireOption(opts, "agentSessionId", "--agent-session-id")
}))
};
}
if (name === "cleanup") {
return {
ok: true,
...(await cleanupLocalAgentSession({
stateDir: opts.stateDir,
agentSessionId: requireOption(opts, "agentSessionId", "--agent-session-id"),
actor: AGENT_MGR_SERVICE_ID
}))
};
}
throw new Error(`unknown command ${JSON.stringify(name)}`);
}
function parseOptions(argv) {
const opts = {
stateDir: process.env.HWLAB_AGENT_RUNTIME_STATE_DIR || ""
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--agent-session-id") opts.agentSessionId = argv[++i] || "";
else if (arg === "--project-id") opts.projectId = argv[++i] || "";
else if (arg === "--goal") opts.goal = argv[++i] || "";
else if (arg === "--prompt") opts.prompt = argv[++i] || "";
else if (arg === "--requested-by") opts.requestedBy = argv[++i] || "";
else if (arg === "--skill-commit-id") opts.skillCommitId = argv[++i] || "";
else if (arg === "--skill-version") opts.skillVersion = argv[++i] || "";
else if (arg === "--state-dir") opts.stateDir = argv[++i] || "";
else if (arg === "--help" || arg === "-h") opts.help = true;
else throw new Error(`unknown option ${JSON.stringify(arg)}`);
}
if (opts.stateDir) {
opts.stateDir = resolveAgentRuntimeStateDir(opts.stateDir);
}
return opts;
}
function requireOption(opts, key, flag) {
if (!opts[key]) {
throw new Error(`${flag} is required`);
}
return opts[key];
}
function createHealthResponse(opts) {
const managerSkills = assessSkillsInjection({
skillCommitId: opts.skillCommitId,
skillVersion: opts.skillVersion,
role: "manager"
});
const health = createRuntimeHealth({
stateDir: opts.stateDir,
managerSkills
});
return {
serviceId: AGENT_MGR_SERVICE_ID,
ok: true,
status: health.status,
environment: process.env.HWLAB_ENVIRONMENT || "dev",
...buildMetadataFromEnv(process.env, { serviceId: AGENT_MGR_SERVICE_ID }),
health,
skillsManifest: managerSkills.status === "ready"
? createSkillsManifest({ commitId: managerSkills.commitId, version: managerSkills.version })
: null
};
}
async function serveHealth(opts) {
const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "7410", 10);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
throw new Error(`invalid PORT/HWLAB_PORT ${JSON.stringify(process.env.PORT || process.env.HWLAB_PORT)}`);
}
const server = createServer((request, response) => {
const url = new URL(request.url || "/", "http://hwlab-agent-mgr.local");
if (url.pathname === "/health" || url.pathname === "/health/live") {
sendJson(response, 200, createHealthResponse(opts));
return;
}
if (url.pathname === "/" || url.pathname === "/help") {
sendJson(response, 200, help());
return;
}
sendJson(response, 404, { serviceId: AGENT_MGR_SERVICE_ID, ok: false, error: "not_found", path: url.pathname });
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(port, "0.0.0.0", resolve);
});
printJSON({ serviceId: AGENT_MGR_SERVICE_ID, ok: true, status: "listening", port });
return new Promise((resolve, reject) => {
server.once("close", resolve);
server.once("error", reject);
});
}
function sendJson(response, statusCode, body) {
const payload = JSON.stringify(body, null, 2);
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload)
});
response.end(payload);
}
function help() {
return {
serviceId: AGENT_MGR_SERVICE_ID,
ok: true,
usage: [
"bun run cmd/hwlab-agent-mgr/main.ts server [--skill-commit-id COMMIT --skill-version VERSION] [--state-dir DIR]",
"bun run cmd/hwlab-agent-mgr/main.ts health [--skill-commit-id COMMIT --skill-version VERSION] [--state-dir DIR]",
"bun run cmd/hwlab-agent-mgr/main.ts create --agent-session-id ID --project-id ID [--goal TEXT --prompt TEXT] [--skill-commit-id COMMIT --skill-version VERSION] [--state-dir DIR]",
"bun run cmd/hwlab-agent-mgr/main.ts status --agent-session-id ID [--state-dir DIR]",
"bun run cmd/hwlab-agent-mgr/main.ts trace --agent-session-id ID [--state-dir DIR]",
"bun run cmd/hwlab-agent-mgr/main.ts evidence --agent-session-id ID [--state-dir DIR]",
"bun run cmd/hwlab-agent-mgr/main.ts cleanup --agent-session-id ID [--state-dir DIR]"
],
contract: {
stateDir: resolveAgentRuntimeStateDir(),
output: "json",
runtimeMode: "local-dry-run",
prodDisabled: true
}
};
}
function printJSON(value, { stderr = false } = {}) {
const target = stderr ? process.stderr : process.stdout;
target.write(`${JSON.stringify(value, null, 2)}\n`);
}
-83
View File
@@ -1,83 +0,0 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { access, mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
const bunCommand = process.env.HWLAB_TEST_BUN_COMMAND || process.env.HWLAB_BUN_COMMAND || (process.versions.bun ? process.execPath : "bun");
test("agent worker health and dry-run preserve session evidence and cleanup behavior", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-worker-"));
try {
const health = await runWorkerJson(["health", "--state-dir", stateDir]);
assert.equal(health.serviceId, "hwlab-agent-worker");
assert.equal(health.ok, true);
assert.equal(health.status, "ok");
assert.equal(health.skills.status, "degraded");
await runManagerJson([
"create",
"--agent-session-id", "ags_worker",
"--project-id", "prj_worker",
"--skill-commit-id", "commit-worker",
"--skill-version", "2.0.0",
"--state-dir", stateDir
]);
const dryRun = await runWorkerJson([
"dry-run",
"--agent-session-id", "ags_worker",
"--skill-commit-id", "commit-worker",
"--skill-version", "2.0.0",
"--state-dir", stateDir
]);
assert.equal(dryRun.serviceId, "hwlab-agent-worker");
assert.equal(dryRun.dryRun, true);
assert.equal(dryRun.task.status, "completed");
assert.equal(dryRun.health.status, "ok");
assert.equal(dryRun.evidenceRecord.evidenceId, "evi_ags_worker");
assert.equal(dryRun.cleanup.completed, true);
assert.equal(dryRun.workspace.cleaned, true);
assert.equal(dryRun.workspace.existsAfterCleanup, false);
await assert.rejects(access(dryRun.workspace.path));
const evidence = await runManagerJson(["evidence", "--agent-session-id", "ags_worker", "--state-dir", stateDir]);
assert.equal(evidence.evidenceRecords.length, 1);
assert.equal(evidence.evidenceRecords[0].serviceId, "hwlab-agent-worker");
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
test("agent worker dry-run requires agent session id", async () => {
const result = await execFileAsync(bunCommand, ["run", "cmd/hwlab-agent-worker/main.ts", "dry-run"], {
cwd: process.cwd(),
maxBuffer: 1024 * 1024
}).then(
() => ({ code: 0, stderr: "" }),
(error) => ({ code: error.code, stderr: error.stderr })
);
assert.equal(result.code, 1);
const payload = JSON.parse(result.stderr);
assert.equal(payload.serviceId, "hwlab-agent-worker");
assert.match(payload.error.message, /--agent-session-id is required/u);
});
async function runManagerJson(args) {
const { stdout } = await execFileAsync(bunCommand, ["run", "cmd/hwlab-agent-mgr/main.ts", ...args], {
cwd: process.cwd(),
maxBuffer: 1024 * 1024
});
return JSON.parse(stdout);
}
async function runWorkerJson(args) {
const { stdout } = await execFileAsync(bunCommand, ["run", "cmd/hwlab-agent-worker/main.ts", ...args], {
cwd: process.cwd(),
maxBuffer: 1024 * 1024
});
return JSON.parse(stdout);
}
-118
View File
@@ -1,118 +0,0 @@
#!/usr/bin/env node
import { buildMetadataFromEnv } from "../../internal/build-metadata.mjs";
import { AGENT_WORKER_SERVICE_ID } from "../../internal/agent/index.mjs";
import {
assessSkillsInjection,
resolveAgentRuntimeStateDir,
runLocalWorkerDryRun
} from "../../internal/agent/runtime.mjs";
const command = process.argv[2] || "help";
const options = parseOptions(process.argv.slice(3));
try {
const result = await dispatch(command, options);
printJSON(result);
} catch (error) {
printJSON(
{
serviceId: AGENT_WORKER_SERVICE_ID,
ok: false,
error: {
message: error.message
}
},
{ stderr: true }
);
process.exit(1);
}
async function dispatch(name, opts) {
if (name === "help" || opts.help) {
return help();
}
if (name === "health") {
return {
serviceId: AGENT_WORKER_SERVICE_ID,
ok: true,
status: "ok",
environment: process.env.HWLAB_ENVIRONMENT || "dev",
...buildMetadataFromEnv(process.env, { serviceId: AGENT_WORKER_SERVICE_ID }),
runtimeMode: "local-dry-run",
skills: assessSkillsInjection({
skillCommitId: opts.skillCommitId,
skillVersion: opts.skillVersion,
role: "worker"
})
};
}
if (name === "dry-run" || name === "smoke") {
return {
ok: true,
...(await runLocalWorkerDryRun({
stateDir: opts.stateDir,
agentSessionId: requireOption(opts, "agentSessionId", "--agent-session-id"),
skillCommitId: opts.skillCommitId || null,
skillVersion: opts.skillVersion || null
}))
};
}
throw new Error(`unknown command ${JSON.stringify(name)}`);
}
function parseOptions(argv) {
const opts = {
stateDir: process.env.HWLAB_AGENT_RUNTIME_STATE_DIR || "",
agentSessionId: process.env.HWLAB_AGENT_SESSION_ID || "",
skillCommitId: process.env.HWLAB_SKILL_COMMIT_ID || "",
skillVersion: process.env.HWLAB_SKILL_VERSION || ""
};
for (let i = 0; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === "--agent-session-id") opts.agentSessionId = argv[++i] || "";
else if (arg === "--skill-commit-id") opts.skillCommitId = argv[++i] || "";
else if (arg === "--skill-version") opts.skillVersion = argv[++i] || "";
else if (arg === "--state-dir") opts.stateDir = argv[++i] || "";
else if (arg === "--help" || arg === "-h") opts.help = true;
else throw new Error(`unknown option ${JSON.stringify(arg)}`);
}
if (opts.stateDir) {
opts.stateDir = resolveAgentRuntimeStateDir(opts.stateDir);
}
return opts;
}
function requireOption(opts, key, flag) {
if (!opts[key]) {
throw new Error(`${flag} is required`);
}
return opts[key];
}
function help() {
return {
serviceId: AGENT_WORKER_SERVICE_ID,
ok: true,
usage: [
"bun run cmd/hwlab-agent-worker/main.ts health [--skill-commit-id COMMIT --skill-version VERSION]",
"bun run cmd/hwlab-agent-worker/main.ts dry-run --agent-session-id ID [--skill-commit-id COMMIT --skill-version VERSION] [--state-dir DIR]"
],
contract: {
output: "json",
runtimeMode: "local-dry-run",
prodDisabled: true,
behavior: "claim queued local session, write trace and evidence, cleanup workspace, exit"
}
};
}
function printJSON(value, { stderr = false } = {}) {
const target = stderr ? process.stderr : process.stdout;
target.write(`${JSON.stringify(value, null, 2)}\n`);
}
-94
View File
@@ -136,76 +136,6 @@
},
"reusedFrom": "fdd27830a0cef173fc07a2cab8cd469d67aaff9da12bb0195f188e323854f93a"
},
{
"serviceId": "hwlab-agent-mgr",
"commitId": "278bbe1",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:278bbe1",
"imageTag": "278bbe1",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"sourceState": "source-present",
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": "publish_not_run",
"buildCreatedAt": null,
"buildSource": null,
"componentCommitId": "a56ea7e33ef67040cc9a8748c3759b4f9c87ca46",
"componentInputHash": "ce4aa5c0b74c873d3270a71d9481a8255571b574f024bd5980f18547b4361860",
"dockerfileHash": "8b2595a09276479b8809e70e99516c1539a6a7cc200e73266d247176b56be962",
"baseImageReference": "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim",
"baseImageDigest": null,
"buildArgsHash": "804a2e2f48cae4f739c3e88bb0d961359fca0a8e1b8cac43517fb065989df72b",
"ciAffected": false,
"ciReason": [
"component-inputs-unchanged"
],
"reuse": {
"status": "ready",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:af46386",
"digest": "sha256:5a55d6199b5bf2db4afa55a0be8cc6eae38f2dcac8a7b374ebbec2af2f36d71c",
"reusedFrom": "ce4aa5c0b74c873d3270a71d9481a8255571b574f024bd5980f18547b4361860"
},
"reusedFrom": "ce4aa5c0b74c873d3270a71d9481a8255571b574f024bd5980f18547b4361860"
},
{
"serviceId": "hwlab-agent-worker",
"commitId": "278bbe1",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:278bbe1",
"imageTag": "278bbe1",
"digest": "not_published",
"publishState": "skeleton-only",
"profile": "dev",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"sourceState": "source-present",
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"notPublishedReason": "publish_not_run",
"buildCreatedAt": null,
"buildSource": null,
"componentCommitId": "a56ea7e33ef67040cc9a8748c3759b4f9c87ca46",
"componentInputHash": "7b426c226ec6f6d4de516f58055f3bf26d911477a88ebeef320066b1903ef40f",
"dockerfileHash": "8b2595a09276479b8809e70e99516c1539a6a7cc200e73266d247176b56be962",
"baseImageReference": "127.0.0.1:5000/hwlab/hwlab-node20-base:20-bookworm-slim",
"baseImageDigest": null,
"buildArgsHash": "d5ea384583ca6916b9fdf4469a8c591e33e6e1a0ba9a59b9901fbaa797824cab",
"ciAffected": false,
"ciReason": [
"component-inputs-unchanged"
],
"reuse": {
"status": "ready",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:af46386",
"digest": "sha256:03f09333657bb37ad059004aabb4484503f79866ba19272d5af9bf611bbb9c5d",
"reusedFrom": "7b426c226ec6f6d4de516f58055f3bf26d911477a88ebeef320066b1903ef40f"
},
"reusedFrom": "7b426c226ec6f6d4de516f58055f3bf26d911477a88ebeef320066b1903ef40f"
},
{
"serviceId": "hwlab-device-pod",
"commitId": "278bbe1",
@@ -350,8 +280,6 @@
"requiredServiceIds": [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
@@ -381,28 +309,6 @@
"entrypoint": "web/hwlab-cloud-web/index.html",
"disabledReason": null
},
{
"serviceId": "hwlab-agent-mgr",
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"runtimeKind": "node-command",
"implementationState": "repo-entrypoint",
"sourceState": "source-present",
"entrypoint": "cmd/hwlab-agent-mgr/main.ts",
"disabledReason": null
},
{
"serviceId": "hwlab-agent-worker",
"publishEnabled": true,
"artifactRequired": true,
"artifactScope": "required",
"runtimeKind": "node-command",
"implementationState": "repo-entrypoint",
"sourceState": "source-present",
"entrypoint": "cmd/hwlab-agent-worker/main.ts",
"disabledReason": null
},
{
"serviceId": "hwlab-device-pod",
"publishEnabled": true,
-31
View File
@@ -82,13 +82,6 @@
"port": 7430,
"targetPort": "http"
},
{
"serviceId": "hwlab-agent-mgr",
"name": "hwlab-agent-mgr",
"namespace": "hwlab-dev",
"port": 7410,
"targetPort": "http"
},
{
"serviceId": "hwlab-device-pod",
"name": "hwlab-device-pod",
@@ -138,12 +131,10 @@
"imageTagMode": "full",
"envReuseServices": [
"hwlab-cloud-web",
"hwlab-agent-worker",
"hwlab-device-pod"
],
"bootScripts": {
"hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh",
"hwlab-agent-worker": "deploy/runtime/boot/hwlab-agent-worker.sh",
"hwlab-device-pod": "deploy/runtime/boot/hwlab-device-pod.sh"
},
"services": [
@@ -236,28 +227,6 @@
"HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS": "1260000"
}
},
{
"serviceId": "hwlab-agent-mgr",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
"replicas": 1,
"env": {
"HWLAB_AGENT_SESSION_MODE": "control-plane",
"HWLAB_ENVIRONMENT": "dev"
}
},
{
"serviceId": "hwlab-agent-worker",
"namespace": "hwlab-dev",
"healthPath": "/health/live",
"profile": "dev",
"replicas": 0,
"env": {
"HWLAB_AGENT_SESSION_MODE": "session-scoped",
"HWLAB_ENVIRONMENT": "dev"
}
},
{
"serviceId": "hwlab-device-pod",
"namespace": "hwlab-dev",
-2
View File
@@ -67,8 +67,6 @@
"enum": [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
-25
View File
@@ -103,31 +103,6 @@
]
}
},
{
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": "hwlab-agent-mgr",
"namespace": "hwlab-dev",
"labels": {
"app.kubernetes.io/name": "hwlab-agent-mgr",
"hwlab.pikastech.local/service-id": "hwlab-agent-mgr"
}
},
"spec": {
"type": "ClusterIP",
"selector": {
"app.kubernetes.io/name": "hwlab-agent-mgr"
},
"ports": [
{
"name": "http",
"port": 7410,
"targetPort": "http"
}
]
}
},
{
"apiVersion": "v1",
"kind": "Service",
-98
View File
@@ -498,104 +498,6 @@
}
}
},
{
"apiVersion": "apps/v1",
"kind": "Deployment",
"metadata": {
"name": "hwlab-agent-mgr",
"namespace": "hwlab-dev",
"labels": {
"app.kubernetes.io/name": "hwlab-agent-mgr",
"hwlab.pikastech.local/service-id": "hwlab-agent-mgr"
}
},
"spec": {
"replicas": 1,
"selector": {
"matchLabels": {
"app.kubernetes.io/name": "hwlab-agent-mgr"
}
},
"template": {
"metadata": {
"labels": {
"app.kubernetes.io/name": "hwlab-agent-mgr",
"hwlab.pikastech.local/service-id": "hwlab-agent-mgr"
}
},
"spec": {
"containers": [
{
"name": "hwlab-agent-mgr",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-mgr:af46386",
"ports": [
{
"name": "http",
"containerPort": 7410
}
],
"env": [
{
"name": "HWLAB_AGENT_SESSION_MODE",
"value": "control-plane"
}
],
"readinessProbe": {
"httpGet": {
"path": "/health/live",
"port": "http"
}
},
"livenessProbe": {
"httpGet": {
"path": "/health/live",
"port": "http"
}
}
}
]
}
}
}
},
{
"apiVersion": "batch/v1",
"kind": "Job",
"metadata": {
"name": "hwlab-agent-worker-template",
"namespace": "hwlab-dev",
"labels": {
"app.kubernetes.io/name": "hwlab-agent-worker",
"hwlab.pikastech.local/service-id": "hwlab-agent-worker"
}
},
"spec": {
"suspend": true,
"template": {
"metadata": {
"labels": {
"app.kubernetes.io/name": "hwlab-agent-worker",
"hwlab.pikastech.local/service-id": "hwlab-agent-worker"
}
},
"spec": {
"restartPolicy": "Never",
"containers": [
{
"name": "hwlab-agent-worker",
"image": "127.0.0.1:5000/hwlab/hwlab-agent-worker:af46386",
"env": [
{
"name": "HWLAB_AGENT_SESSION_MODE",
"value": "session-scoped"
}
]
}
]
}
}
}
},
{
"apiVersion": "apps/v1",
"kind": "Deployment",
+1 -1
View File
@@ -14,7 +14,7 @@
"cloud-api": "GET /health/live via hwlab-edge-proxy then hwlab-cloud-api:6667",
"cloud-api-db": "DEV DB config gate requires HWLAB_CLOUD_DB_URL from Secret hwlab-cloud-api-dev-db/database-url and HWLAB_CLOUD_DB_SSL_MODE=disable. Runtime readiness dials the redacted host parsed from the Secret URL and reports endpointSource=secret-url-host. cloud-api-db is an optional desired alias only until this repo owns Service plus Endpoint or EndpointSlice manifests and rollout/apply contract; health reports env presence, env injection, redacted connection attempt/result, and liveConnected without exposing secret values",
"cloud-web": "GET /health/live on hwlab-cloud-web:8080; consumes cloud-api only",
"agent": "hwlab-agent-mgr readiness gates worker template creation; hwlab-agent-worker is suspended until a session is scheduled",
"agent": "hwlab-cloud-api authorizes Code Agent sessions and dispatches execution to AgentRun v0.1; no HWLAB-owned agent manager/worker service is deployed",
"edge-proxy": "hwlab-edge-proxy:6667 exposes /health/live and proxies DEV endpoint traffic to hwlab-cloud-api",
"runtime-substitute-policy": "Do not replace HWLAB runtime with UniDesk backend, provider-gateway, or microservice proxy",
"device-pod": "hwlab-device-pod:7601 exposes /health/live and serves Device Pod profile/job state through cloud-api authority"
+2 -2
View File
@@ -12,8 +12,8 @@ Health ownership:
- `hwlab-edge-proxy`: accepts public DEV traffic and exposes `/health/live`.
- `hwlab-cloud-api`: receives proxied API traffic and exposes `/health/live`.
- `hwlab-cloud-web`: reads cloud APIs only; it does not control hardware.
- `hwlab-agent-mgr` and `hwlab-agent-worker`: own agent session and worker
session health boundaries.
- Code Agent sessions are authorized by `hwlab-cloud-api` and dispatched to
AgentRun v0.1; HWLAB no longer owns an agent manager/worker service pair.
- `hwlab-device-pod` and `hwlab-gateway`: own the current hardware-facing
runtime boundaries; removed simulator/router/tunnel services are not part of
the default DEV/v0.2 runtime contract.
-10
View File
@@ -24,16 +24,6 @@
"serviceId": "hwlab-cloud-web",
"owner": "web frontend boundary",
"health": "GET /health/live"
},
{
"serviceId": "hwlab-agent-mgr",
"owner": "agent session scheduling",
"health": "GET /health/live"
},
{
"serviceId": "hwlab-agent-worker",
"owner": "session-scoped worker execution",
"health": "job exit status or future GET /health/live sidecar"
}
],
"forbiddenRuntimeSubstitutes": [
-29
View File
@@ -1,29 +0,0 @@
#!/bin/sh
set -eu
export HWLAB_SERVICE_ID="hwlab-agent-worker"
export HWLAB_RUNTIME_MODE="${HWLAB_RUNTIME_MODE:-env-reuse-git-mirror-checkout}"
export HWLAB_COMMIT_ID="${HWLAB_BOOT_COMMIT:?HWLAB_BOOT_COMMIT is required}"
export HWLAB_REVISION="${HWLAB_BOOT_COMMIT}"
export HWLAB_IMAGE="${HWLAB_ENVIRONMENT_IMAGE:-${HWLAB_IMAGE:-}}"
export HWLAB_IMAGE_DIGEST="${HWLAB_ENVIRONMENT_DIGEST:-${HWLAB_IMAGE_DIGEST:-unknown}}"
bun_bin="${HWLAB_BUN_COMMAND:-}"
if [ -z "$bun_bin" ]; then
if command -v bun >/dev/null 2>&1; then
bun_bin="$(command -v bun)"
elif [ -x /usr/local/bin/bun ]; then
bun_bin="/usr/local/bin/bun"
elif [ -x ./node_modules/.bin/bun ]; then
bun_bin="./node_modules/.bin/bun"
else
echo "hwlab-agent-worker boot failed: bun not found" >&2
exit 127
fi
fi
if [ "$#" -eq 0 ]; then
set -- health
fi
exec "$bun_bin" cmd/hwlab-agent-worker/main.ts "$@"
+1 -1
View File
@@ -95,7 +95,7 @@ G14 CI/CD 加速的第一判定标准不是 PipelineRun 总耗时单点变短,
- 并发 fan-outper-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。
- CD rolloutunchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digestpod template 不应因全局 source commit 改变而无意义滚动。手写 manifest(例如 `deepseek-proxy`、device-agent 类辅助 workload)只要复用某个已发布服务镜像,也必须走同一个 catalog image 选择逻辑,不能直接用当前 source commit tag。
- 运行态验证:GitOps promote 成功只说明 `G14-gitops` 分支更新;最终通过必须看 Argo Application revision、sync 状态、目标 namespace Deployment/StatefulSet ready、公网 health。Argo 还停在旧 revision 时,优先做 `argocd.argoproj.io/refresh=hard` 刷新;不要把已经推送的 `G14-gitops` 分支误判成运行面已滚动。
- Argo health 判定:`spec.suspend=true` 的模板型 Job 会让 Application 总 health 显示 `Suspended``hwlab-agent-worker-template` 这类只作为手动启动模板的 Job 必须带 `argocd.argoproj.io/ignore-healthcheck: "true"`,避免 DEV 实际 workload 全部 ready 时仍被误判为 Suspended`hwlab-cli` 不创建镜像、Service 或 Job template,只在固定 repo 内短连接执行。真正发布验收仍以长驻 workload ready、公网 health 和失败 Pod 清单为准。
- Argo health 判定:v0.2 不再生成 HWLAB 自有 agent worker Job templateAgentRun v0.1 作为外部共享执行基础设施接入。`hwlab-cli` 不创建镜像、Service 或 Job template,只在固定 repo 内短连接执行。真正发布验收仍以长驻 workload ready、公网 health 和失败 Pod 清单为准。
当前仍然慢的主要位置:
+2 -2
View File
@@ -10,7 +10,7 @@
## 在系统中的职责划分
用户和权限管理不是独立微服务,权威实现收敛在 `hwlab-cloud-api`:它消费 [spec-v02-auth.md](spec-v02-auth.md) 产出的 `AuthPrincipal`,负责角色、device pod grant、用户 API key 和 code agent session owner 校验。`hwlab-cloud-web` 只提供浏览器 UI 和同源代理;`hwlab-device-pod` 只执行设备语义;`hwlab-agent-mgr``hwlab-agent-worker` 和 Code Agent runtime消费已经由 cloud-api 判断过的 actor/session/device 权限。
用户和权限管理不是独立微服务,权威实现收敛在 `hwlab-cloud-api`:它消费 [spec-v02-auth.md](spec-v02-auth.md) 产出的 `AuthPrincipal`,负责角色、device pod grant、用户 API key 和 code agent session owner 校验。`hwlab-cloud-web` 只提供浏览器 UI 和同源代理;`hwlab-device-pod` 只执行设备语义;AgentRun v0.1 只消费 cloud-api 传入的 actor/session/device 权限上下文,不成为 HWLAB 用户权限 authority
Postgres 是该规格的数据持久化边界。Kubernetes namespace、ServiceAccount、Service 直连和 gateway route 都不能替代用户权限模型;普通用户不获得 kubeconfig、内部 Service 直连能力或长期 Secret。
@@ -292,7 +292,7 @@ v0.2 不新增独立用户管理微服务。Keycloak 是独立身份提供方,
| --- | --- |
| `hwlab-cloud-web` | Keycloak 登录入口、普通用户工作台、API key 管理入口和 admin 用户/授权 UI;浏览器 `/auth/*` 可由 cloud-web 代理到 cloud-api。 |
| `hwlab-cloud-api` | 用户映射、Web session/API key 消费、授权、device grant、code agent owner 校验和对 device pod 的受控转发。 |
| `hwlab-agent-mgr` / `hwlab-agent-worker` | 执行 code agent session;接收 owner/session label 或 env 方便观测,但不作为最终权限 authority。 |
| AgentRun v0.1 runner | 执行 code agent session;接收 cloud-api 提供的 owner/session/device 上下文方便观测,但不作为最终权限 authority。 |
| `hwlab-device-pod` | 暴露设备语义 API;不保存用户权限,不直接面向浏览器或普通用户 session Pod。 |
| `hwlab-edge-proxy` | 公网/FRP 入口和 HTTP 转发;不做业务权限,只转发 cookie/header,不注入伪 actor。 |
| Postgres | v0.2 用户、session、授权、device pod 和既有 runtime durable state。 |
+2 -2
View File
@@ -122,7 +122,7 @@ devops-infra git mirror 仍是 PipelineRun 和 Argo CD 的集群内读写源。`
7. affected service 通过 BuildKit 发布到 G14 本地 registryreused service 复用 catalog digest。
所有 selected service 的 build TaskRun 都只依赖 `plan-artifacts`,不按 service 串行排队,也不设置 8 并发或其他 Pipeline 级限流。
实际并发由 Tekton controller、G14 节点资源、PVC I/O、BuildKit sidecar 和本地 registry 承载能力决定。
`hwlab-cloud-web``hwlab-agent-worker``hwlab-device-pod` 必须使用 `env-reuse-git-mirror-checkout`
`hwlab-cloud-web``hwlab-device-pod` 必须使用 `env-reuse-git-mirror-checkout`
只有 package/runtime/env 输入变化时才构建 `<service>-env` 镜像。
纯前端源码、Device Pod 源码或 boot code 变化只更新三变量和 GitOps desired state
不再重新构建业务镜像。
@@ -218,7 +218,7 @@ Git 读写都走 `devops-infra` 本地 mirror/relay。读路径用 HTTP mirror c
任何仍需要访问 GitHub canonical remote 的 SSH 操作都必须显式走 G14 proxy。Host SSH config 可以使用 OpenBSD `nc -X connect -x 127.0.0.1:10808 %h %p`Tekton `GIT_SSH_COMMAND`、git-mirror `sync`/`flush` 和其他容器内 `git@github.com``ssh://git@ssh.github.com:443` 路径必须使用 repo-owned Node HTTP CONNECT ProxyCommand,并保留短 `ConnectTimeout``ServerAlive*`。只改 `HTTP_PROXY`/`HTTPS_PROXY` 对 OpenSSH 无效;BusyBox `nc` 不支持 `-X connect`,不得再把 `ssh.github.com:443` 当作等价 proxy 或让 GitHub SSH 直连反复 60s 超时。
env image 复用把系统依赖和业务代码身份分离。`environmentDigest` 表示可复用运行环境,`HWLAB_BOOT_REPO``HWLAB_BOOT_COMMIT``HWLAB_BOOT_SH` 表示本次代码启动身份。code-only 变更只更新 boot metadata 和 runtime identity,不发布新 env image;只有真实 env 输入变化或缺少可复用 env digest 时才进入 env rebuild。`hwlab-agent-worker` 是 suspended Job template,也必须复用 env imageworker 代码变化只能更新 boot commit 和 Job template identity,不能重新构建 `hwlab-agent-worker` service image`package.json` 只按 dependency/runtime 字段参与 env/runtime hash`scripts` 只属于开发入口,不得因为清理旧门禁脚本而重建所有 service 或重建 env image。
env image 复用把系统依赖和业务代码身份分离。`environmentDigest` 表示可复用运行环境,`HWLAB_BOOT_REPO``HWLAB_BOOT_COMMIT``HWLAB_BOOT_SH` 表示本次代码启动身份。code-only 变更只更新 boot metadata 和 runtime identity,不发布新 env image;只有真实 env 输入变化或缺少可复用 env digest 时才进入 env rebuild。AgentRun v0.1 是 HWLAB 外部共享执行基础设施,`hwlab-agent-worker` 不再作为 v0.2 service image 或 suspended Job template 参与 planner、BuildKit、artifact catalog 或 GitOps render`package.json` 只按 dependency/runtime 字段参与 env/runtime hash`scripts` 只属于开发入口,不得因为清理旧门禁脚本而重建所有 service 或重建 env image。
BuildKit publish 采用 service 级全并行 fan-out。
每个 service build task 拥有独立 BuildKit sidecar、独立 service workdir 和独立 report 文件,均从同一 `plan-artifacts` 结果判断是否需要执行。
@@ -1,47 +0,0 @@
# v0.2 hwlab-agent-mgr 服务规格
`hwlab-agent-mgr` 是 agent runtime 管理服务,运行在 `hwlab-v02` namespace,内部端口 `7410`。当前实现是本地状态目录驱动的 runtime skeleton,不是 `hwlab-cloud-api` Code Agent 对话的主执行路径。
## 在系统中的职责划分
- 管理 agent session 生命周期模型:create、start、trace、finish、cleanup。
- 生成 workspace volume、worker session、trace event 和 evidence record 的结构化状态。
- 与 `hwlab-agent-worker``hwlab-agent-skills` 共同定义未来 session-scoped worker 执行边界。
- 不直接暴露给普通用户;用户态 Code Agent 请求必须先进入 `hwlab-cloud-api`
## 内部架构
- `cmd/hwlab-agent-mgr/main.ts` 同时提供 CLI 和健康 HTTP server。
- `internal/agent/runtime.mjs` 负责文件系统状态、session lifecycle、trace/evidence 和 cleanup。
- `internal/agent/index.mjs` 定义 serviceId、session status 和 skills manifest 结构。
- `HWLAB_AGENT_RUNTIME_STATE_DIR` 可隔离状态目录;默认 `.state/agent-runtime` 只用于本地/测试状态。
## API 接口说明
| 接口 | 说明 |
| --- | --- |
| `GET /health``GET /health/live` | HTTP server 模式下返回 manager health、skills 注入状态和 build metadata。 |
| `GET /``GET /help` | 返回 CLI/服务用法。 |
| `bun run cmd/hwlab-agent-mgr/main.ts health` | 本地 JSON health。 |
| `create/status/trace/evidence/cleanup` | 本地 session lifecycle CLI;必须传 `--agent-session-id`create 还需 `--project-id`。 |
## 测试规格
## T1
阅读 docs/reference/spec-v02-hwlab-agent-mgr.md,然后用 cli 手动测试以下内容:访问 v02 ClusterIP 或 pod 内 `/health/live`,确认 `serviceId=hwlab-agent-mgr`,并记录 skills missing/ready 状态。
## T2
阅读 docs/reference/spec-v02-hwlab-agent-mgr.md,然后用 cli 手动测试以下内容:在隔离 `--state-dir` 下执行 create、status、trace、evidence、cleanup,确认每步输出 JSON 且生命周期顺序稳定。
## 规格的实现情况
| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| HTTP health server | 已实现 | `server` 模式提供 `/health/live`。 |
| 本地 session lifecycle CLI | 已实现 | create/status/trace/evidence/cleanup 可用。 |
| skills commit/version readiness | 已实现 | health 会报告缺失字段;不静默 fallback。 |
| 调度真实 worker Job | 未完全实现 | 当前是 skeleton,本身不负责创建 Kubernetes worker Job。 |
| 接入 cloud-api 用户会话 | 未完全实现 | 当前 Code Agent 对话主要由 cloud-api 内部 Codex stdio manager 承担。 |
@@ -7,7 +7,7 @@ Code Agent 第一版 skill 来源分为两类:预装 skill 读取镜像内只
## 在系统中的职责划分
- 证明当前 lane 中 skills bundle 随 source commit 发布,并为 live-build inventory 和 M4/M5 agent-loop 验收提供 health metadata。
- 与 `hwlab-agent-mgr``hwlab-agent-worker` skills commit/version readiness 对齐。
- 与 `hwlab-cloud-api` 注入给 AgentRun v0.1 的 `skillRefs` skills commit/version readiness 对齐。
- 预装 skill 只由镜像内 `/app/skills` 提供,不保存用户数据,不保存 Secret,不执行用户代码。
- 用户上传 skill 由 `hwlab-cloud-api` 管理,普通文件直接落到 PVC `/data/user-skills`,本服务不承载上传、预览或用户数据持久化 API。
@@ -1,46 +0,0 @@
# v0.2 hwlab-agent-worker 服务规格
`hwlab-agent-worker` 是 session-scoped 执行模板,在 `hwlab-v02` 中以 suspended Job template `hwlab-agent-worker-template` 存在。它不是长驻 HTTP 服务。
## 在系统中的职责划分
- 在未来由 manager 或调度器实例化为单次 worker Job,执行一个 agent session 的受控工作。
- 负责写入 worker trace、evidence、workspace dry-run 结果,并在完成后退出。
- 不保存长期用户状态,不直接处理浏览器或公网请求。
## 内部架构
- `cmd/hwlab-agent-worker/main.ts` 提供 CLI 命令 `health``dry-run/smoke`
- `internal/agent/runtime.mjs` 提供 `runLocalWorkerDryRun`,读取 manager 创建的本地 session 状态并写入 evidence。
- Job template 通过 env 注入 `HWLAB_AGENT_SESSION_ID``HWLAB_SKILL_COMMIT_ID``HWLAB_SKILL_VERSION`
- v0.2 CI/CD 中 worker 使用 `env-reuse-git-mirror-checkout`:环境镜像只承载 runtime、依赖、launcher 和 git client,实际 worker 代码由 `HWLAB_BOOT_REPO``HWLAB_BOOT_COMMIT``HWLAB_BOOT_SH=deploy/runtime/boot/hwlab-agent-worker.sh``devops-infra` git mirror checkout 后启动。worker code-only 变更不得触发 `hwlab-agent-worker` service image BuildKit 重建,只允许更新 boot commit 和 Job template identity。
## API 接口说明
| 接口 | 说明 |
| --- | --- |
| `bun run cmd/hwlab-agent-worker/main.ts health` | 返回 worker health、runtime mode 和 skills 注入状态。 |
| `bun run cmd/hwlab-agent-worker/main.ts dry-run --agent-session-id ID` | 执行本地 dry-run worker 流程,写入 trace/evidence。 |
| Kubernetes Job template | `hwlab-agent-worker-template` 默认 `suspend: true`,只能由受控调度实例化。 |
## 测试规格
## T1
阅读 docs/reference/spec-v02-hwlab-agent-worker.md,然后用 cli 手动测试以下内容:从 Job template dry-run 或本地 CLI 执行 `health`,确认输出 JSON,且缺少 skill commit/version 时明确 degraded 或 blocked。
## T2
阅读 docs/reference/spec-v02-hwlab-agent-worker.md,然后用 cli 手动测试以下内容:先用 agent-mgr 创建 session,再执行 worker `dry-run`,确认 trace 和 evidence 均写入同一 isolated state-dir。
## 规格的实现情况
| 规格项 | 状态 | 说明 |
| --- | --- | --- |
| CLI health/dry-run | 已实现 | worker 以 JSON 输出。 |
| suspended Job template | 已实现 | runtime-v02 中存在 `hwlab-agent-worker-template`。 |
| v0.2 env-reuse 启动 | 已实现 | `deploy/runtime/boot/hwlab-agent-worker.sh` 通过 git mirror checkout 后执行 worker CLIcode-only 变更复用 env image。 |
| session-scoped evidence 写入 | 已实现 | 本地 dry-run 覆盖 trace/evidence。 |
| 长驻 HTTP API | 不适用 | worker 不是长驻服务。 |
| 生产调度与真实执行 | 未完全实现 | 当前仍是 skeleton/dry-run 模式。 |
+5 -5
View File
@@ -20,6 +20,7 @@
- `hwlab-cloud-api` 是 v0.2 应用层 authority:用户身份、`admin/user`、Code Agent session owner、device pod grant、profile authority 和用户态 REST 都在这里判定。
- `hwlab-cloud-web` 只作为用户入口和 API proxy,不拥有业务 authorityCLI 可以旁路 UI,但不能旁路 `cloud-api` 的授权。
- `hwlab-device-pod` 是正式设备业务承载点;用户态请求必须走 `cloud-api -> hwlab-device-pod -> hwlab-gateway -> device-host-cli -> hardware`
- Code Agent session 归属、鉴权、trace 和用户态 API 收敛在 `hwlab-cloud-api`;执行调度接入 AgentRun v0.1 共享基础设施。`hwlab-agent-mgr``hwlab-agent-worker` 和 repo-owned codex-stdio supervisor 不是 v0.2 runtime service matrix,不再生成 Deployment、Job template、Service、artifact 或 GitOps desired state。
- `hwlab-gateway` 是 transport,不理解用户权限、不保存 profile authority;用户端已经验证稳定,v0.2 第一阶段先不改造它。
- Code Agent provider 通道分为 `codex-api` loopback forwarder 和 `deepseek` bridge/Moon Bridge;自研 bridge/forwarder 属于 HWLAB 常驻服务,Moon Bridge 和 hyueapi/DeepSeek upstream 是稳定外部依赖。
- `hwlab-router``hwlab-tunnel-client``hwlab-gateway-simu``hwlab-box-simu``hwlab-patch-panel` 在 v0.2 裁撤;不再为这些裁撤对象保留单独规格文档。
@@ -82,7 +83,7 @@ origin/v0.2
| Device Pod | `/v1/device-pods*`、正式 job/admin API | [spec-device-pod.md](spec-device-pod.md)、[spec-v02-hwlab-device-pod-service.md](spec-v02-hwlab-device-pod-service.md) |
| Gateway transport | `cloud-api /v1/gateway/poll``/v1/gateway/result`、gateway `/status` | [spec-v02-hwlab-gateway.md](spec-v02-hwlab-gateway.md) |
| Code Agent provider | `deepseek``codex-api` provider profile | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md)、[spec-v02-codex-api-forwarder.md](spec-v02-codex-api-forwarder.md) |
| Agent runtime skeleton | manager HTTP/CLI、worker Job template、skills health | [spec-v02-hwlab-agent-mgr.md](spec-v02-hwlab-agent-mgr.md)、[spec-v02-hwlab-agent-worker.md](spec-v02-hwlab-agent-worker.md)、[spec-v02-hwlab-agent-skills.md](spec-v02-hwlab-agent-skills.md) |
| Code Agent AgentRun 调度 | `hwlab-cloud-api` 会话 owner/auth/trace -> AgentRun v0.1 dispatch | [agentrun-code-agent-dispatch.md](agentrun-code-agent-dispatch.md)、[spec-v02-hwlab-agent-skills.md](spec-v02-hwlab-agent-skills.md) |
| 短连接 CLI | `G14:/root/hwlab-v02` 内直接运行 `hwlab-cli client ...` | [spec-v02-hwlab-cli.md](spec-v02-hwlab-cli.md) |
| Durable runtime store | Postgres TCP `5432` and cloud-api DB readiness | [spec-v02-postgres.md](spec-v02-postgres.md) |
| 公网 FRP | master `frps` + `hwlab-v02-frpc` TCP `19666/19667` | [spec-v02-frpc.md](spec-v02-frpc.md) |
@@ -98,10 +99,9 @@ origin/v0.2
| `hwlab-cloud-web` runtime wrapper | HWLAB 自研常驻 web/proxy wrapper | 保留 | 是,P0 | [spec-v02-hwlab-cloud-web.md](spec-v02-hwlab-cloud-web.md)、[cloud-workbench.md](cloud-workbench.md) |
| `hwlab-edge-proxy` | HWLAB 自研常驻服务 | 保留 | 是,P0 | [spec-v02-hwlab-edge-proxy.md](spec-v02-hwlab-edge-proxy.md) |
| `hwlab-device-pod` | HWLAB 自研常驻服务 | 保留并增强 | 是,P0 | [spec-v02-hwlab-device-pod-service.md](spec-v02-hwlab-device-pod-service.md)、[spec-device-pod.md](spec-device-pod.md) |
| `hwlab-agent-mgr` | HWLAB 自研常驻服务 | 保留,但职责需收敛 | 是,P1 | [spec-v02-hwlab-agent-mgr.md](spec-v02-hwlab-agent-mgr.md) |
| `hwlab-codex-api-responses-forwarder` | HWLAB 自研常驻 sidecar | 保留 | 是,P1 | [spec-v02-codex-api-forwarder.md](spec-v02-codex-api-forwarder.md) |
| `hwlab-deepseek-responses-bridge` / `hwlab-deepseek-proxy` | HWLAB 自研 bridge + Moon Bridge 外部依赖 | 保留 | 是,P1 for bridge | [spec-v02-deepseek-proxy.md](spec-v02-deepseek-proxy.md) |
| `hwlab-agent-worker` | HWLAB 自研 Job/执行入口 | 保留,非第一波 | 建议迁,P2 | [spec-v02-hwlab-agent-worker.md](spec-v02-hwlab-agent-worker.md) |
| AgentRun v0.1 runner | 共享 Agent 执行基础设施 | 作为外部基础设施接入,不进 HWLAB service/artifact matrix | 否 | [agentrun-code-agent-dispatch.md](agentrun-code-agent-dispatch.md) |
| `hwlab-agent-skills` wrapper | HWLAB 自研 bundle/health wrapper | 保留 | 仅常驻 wrapper 需要,P2 | [spec-v02-hwlab-agent-skills.md](spec-v02-hwlab-agent-skills.md) |
| `hwlab-gateway` | HWLAB 自研用户端/硬件 transport | 保留 | 暂不迁 | [spec-v02-hwlab-gateway.md](spec-v02-hwlab-gateway.md)、[gateway-outbound-demo.md](gateway-outbound-demo.md) |
| `hwlab-router` | HWLAB 自研路由占位服务 | 裁撤 | 否 | 本文即裁撤权威,不保留单独 spec |
@@ -140,7 +140,6 @@ hwlab-cloud-api
hwlab-cloud-web runtime wrapper
hwlab-edge-proxy
hwlab-device-pod
hwlab-agent-mgr
hwlab-codex-api-responses-forwarder
hwlab-deepseek-responses-bridge
browser-side Cloud Web JS
@@ -149,7 +148,6 @@ browser-side Cloud Web JS
后续迁移集合:
```text
hwlab-agent-worker
hwlab-agent-skills wrapper
```
@@ -170,6 +168,8 @@ hwlab-tunnel-client
hwlab-gateway-simu
hwlab-box-simu
hwlab-patch-panel
hwlab-agent-mgr
hwlab-agent-worker
```
裁撤集合不再新增单服务 spec。若历史文档仍提到这些服务作为 v0.2 必需依赖,应删除旧口径或交叉引用本文。
-361
View File
@@ -1,361 +0,0 @@
import crypto from "node:crypto";
import path from "node:path";
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
export const AGENT_MGR_SERVICE_ID = "hwlab-agent-mgr";
export const AGENT_WORKER_SERVICE_ID = "hwlab-agent-worker";
export const AGENT_SKILLS_SERVICE_ID = "hwlab-agent-skills";
export const AGENT_SESSION_STATUSES = Object.freeze([
"create",
"start",
"trace",
"finish",
"cleanup"
]);
export const WORKER_SESSION_STATUSES = Object.freeze([
"create",
"start",
"trace",
"finish",
"cleanup"
]);
export function createAgentSession({
agentSessionId,
projectId,
requestedBy = "usr_local",
goal = "",
environment = ENVIRONMENT_DEV,
now = () => new Date().toISOString(),
metadata = {}
}) {
const timestamp = now();
return {
agentSessionId,
projectId,
serviceId: AGENT_MGR_SERVICE_ID,
status: "create",
environment,
requestedBy,
goal,
startedAt: timestamp,
updatedAt: timestamp,
metadata: {
...metadata,
lifecycle: AGENT_SESSION_STATUSES.slice(),
currentState: "create"
}
};
}
export function advanceAgentSession(session, nextStatus, { now = () => new Date().toISOString(), extra = {} } = {}) {
assertAgentStatus(nextStatus);
const timestamp = now();
const completedAt = nextStatus === "finish" ? timestamp : session.completedAt;
return {
...session,
status: nextStatus,
completedAt,
updatedAt: timestamp,
metadata: {
...session.metadata,
...extra,
currentState: nextStatus
}
};
}
export function createWorkspaceVolume({ agentSessionId, projectId, now = () => new Date().toISOString() }) {
return {
workspaceVolumeId: `vol_${agentSessionId}`,
agentSessionId,
projectId,
serviceId: AGENT_WORKER_SERVICE_ID,
mountPath: path.posix.join("/workspace", projectId, agentSessionId),
lifecycle: "create",
createdAt: now(),
updatedAt: now()
};
}
export function createWorkerSession({
workerSessionId,
agentSessionId,
projectId,
gatewaySessionId = null,
now = () => new Date().toISOString(),
metadata = {}
}) {
const timestamp = now();
return {
workerSessionId,
agentSessionId,
projectId,
serviceId: AGENT_WORKER_SERVICE_ID,
gatewaySessionId,
status: "create",
environment: ENVIRONMENT_DEV,
startedAt: timestamp,
updatedAt: timestamp,
metadata: {
...metadata,
lifecycle: WORKER_SESSION_STATUSES.slice(),
workspaceVolumeId: `vol_${agentSessionId}`,
currentState: "create"
}
};
}
export function advanceWorkerSession(session, nextStatus, { now = () => new Date().toISOString(), extra = {} } = {}) {
assertWorkerStatus(nextStatus);
const timestamp = now();
const completedAt = nextStatus === "finish" ? timestamp : session.completedAt;
return {
...session,
status: nextStatus,
completedAt,
updatedAt: timestamp,
metadata: {
...session.metadata,
...extra,
currentState: nextStatus
}
};
}
export function createTraceEvent({
traceEventId,
traceId,
projectId,
serviceId = AGENT_MGR_SERVICE_ID,
level = "info",
message,
environment = ENVIRONMENT_DEV,
now = () => new Date().toISOString(),
agentSessionId = null,
workerSessionId = null,
operationId = null,
metadata = {}
}) {
return {
traceEventId,
traceId,
projectId,
serviceId,
level,
message,
environment,
occurredAt: now(),
agentSessionId,
workerSessionId,
operationId,
metadata: {
...metadata
}
};
}
export function createEvidenceRecord({
evidenceId,
projectId,
operationId,
uri,
sha256 = digestHex(uri),
serviceId = AGENT_WORKER_SERVICE_ID,
environment = ENVIRONMENT_DEV,
kind = "artifact",
now = () => new Date().toISOString(),
agentSessionId = null,
workerSessionId = null,
mimeType = "text/plain",
sizeBytes = 0,
metadata = {}
}) {
return {
evidenceId,
projectId,
operationId,
agentSessionId,
workerSessionId,
kind,
uri,
mimeType,
sha256,
sizeBytes,
serviceId,
environment,
metadata: {
...metadata
},
createdAt: now()
};
}
export function createAgentRunPlan({
agentSessionId,
projectId,
goal = "",
prompt = "",
skillCommitId = null,
skillVersion = null,
now = () => new Date().toISOString()
}) {
const traceId = `trc_${agentSessionId}`;
const workerSessionId = `wkr_${agentSessionId}`;
const operationId = `op_${agentSessionId}`;
const evidenceId = `evi_${agentSessionId}`;
const traceEventId = `tev_${agentSessionId}`;
const workspaceVolume = createWorkspaceVolume({ agentSessionId, projectId, now });
const agentSession = createAgentSession({
agentSessionId,
projectId,
goal,
now
});
const workerSession = createWorkerSession({
workerSessionId,
agentSessionId,
projectId,
now
});
const traceEvent = createTraceEvent({
traceEventId,
traceId,
projectId,
message: prompt || goal || "agent runtime dry run",
agentSessionId,
workerSessionId,
operationId,
now
});
const evidenceRecord = createEvidenceRecord({
evidenceId,
projectId,
operationId,
uri: `file://${path.posix.join(workspaceVolume.mountPath, "evidence.txt")}`,
agentSessionId,
workerSessionId,
metadata: {
skillCommitId,
skillVersion,
prompt,
goal,
workspaceVolumeId: workspaceVolume.workspaceVolumeId
},
now
});
return {
agentSession: advanceAgentSession(agentSession, "start", {
now,
extra: {
workerSessionId,
workspaceVolumeId: workspaceVolume.workspaceVolumeId
}
}),
workerSession: advanceWorkerSession(workerSession, "start", {
now,
extra: {
workspaceVolumeId: workspaceVolume.workspaceVolumeId
}
}),
workspaceVolume,
events: [
{ state: "create", actor: AGENT_MGR_SERVICE_ID, agentSessionId },
{ state: "start", actor: AGENT_WORKER_SERVICE_ID, workerSessionId },
{ state: "trace", actor: AGENT_MGR_SERVICE_ID, traceEvent },
{ state: "finish", actor: AGENT_WORKER_SERVICE_ID, evidenceRecord },
{ state: "cleanup", actor: AGENT_WORKER_SERVICE_ID, workspaceVolumeId: workspaceVolume.workspaceVolumeId }
],
traceEvent,
evidenceRecord,
skillCommitId,
skillVersion
};
}
export function finalizeAgentRun(plan, { now = () => new Date().toISOString() } = {}) {
const finishedAgentSession = advanceAgentSession(plan.agentSession, "finish", { now });
const finishedWorkerSession = advanceWorkerSession(plan.workerSession, "finish", { now });
return {
...plan,
agentSession: advanceAgentSession(finishedAgentSession, "cleanup", {
now,
extra: { cleanedUp: true }
}),
workerSession: advanceWorkerSession(finishedWorkerSession, "cleanup", {
now,
extra: { cleanedUp: true }
}),
cleanup: {
workspaceVolumeId: plan.workspaceVolume.workspaceVolumeId,
removed: true
}
};
}
export function createSkillsManifest({
commitId,
version,
skillName = "hwlab-agent-runtime",
serviceId = AGENT_SKILLS_SERVICE_ID,
environment = ENVIRONMENT_DEV,
skills = []
}) {
if (!commitId) {
throw new Error("skills manifest requires explicit commitId");
}
if (!version) {
throw new Error("skills manifest requires explicit version");
}
return {
manifestVersion: "v1",
environment,
commitId,
version,
namespace: "hwlab",
endpoint: "http://74.48.78.17:16667",
profiles: {
dev: {
name: "dev",
enabled: true,
namespace: "hwlab-dev",
endpoint: "http://74.48.78.17:16667",
notes: "explicit repo-local skills artifact"
}
},
services: [
{
serviceId,
image: `${skillName}:${commitId.slice(0, 7)}`,
namespace: "hwlab-dev",
healthPath: "/health",
profile: "dev",
env: {
HWLAB_SKILLS_COMMIT_ID: commitId,
HWLAB_SKILLS_VERSION: version
}
}
],
skills
};
}
function assertAgentStatus(status) {
if (!AGENT_SESSION_STATUSES.includes(status)) {
throw new Error(`unknown agent session status ${JSON.stringify(status)}`);
}
}
function assertWorkerStatus(status) {
if (!WORKER_SESSION_STATUSES.includes(status)) {
throw new Error(`unknown worker session status ${JSON.stringify(status)}`);
}
}
function digestHex(value) {
return crypto.createHash("sha256").update(String(value)).digest("hex");
}
-178
View File
@@ -1,178 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import test from "node:test";
import {
AGENT_SESSION_STATUSES,
WORKER_SESSION_STATUSES,
advanceAgentSession,
advanceWorkerSession,
createAgentRunPlan,
createSkillsManifest,
finalizeAgentRun
} from "./index.mjs";
import {
createLocalAgentSession,
getLocalAgentEvidence,
getLocalAgentStatus,
getLocalAgentTrace,
runLocalWorkerDryRun
} from "./runtime.mjs";
test("agent runtime run plan exposes create/start/trace/finish/cleanup", () => {
const plan = createAgentRunPlan({
agentSessionId: "agt_01J00000000000000000000000",
projectId: "prj_01J00000000000000000000000",
goal: "verify runtime skeleton",
prompt: "dry run"
});
assert.deepEqual(AGENT_SESSION_STATUSES, ["create", "start", "trace", "finish", "cleanup"]);
assert.deepEqual(WORKER_SESSION_STATUSES, ["create", "start", "trace", "finish", "cleanup"]);
assert.equal(plan.agentSession.status, "start");
assert.equal(plan.workerSession.status, "start");
assert.equal(plan.events.map((event) => event.state).join(","), "create,start,trace,finish,cleanup");
assert.equal(plan.traceEvent.message, "dry run");
assert.equal(plan.evidenceRecord.projectId, "prj_01J00000000000000000000000");
assert.equal(plan.evidenceRecord.metadata.workspaceVolumeId, "vol_agt_01J00000000000000000000000");
});
test("finalizeAgentRun marks cleanup and keeps evidence visible", () => {
const plan = createAgentRunPlan({
agentSessionId: "agt_01J00000000000000000000001",
projectId: "prj_01J00000000000000000000001",
goal: "cleanup coverage"
});
const finished = finalizeAgentRun(plan);
assert.equal(finished.agentSession.status, "cleanup");
assert.equal(finished.workerSession.status, "cleanup");
assert.equal(finished.cleanup.removed, true);
assert.ok(finished.evidenceRecord.sha256);
});
test("skills manifest requires explicit commitId", () => {
assert.throws(() => createSkillsManifest({}), /explicit commitId/);
assert.throws(() => createSkillsManifest({ commitId: "6509a35" }), /explicit version/);
const manifest = createSkillsManifest({ commitId: "6509a35", version: "v1" });
assert.equal(manifest.commitId, "6509a35");
assert.equal(manifest.version, "v1");
assert.equal(manifest.services[0].serviceId, "hwlab-agent-skills");
assert.equal(manifest.services[0].env.HWLAB_SKILLS_VERSION, "v1");
});
test("advance helpers preserve completedAt on non-finish states", () => {
const session = {
status: "start",
completedAt: undefined,
metadata: {}
};
const agent = advanceAgentSession(session, "trace");
const worker = advanceWorkerSession(session, "trace");
assert.equal(agent.status, "trace");
assert.equal(worker.status, "trace");
assert.equal(agent.completedAt, undefined);
assert.equal(worker.completedAt, undefined);
});
test("local runtime degrades health and evidence when skills fields are missing", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-runtime-missing-"));
try {
const created = await createLocalAgentSession({
stateDir,
agentSessionId: "agt_runtime_missing_skills",
projectId: "prj_runtime_missing_skills",
goal: "verify degraded skills evidence",
prompt: "missing skills smoke"
});
assert.equal(created.health.status, "degraded");
assert.deepEqual(created.health.skills.manager.missing, ["skillCommitId", "skillVersion"]);
const worker = await runLocalWorkerDryRun({
stateDir,
agentSessionId: "agt_runtime_missing_skills"
});
assert.equal(worker.health.status, "degraded");
assert.equal(worker.evidenceRecord.metadata.health.status, "degraded");
assert.deepEqual(worker.evidenceRecord.metadata.health.skills.worker.missing, [
"skillCommitId",
"skillVersion"
]);
assert.equal(worker.cleanup.removed, true);
assert.equal(worker.workspace.existsAfterCleanup, false);
const evidence = await getLocalAgentEvidence({
stateDir,
agentSessionId: "agt_runtime_missing_skills"
});
assert.equal(evidence.status, "degraded");
assert.equal(evidence.evidenceRecords.length, 1);
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
test("local runtime closes create/status/trace/evidence/cleanup loop", async () => {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-agent-runtime-loop-"));
try {
const created = await createLocalAgentSession({
stateDir,
agentSessionId: "agt_runtime_loop",
projectId: "prj_runtime_loop",
goal: "verify local worker bootstrap",
prompt: "closed loop smoke",
skillCommitId: "abc1234",
skillVersion: "v1"
});
assert.equal(created.health.status, "ok");
assert.equal(created.task.status, "queued");
assert.equal(created.traceCount, 1);
const worker = await runLocalWorkerDryRun({
stateDir,
agentSessionId: "agt_runtime_loop",
skillCommitId: "abc1234",
skillVersion: "v1"
});
assert.equal(worker.health.status, "ok");
assert.equal(worker.agentSession.status, "cleanup");
assert.equal(worker.workerSession.status, "cleanup");
assert.equal(worker.task.status, "completed");
assert.equal(worker.evidenceRecord.metadata.skills.worker.commitId, "abc1234");
assert.equal(worker.evidenceRecord.metadata.skills.worker.version, "v1");
assert.equal(worker.workspace.cleaned, true);
assert.equal(worker.workspace.existsAfterCleanup, false);
const status = await getLocalAgentStatus({
stateDir,
agentSessionId: "agt_runtime_loop"
});
assert.equal(status.health.status, "ok");
assert.equal(status.evidenceCount, 1);
assert.equal(status.cleanup.removed, true);
const trace = await getLocalAgentTrace({
stateDir,
agentSessionId: "agt_runtime_loop"
});
assert.deepEqual(
trace.traceEvents.map((event) => event.metadata.state),
["create", "start", "trace", "finish", "cleanup"]
);
const evidence = await getLocalAgentEvidence({
stateDir,
agentSessionId: "agt_runtime_loop"
});
assert.equal(evidence.status, "ok");
assert.equal(evidence.evidenceRecords[0].kind, "report");
assert.equal(evidence.evidenceRecords[0].sha256.length, 64);
} finally {
await rm(stateDir, { recursive: true, force: true });
}
});
-657
View File
@@ -1,657 +0,0 @@
import crypto from "node:crypto";
import { constants as fsConstants } from "node:fs";
import { access, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import {
AGENT_MGR_SERVICE_ID,
AGENT_WORKER_SERVICE_ID,
advanceAgentSession,
advanceWorkerSession,
createAgentSession,
createEvidenceRecord,
createTraceEvent,
createWorkerSession,
createWorkspaceVolume
} from "./index.mjs";
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const DEFAULT_AGENT_RUNTIME_STATE_DIR = path.join(repoRoot, ".state", "agent-runtime");
export const LOCAL_DRY_RUN_MODE = "local-dry-run";
export function resolveAgentRuntimeStateDir(stateDir = process.env.HWLAB_AGENT_RUNTIME_STATE_DIR) {
return path.resolve(stateDir || DEFAULT_AGENT_RUNTIME_STATE_DIR);
}
export function assessSkillsInjection({ skillCommitId = null, skillVersion = null, role = "runtime" } = {}) {
const commitId = explicitValue(skillCommitId);
const version = explicitValue(skillVersion);
const missing = [];
if (!commitId) missing.push("skillCommitId");
if (!version) missing.push("skillVersion");
return {
role,
status: missing.length === 0 ? "ready" : "degraded",
commitId,
version,
missing,
source: "explicit-field"
};
}
export function createRuntimeHealth({
stateDir,
managerSkills = null,
workerSkills = null,
workspaceCleaned = false
} = {}) {
const missing = [];
const mismatches = [];
for (const skills of [managerSkills, workerSkills].filter(Boolean)) {
for (const field of skills.missing) {
missing.push(`${skills.role}.${field}`);
}
}
if (managerSkills && workerSkills) {
if (managerSkills.commitId && workerSkills.commitId && managerSkills.commitId !== workerSkills.commitId) {
mismatches.push("skillCommitId");
}
if (managerSkills.version && workerSkills.version && managerSkills.version !== workerSkills.version) {
mismatches.push("skillVersion");
}
}
const status = missing.length === 0 && mismatches.length === 0 ? "ok" : "degraded";
return {
serviceId: AGENT_MGR_SERVICE_ID,
status,
environment: ENVIRONMENT_DEV,
runtimeMode: LOCAL_DRY_RUN_MODE,
stateDir: stateDir ? resolveAgentRuntimeStateDir(stateDir) : undefined,
workspaceCleaned,
skills: {
manager: managerSkills,
worker: workerSkills,
missing,
mismatches
}
};
}
export async function createLocalAgentSession({
stateDir,
agentSessionId,
projectId,
goal = "",
prompt = "",
requestedBy = "usr_local",
skillCommitId = null,
skillVersion = null,
now = () => new Date().toISOString()
}) {
requireId(agentSessionId, "agentSessionId");
requireId(projectId, "projectId");
const root = resolveAgentRuntimeStateDir(stateDir);
const sessionDir = localAgentSessionDir(root, agentSessionId);
if (await pathExists(sessionDir)) {
throw new Error(`agent session already exists: ${agentSessionId}`);
}
const timestamp = now();
const traceId = `trc_${agentSessionId}`;
const operationId = `op_${agentSessionId}`;
const workerSessionId = `wkr_${agentSessionId}`;
const taskId = `tsk_${agentSessionId}`;
const workspacePath = path.join(root, "workspaces", agentSessionId);
const managerSkills = assessSkillsInjection({
skillCommitId,
skillVersion,
role: "manager"
});
const health = createRuntimeHealth({ stateDir: root, managerSkills });
const workspaceVolume = {
...createWorkspaceVolume({ agentSessionId, projectId, now }),
mountPath: workspacePath,
localPath: workspacePath
};
const agentSession = createAgentSession({
agentSessionId,
projectId,
requestedBy,
goal,
now,
metadata: {
runtimeMode: LOCAL_DRY_RUN_MODE,
traceId,
operationId,
workerSessionId,
taskId,
prompt,
skills: managerSkills
}
});
const workerSession = createWorkerSession({
workerSessionId,
agentSessionId,
projectId,
now,
metadata: {
runtimeMode: LOCAL_DRY_RUN_MODE,
traceId,
operationId,
taskId,
scopedProjectId: projectId,
workspaceVolumeId: workspaceVolume.workspaceVolumeId,
expectedSkills: managerSkills
}
});
const state = {
schemaVersion: 1,
environment: ENVIRONMENT_DEV,
runtimeMode: LOCAL_DRY_RUN_MODE,
stateDir: root,
sessionDir,
createdAt: timestamp,
updatedAt: timestamp,
health,
task: {
taskId,
traceId,
operationId,
status: "queued",
goal,
prompt,
createdAt: timestamp,
updatedAt: timestamp
},
agentSession,
workerSession,
workspaceVolume,
workspace: {
workspaceVolumeId: workspaceVolume.workspaceVolumeId,
path: workspacePath,
cleaned: false,
existsAfterCleanup: null
},
traceEvents: [],
evidenceRecords: [],
cleanup: null
};
state.traceEvents.push(
createRuntimeTraceEvent(state, {
suffix: "create",
serviceId: AGENT_MGR_SERVICE_ID,
message: "Agent manager created local dry-run session",
state: "create",
now,
metadata: {
taskId,
healthStatus: health.status,
skills: managerSkills
}
})
);
await writeLocalAgentState(root, state);
return summarizeLocalAgentSession(state);
}
export async function readLocalAgentSession({ stateDir, agentSessionId }) {
requireId(agentSessionId, "agentSessionId");
const root = resolveAgentRuntimeStateDir(stateDir);
return readLocalAgentState(root, agentSessionId);
}
export async function getLocalAgentStatus({ stateDir, agentSessionId }) {
const state = await readLocalAgentSession({ stateDir, agentSessionId });
return summarizeLocalAgentSession(state);
}
export async function getLocalAgentTrace({ stateDir, agentSessionId }) {
const state = await readLocalAgentSession({ stateDir, agentSessionId });
return {
serviceId: AGENT_MGR_SERVICE_ID,
agentSessionId,
traceId: state.task.traceId,
status: state.health.status,
tracePath: localAgentTracePath(state.sessionDir),
traceEvents: state.traceEvents
};
}
export async function getLocalAgentEvidence({ stateDir, agentSessionId }) {
const state = await readLocalAgentSession({ stateDir, agentSessionId });
const evidenceHealth = state.evidenceRecords.some((record) => record.metadata?.health?.status === "degraded")
? "degraded"
: state.health.status;
return {
serviceId: AGENT_MGR_SERVICE_ID,
agentSessionId,
operationId: state.task.operationId,
status: evidenceHealth,
evidenceDir: localAgentEvidenceDir(state.sessionDir),
evidenceRecords: state.evidenceRecords
};
}
export async function cleanupLocalAgentSession({
stateDir,
agentSessionId,
actor = AGENT_MGR_SERVICE_ID,
now = () => new Date().toISOString()
}) {
requireId(agentSessionId, "agentSessionId");
const root = resolveAgentRuntimeStateDir(stateDir);
const state = await readLocalAgentState(root, agentSessionId);
if (state.cleanup?.completed === true) {
return {
serviceId: actor,
agentSessionId,
idempotent: true,
cleanup: state.cleanup,
workspace: state.workspace,
status: state.health.status
};
}
await cleanupWorkspaceForState(state, { actor, now });
await writeLocalAgentState(root, state);
return {
serviceId: actor,
agentSessionId,
idempotent: false,
cleanup: state.cleanup,
workspace: state.workspace,
status: state.health.status
};
}
export async function runLocalWorkerDryRun({
stateDir,
agentSessionId,
skillCommitId = null,
skillVersion = null,
now = () => new Date().toISOString()
}) {
requireId(agentSessionId, "agentSessionId");
const root = resolveAgentRuntimeStateDir(stateDir);
const state = await readLocalAgentState(root, agentSessionId);
if (state.cleanup?.completed === true) {
throw new Error(`agent session is already cleaned up: ${agentSessionId}`);
}
const workerSkills = assessSkillsInjection({
skillCommitId,
skillVersion,
role: "worker"
});
const health = createRuntimeHealth({
stateDir: root,
managerSkills: state.health.skills.manager,
workerSkills
});
const timestamp = now();
state.updatedAt = timestamp;
state.health = health;
state.task = {
...state.task,
status: "claimed",
claimedAt: timestamp,
updatedAt: timestamp
};
state.agentSession = advanceAgentSession(state.agentSession, "start", {
now,
extra: {
workerSessionId: state.workerSession.workerSessionId,
workspaceVolumeId: state.workspace.workspaceVolumeId,
taskStatus: "claimed",
healthStatus: health.status
}
});
state.workerSession = advanceWorkerSession(state.workerSession, "start", {
now,
extra: {
workspaceVolumeId: state.workspace.workspaceVolumeId,
workspacePath: state.workspace.path,
taskStatus: "claimed",
healthStatus: health.status,
skills: workerSkills
}
});
await mkdir(state.workspace.path, { recursive: true });
state.traceEvents.push(
createRuntimeTraceEvent(state, {
suffix: "start",
serviceId: AGENT_WORKER_SERVICE_ID,
message: "Agent worker claimed local dry-run task",
state: "start",
now,
metadata: {
taskId: state.task.taskId,
workspacePath: state.workspace.path,
healthStatus: health.status,
skills: workerSkills
}
})
);
const workspaceTracePath = path.join(state.workspace.path, "trace.ndjson");
const workspaceTrace = createRuntimeTraceEvent(state, {
suffix: "trace",
serviceId: AGENT_WORKER_SERVICE_ID,
message: "Agent worker wrote local dry-run trace",
state: "trace",
now,
metadata: {
taskId: state.task.taskId,
workspaceTracePath,
healthStatus: health.status
}
});
state.agentSession = advanceAgentSession(state.agentSession, "trace", {
now,
extra: {
taskStatus: "running",
healthStatus: health.status
}
});
state.workerSession = advanceWorkerSession(state.workerSession, "trace", {
now,
extra: {
taskStatus: "running",
healthStatus: health.status
}
});
state.traceEvents.push(workspaceTrace);
await writeFile(workspaceTracePath, `${JSON.stringify(workspaceTrace)}\n`, "utf8");
const evidencePayload = {
schemaVersion: 1,
environment: ENVIRONMENT_DEV,
runtimeMode: LOCAL_DRY_RUN_MODE,
agentSessionId,
workerSessionId: state.workerSession.workerSessionId,
taskId: state.task.taskId,
traceId: state.task.traceId,
operationId: state.task.operationId,
goal: state.task.goal,
prompt: state.task.prompt,
health,
traceEventIds: state.traceEvents.map((event) => event.traceEventId),
cleanup: {
workspacePath: state.workspace.path,
workspaceWillBeRemoved: true
},
createdAt: now()
};
const evidenceContent = `${JSON.stringify(evidencePayload, null, 2)}\n`;
const evidenceDir = localAgentEvidenceDir(state.sessionDir);
const evidenceArtifactPath = path.join(evidenceDir, `worker-dry-run-${agentSessionId}.json`);
await mkdir(evidenceDir, { recursive: true });
await writeFile(evidenceArtifactPath, evidenceContent, "utf8");
const evidenceStats = await stat(evidenceArtifactPath);
const evidenceRecord = createEvidenceRecord({
evidenceId: `evi_${agentSessionId}`,
projectId: state.agentSession.projectId,
operationId: state.task.operationId,
uri: pathToFileURL(evidenceArtifactPath).href,
sha256: sha256Hex(evidenceContent),
serviceId: AGENT_WORKER_SERVICE_ID,
environment: ENVIRONMENT_DEV,
kind: "report",
agentSessionId,
workerSessionId: state.workerSession.workerSessionId,
mimeType: "application/json",
sizeBytes: evidenceStats.size,
metadata: {
runtimeMode: LOCAL_DRY_RUN_MODE,
traceId: state.task.traceId,
taskId: state.task.taskId,
workspaceVolumeId: state.workspace.workspaceVolumeId,
health,
skills: {
manager: state.health.skills.manager,
worker: workerSkills
}
},
now
});
state.evidenceRecords.push(evidenceRecord);
state.traceEvents.push(
createRuntimeTraceEvent(state, {
suffix: "finish",
serviceId: AGENT_WORKER_SERVICE_ID,
message: "Agent worker persisted local dry-run evidence",
state: "finish",
now,
metadata: {
taskId: state.task.taskId,
evidenceId: evidenceRecord.evidenceId,
healthStatus: health.status
}
})
);
state.task = {
...state.task,
status: "completed",
completedAt: now(),
updatedAt: now()
};
state.agentSession = advanceAgentSession(state.agentSession, "finish", {
now,
extra: {
taskStatus: "completed",
healthStatus: health.status
}
});
state.workerSession = advanceWorkerSession(state.workerSession, "finish", {
now,
extra: {
taskStatus: "completed",
evidenceId: evidenceRecord.evidenceId,
healthStatus: health.status
}
});
await cleanupWorkspaceForState(state, {
actor: AGENT_WORKER_SERVICE_ID,
now
});
await writeLocalAgentState(root, state);
return {
serviceId: AGENT_WORKER_SERVICE_ID,
dryRun: true,
agentSession: state.agentSession,
workerSession: state.workerSession,
task: state.task,
health: state.health,
traceEvents: state.traceEvents,
evidenceRecord,
evidencePath: evidenceArtifactPath,
cleanup: state.cleanup,
workspace: state.workspace
};
}
export function summarizeLocalAgentSession(state) {
return {
serviceId: AGENT_MGR_SERVICE_ID,
agentSessionId: state.agentSession.agentSessionId,
status: state.health.status,
environment: state.environment,
runtimeMode: state.runtimeMode,
stateDir: state.stateDir,
sessionDir: state.sessionDir,
health: state.health,
task: state.task,
agentSession: state.agentSession,
workerSession: state.workerSession,
workspace: state.workspace,
traceCount: state.traceEvents.length,
evidenceCount: state.evidenceRecords.length,
cleanup: state.cleanup
};
}
function createRuntimeTraceEvent(state, { suffix, serviceId, message, state: lifecycleState, metadata = {}, now }) {
return createTraceEvent({
traceEventId: `tev_${state.agentSession.agentSessionId}_${suffix}`,
traceId: state.task.traceId,
projectId: state.agentSession.projectId,
serviceId,
level: metadata.healthStatus === "degraded" ? "warn" : "info",
message,
environment: ENVIRONMENT_DEV,
agentSessionId: state.agentSession.agentSessionId,
workerSessionId: state.workerSession.workerSessionId,
operationId: state.task.operationId,
metadata: {
...metadata,
state: lifecycleState
},
now
});
}
async function cleanupWorkspaceForState(state, { actor, now }) {
const existedBeforeCleanup = await pathExists(state.workspace.path);
await rm(state.workspace.path, { recursive: true, force: true });
const existsAfterCleanup = await pathExists(state.workspace.path);
const timestamp = now();
state.cleanup = {
cleanupId: `cln_${state.agentSession.agentSessionId}`,
serviceId: actor,
agentSessionId: state.agentSession.agentSessionId,
workerSessionId: state.workerSession.workerSessionId,
workspaceVolumeId: state.workspace.workspaceVolumeId,
workspacePath: state.workspace.path,
completed: true,
existedBeforeCleanup,
removed: !existsAfterCleanup,
existsAfterCleanup,
cleanedAt: timestamp
};
state.workspace = {
...state.workspace,
cleaned: true,
existsAfterCleanup
};
state.health = {
...state.health,
workspaceCleaned: true
};
state.agentSession = advanceAgentSession(state.agentSession, "cleanup", {
now,
extra: {
cleanedUp: true,
cleanupId: state.cleanup.cleanupId,
healthStatus: state.health.status
}
});
state.workerSession = advanceWorkerSession(state.workerSession, "cleanup", {
now,
extra: {
cleanedUp: true,
cleanupId: state.cleanup.cleanupId,
healthStatus: state.health.status
}
});
state.traceEvents.push(
createRuntimeTraceEvent(state, {
suffix: "cleanup",
serviceId: actor,
message: "Agent worker cleaned local dry-run workspace",
state: "cleanup",
now,
metadata: {
cleanupId: state.cleanup.cleanupId,
workspaceVolumeId: state.workspace.workspaceVolumeId,
workspacePath: state.workspace.path,
removed: state.cleanup.removed,
healthStatus: state.health.status
}
})
);
state.updatedAt = timestamp;
}
async function readLocalAgentState(root, agentSessionId) {
const statePath = localAgentStatePath(localAgentSessionDir(root, agentSessionId));
try {
const raw = await readFile(statePath, "utf8");
return JSON.parse(raw);
} catch (error) {
if (error.code === "ENOENT") {
throw new Error(`agent session not found: ${agentSessionId}`);
}
throw error;
}
}
async function writeLocalAgentState(root, state) {
await mkdir(state.sessionDir, { recursive: true });
await mkdir(localAgentEvidenceDir(state.sessionDir), { recursive: true });
await writeFile(localAgentStatePath(state.sessionDir), `${JSON.stringify(state, null, 2)}\n`, "utf8");
const traceContent = state.traceEvents.map((event) => JSON.stringify(event)).join("\n");
await writeFile(localAgentTracePath(state.sessionDir), `${traceContent}${traceContent ? "\n" : ""}`, "utf8");
}
function localAgentSessionDir(root, agentSessionId) {
return path.join(root, "sessions", agentSessionId);
}
function localAgentStatePath(sessionDir) {
return path.join(sessionDir, "session.json");
}
function localAgentTracePath(sessionDir) {
return path.join(sessionDir, "trace.ndjson");
}
function localAgentEvidenceDir(sessionDir) {
return path.join(sessionDir, "evidence");
}
async function pathExists(targetPath) {
try {
await access(targetPath, fsConstants.F_OK);
return true;
} catch (error) {
if (error.code === "ENOENT") return false;
throw error;
}
}
function requireId(value, fieldName) {
if (!explicitValue(value)) {
throw new Error(`${fieldName} is required`);
}
}
function explicitValue(value) {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function sha256Hex(value) {
return crypto.createHash("sha256").update(value).digest("hex");
}
+17 -2
View File
@@ -41,6 +41,7 @@ test("M3 IO live descriptor enables controls only after read-only backend readin
const fixture = createM3ControlFixture();
const contract = await describeM3IoControlLive({
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -81,6 +82,7 @@ test("M3 IO live descriptor blocks controls with Chinese reason when readiness i
});
const contract = await describeM3IoControlLive({
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -103,6 +105,7 @@ test("M3 IO live descriptor blocks controls with Chinese reason when readiness i
test("M3 IO live descriptor classifies gateway unavailable and patch-panel unavailable blockers", async () => {
const gatewayBlocked = await describeM3IoControlLive({
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -130,6 +133,7 @@ test("M3 IO live descriptor classifies gateway unavailable and patch-panel unava
const fixture = createM3ControlFixture();
const patchPanelBlocked = await describeM3IoControlLive({
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -175,6 +179,7 @@ test("M3 status live descriptor aggregates gateways, boxes, patch-panel, IO valu
const status = await describeM3StatusLive({
runtimeStore: createBlockedDurableRuntimeStore({ blocker: "runtime_durable_adapter_auth_blocked" }),
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -250,6 +255,7 @@ test("M3 status clears nested runtime blocker when trusted durable evidence is g
const status = await describeM3StatusLive({
runtimeStore: createGreenDurableRuntimeStore({ blocker: M3_IO_BLOCKER_CODES.runtimeDurableBlocked }),
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -306,6 +312,7 @@ test("M3 status live descriptor keeps SOURCE fallback unverified when patch-pane
const status = await describeM3StatusLive({
runtimeStore: createBlockedDurableRuntimeStore(),
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -346,6 +353,7 @@ test("M3 DO write dispatches through gateway-simu, ticks patch-panel, reads DI,
runtimeStore,
now: () => fixedNow,
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -450,6 +458,7 @@ test("M3 DO write can prove control reachability while durable trusted persisten
runtimeStore: createBlockedDurableRuntimeStore(),
now: () => fixedNow,
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -503,6 +512,7 @@ test("M3 DI read returns structured value through gateway-simu without patch-pan
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
now: () => fixedNow,
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -547,6 +557,7 @@ test("M3 IO control blocks with Chinese reason when gateway session is unavailab
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
now: () => fixedNow,
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -608,6 +619,7 @@ test("M3 IO control blocks when patch-panel wiring does not deliver DO1 to DI1",
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
now: () => fixedNow,
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -649,6 +661,7 @@ test("M3 IO control blocks when patch-panel reports missing target box", async (
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
now: () => fixedNow,
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -674,6 +687,7 @@ test("M3 IO JSON-RPC methods route do.write and di.read through the cloud-api ha
runtimeStore,
now: () => fixedNow,
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
@@ -698,7 +712,7 @@ test("M3 IO JSON-RPC methods route do.write and di.read through the cloud-api ha
meta: {
traceId: "trc_m3_rpc_write_true",
actorId: "usr_m3_rpc",
serviceId: "hwlab-agent-worker",
serviceId: "hwlab-cloud-api",
environment: "dev"
}
},
@@ -733,7 +747,7 @@ test("M3 IO JSON-RPC methods route do.write and di.read through the cloud-api ha
meta: {
traceId: "trc_m3_rpc_read_di",
actorId: "usr_m3_rpc",
serviceId: "hwlab-agent-worker",
serviceId: "hwlab-cloud-api",
environment: "dev"
}
},
@@ -769,6 +783,7 @@ test("M3 IO fixture success cannot be promoted to DEV-LIVE without durable evide
runtimeStore: createCloudRuntimeStore({ now: () => fixedNow }),
now: () => fixedNow,
env: {
HWLAB_M3_IO_CONTROL_ENABLED: "true",
HWLAB_M3_GATEWAY_SIMU_1_URL: "http://gateway-1",
HWLAB_M3_GATEWAY_SIMU_2_URL: "http://gateway-2",
HWLAB_M3_PATCH_PANEL_URL: "http://patch-panel"
+1 -1
View File
@@ -2082,7 +2082,7 @@ test("cloud api /v1/agent/chat does not call OpenAI provider 502/503 fallback",
const payload = await response.json();
assert.equal(payload.status, "failed");
assert.equal(payload.traceId, `trc_server-test-agent-chat-provider-${status}`);
assert.match(payload.error.code, /^(codex_cli_binary_missing|stdio_protocol_not_wired)$/u);
assert.match(payload.error.code, /^(codex_cli_binary_missing|codex_cli_native_dependency_missing|stdio_protocol_not_wired)$/u);
assert.match(payload.error.layer, /^(runner|api)$/u);
assert.equal(typeof payload.error.retryable, "boolean");
assert.equal(payload.error.blocker.traceId, `trc_server-test-agent-chat-provider-${status}`);
+25 -1
View File
@@ -5,7 +5,7 @@ import os from "node:os";
import path from "node:path";
import { test } from "bun:test";
import { createCloudApiServer } from "./server.ts";
import { createCloudApiServer, ensureCodeAgentRuntimeBase } from "./server.ts";
import { createCodexStdioSessionManager } from "./codex-stdio-session.ts";
import {
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
@@ -845,3 +845,27 @@ test("cloud api REST JSON-RPC bridge exposes unknown serviceId reason for Code A
});
}
});
test("AgentRun adapter does not initialize repo-owned Codex stdio runtime defaults", () => {
const env = {
HWLAB_CODE_AGENT_ADAPTER: "agentrun-v01"
};
ensureCodeAgentRuntimeBase(env);
assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, undefined);
assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, undefined);
assert.equal(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, undefined);
assert.equal(env.CODEX_HOME, undefined);
assert.equal(env.HWLAB_PREINSTALLED_SKILLS_DIR, "/app/skills");
assert.equal(env.HWLAB_USER_SKILLS_DIR, "/data/user-skills");
});
test("explicit Codex stdio mode keeps local runtime defaults available", () => {
const env = {
HWLAB_CODE_AGENT_PROVIDER: "codex-stdio"
};
ensureCodeAgentRuntimeBase(env);
assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED, "1");
assert.equal(env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR, "repo-owned");
assert.equal(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE, "/workspace/hwlab");
assert.equal(env.CODEX_HOME, "/codex-home");
});
+28 -41
View File
@@ -23,13 +23,13 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
}
}],
["/hwlab-agent-mgr/health/live", {
serviceId: "hwlab-agent-mgr",
["/hwlab-device-pod/health/live", {
serviceId: "hwlab-device-pod",
status: "ok",
revision: "48dfbf9",
commit: { id: "48dfbf9", source: "test" },
image: {
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
reference: "127.0.0.1:5000/hwlab/hwlab-device-pod:48dfbf9",
tag: "48dfbf9",
digest: "sha256:" + "2".repeat(64)
}
@@ -66,8 +66,8 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
replicas: 1
},
{
serviceId: "hwlab-agent-mgr",
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:3df89fe",
serviceId: "hwlab-device-pod",
image: "127.0.0.1:5000/hwlab/hwlab-device-pod:3df89fe",
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
@@ -88,14 +88,6 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
HWLAB_BUILD_SOURCE: "deploy-env-test"
}
},
{
serviceId: "hwlab-agent-worker",
image: "127.0.0.1:5000/hwlab/hwlab-agent-worker:workcat",
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
replicas: 0
},
{
serviceId: "hwlab-edge-proxy",
image: "127.0.0.1:5000/hwlab/hwlab-edge-proxy:edgecat",
@@ -117,9 +109,9 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
artifactCatalog: {
services: [
{
serviceId: "hwlab-agent-mgr",
serviceId: "hwlab-device-pod",
commitId: "3df89fe",
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:3df89fe",
image: "127.0.0.1:5000/hwlab/hwlab-device-pod:3df89fe",
imageTag: "3df89fe",
digest: "sha256:" + "3".repeat(64),
namespace: "hwlab-dev",
@@ -167,8 +159,7 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
POD_NAMESPACE: "hwlab-dev",
HWLAB_RUNTIME_CONTAINER: "hwlab-cloud-api",
HWLAB_CLOUD_WEB_SERVICE_URL: "http://live.test/hwlab-cloud-web",
HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr",
HWLAB_AGENT_WORKER_URL: "http://live.test/missing-agent-worker",
HWLAB_DEVICE_POD_URL: "http://live.test/hwlab-device-pod",
HWLAB_GATEWAY_URL: "http://live.test/missing-gateway",
HWLAB_GATEWAY_SIMU_URL: "http://live.test/missing-gateway-simu",
HWLAB_BOX_SIMU_URL: "http://live.test/missing-box-simu",
@@ -223,21 +214,17 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep
assert.equal(payload.counts.total, payload.services.length);
assert.equal(payload.counts.external, 2);
assert.equal(payload.counts.withBuildTime, 3);
const manager = payload.services.find((service) => service.serviceId === "hwlab-agent-mgr");
assert.equal(manager.build.createdAt, null);
assert.equal(manager.image.tag, "48dfbf9");
assert.equal(manager.revision, "48dfbf9");
assert.equal(manager.build.metadataSource, "live-health:controlled-metadata-mismatch");
assert.equal(manager.build.unavailableReason, "构建时间不可用:live tag 与 catalog metadata 不匹配");
assert.match(manager.build.unavailableDetail, /live tag 48dfbf9/u);
assert.match(manager.build.unavailableDetail, /metadata tag 3df89fe/u);
const devicePod = payload.services.find((service) => service.serviceId === "hwlab-device-pod");
assert.equal(devicePod.build.createdAt, null);
assert.equal(devicePod.image.tag, "48dfbf9");
assert.equal(devicePod.revision, "48dfbf9");
assert.equal(devicePod.build.metadataSource, "live-health:controlled-metadata-mismatch");
assert.equal(devicePod.build.unavailableReason, "构建时间不可用:live tag 与 catalog metadata 不匹配");
assert.match(devicePod.build.unavailableDetail, /live tag 48dfbf9/u);
assert.match(devicePod.build.unavailableDetail, /metadata tag 3df89fe/u);
const extra = payload.services.find((service) => service.serviceId === "hwlab-extra-lab");
assert.equal(extra.build.createdAt, null);
assert.match(extra.build.unavailableReason, / live health /u);
const worker = payload.services.find((service) => service.serviceId === "hwlab-agent-worker");
assert.equal(worker.build.createdAt, null);
assert.match(worker.build.unavailableReason, / live health /u);
assert.match(worker.build.unavailableReason, /hwlab-agent-worker hwlab-dev desired replicas=0/u);
assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-edge-proxy" && service.healthUrl.endsWith("/health")));
assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-frpc" && service.image.tag === "v0.68.1"));
assert.ok(payload.services.some((service) => service.serviceId === "hwlab-device-pod"));
@@ -256,8 +243,8 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
await writeFile(path.join(deployDir, "deploy.json"), `${JSON.stringify({
services: [
{
serviceId: "hwlab-agent-mgr",
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
serviceId: "hwlab-device-pod",
image: "127.0.0.1:5000/hwlab/hwlab-device-pod:48dfbf9",
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
@@ -268,9 +255,9 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
await writeFile(path.join(deployDir, "artifact-catalog.dev.json"), `${JSON.stringify({
services: [
{
serviceId: "hwlab-agent-mgr",
serviceId: "hwlab-device-pod",
commitId: "48dfbf9abcdef",
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
image: "127.0.0.1:5000/hwlab/hwlab-device-pod:48dfbf9",
imageTag: "48dfbf9",
digest: "sha256:" + "6".repeat(64),
namespace: "hwlab-dev",
@@ -285,9 +272,9 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
artifactPublish: {
services: [
{
serviceId: "hwlab-agent-mgr",
serviceId: "hwlab-device-pod",
sourceCommitId: "48dfbf9abcdef",
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
image: "127.0.0.1:5000/hwlab/hwlab-device-pod:48dfbf9",
imageTag: "48dfbf9",
digest: "sha256:" + "6".repeat(64),
buildCreatedAt: "2099-01-01T00:00:00.000Z",
@@ -301,22 +288,22 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
const server = createCloudApiServer({
env: {
PATH: "",
HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr"
HWLAB_DEVICE_POD_URL: "http://live.test/hwlab-device-pod"
},
repoRoot: tempRoot,
fetchImpl: async (url) => {
if (new URL(url).pathname === "/hwlab-agent-mgr/health/live") {
if (new URL(url).pathname === "/hwlab-device-pod/health/live") {
return {
ok: true,
status: 200,
async text() {
return JSON.stringify({
serviceId: "hwlab-agent-mgr",
serviceId: "hwlab-device-pod",
status: "ok",
revision: "48dfbf9abcdef",
commit: { id: "48dfbf9abcdef", source: "test" },
image: {
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:48dfbf9",
reference: "127.0.0.1:5000/hwlab/hwlab-device-pod:48dfbf9",
tag: "48dfbf9",
digest: "sha256:" + "6".repeat(64)
}
@@ -351,7 +338,7 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
await rm(path.join(tempRoot, "reports"), { recursive: true, force: true });
const withoutOldReport = await observe();
const summarize = (payload) => {
const manager = payload.services.find((service) => service.serviceId === "hwlab-agent-mgr");
const manager = payload.services.find((service) => service.serviceId === "hwlab-device-pod");
return {
latest: payload.latest,
source: payload.source,
@@ -365,7 +352,7 @@ test("cloud api ignores old repository reports and keeps missing buildCreatedAt
};
assert.deepEqual(summarize(withOldReport), summarize(withoutOldReport));
const manager = withOldReport.services.find((service) => service.serviceId === "hwlab-agent-mgr");
const manager = withOldReport.services.find((service) => service.serviceId === "hwlab-device-pod");
assert.equal(withOldReport.latest, null);
assert.equal(manager.build.createdAt, null);
assert.equal(Object.hasOwn(withOldReport.source, "artifactReportSource"), false);
+1 -5
View File
@@ -13,8 +13,6 @@ const LIVE_BUILD_DEPLOY_MANIFEST_PATH = "deploy/deploy.json";
const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
"hwlab-cloud-api": Object.freeze({ urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL" }),
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL" }),
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL" }),
"hwlab-device-pod": Object.freeze({ urlEnv: "HWLAB_DEVICE_POD_URL" }),
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL" }),
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", healthPath: "/health" }),
@@ -446,7 +444,7 @@ function liveBuildRuntimeSummary(service, payload = {}) {
}
function defaultRuntimeContainer(serviceId) {
if (["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-mgr", "hwlab-agent-skills"].includes(serviceId)) return serviceId;
if (["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-skills"].includes(serviceId)) return serviceId;
return "";
}
@@ -884,8 +882,6 @@ function serviceDefaultUrl(serviceId, namespace = null) {
const port = {
"hwlab-cloud-api": 6667,
"hwlab-cloud-web": 8080,
"hwlab-agent-mgr": 7410,
"hwlab-agent-worker": 7411,
"hwlab-device-pod": 7601,
"hwlab-gateway": 7001,
"hwlab-edge-proxy": 6667,
+5 -1
View File
@@ -104,6 +104,11 @@ export function createCloudApiServer(options = {}) {
}
export function ensureCodeAgentRuntimeBase(env = process.env) {
const codeAgentAdapter = String(env.HWLAB_CODE_AGENT_ADAPTER ?? env.HWLAB_CODE_AGENT_PROVIDER ?? "").trim().toLowerCase();
const useCodexStdioRuntime = !codeAgentAdapter || codeAgentAdapter === "codex-stdio";
const skillRuntime = configureSkillRuntime(env);
if (!useCodexStdioRuntime) return;
const workspace = env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab";
const codexHome = env.CODEX_HOME || env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home";
const appCodexCommand = "/app/node_modules/.bin/codex";
@@ -120,7 +125,6 @@ export function ensureCodeAgentRuntimeBase(env = process.env) {
env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR ||= "repo-owned";
env.CODEX_HOME ||= codexHome;
env.HWLAB_CODE_AGENT_CODEX_COMMAND ||= codexCommand;
const skillRuntime = configureSkillRuntime(env);
for (const target of [path.dirname(workspace), codexHome]) {
try {
-2
View File
@@ -8,8 +8,6 @@ export const JSON_RPC_VERSION = "2.0";
export const SERVICE_IDS = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
-1
View File
@@ -11,7 +11,6 @@
"check:cloud-web": "node scripts/check-runner.mjs --profile check --group cloud-web",
"dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs",
"cloud-api:smoke": "node scripts/run-bun.mjs scripts/cloud-api-runtime-smoke.mjs",
"m1:smoke": "node scripts/run-bun.mjs scripts/m1-contract-smoke.mjs",
"l2:smoke": "node scripts/l2-runtime-contract-smoke.mjs",
"dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty",
"dev:evidence:check": "node scripts/dev-evidence-blocker-aggregator.mjs --check",
@@ -37,66 +37,6 @@
"updatedAt": "2026-05-21T00:20:05.000Z"
},
"auditEvents": [
{
"auditId": "aud_agent-auto-session-started",
"traceId": "trc_agent-auto-loop-001",
"actorType": "user",
"actorId": "usr_alice",
"action": "agent.session.started",
"targetType": "agent_session",
"targetId": "ags_agent-auto-001",
"projectId": "prj_evidence-chain",
"operationId": "op_agent-auto-loop-001",
"serviceId": "hwlab-agent-mgr",
"environment": "dev",
"outcome": "accepted",
"metadata": {
"agentSessionId": "ags_agent-auto-001",
"goal": "run firmware smoke and persist evidence"
},
"occurredAt": "2026-05-21T00:20:00.000Z"
},
{
"auditId": "aud_agent-auto-worker-started",
"traceId": "trc_agent-auto-loop-001",
"actorType": "agent",
"actorId": "agt_agent-auto-001",
"action": "worker.session.started",
"targetType": "worker_session",
"targetId": "wks_agent-auto-001",
"projectId": "prj_evidence-chain",
"gatewaySessionId": "gws_evidence-gateway-001",
"workerSessionId": "wks_agent-auto-001",
"operationId": "op_agent-auto-loop-001",
"serviceId": "hwlab-agent-worker",
"environment": "dev",
"outcome": "accepted",
"metadata": {
"agentSessionId": "ags_agent-auto-001"
},
"occurredAt": "2026-05-21T00:20:01.000Z"
},
{
"auditId": "aud_agent-auto-operation-requested",
"traceId": "trc_agent-auto-loop-001",
"actorType": "worker",
"actorId": "wkr_agent-auto-001",
"action": "hardware.operation.requested",
"targetType": "hardware_operation",
"targetId": "op_agent-auto-loop-001",
"projectId": "prj_evidence-chain",
"gatewaySessionId": "gws_evidence-gateway-001",
"workerSessionId": "wks_agent-auto-001",
"operationId": "op_agent-auto-loop-001",
"serviceId": "hwlab-agent-worker",
"environment": "dev",
"outcome": "accepted",
"metadata": {
"agentSessionId": "ags_agent-auto-001",
"capabilityId": "cap_firmware-smoke"
},
"occurredAt": "2026-05-21T00:20:02.000Z"
},
{
"auditId": "aud_agent-auto-operation-succeeded",
"traceId": "trc_agent-auto-loop-001",
@@ -117,63 +57,9 @@
"exitCode": 0
},
"occurredAt": "2026-05-21T00:20:05.000Z"
},
{
"auditId": "aud_agent-auto-evidence-recorded",
"traceId": "trc_agent-auto-loop-001",
"actorType": "worker",
"actorId": "wkr_agent-auto-001",
"action": "evidence.recorded",
"targetType": "evidence_record",
"targetId": "evd_agent-auto-report",
"projectId": "prj_evidence-chain",
"gatewaySessionId": "gws_evidence-gateway-001",
"workerSessionId": "wks_agent-auto-001",
"operationId": "op_agent-auto-loop-001",
"serviceId": "hwlab-agent-worker",
"environment": "dev",
"outcome": "succeeded",
"metadata": {
"agentSessionId": "ags_agent-auto-001",
"evidenceId": "evd_agent-auto-report",
"evidenceKind": "report"
},
"occurredAt": "2026-05-21T00:20:06.000Z"
}
],
"traceEvents": [
{
"traceEventId": "tev_agent-auto-goal",
"traceId": "trc_agent-auto-loop-001",
"projectId": "prj_evidence-chain",
"agentSessionId": "ags_agent-auto-001",
"workerSessionId": "wks_agent-auto-001",
"operationId": "op_agent-auto-loop-001",
"serviceId": "hwlab-agent-mgr",
"level": "info",
"message": "Agent session accepted automation goal",
"environment": "dev",
"metadata": {
"auditId": "aud_agent-auto-session-started"
},
"occurredAt": "2026-05-21T00:20:00.000Z"
},
{
"traceEventId": "tev_agent-auto-worker",
"traceId": "trc_agent-auto-loop-001",
"projectId": "prj_evidence-chain",
"agentSessionId": "ags_agent-auto-001",
"workerSessionId": "wks_agent-auto-001",
"operationId": "op_agent-auto-loop-001",
"serviceId": "hwlab-agent-worker",
"level": "info",
"message": "Worker requested firmware smoke operation",
"environment": "dev",
"metadata": {
"auditId": "aud_agent-auto-operation-requested"
},
"occurredAt": "2026-05-21T00:20:02.000Z"
},
{
"traceEventId": "tev_agent-auto-operation",
"traceId": "trc_agent-auto-loop-001",
@@ -189,47 +75,7 @@
"auditId": "aud_agent-auto-operation-succeeded"
},
"occurredAt": "2026-05-21T00:20:05.000Z"
},
{
"traceEventId": "tev_agent-auto-evidence",
"traceId": "trc_agent-auto-loop-001",
"projectId": "prj_evidence-chain",
"agentSessionId": "ags_agent-auto-001",
"workerSessionId": "wks_agent-auto-001",
"operationId": "op_agent-auto-loop-001",
"serviceId": "hwlab-agent-worker",
"level": "info",
"message": "Agent report evidence persisted",
"environment": "dev",
"metadata": {
"auditId": "aud_agent-auto-evidence-recorded",
"evidenceId": "evd_agent-auto-report"
},
"occurredAt": "2026-05-21T00:20:06.000Z"
}
],
"evidenceRecords": [
{
"evidenceId": "evd_agent-auto-report",
"projectId": "prj_evidence-chain",
"operationId": "op_agent-auto-loop-001",
"agentSessionId": "ags_agent-auto-001",
"workerSessionId": "wks_agent-auto-001",
"kind": "report",
"uri": "protocol/examples/evidence-chain/artifacts/agent-automation-report.json",
"mimeType": "application/json",
"sha256": "2fc63687cd3170c9cf34cd650aaedfb84d3fe75683ae969ae286230ce3b425a7",
"sizeBytes": 340,
"serviceId": "hwlab-agent-worker",
"environment": "dev",
"metadata": {
"traceId": "trc_agent-auto-loop-001",
"producedByAuditId": "aud_agent-auto-evidence-recorded",
"agentSessionId": "ags_agent-auto-001",
"workerSessionId": "wks_agent-auto-001",
"producerServiceId": "hwlab-agent-worker"
},
"createdAt": "2026-05-21T00:20:06.000Z"
}
]
"evidenceRecords": []
}
@@ -10,7 +10,7 @@
"gatewaySessionId": "gws_01J00000000000000000000010",
"workerSessionId": "wks_01J00000000000000000000010",
"operationId": "op_01J00000000000000000000010",
"serviceId": "hwlab-agent-worker",
"serviceId": "hwlab-cloud-api",
"environment": "dev",
"outcome": "succeeded",
"reason": "operation_completed",
@@ -34,7 +34,9 @@
"state": "available",
"environment": "dev",
"metadata": {
"ports": ["out0"]
"ports": [
"out0"
]
},
"createdAt": "2026-05-21T00:00:00.000Z",
"updatedAt": "2026-05-21T00:00:00.000Z"
@@ -49,7 +51,10 @@
"state": "reserved",
"environment": "dev",
"metadata": {
"ports": ["vin", "uart0"]
"ports": [
"vin",
"uart0"
]
},
"createdAt": "2026-05-21T00:00:00.000Z",
"updatedAt": "2026-05-21T00:00:00.000Z"
@@ -140,28 +145,6 @@
"connectionCount": 1
}
},
"agentSession": {
"agentSessionId": "ags_01J00000000000000000000010",
"projectId": "prj_01J00000000000000000000010",
"serviceId": "hwlab-agent-mgr",
"status": "active",
"environment": "dev",
"requestedBy": "usr_01J00000000000000000000010",
"goal": "Cycle DUT power and persist evidence.",
"startedAt": "2026-05-21T00:00:02.000Z",
"updatedAt": "2026-05-21T00:00:02.000Z"
},
"workerSession": {
"workerSessionId": "wks_01J00000000000000000000010",
"agentSessionId": "ags_01J00000000000000000000010",
"projectId": "prj_01J00000000000000000000010",
"serviceId": "hwlab-agent-worker",
"gatewaySessionId": "gws_01J00000000000000000000010",
"status": "active",
"environment": "dev",
"startedAt": "2026-05-21T00:00:03.000Z",
"updatedAt": "2026-05-21T00:00:03.000Z"
},
"hardwareOperation": {
"operationId": "op_01J00000000000000000000010",
"projectId": "prj_01J00000000000000000000010",
@@ -185,40 +168,5 @@
"completedAt": "2026-05-21T00:00:05.000Z",
"updatedAt": "2026-05-21T00:00:05.000Z"
},
"traceEvents": [
{
"traceEventId": "tev_01J00000000000000000000010",
"traceId": "trc_01J00000000000000000000010",
"projectId": "prj_01J00000000000000000000010",
"agentSessionId": "ags_01J00000000000000000000010",
"workerSessionId": "wks_01J00000000000000000000010",
"operationId": "op_01J00000000000000000000010",
"serviceId": "hwlab-agent-worker",
"level": "info",
"message": "hardware operation completed",
"environment": "dev",
"metadata": {
"status": "succeeded"
},
"occurredAt": "2026-05-21T00:00:05.000Z"
}
],
"evidenceRecord": {
"evidenceId": "evd_01J00000000000000000000010",
"projectId": "prj_01J00000000000000000000010",
"operationId": "op_01J00000000000000000000010",
"agentSessionId": "ags_01J00000000000000000000010",
"workerSessionId": "wks_01J00000000000000000000010",
"kind": "report",
"uri": "protocol/examples/m0-contract/evidence/power-cycle-report.md",
"mimeType": "text/markdown",
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"sizeBytes": 0,
"serviceId": "hwlab-agent-worker",
"environment": "dev",
"metadata": {
"source": "m0_contract_example"
},
"createdAt": "2026-05-21T00:00:05.000Z"
}
"traceEvents": []
}
@@ -33,22 +33,6 @@
"profile": "dev",
"replicas": 1
},
{
"serviceId": "hwlab-agent-mgr",
"image": "ghcr.io/pikastech/hwlab-agent-mgr:6509a35",
"namespace": "hwlab-dev",
"healthPath": "/healthz",
"profile": "dev",
"replicas": 1
},
{
"serviceId": "hwlab-agent-worker",
"image": "ghcr.io/pikastech/hwlab-agent-worker:6509a35",
"namespace": "hwlab-dev",
"healthPath": "/healthz",
"profile": "dev",
"replicas": 1
},
{
"serviceId": "hwlab-device-pod",
"image": "ghcr.io/pikastech/hwlab-device-pod:6509a35",
@@ -8,7 +8,7 @@
},
"meta": {
"traceId": "trc_01J00000000000000000000010",
"serviceId": "hwlab-agent-mgr",
"serviceId": "hwlab-cloud-api",
"environment": "dev"
}
}
@@ -4,8 +4,6 @@
"serviceIds": [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-gateway-simu",
@@ -9,7 +9,11 @@
"runtimeSubstitution": "forbidden",
"prodDeployment": "forbidden",
"realDevDeployment": "out_of_scope",
"allowedUniDeskRoles": ["ci", "cd", "code_queue_scheduling"],
"allowedUniDeskRoles": [
"ci",
"cd",
"code_queue_scheduling"
],
"forbiddenRuntimeSubstitutes": [
"unidesk-backend",
"provider-gateway",
@@ -24,8 +28,6 @@
"requiredServiceIds": [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
@@ -50,8 +52,6 @@
"hwlab-tunnel-client",
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-gateway-simu",
"hwlab-box-simu",
"hwlab-patch-panel"
@@ -67,8 +67,6 @@
"requiredWorkloadServiceIds": [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
@@ -85,7 +83,6 @@
"hwlab-gateway",
"hwlab-cli",
"hwlab-agent-skills",
"hwlab-agent-mgr",
"hwlab-gateway-simu",
"hwlab-box-simu",
"hwlab-patch-panel",
+1 -1
View File
@@ -32,7 +32,7 @@ HWLAB command-style APIs use JSON-RPC 2.0 envelopes.
},
"meta": {
"traceId": "trc_01J00000000000000000000000",
"serviceId": "hwlab-agent-mgr",
"serviceId": "hwlab-cloud-api",
"environment": "dev"
}
}
+1 -1
View File
@@ -20,7 +20,7 @@
"$ref": "common.json#/$defs/id"
},
"serviceId": {
"const": "hwlab-agent-mgr"
"const": "hwlab-cloud-api"
},
"status": {
"type": "string",
-2
View File
@@ -23,8 +23,6 @@
"enum": [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
+1 -1
View File
@@ -24,7 +24,7 @@
"$ref": "common.json#/$defs/id"
},
"serviceId": {
"const": "hwlab-agent-worker"
"const": "hwlab-cloud-api"
},
"gatewaySessionId": {
"$ref": "common.json#/$defs/id"
+4 -5
View File
@@ -1,15 +1,14 @@
# Topology Constraints
The MVP topology separates cloud, gateway, worker, simulated box, patch panel,
router, tunnel, and edge proxy responsibilities.
The MVP topology separates cloud, gateway, AgentRun-backed execution, device pod,
hardware transport, and edge proxy responsibilities.
## Constraints
- `hwlab-cloud-api` is the cloud API boundary for DEV.
- `hwlab-cloud-web` consumes cloud APIs and must not control hardware directly.
- `hwlab-agent-mgr` schedules agent work and creates worker sessions.
- `hwlab-agent-worker` executes scoped work through declared gateway and box
capabilities.
- `hwlab-cloud-api` owns Code Agent session authorization and dispatches execution
to AgentRun v0.1.
- `hwlab-gateway` is the hardware control boundary.
- `hwlab-gateway-simu` and `hwlab-box-simu` may simulate gateway and box
behavior for MVP validation.
+1 -16
View File
@@ -61,8 +61,6 @@ const ciArtifactIdentityEnvNames = [
const servicePorts = new Map([
["hwlab-cloud-api", 6667],
["hwlab-cloud-web", 8080],
["hwlab-agent-mgr", 7410],
["hwlab-agent-worker", 7411],
["hwlab-device-pod", 7601],
["hwlab-gateway", 7001],
["hwlab-edge-proxy", 6667],
@@ -72,8 +70,6 @@ const servicePorts = new Map([
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
@@ -784,15 +780,6 @@ function dockerfile(baseImage, port, labels = []) {
"WORKDIR /app",
...labels.map(([name, value]) => `LABEL ${name}=${dockerfileQuote(value)}`),
"ARG HWLAB_ARTIFACT_KIND",
"ENV CODEX_HOME=/codex-home",
"ENV HWLAB_CODE_AGENT_CODEX_HOME=/codex-home",
"ENV HWLAB_CODE_AGENT_WORKSPACE=/workspace/hwlab",
"ENV HWLAB_CODE_AGENT_CODEX_WORKSPACE=/workspace/hwlab",
"ENV HWLAB_CODE_AGENT_CODEX_SANDBOX=danger-full-access",
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED=1",
"ENV HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR=repo-owned",
"ENV HWLAB_CODE_AGENT_CODEX_COMMAND=/app/node_modules/.bin/codex",
"ENV HWLAB_CODE_AGENT_PROVIDER=codex-stdio",
"ENV HWLAB_CODE_AGENT_SKILLS_DIRS=/app/skills",
"COPY package.json ./package.json",
"COPY package-lock.json* ./",
@@ -1930,9 +1917,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
],
localSmoke: {
status: "not_run",
commands: [
"node scripts/m1-contract-smoke.mjs"
],
commands: [],
evidence: [
"This report records artifact build/publish state only; local smoke remains a separate gate."
],
-620
View File
@@ -1,620 +0,0 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
AGENT_MGR_SERVICE_ID,
AGENT_SKILLS_SERVICE_ID,
AGENT_WORKER_SERVICE_ID
} from "../internal/agent/index.mjs";
import {
createLocalAgentSession,
getLocalAgentEvidence,
getLocalAgentStatus,
getLocalAgentTrace,
runLocalWorkerDryRun
} from "../internal/agent/runtime.mjs";
import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs";
import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs";
import {
collectD601K3sNativeEvidence,
collectPublicEntrypoints,
classifyCloudApiLiveReadiness,
d601K3sReadonlyCommand,
d601Kubeconfig,
devNamespace,
requestJson,
runtimeDurabilityFromHealth,
workerServerDryRunCommand
} from "./src/dev-m4-agent-loop-smoke-lib.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixedNow = () => "2026-05-22T00:00:00.000Z";
const requiredDocs = ["docs/reference/MVP-e2e-acceptance.md", "docs/reference/code-agent-chat-readiness.md"];
const reportPath = tempReportPath("dev-m4-agent-loop.json");
const preflightCommand = "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production";
const dryRunCommand = "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run";
function parseArgs(argv) {
const flags = new Set();
for (const arg of argv) {
if (arg.startsWith("--")) {
flags.add(arg);
}
}
return {
live: flags.has("--live") || flags.has("--allow-live"),
dryRun: flags.has("--dry-run"),
confirmDev: flags.has("--confirm-dev"),
confirmedNonProduction: flags.has("--confirmed-non-production")
};
}
async function readJSON(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
}
async function readText(relativePath) {
return readFile(path.join(repoRoot, relativePath), "utf8");
}
async function writeJSON(relativePath, value) {
const absolutePath = ensureNotRepoReportsPath(repoRoot, relativePath, "--report");
await mkdir(path.dirname(absolutePath), { recursive: true });
await writeFile(absolutePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function currentOriginMainCommitId() {
return execFileSync("git", ["rev-parse", "--short=7", "origin/main"], {
cwd: repoRoot,
encoding: "utf8"
}).trim();
}
function statusForResult(result) {
return result.blocked || (result.additionalBlockers?.length ?? 0) > 0 ? "blocked" : "pass";
}
function dbLiveReadyForResult(result) {
return result.dbLiveReady === true || (!result.blocked && result.dbLiveReady !== false);
}
function runtimeDurableReadyForResult(result) {
return result.runtimeDurableReady === true || (!result.blocked && result.runtimeDurableReady !== false);
}
function blockerForResult(result) {
const blockers = [];
if (result.blocked && result.blockerType) {
blockers.push({
type: result.blockerType,
scope: result.blockerScope ?? "devPreconditions",
status: "open",
summary: result.blockerSummary,
classification: result.blockedClassification ?? "other",
...(result.sourceIssue ? { sourceIssue: result.sourceIssue } : {})
});
}
for (const blocker of result.additionalBlockers ?? []) {
blockers.push({ status: "open", ...blocker });
}
const seen = new Set();
return blockers.filter((blocker) => {
const key = `${blocker.type}:${blocker.scope}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function classificationForResult(result) {
return result.blockedClassification ?? result.additionalBlockers?.[0]?.classification ?? "none";
}
function buildDryRunEvidence({ status = "not_run", details = [] } = {}) {
return {
status,
commands: [dryRunCommand],
evidence: details.length
? details
: ["No dry-run output is attached to this live preflight report."],
summary: status === "pass"
? "Local dry-run covered agent manager, worker, workspace isolation, skills commit, trace, evidence, and cleanup without live DEV mutation."
: "The live preflight report did not attach a dry-run agent run."
};
}
function buildComponentEvidence(fixture, dryRun, result) {
const dbLiveReady = dbLiveReadyForResult(result);
const runtimeDurableReady = runtimeDurableReadyForResult(result);
const dbLiveStatus = result.blockedClassification === "DB live"
? "blocked"
: dbLiveReady
? "pass"
: "not_run";
const m3DependencyStatus = result.blockedClassification === "hardware loop"
? "blocked"
: result.blocked
? "not_run"
: "pass";
return {
agentManagerWorker: {
level: dryRun.status === "pass" ? "DRY-RUN" : "LOCAL",
status: dryRun.status === "pass" ? "pass" : "not_run",
services: [AGENT_MGR_SERVICE_ID, AGENT_WORKER_SERVICE_ID],
summary: dryRun.status === "pass"
? "Manager created a scoped local session and worker completed the bounded dry-run task."
: "Manager/worker live scheduling was not attempted before preflight blockers were classified."
},
workspaceIsolation: {
level: dryRun.status === "pass" ? "DRY-RUN" : "LOCAL",
status: "pass",
evidence: [
`primary=${fixture.projectId}/${fixture.agentSessionId}`,
`isolation=${fixture.workspaceIsolation.projectId}/${fixture.workspaceIsolation.agentSessionId}`
],
summary: "Workspace identity is per agent session and fixture isolation remains source/local evidence only."
},
skillsCommit: {
level: dryRun.status === "pass" ? "DRY-RUN" : "LOCAL",
status: "pass",
serviceId: AGENT_SKILLS_SERVICE_ID,
commitId: fixture.skillCommitId,
version: fixture.skillVersion,
summary: "Agent skills are accepted only with explicit commitId and version wiring."
},
traceEvidenceCleanup: {
level: dryRun.status === "pass" ? "DRY-RUN" : "LOCAL",
status: dryRun.status === "pass" ? "pass" : "not_run",
summary: dryRun.status === "pass"
? "Dry-run produced trace/evidence records and removed the temporary worker workspace."
: "Trace/evidence/cleanup live closure was not attempted before preflight blockers were classified."
},
dbLive: {
level: dbLiveStatus === "pass" ? "DEV-LIVE" : "BLOCKED",
status: dbLiveStatus,
summary: dbLiveStatus === "blocked"
? "Live scheduling is stopped at cloud-api DB readiness; DB health is classified separately from local dry-run evidence."
: dbLiveStatus === "pass"
? "Current read-only /health/live evidence reports DB ready=true, connected=true, and liveDbEvidence=true; DB live is not the active M4 blocker."
: "Live cloud-api DB readiness did not block before the current M4 classification point."
},
runtimeDurableAdapter: {
level: runtimeDurableReady ? "DEV-LIVE" : "BLOCKED",
status: result.blockerScope === "runtime-durable-adapter"
? "blocked"
: runtimeDurableReady
? "pass"
: "not_run",
summary: result.blockerScope === "runtime-durable-adapter"
? "Live scheduling is stopped at runtime durable adapter readiness; DB live evidence alone is not runtime durability evidence."
: runtimeDurableReady
? "Current read-only /health/live evidence reports runtime.adapter=postgres, durable=true, ready=true, and liveRuntimeEvidence=true."
: "Runtime durable adapter readiness did not produce the active M4 blocker."
},
m3HardwareLoopDependency: {
level: "BLOCKED",
status: m3DependencyStatus,
requiredPath: "DO1 -> hwlab-patch-panel -> DI1",
summary: m3DependencyStatus === "blocked"
? "M4 reached the hardware dispatch dependency, but the M3 patch-panel trusted loop is not accepted as live."
: "M4 can only support M3 through cloud-api/hardware API and the patch-panel trusted path; no direct box/gateway path is used."
}
};
}
function buildDependencyState(result) {
const dbLiveReady = dbLiveReadyForResult(result);
const runtimeDurableReady = runtimeDurableReadyForResult(result);
return {
dbLive: {
status: result.blockedClassification === "DB live" ? "blocked" : dbLiveReady ? "pass" : "not_run",
summary: result.blockedClassification === "DB live"
? result.blockerSummary
: dbLiveReady
? "cloud-api /health/live reports DB ready=true, connected=true, and liveDbEvidence=true."
: "DB live readiness did not produce the active M4 blocker."
},
runtimeDurableAdapter: {
status: result.blockerScope === "runtime-durable-adapter" ? "blocked" : runtimeDurableReady ? "pass" : "not_run",
requiredEvidence: "Postgres runtime adapter schema, migration ledger, and read-query readiness with liveRuntimeEvidence=true.",
summary: result.blockerScope === "runtime-durable-adapter"
? result.blockerSummary
: runtimeDurableReady
? "cloud-api /health/live reports runtime.adapter=postgres, durable=true, ready=true, and liveRuntimeEvidence=true."
: "Runtime durable adapter readiness did not produce the active M4 blocker."
},
m3HardwareLoop: {
status: result.blockedClassification === "hardware loop" ? "blocked" : result.blocked ? "not_run" : "pass",
requiredEvidence: "Traceable DO1 -> hwlab-patch-panel -> DI1 operation with auditId and evidenceId.",
summary: result.blockedClassification === "hardware loop"
? result.blockerSummary
: "M3 hardware loop was not reached while an earlier M4 precondition remained blocked."
},
hardwareDispatchBoundary: {
status: "pass",
allowedPath: "cloud-api /rpc hardware.operation.request -> hwlab-patch-panel trusted route",
forbiddenPaths: [
"direct hwlab-box-simu access",
"direct hwlab-gateway-simu access",
"agent-side fixture promotion to DEV-LIVE"
]
}
};
}
function buildReport(
fixture,
liveEvidence,
result,
{ dryRun = buildDryRunEvidence(), d601K3s = null, publicEntrypoints = null } = {}
) {
const sourceContract = {
status: "pass",
documents: requiredDocs,
summary: "The DEV M4 report is anchored to the frozen acceptance matrix and the M4 loop doc."
};
const validationCommands = [
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs",
"node --check scripts/dev-m4-agent-loop-smoke.mjs",
"node --check scripts/src/dev-m4-agent-loop-smoke-lib.mjs",
dryRunCommand,
preflightCommand,
d601K3sReadonlyCommand,
workerServerDryRunCommand
];
const blockers = blockerForResult(result);
return {
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json",
$id: "https://hwlab.pikastech.local/dev-m4-agent-loop.json",
reportVersion: "v1",
issue: "pikasTech/HWLAB#37",
taskId: "dev-m4-agent-loop",
commitId: currentOriginMainCommitId(),
acceptanceLevel: "dev_m4_agent_loop",
devOnly: true,
prodDisabled: true,
reportLifecycle: activeReportLifecycle("Current DEV M4 agent-loop preflight report; it does not substitute for M3 DEV-LIVE acceptance."),
sourceContract,
validationCommands,
localSmoke: {
status: "pass",
commands: ["node scripts/m4-agent-loop-smoke.mjs"],
evidence: [
"Local runtime skeleton smoke confirms create/start/trace/finish/cleanup coverage.",
"Workspace isolation and explicit skills commit wiring are covered by fixture assertions."
],
summary: "Local contract smoke passes on the repo fixture."
},
dryRun,
componentEvidence: buildComponentEvidence(fixture, dryRun, result),
dependencyState: buildDependencyState(result),
d601K3sNative: d601K3s,
publicEntrypoints,
safetyEvidence: {
devOnly: true,
namespace: devNamespace,
kubeconfig: d601Kubeconfig,
prodTouched: false,
prodNamespaceTouched: false,
secretMaterialRead: false,
secretResourcesRead: false,
servicesRestarted: false,
longRunningAgentStarted: false,
workerJobPersisted: false,
notes: [
"kubectl commands were scoped to hwlab-dev and /etc/rancher/k3s/k3s.yaml.",
"Secret RBAC was checked with kubectl auth can-i only; no Secret resource or secret value was read.",
"Worker createability used server-side dry-run with suspend=true and did not persist a Job."
]
},
devPreconditions: {
status: statusForResult(result),
classification: classificationForResult(result),
requirements: [
`DEV route remains frozen at ${DEV_ENDPOINT}`,
`DEV browser route remains frozen at ${DEV_FRONTEND_ENDPOINT}`,
`D601 native k3s access uses KUBECONFIG=${d601Kubeconfig} and namespace ${devNamespace}`,
"No secret reads, real deployment, or long-running agent task is attempted",
"DB live readiness and runtime durable adapter readiness are separate gates; DB live alone cannot promote M4",
"Agent manager, worker, and skills are reachable only through the cloud API boundary",
"M4 hardware assistance must go through cloud-api/hardware API and patch-panel; direct box/gateway access is forbidden"
],
summary: result.summary
},
blockers,
livePreflight: {
status: blockers.length > 0 ? "blocked" : "pass",
classification: classificationForResult(result),
commands: [preflightCommand],
evidence: liveEvidence.length ? liveEvidence : ["No live DEV observation was recorded."],
summary: result.summary
},
notes: "DEV M4 agent loop report. SOURCE/LOCAL/DRY-RUN evidence is not DEV-LIVE, and no secret material is read."
};
}
async function loadFixture() {
const fixture = await readJSON("fixtures/mvp/m4-agent-loop/runtime.json");
const localEvidence = await readText(fixture.evidencePath);
assert.ok(localEvidence.includes("Boundary: local only"));
return fixture;
}
async function runDryRun(fixture) {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-dev-m4-agent-loop-"));
try {
const created = await createLocalAgentSession({
stateDir,
agentSessionId: fixture.agentSessionId,
projectId: fixture.projectId,
goal: fixture.goal,
prompt: fixture.prompt,
skillCommitId: fixture.skillCommitId,
skillVersion: fixture.skillVersion,
now: fixedNow
});
assert.equal(created.status, "ok");
assert.equal(created.environment, ENVIRONMENT_DEV);
const worker = await runLocalWorkerDryRun({
stateDir,
agentSessionId: fixture.agentSessionId,
skillCommitId: fixture.skillCommitId,
skillVersion: fixture.skillVersion,
now: fixedNow
});
assert.equal(worker.dryRun, true);
assert.equal(worker.health.status, "ok");
assert.equal(worker.agentSession.status, "cleanup");
assert.equal(worker.workerSession.status, "cleanup");
assert.equal(worker.workspace.cleaned, true);
assert.equal(worker.workspace.existsAfterCleanup, false);
const status = await getLocalAgentStatus({
stateDir,
agentSessionId: fixture.agentSessionId
});
const trace = await getLocalAgentTrace({
stateDir,
agentSessionId: fixture.agentSessionId
});
const evidence = await getLocalAgentEvidence({
stateDir,
agentSessionId: fixture.agentSessionId
});
assert.equal(status.evidenceCount, 1);
assert.equal(trace.traceEvents.length, 5);
assert.equal(evidence.evidenceRecords.length, 1);
return buildDryRunEvidence({
status: "pass",
details: [
`${AGENT_MGR_SERVICE_ID}:session=${fixture.agentSessionId}:created=${created.task.status}:final=${worker.agentSession.status}`,
`${AGENT_WORKER_SERVICE_ID}:session=${worker.workerSession.workerSessionId}:status=${worker.workerSession.status}`,
`workspace:${worker.workspace.workspaceVolumeId}:cleaned=${worker.workspace.cleaned}:existsAfterCleanup=${worker.workspace.existsAfterCleanup}`,
`${AGENT_SKILLS_SERVICE_ID}:commit=${fixture.skillCommitId}:version=${fixture.skillVersion}`,
`trace:${trace.traceId}:events=${trace.traceEvents.length}`,
`evidence:${evidence.evidenceRecords[0].evidenceId}:kind=${evidence.evidenceRecords[0].kind}`,
`cleanup:${worker.cleanup.cleanupId}:removed=${worker.cleanup.removed}`
]
});
} finally {
await rm(stateDir, { recursive: true, force: true });
}
}
async function runLivePreflight(fixture, dryRun) {
const liveEvidence = [];
const [d601K3s, publicEntrypoints] = await Promise.all([
collectD601K3sNativeEvidence(fixture),
collectPublicEntrypoints()
]);
liveEvidence.push(...d601K3s.evidence);
liveEvidence.push(publicEntrypoints.summary);
let result = {
blocked: true,
blockerType: "network_blocker",
blockerScope: "frp",
blockedClassification: "frp",
blockerSummary: "The fixed DEV endpoint did not accept a live connection from this runner.",
summary: `Blocked before agent scheduling because ${DEV_ENDPOINT} is not reachable from this D601 runner.`
};
try {
const health = await requestJson(`${DEV_ENDPOINT}/health`);
if (health.statusCode < 200 || health.statusCode >= 300) {
result = {
blocked: true,
blockerType: "network_blocker",
blockerScope: "frp",
blockedClassification: "frp",
blockerSummary: `DEV health returned HTTP ${health.statusCode}.`,
summary: `Blocked on DEV ingress health because ${DEV_ENDPOINT}/health did not return success.`
};
} else {
const body = health.body || {};
liveEvidence.push(`health:${body.serviceId}:${body.environment}:${body.status}`);
const live = await requestJson(`${DEV_ENDPOINT}/health/live`);
const liveBody = live.body || {};
if (live.statusCode < 200 || live.statusCode >= 300 || liveBody?.serviceId !== "hwlab-cloud-api") {
result = {
blocked: true,
blockerType: "network_blocker",
blockerScope: "frp",
blockedClassification: "frp",
blockerSummary: "DEV live probe did not present the expected HWLAB cloud API identity.",
summary: "Blocked because the live DEV boundary did not expose the expected cloud API identity."
};
} else {
const liveReadiness = classifyCloudApiLiveReadiness(liveBody);
if (liveReadiness.blocked) {
liveEvidence.push(`live:${liveBody.serviceId}:${liveBody.status}:${liveReadiness.evidenceSuffix}`);
const runtime = runtimeDurabilityFromHealth(liveBody);
liveEvidence.push(`runtime-durable-adapter:adapter=${runtime.adapter}:blocker=${runtime.blocker}:migration=${runtime.migration.requiredMigrationId}:migrationChecked=${runtime.migration.checked}:migrationReady=${runtime.migration.ready}:queryResult=${runtime.queryResult}`);
result = liveReadiness;
} else {
liveEvidence.push(`live:${liveBody.serviceId}:${liveBody.status}`);
const runtime = runtimeDurabilityFromHealth(liveBody);
liveEvidence.push(`runtime-durable-adapter:adapter=${runtime.adapter}:ready=${runtime.ready}:migration=${runtime.migration.requiredMigrationId}:queryResult=${runtime.queryResult}`);
const rpcHealth = await requestJson(`${DEV_ENDPOINT}/rpc`, {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "req_dev_m4_health",
method: "system.health",
params: {},
meta: {
traceId: "trc_dev_m4_health",
serviceId: "hwlab-cloud-api",
environment: ENVIRONMENT_DEV
}
})
});
const rpcHealthBody = rpcHealth.body || {};
liveEvidence.push(`rpc-health:${rpcHealthBody.result?.serviceId ?? "unknown"}`);
if (rpcHealth.statusCode < 200 || rpcHealth.statusCode >= 300 || rpcHealthBody.error) {
result = {
blocked: true,
blockerType: "agent_blocker",
blockerScope: "agent-runtime",
blockedClassification: "agent runtime",
blockerSummary: rpcHealthBody.error?.message ?? `system.health returned HTTP ${rpcHealth.statusCode}.`,
summary: "Blocked at agent runtime health before attempting hardware dispatch."
};
} else {
const rpcHardware = await requestJson(`${DEV_ENDPOINT}/rpc`, {
method: "POST",
headers: {
"content-type": "application/json"
},
body: JSON.stringify({
jsonrpc: "2.0",
id: "req_dev_m4_hw",
method: "hardware.operation.request",
params: {
projectId: fixture.projectId,
input: {
requestedPath: "DO1 -> hwlab-patch-panel -> DI1",
directBoxGatewayAccess: false,
source: "dev-m4-agent-loop-preflight"
}
},
meta: {
traceId: "trc_dev_m4_hw",
serviceId: "hwlab-cloud-api",
environment: ENVIRONMENT_DEV
}
})
});
const rpcHardwareBody = rpcHardware.body || {};
const accepted = rpcHardwareBody.result?.accepted === true;
const status = rpcHardwareBody.result?.status || rpcHardwareBody.error?.message || "unknown";
liveEvidence.push(`rpc-hardware:${status}`);
if (!accepted) {
result = {
blocked: true,
blockerType: "agent_blocker",
blockerScope: "hardware-loop",
blockedClassification: "hardware loop",
blockerSummary: "hardware.operation.request did not accept the patch-panel-bounded M3 request.",
summary: "Blocked by M3 readiness because M4 did not observe an accepted DO1 -> patch-panel -> DI1 hardware request."
};
} else if (!rpcHardwareBody.result?.auditId || !rpcHardwareBody.result?.evidenceId) {
result = {
blocked: true,
blockerType: "observability_blocker",
blockerScope: "evidence-audit",
blockedClassification: "evidence/audit",
blockerSummary: "hardware.operation.request accepted but did not return audit/evidence identifiers.",
summary: "Blocked because accepted DEV agent hardware dispatch lacks audit/evidence closure."
};
} else {
result = {
blocked: false,
blockerType: null,
blockerScope: null,
blockedClassification: "none",
blockerSummary: "",
summary: "Live DEV preflight observed a non-skeleton agent hardware path."
};
}
}
}
}
}
} catch (error) {
result = {
blocked: true,
blockerType: "network_blocker",
blockerScope: "frp",
blockedClassification: "frp",
blockerSummary: error instanceof Error ? error.message : String(error),
summary: `Blocked before agent scheduling because ${DEV_ENDPOINT} is unreachable from this runner.`
};
}
result.additionalBlockers = d601K3s.blockers;
if (!result.blocked && result.additionalBlockers.length > 0) {
result.summary = "D601 native k3s agent evidence is blocked even though the public preflight path did not produce an earlier blocker.";
}
const report = buildReport(fixture, liveEvidence, result, { dryRun, d601K3s, publicEntrypoints });
await writeJSON(reportPath, report);
process.stdout.write(JSON.stringify({
reportPath,
status: report.devPreconditions.status,
blocker: report.blockers[0] ?? null,
classification: report.devPreconditions.classification,
evidence: report.livePreflight.evidence,
summary: report.livePreflight.summary,
observedAt: fixedNow()
}, null, 2));
process.stdout.write("\n");
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const fixture = await loadFixture();
if (args.dryRun) {
assert.equal(args.live, false, "--dry-run cannot be combined with --live");
const dryRun = await runDryRun(fixture);
process.stdout.write(JSON.stringify({
reportPath,
status: dryRun.status,
evidence: dryRun.evidence,
summary: dryRun.summary,
observedAt: fixedNow()
}, null, 2));
process.stdout.write("\n");
return;
}
assert.equal(args.live, true, "live preflight requires --live");
assert.equal(args.confirmDev, true, "live preflight requires --confirm-dev");
assert.equal(args.confirmedNonProduction, true, "live preflight requires --confirmed-non-production");
await runLivePreflight(fixture, await runDryRun(fixture));
}
await main();
+18 -106
View File
@@ -231,7 +231,7 @@ test("planner scopes cloud-web runtime changes to cloud-web", async () => {
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]);
assert.equal(plan.buildServices.includes("hwlab-agent-worker"), false);
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, ["component-path-changed"]);
});
@@ -408,96 +408,19 @@ test("v02 planner marks cloud-web code-only change as env reuse rollout", async
assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout");
});
test("v02 planner marks agent-worker code-only change as env reuse rollout", async () => {
const repo = await createFixtureRepo({ includeDevicePod: true, includeAgentWorker: true });
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
const initialPlan = await createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD",
targetRef: initialSha,
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-agent-worker"]
});
const initialWorker = initialPlan.services.find((service) => service.serviceId === "hwlab-agent-worker");
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
agentWorkerEnvironmentInputHash: initialWorker.environmentInputHash,
agentWorkerCodeInputHash: initialWorker.codeInputHash,
agentWorkerBootCommit: initialPlan.sourceCommitId
}), null, 2));
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
await git(repo, ["commit", "-m", "seed v02 worker catalog"]);
await writeFile(path.join(repo, "cmd/hwlab-agent-worker/main.ts"), "console.log('worker v2');\n");
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change agent worker code"]);
const plan = await createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD~1",
targetRef: "HEAD",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-agent-worker"]
});
const worker = plan.services.find((service) => service.serviceId === "hwlab-agent-worker");
assert.equal(worker.runtimeMode, "env-reuse-git-mirror-checkout");
assert.equal(worker.envChanged, false);
assert.equal(worker.codeChanged, true);
assert.equal(worker.buildRequired, false);
assert.equal(worker.bootSh, "deploy/runtime/boot/hwlab-agent-worker.sh");
assert.deepEqual(plan.affectedServices, ["hwlab-agent-worker"]);
assert.deepEqual(plan.rolloutServices, ["hwlab-agent-worker"]);
assert.deepEqual(plan.buildServices, []);
assert.equal(plan.imageBuildRequired, false);
assert.equal(plan.ciCdPlan.noImageBuildReason, "env-reuse-code-only-rollout");
});
test("v02 planner rebuilds agent-worker env image only for runtime dependency changes", async () => {
const repo = await createFixtureRepo({ includeDevicePod: true, includeAgentWorker: true });
const initialSha = (await git(repo, ["rev-parse", "HEAD"])).stdout.trim();
const initialPlan = await createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD",
targetRef: initialSha,
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-agent-worker"]
});
const initialWorker = initialPlan.services.find((service) => service.serviceId === "hwlab-agent-worker");
await writeFile(path.join(repo, "deploy/artifact-catalog.v02.json"), JSON.stringify(createCatalogFixture("ready", {
agentWorkerEnvironmentInputHash: initialWorker.environmentInputHash,
agentWorkerCodeInputHash: initialWorker.codeInputHash,
agentWorkerBootCommit: initialPlan.sourceCommitId
}), null, 2));
await git(repo, ["add", "deploy/artifact-catalog.v02.json"]);
await git(repo, ["commit", "-m", "seed v02 worker catalog"]);
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 worker env dependency"]);
const plan = await createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD~1",
targetRef: "HEAD",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-agent-worker"]
});
const worker = plan.services.find((service) => service.serviceId === "hwlab-agent-worker");
assert.equal(worker.runtimeMode, "env-reuse-git-mirror-checkout");
assert.equal(worker.envChanged, true);
assert.equal(worker.buildRequired, true);
assert.deepEqual(plan.affectedServices, ["hwlab-agent-worker"]);
assert.deepEqual(plan.buildServices, ["hwlab-agent-worker"]);
assert.ok(worker.reason.includes("environment-input-changed"));
assert.ok(worker.reason.includes("runtime-deps-changed"));
test("v02 planner rejects removed HWLAB-owned agent worker service selections", async () => {
const repo = await createFixtureRepo({ includeDevicePod: true });
await assert.rejects(
() => createG14CiPlan({
repoRoot: repo,
lane: "v02",
baseRef: "HEAD",
targetRef: "HEAD",
artifactCatalogPath: "deploy/artifact-catalog.v02.json",
services: ["hwlab-agent-worker"]
}),
/unknown service IDs for G14 CI plan: hwlab-agent-worker/u
);
});
test("v02 planner keeps hwlab-cli-only changes out of runtime service builds", async () => {
@@ -604,11 +527,9 @@ async function createFixtureRepo(options = {}) {
const deployServices = options.deployServices !== false;
const k3sServiceMappings = options.k3sServiceMappings === true;
const includeDevicePod = options.includeDevicePod === true;
const includeAgentWorker = options.includeAgentWorker === true;
const repo = await mkdtemp(path.join(os.tmpdir(), "hwlab-g14-ci-plan-"));
await mkdir(path.join(repo, "cmd/hwlab-cloud-api"), { recursive: true });
await mkdir(path.join(repo, "cmd/hwlab-deepseek-responses-bridge"), { recursive: true });
await mkdir(path.join(repo, "cmd/hwlab-agent-worker"), { 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/device-pod"), { recursive: true });
@@ -627,7 +548,6 @@ async function createFixtureRepo(options = {}) {
await writeFile(path.join(repo, "cmd/hwlab-cloud-api/main.ts"), "console.log('api');\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-agent-worker/main.ts"), "console.log('worker');\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");
@@ -642,7 +562,6 @@ async function createFixtureRepo(options = {}) {
await writeFile(path.join(repo, "skills/device-pod-cli/scripts/device-pod-cli.mjs"), "console.log('skill wrapper');\n");
await writeFile(path.join(repo, "skills/device-pod-cli/assets/device-host-cli.mjs"), "console.log('host cli');\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-agent-worker.sh"), "#!/bin/sh\nexec bun cmd/hwlab-agent-worker/main.ts \"$@\"\n");
await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-device-pod.sh"), "#!/bin/sh\nexec bun cmd/hwlab-device-pod/main.ts\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");
@@ -656,7 +575,7 @@ async function createFixtureRepo(options = {}) {
baseRef: "HEAD",
targetRef: "HEAD",
artifactCatalogPath: "deploy/nonexistent-artifact-catalog.json",
services: ["hwlab-cloud-api", "hwlab-cloud-web", ...(includeAgentWorker ? ["hwlab-agent-worker"] : []), ...(includeDevicePod ? ["hwlab-device-pod"] : [])]
services: ["hwlab-cloud-api", "hwlab-cloud-web", ...(includeDevicePod ? ["hwlab-device-pod"] : [])]
});
const componentInputHashes = Object.fromEntries(initialPlan.services.map((service) => [service.serviceId, service.componentInputHash]));
const catalog = createCatalogFixture(catalogMode, { sourceCommitId: initialSha, componentInputHashes });
@@ -691,10 +610,9 @@ function createDeployFixture({ deployServices, k3sServiceMappings, includeDevice
lanes: {
v02: {
sourceRepo: "git@github.com:pikasTech/HWLAB.git",
envReuseServices: ["hwlab-cloud-web", "hwlab-agent-worker", "hwlab-device-pod"],
envReuseServices: ["hwlab-cloud-web", "hwlab-device-pod"],
bootScripts: {
"hwlab-cloud-web": "deploy/runtime/boot/hwlab-cloud-web.sh",
"hwlab-agent-worker": "deploy/runtime/boot/hwlab-agent-worker.sh",
"hwlab-device-pod": "deploy/runtime/boot/hwlab-device-pod.sh"
}
}
@@ -722,7 +640,7 @@ function createDeployFixture({ deployServices, k3sServiceMappings, includeDevice
function createCatalogFixture(mode, options = {}) {
const digest = mode === "ready" ? `sha256:${"1".repeat(64)}` : "not_published";
return {
services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-agent-worker", "hwlab-device-pod"].map((serviceId) => ({
services: ["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod"].map((serviceId) => ({
serviceId,
commitId: "abc1234",
sourceCommitId: options.sourceCommitId ?? "abc1234",
@@ -732,26 +650,20 @@ function createCatalogFixture(mode, options = {}) {
...((options.componentInputHashes?.[serviceId] || (serviceId === "hwlab-cloud-api" && options.cloudApiComponentInputHash)) ? {
componentInputHash: options.cloudApiComponentInputHash ?? options.componentInputHashes?.[serviceId]
} : {}),
...((serviceId === "hwlab-cloud-web" || serviceId === "hwlab-agent-worker" || serviceId === "hwlab-device-pod") ? {
...((serviceId === "hwlab-cloud-web" || serviceId === "hwlab-device-pod") ? {
runtimeMode: "env-reuse-git-mirror-checkout",
envReuse: true,
environmentImage: `127.0.0.1:5000/hwlab/${serviceId}-env:env-abc1234`,
environmentDigest: digest,
environmentInputHash: serviceId === "hwlab-cloud-web"
? options.cloudWebEnvironmentInputHash ?? "e".repeat(64)
: serviceId === "hwlab-agent-worker"
? options.agentWorkerEnvironmentInputHash ?? "e".repeat(64)
: options.devicePodEnvironmentInputHash ?? "e".repeat(64),
codeInputHash: serviceId === "hwlab-cloud-web"
? options.cloudWebCodeInputHash ?? "c".repeat(64)
: serviceId === "hwlab-agent-worker"
? options.agentWorkerCodeInputHash ?? "c".repeat(64)
: options.devicePodCodeInputHash ?? "c".repeat(64),
bootRepo: "git@github.com:pikasTech/HWLAB.git",
bootCommit: serviceId === "hwlab-cloud-web"
? options.cloudWebBootCommit ?? "abc1234"
: serviceId === "hwlab-agent-worker"
? options.agentWorkerBootCommit ?? "abc1234"
: options.devicePodBootCommit ?? "abc1234",
bootSh: `deploy/runtime/boot/${serviceId}.sh`
} : {})
+37 -7
View File
@@ -33,8 +33,6 @@ const defaultDevBaseImage = process.env.HWLAB_G14_DEV_BASE_IMAGE || "127.0.0.1:5
const defaultServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
@@ -43,14 +41,16 @@ const defaultServiceIds = Object.freeze([
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
"hwlab-agent-skills"
]);
const v02RemovedServiceIds = new Set([
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-agent-worker-template",
"hwlab-code-agent-workspace",
"hwlab-router",
"hwlab-tunnel-client",
"hwlab-gateway-simu",
@@ -58,6 +58,18 @@ const v02RemovedServiceIds = new Set([
"hwlab-patch-panel"
]);
const v02RemovedServiceEnvNames = new Set([
"CODEX_HOME",
"OPENAI_API_KEY",
"HWLAB_CODE_AGENT_PROVIDER",
"HWLAB_CODE_AGENT_MODEL",
"HWLAB_CODE_AGENT_OPENAI_BASE_URL",
"HWLAB_CODE_AGENT_WORKSPACE",
"HWLAB_CODE_AGENT_CODEX_HOME",
"HWLAB_CODE_AGENT_CODEX_WORKSPACE",
"HWLAB_CODE_AGENT_CODEX_SANDBOX",
"HWLAB_CODE_AGENT_CODEX_COMMAND",
"HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED",
"HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR",
"HWLAB_M3_GATEWAY_SIMU_1_URL",
"HWLAB_M3_GATEWAY_SIMU_2_URL",
"HWLAB_M3_PATCH_PANEL_URL",
@@ -728,6 +740,21 @@ function removeV02LegacySimulatorEnv(envList) {
return envList.filter((entry) => !v02RemovedServiceEnvNames.has(entry?.name));
}
function removeV02RepoOwnedCodeAgentPodConfig(podSpec) {
if (!podSpec) return;
podSpec.initContainers = (podSpec.initContainers ?? []).filter((entry) => entry?.name !== "hwlab-code-agent-workspace-init");
const removedVolumeNames = new Set([
"hwlab-code-agent-workspace",
"hwlab-code-agent-codex-home",
"hwlab-code-agent-codex-config",
"hwlab-code-agent-codex-auth"
]);
podSpec.volumes = (podSpec.volumes ?? []).filter((volume) => !removedVolumeNames.has(volume?.name));
for (const container of podSpec.containers ?? []) {
container.volumeMounts = (container.volumeMounts ?? []).filter((mount) => !removedVolumeNames.has(mount?.name));
}
}
function deployServicesForProfile(deploy, profile) {
const allowed = new Set(serviceIdsForProfile(profile));
const services = new Map((deploy.services ?? [])
@@ -867,8 +894,8 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
upsertEnv(container.env, "HWLAB_PUBLIC_ENDPOINT", runtimeEndpoint);
}
if (serviceId === "hwlab-cloud-api") {
syncCodeAgentWorkspaceInitContainer(podTemplate.spec.initContainers, { image, runtimeCommit, runtimeImageTag });
if (profile === "v02") {
removeV02RepoOwnedCodeAgentPodConfig(podTemplate.spec);
upsertEnv(container.env, "HWLAB_M3_IO_CONTROL_ENABLED", "false");
upsertEnv(container.env, "HWLAB_ACCESS_CONTROL_REQUIRED", "1");
upsertEnv(container.env, "HWLAB_BOOTSTRAP_ADMIN_ID", "usr_v02_admin");
@@ -885,6 +912,8 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch =
upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_SECRET_NAMESPACE", "agentrun-v01");
upsertEnv(container.env, "HWLAB_CODE_AGENT_AGENTRUN_REPO_URL", "http://git-mirror-http.devops-infra.svc.cluster.local/pikasTech/HWLAB.git");
upsertEnv(container.env, "UNIDESK_MAIN_SERVER_IP", "http://74.48.78.17:18081");
} else {
syncCodeAgentWorkspaceInitContainer(podTemplate.spec.initContainers, { image, runtimeCommit, runtimeImageTag });
}
upsertEnv(container.env, "HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE", "deepseek");
upsertEnv(container.env, "HWLAB_CODE_AGENT_DEEPSEEK_MODEL", deepSeekProfileModel);
@@ -4339,7 +4368,8 @@ function v02PostgresManifest({ migrationSql, source }) {
}
function runtimeKustomization({ profile = "dev", includeDeviceAgent = profile === "dev" } = {}) {
const resources = ["namespace.yaml", "code-agent-codex-config.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"];
const resources = ["namespace.yaml", "services.yaml", "health-contract.yaml", "workloads.yaml", "deepseek-proxy.yaml"];
if (profile !== "v02") resources.splice(1, 0, "code-agent-codex-config.yaml");
if (profile === "v02") resources.push("postgres.yaml");
if (includeDeviceAgent) resources.push("device-agent-71-freq.yaml");
resources.push("g14-frpc.yaml");
@@ -4467,7 +4497,7 @@ async function plannedFiles(args) {
const endpoints = profileEndpoint(args, profile);
putJson(`${runtimePath}/kustomization.yaml`, runtimeKustomization({ profile, includeDeviceAgent }));
putJson(`${runtimePath}/namespace.yaml`, runtimeNamespace);
putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
if (profile !== "v02") putJson(`${runtimePath}/code-agent-codex-config.yaml`, transformListNamespace(config, namespace, profileLabels, annotations));
putJson(`${runtimePath}/services.yaml`, transformServices({ services, namespace, labels: profileLabels, annotations, profile }));
putJson(`${runtimePath}/health-contract.yaml`, transformHealthContract(health, namespace, profileLabels, annotations, endpoints.runtimeEndpoint, endpoints.webEndpoint, profile));
putJson(`${runtimePath}/workloads.yaml`, transformWorkloads({ workloads, deploy, catalog: artifactCatalog, source, sourceBranch: args.sourceBranch, registryPrefix: args.registryPrefix, runtimeEndpoint: endpoints.runtimeEndpoint, webEndpoint: endpoints.webEndpoint, profile, useDeployImages: args.useDeployImages }));
+12 -11
View File
@@ -166,6 +166,10 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
}
const removedServiceIds = [
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-agent-worker-template",
"hwlab-code-agent-workspace",
"hwlab-router",
"hwlab-tunnel-client",
"hwlab-gateway-simu",
@@ -179,8 +183,6 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
const retainedServiceIds = [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
@@ -231,19 +233,18 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots
assert.ok(cloudApi, "expected v02 hwlab-cloud-api workload");
const cloudApiEnv = cloudApi.spec.template.spec.containers[0].env;
assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_SOURCE_COMMIT" && entry.value === sourceRevision));
assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_CODE_AGENT_ADAPTER" && entry.value === "agentrun-v01"));
assert.equal(cloudApi.spec.template.spec.initContainers?.some((entry) => entry.name === "hwlab-code-agent-workspace-init"), false);
assert.equal(cloudApi.spec.template.spec.volumes?.some((entry) => /code-agent-codex|code-agent-workspace/u.test(entry.name)), false);
assert.equal(cloudApi.spec.template.spec.containers[0].volumeMounts?.some((entry) => /code-agent-codex|code-agent-workspace/u.test(entry.name)), false);
assert.equal(cloudApiEnv.some((entry) => entry.name === "HWLAB_CODE_AGENT_PROVIDER" && entry.value === "codex-stdio"), false);
assert.equal(cloudApiEnv.some((entry) => entry.name === "HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR"), false);
assert.ok(cloudApiEnv.some((entry) => entry.name === "UNIDESK_MAIN_SERVER_IP" && entry.value === "http://74.48.78.17:18081"));
assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_KEYCLOAK_ISSUER" && entry.value === "https://auth.74-48-78-17.nip.io/realms/hwlab"));
assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_KEYCLOAK_CLIENT_ID" && entry.value === "hwlab-cloud-web"));
assert.ok(cloudApiEnv.some((entry) => entry.name === "HWLAB_KEYCLOAK_CLIENT_SECRET" && entry.valueFrom?.secretKeyRef?.name === "hwlab-cloud-web-client" && entry.valueFrom?.secretKeyRef?.key === "client-secret"));
const agentWorker = workloadsJson.items.find((item) => item.metadata?.labels?.["hwlab.pikastech.local/service-id"] === "hwlab-agent-worker");
assert.ok(agentWorker, "expected v02 hwlab-agent-worker suspended Job template");
const agentWorkerEnv = agentWorker?.spec?.template?.spec?.containers?.[0]?.env ?? [];
assert.ok(agentWorkerEnv.some((entry) => entry.name === "HWLAB_RUNTIME_MODE" && entry.value === "env-reuse-git-mirror-checkout"));
assert.ok(agentWorkerEnv.some((entry) => entry.name === "HWLAB_BOOT_REPO" && entry.value === "git@github.com:pikasTech/HWLAB.git"));
assert.ok(agentWorkerEnv.some((entry) => entry.name === "HWLAB_BOOT_COMMIT" && entry.value === sourceRevision));
assert.ok(agentWorkerEnv.some((entry) => entry.name === "HWLAB_BOOT_SH" && entry.value === "deploy/runtime/boot/hwlab-agent-worker.sh"));
assert.equal(agentWorker?.spec?.template?.metadata?.annotations?.["hwlab.pikastech.local/runtime-mode"], "env-reuse-git-mirror-checkout");
assert.equal(agentWorker?.spec?.template?.metadata?.annotations?.["hwlab.pikastech.local/boot-commit"], sourceRevision);
assert.equal(workloadsJson.items.some((item) => item.metadata?.labels?.["hwlab.pikastech.local/service-id"] === "hwlab-agent-worker"), false);
assert.doesNotMatch(await readFile(path.join(outDir, "runtime-v02", "kustomization.yaml"), "utf8"), /code-agent-codex-config/u);
const workloadTemplates = workloadsJson.items.filter((item) => item.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/source-commit"]);
assert.ok(workloadTemplates.length > 0, "expected rendered pod-template source labels");
assert.equal(devicePod.spec.template.metadata.labels["hwlab.pikastech.local/source-commit"], sourceRevision);
-634
View File
@@ -1,634 +0,0 @@
#!/usr/bin/env bun
import assert from "node:assert/strict";
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import net from "node:net";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs";
import { validateCodeAgentChatSchema } from "../internal/cloud/code-agent-chat.ts";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const children = [];
const bunCommand = resolveBunCommand();
const CODEX_STDIO_FEASIBILITY_BLOCKERS = new Set([
"codex_cli_binary_missing",
"codex_stdio_supervisor_disabled",
"codex_stdio_egress_boundary"
]);
function logOk(name) {
process.stdout.write(`[m1-smoke] ok ${name}\n`);
}
function assertCodexStdioFeasibilityBlocker(value, label = "codex stdio blocker") {
assert.equal(CODEX_STDIO_FEASIBILITY_BLOCKERS.has(value), true, `${label}: ${value}`);
}
function freePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.on("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : null;
server.close((error) => {
if (error) {
reject(error);
} else {
resolve(port);
}
});
});
});
}
function startNode(relativeScript, env = {}) {
const command = commandForScript(relativeScript);
const child = spawn(command.bin, command.args, {
cwd: repoRoot,
env: {
...process.env,
...env
},
stdio: ["ignore", "pipe", "pipe"]
});
const output = {
stdout: "",
stderr: ""
};
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
output.stdout += chunk;
});
child.stderr.on("data", (chunk) => {
output.stderr += chunk;
});
const record = { child, relativeScript, output };
children.push(record);
return record;
}
function resolveBunCommand() {
const candidates = [
process.env.HWLAB_BUN_COMMAND,
process.env.HOME ? path.join(process.env.HOME, ".bun/bin/bun") : null,
"bun"
].filter(Boolean);
return candidates.find((candidate) => !candidate.includes("/") || existsSync(candidate)) || "bun";
}
function runNodeJson(relativeScript, args = [], env = {}) {
return new Promise((resolve, reject) => {
const command = commandForScript(relativeScript, args);
const child = spawn(command.bin, command.args, {
cwd: repoRoot,
env: {
...process.env,
...env
},
stdio: ["ignore", "pipe", "pipe"]
});
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => {
stdout += chunk;
});
child.stderr.on("data", (chunk) => {
stderr += chunk;
});
child.on("error", reject);
child.on("close", (code) => {
if (code !== 0) {
reject(new Error(`${relativeScript} ${args.join(" ")} exited with ${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`));
return;
}
try {
resolve(JSON.parse(stdout));
} catch (error) {
reject(new Error(`${relativeScript} ${args.join(" ")} did not return JSON: ${error.message}\nstdout:\n${stdout}\nstderr:\n${stderr}`));
}
});
});
}
function commandForScript(relativeScript, args = []) {
if (relativeScript.endsWith(".ts")) {
return {
bin: bunCommand,
args: ["run", relativeScript, ...args]
};
}
return {
bin: process.execPath,
args: [relativeScript, ...args]
};
}
async function stopNode(record) {
const { child } = record;
if (child.exitCode !== null || child.signalCode !== null) {
return;
}
await new Promise((resolve) => {
const killTimer = setTimeout(() => {
child.kill("SIGKILL");
}, 1500);
child.once("exit", () => {
clearTimeout(killTimer);
resolve();
});
child.kill("SIGTERM");
});
}
async function requestJson(url, options = {}) {
const response = await fetch(url, {
...options,
headers: {
"content-type": "application/json",
...(options.headers ?? {})
}
});
const text = await response.text();
const body = text ? JSON.parse(text) : null;
return { response, body };
}
async function waitForHttp(url, record) {
const startedAt = Date.now();
let lastError;
while (Date.now() - startedAt < 5000) {
if (record.child.exitCode !== null) {
throw new Error(
`${record.relativeScript} exited before becoming ready\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}`
);
}
try {
const { response } = await requestJson(url);
if (response.ok) {
return;
}
lastError = new Error(`HTTP ${response.status}`);
} catch (error) {
lastError = error;
}
await new Promise((resolve) => setTimeout(resolve, 75));
}
throw new Error(
`${record.relativeScript} did not become ready at ${url}: ${lastError?.message ?? "timeout"}\nstdout:\n${record.output.stdout}\nstderr:\n${record.output.stderr}`
);
}
function rpcEnvelope({ id, method, params = {} }) {
return {
jsonrpc: "2.0",
id,
method,
params,
meta: {
traceId: `trc_${id}`,
serviceId: "hwlab-cloud-api",
environment: ENVIRONMENT_DEV
}
};
}
async function smokeCloudApi() {
const port = await freePort();
const service = startNode("cmd/hwlab-cloud-api/main.ts", {
HWLAB_CLOUD_API_HOST: "127.0.0.1",
HWLAB_CLOUD_API_PORT: String(port),
HWLAB_CLOUD_DB_URL: "",
HWLAB_CLOUD_DB_SSL_MODE: "",
HWLAB_CODE_AGENT_PROVIDER: "codex-cli",
HWLAB_CODE_AGENT_MODEL: "gpt-m1-local",
HWLAB_CODE_AGENT_CODEX_COMMAND: "/tmp/hwlab-missing-codex",
OPENAI_API_KEY: "",
PATH: ""
});
const baseUrl = `http://127.0.0.1:${port}`;
await waitForHttp(`${baseUrl}/live`, service);
const health = await requestJson(`${baseUrl}/health`);
assert.equal(health.response.status, 200);
assert.equal(health.body.serviceId, "hwlab-cloud-api");
assert.equal(health.body.environment, ENVIRONMENT_DEV);
assert.equal(health.body.status, "degraded");
assert.equal(health.body.db.connected, false);
assert.equal(health.body.db.liveConnected, false);
assert.equal(health.body.db.liveDbEvidence, false);
assert.equal(health.body.db.connectionAttempted, false);
assert.equal(health.body.db.connectionResult, "not_attempted_missing_env");
assert.equal(health.body.db.status, "blocked");
assert.equal(health.body.runtime.durable, false);
assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]);
assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db");
assert.equal(health.body.db.secretRefs[0].redacted, true);
assert.equal(health.body.codeAgent.status, "partial");
assert.match(health.body.codeAgent.blocker, /Codex stdio|long-lived|Codex CLI command/u);
assertCodexStdioFeasibilityBlocker(health.body.codeAgent.reason, "health codeAgent reason");
assert.equal(health.body.readiness.provider.status, "blocked");
assert.equal(health.body.readiness.provider.blocker, "provider_config_blocked");
assert.equal(health.body.readiness.sessionRunner.status, "read_only_long_lived_ready");
assert.equal(health.body.readiness.sessionRunner.ready, true);
assert.equal(health.body.readiness.codexStdio.status, "blocked");
assertCodexStdioFeasibilityBlocker(health.body.readiness.codexStdio.blocker, "health readiness codexStdio blocker");
assert.equal(health.body.readiness.codeAgent.currentBlockers.includes("provider_config_blocked"), true);
assert.equal(health.body.readiness.codeAgent.currentBlockers.includes(health.body.readiness.codexStdio.blocker), true);
assert.ok(health.body.codeAgent.missingEnv.includes("OPENAI_API_KEY"));
assert.equal(health.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
assert.equal(health.body.codeAgent.secretRefs[0].secretKey, "openai-api-key");
assert.equal(health.body.codeAgent.secretRefs[0].redacted, true);
assert.equal(JSON.stringify(health.body.codeAgent).includes("sk-"), false);
const live = await requestJson(`${baseUrl}/live`);
assert.equal(live.response.status, 200);
assert.equal(live.body.status, "live");
const adapter = await requestJson(`${baseUrl}/v1`);
assert.equal(adapter.response.status, 200);
assert.equal(adapter.body.status, "degraded");
assert.ok(adapter.body.methods.includes("system.health"));
assert.ok(adapter.body.methods.includes("gateway.session.register"));
assert.ok(adapter.body.methods.includes("box.capability.report"));
assert.ok(adapter.body.methods.includes("hardware.operation.request"));
assert.ok(adapter.body.methods.includes("audit.event.query"));
assert.ok(adapter.body.methods.includes("evidence.record.query"));
assert.equal(adapter.body.codeAgent.endpoint, "POST /v1/agent/chat");
assert.equal(adapter.body.codeAgent.status, "partial");
assert.match(adapter.body.codeAgent.blocker, /Codex stdio|long-lived|Codex CLI command/u);
assert.equal(adapter.body.codeAgent.provider, "codex-cli");
assert.equal(adapter.body.codeAgent.model, "gpt-m1-local");
assert.equal(adapter.body.codeAgent.backend, "hwlab-cloud-api/codex-cli");
assert.equal(adapter.body.codeAgent.ready, false);
assert.equal(adapter.body.readiness.provider.status, "blocked");
assert.equal(adapter.body.readiness.sessionRunner.status, "read_only_long_lived_ready");
assert.equal(adapter.body.readiness.codexStdio.status, "blocked");
assert.ok(adapter.body.codeAgent.missingEnv.includes("OPENAI_API_KEY"));
assert.equal(adapter.body.codeAgent.secretRefs[0].secretName, "hwlab-code-agent-provider");
assert.equal(adapter.body.codeAgent.secretRefs[0].secretKey, "openai-api-key");
assert.equal(adapter.body.codeAgent.secretRefs[0].redacted, true);
assert.match(adapter.body.codeAgent.summary, /hwlab-code-agent-provider\/openai-api-key/u);
assert.equal(JSON.stringify(adapter.body.codeAgent).includes("sk-"), false);
const agentChat = await requestJson(`${baseUrl}/v1/agent/chat`, {
method: "POST",
body: JSON.stringify({
conversationId: "cnv_m1_local_chat",
traceId: "trc_m1_local_chat",
projectId: "prj_m1_local_contract",
message: "请用一句话说明本地 SOURCE/LOCAL 合同。"
})
});
assert.equal(agentChat.response.status, 200);
validateCodeAgentChatSchema(agentChat.body);
assert.equal(agentChat.body.conversationId, "cnv_m1_local_chat");
assert.equal(agentChat.body.sessionId, "cnv_m1_local_chat");
assert.match(agentChat.body.messageId, /^msg_/);
assert.equal(agentChat.body.status, "failed");
assert.equal(agentChat.body.traceId, "trc_m1_local_chat");
assert.equal(agentChat.body.projectId, "prj_m1_local_contract");
assert.equal(agentChat.body.provider, "codex-cli");
assert.equal(agentChat.body.model, "gpt-m1-local");
assert.equal(agentChat.body.backend, "hwlab-cloud-api/codex-cli");
assert.equal(agentChat.body.error.code, "codex_cli_binary_missing");
assert.match(agentChat.body.error.message, /Codex CLI command is not available/);
assert.deepEqual(agentChat.body.error.missingCommands, ["/tmp/hwlab-missing-codex"]);
assert.ok(agentChat.body.error.missingEnv.includes("OPENAI_API_KEY"));
assert.equal(agentChat.body.availability.status, "partial");
assert.match(agentChat.body.availability.blocker, /Codex stdio|long-lived|Codex CLI command/u);
assertCodexStdioFeasibilityBlocker(agentChat.body.availability.reason, "agent chat availability reason");
assert.equal(agentChat.body.availability.fallback.status, "blocked");
assert.equal(agentChat.body.availability.fallback.reason, "provider_unavailable");
assert.equal(agentChat.body.availability.secretRefs[0].secretName, "hwlab-code-agent-provider");
assert.equal(agentChat.body.availability.secretRefs[0].secretKey, "openai-api-key");
assert.equal(agentChat.body.availability.secretRefs[0].redacted, true);
assert.match(agentChat.body.availability.summary, /hwlab-code-agent-provider\/openai-api-key/u);
assert.equal(JSON.stringify(agentChat.body).includes("sk-"), false);
assert.equal(Object.hasOwn(agentChat.body, "reply"), false);
const rpcHealth = await requestJson(`${baseUrl}/rpc`, {
method: "POST",
body: JSON.stringify(rpcEnvelope({ id: "req_m1_health", method: "system.health" }))
});
assert.equal(rpcHealth.response.status, 200);
assert.equal(rpcHealth.body.jsonrpc, "2.0");
assert.equal(rpcHealth.body.result.status, "degraded");
assert.equal(rpcHealth.body.result.serviceId, "hwlab-cloud-api");
assert.equal(rpcHealth.body.result.db.status, "blocked");
assert.equal(rpcHealth.body.meta.serviceId, "hwlab-cloud-api");
const projectId = "prj_mvp_topology";
const gatewaySessionId = "gws_m1_runtime";
const resourceId = "res_m1_runtime";
const capabilityId = "cap_m1_runtime";
const rpcGateway = await requestJson(`${baseUrl}/rpc`, {
method: "POST",
body: JSON.stringify(
rpcEnvelope({
id: "req_m1_gateway",
method: "gateway.session.register",
params: {
projectId,
gatewaySessionId,
gatewayId: "gtw_m1_runtime",
serviceId: "hwlab-gateway-simu",
endpoint: `http://127.0.0.1:${port}/gateway`
}
})
)
});
assert.equal(rpcGateway.response.status, 200);
assert.equal(rpcGateway.body.result.registered, true);
const rpcBox = await requestJson(`${baseUrl}/rpc`, {
method: "POST",
body: JSON.stringify(
rpcEnvelope({
id: "req_m1_box",
method: "box.resource.register",
params: {
projectId,
gatewaySessionId,
resourceId,
boxId: "box_m1_runtime",
resourceType: "board"
}
})
)
});
assert.equal(rpcBox.response.status, 200);
assert.equal(rpcBox.body.result.registered, true);
const rpcCapability = await requestJson(`${baseUrl}/rpc`, {
method: "POST",
body: JSON.stringify(
rpcEnvelope({
id: "req_m1_capability",
method: "box.capability.report",
params: {
capabilityId,
resourceId,
projectId,
name: "shell.exec",
direction: "bidirectional",
valueType: "object",
mutatesState: true
}
})
)
});
assert.equal(rpcCapability.response.status, 200);
assert.equal(rpcCapability.body.result.reported, true);
const rpcHardware = await requestJson(`${baseUrl}/rpc`, {
method: "POST",
body: JSON.stringify(
rpcEnvelope({
id: "req_m1_hardware",
method: "hardware.operation.request",
params: {
projectId,
gatewaySessionId,
resourceId,
capabilityId,
input: {
command: "echo m1"
}
}
})
)
});
assert.equal(rpcHardware.response.status, 200);
assert.equal(rpcHardware.body.result.accepted, true);
assert.equal(rpcHardware.body.result.status, "accepted");
assert.equal(rpcHardware.body.result.auditEvent.serviceId, "hwlab-cloud-api");
assert.equal(rpcHardware.body.result.persistence.durable, false);
logOk("cloud-api health/live/rpc runtime");
}
async function smokePatchPanelAndSimulators() {
const gatewayPort = await freePort();
const boxPort = await freePort();
const patchPort = await freePort();
const gateway = startNode("cmd/hwlab-gateway-simu/main.mjs", {
PORT: String(gatewayPort),
HWLAB_GATEWAY_ID: "gwsimu_m1",
HWLAB_BOX_RESOURCES: "res_boxsim_alpha,res_boxsim_beta"
});
const box = startNode("cmd/hwlab-box-simu/main.mjs", {
PORT: String(boxPort),
HWLAB_BOX_ID: "boxsim_alpha"
});
const patchPanel = startNode("cmd/hwlab-patch-panel/main.mjs", {
PORT: String(patchPort),
HWLAB_WIRING_CONFIG_PATH: path.join(repoRoot, "fixtures/mvp-topology/topology.json")
});
const gatewayBase = `http://127.0.0.1:${gatewayPort}`;
const boxBase = `http://127.0.0.1:${boxPort}`;
const patchBase = `http://127.0.0.1:${patchPort}`;
await Promise.all([
waitForHttp(`${gatewayBase}/health/live`, gateway),
waitForHttp(`${boxBase}/health/live`, box),
waitForHttp(`${patchBase}/health/live`, patchPanel)
]);
const gatewayStatus = await requestJson(`${gatewayBase}/status`);
assert.equal(gatewayStatus.response.status, 200);
assert.equal(gatewayStatus.body.serviceId, "hwlab-gateway-simu");
assert.equal(gatewayStatus.body.gatewayId, "gwsimu_m1");
assert.equal(gatewayStatus.body.live, true);
assert.deepEqual(gatewayStatus.body.boxes, ["res_boxsim_alpha", "res_boxsim_beta"]);
const boxStatus = await requestJson(`${boxBase}/status`);
assert.equal(boxStatus.response.status, 200);
assert.equal(boxStatus.body.serviceId, "hwlab-box-simu");
assert.equal(boxStatus.body.boxId, "boxsim_alpha");
assert.equal(boxStatus.body.live, true);
assert.equal(boxStatus.body.constraints.crossDevicePropagation, "patch-panel-only");
assert.equal(boxStatus.body.constraints.localLoopbackEnabled, false);
assert.ok(boxStatus.body.ports.uart0);
const writePort = await requestJson(`${boxBase}/ports/write`, {
method: "POST",
body: JSON.stringify({
port: "uart0",
value: "m1-smoke",
source: "local"
})
});
assert.equal(writePort.response.status, 200);
assert.equal(writePort.body.accepted, true);
assert.equal(writePort.body.crossDevicePropagation, "patch-panel-only");
const patchStatus = await requestJson(`${patchBase}/status`);
assert.equal(patchStatus.response.status, 200);
assert.equal(patchStatus.body.serviceId, "hwlab-patch-panel");
assert.equal(patchStatus.body.state, "active");
assert.equal(patchStatus.body.metadata.propagation, "patch-panel-only");
assert.equal(patchStatus.body.activeConnections.length, 2);
const routed = await requestJson(`${patchBase}/signals/route`, {
method: "POST",
body: JSON.stringify({
fromResourceId: "res_boxsim_alpha",
fromPort: "uart0",
value: "m1-smoke"
})
});
assert.equal(routed.response.status, 200);
assert.equal(routed.body.accepted, true);
assert.equal(routed.body.propagatedBy, "hwlab-patch-panel");
assert.equal(routed.body.deliveryCount, 1);
assert.deepEqual(routed.body.deliveries[0], {
resourceId: "res_boxsim_beta",
port: "uart0",
value: "m1-smoke",
sourceResourceId: "res_boxsim_alpha",
sourcePort: "uart0",
observedAt: routed.body.deliveries[0].observedAt
});
logOk("patch-panel/sim status and route runtime");
}
async function smokeAgentMgrLocalContract() {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-m1-agent-mgr-"));
const agentSessionId = "agt_m1_local_contract";
const agentMgrPort = await freePort();
let agentMgr;
try {
agentMgr = startNode("cmd/hwlab-agent-mgr/main.ts", {
PORT: String(agentMgrPort),
HWLAB_AGENT_RUNTIME_STATE_DIR: stateDir,
HWLAB_ENVIRONMENT: ENVIRONMENT_DEV
});
const baseUrl = `http://127.0.0.1:${agentMgrPort}`;
await waitForHttp(`${baseUrl}/health/live`, agentMgr);
const healthLive = await requestJson(`${baseUrl}/health/live`);
assert.equal(healthLive.response.status, 200);
assert.equal(healthLive.body.serviceId, "hwlab-agent-mgr");
assert.equal(healthLive.body.ok, true);
assert.equal(healthLive.body.environment, ENVIRONMENT_DEV);
assert.equal(healthLive.body.health.serviceId, "hwlab-agent-mgr");
assert.equal(healthLive.body.health.status, "degraded");
assert.equal(healthLive.body.health.runtimeMode, "local-dry-run");
assert.ok(healthLive.body.health.skills.missing.includes("manager.skillCommitId"));
assert.ok(healthLive.body.health.skills.missing.includes("manager.skillVersion"));
assert.equal(healthLive.body.skillsManifest, null);
const health = await runNodeJson("cmd/hwlab-agent-mgr/main.ts", [
"health",
"--state-dir",
stateDir,
"--skill-commit-id",
"local-contract",
"--skill-version",
"m1-local"
]);
assert.equal(health.serviceId, "hwlab-agent-mgr");
assert.equal(health.ok, true);
assert.equal(health.health.serviceId, "hwlab-agent-mgr");
assert.equal(health.health.status, "ok");
assert.equal(health.health.environment, ENVIRONMENT_DEV);
assert.equal(health.health.runtimeMode, "local-dry-run");
assert.equal(health.health.workspaceCleaned, false);
assert.equal(health.health.skills.manager.status, "ready");
assert.equal(health.skillsManifest.services[0].serviceId, "hwlab-agent-skills");
assert.equal(health.skillsManifest.environment, ENVIRONMENT_DEV);
const created = await runNodeJson("cmd/hwlab-agent-mgr/main.ts", [
"create",
"--state-dir",
stateDir,
"--agent-session-id",
agentSessionId,
"--project-id",
"prj_m1_local_contract",
"--goal",
"verify M1 local agent manager contract",
"--prompt",
"local contract sample",
"--skill-commit-id",
"local-contract",
"--skill-version",
"m1-local"
]);
assert.equal(created.ok, true);
assert.equal(created.serviceId, "hwlab-agent-mgr");
assert.equal(created.agentSessionId, agentSessionId);
assert.equal(created.status, "ok");
assert.equal(created.environment, ENVIRONMENT_DEV);
assert.equal(created.runtimeMode, "local-dry-run");
assert.equal(created.agentSession.serviceId, "hwlab-agent-mgr");
assert.equal(created.agentSession.projectId, "prj_m1_local_contract");
assert.equal(created.task.status, "queued");
assert.equal(created.traceCount, 1);
assert.equal(created.evidenceCount, 0);
const status = await runNodeJson("cmd/hwlab-agent-mgr/main.ts", [
"status",
"--state-dir",
stateDir,
"--agent-session-id",
agentSessionId
]);
assert.equal(status.ok, true);
assert.equal(status.serviceId, "hwlab-agent-mgr");
assert.equal(status.agentSessionId, agentSessionId);
assert.equal(status.status, "ok");
assert.equal(status.task.taskId, `tsk_${agentSessionId}`);
const cleanup = await runNodeJson("cmd/hwlab-agent-mgr/main.ts", [
"cleanup",
"--state-dir",
stateDir,
"--agent-session-id",
agentSessionId
]);
assert.equal(cleanup.ok, true);
assert.equal(cleanup.serviceId, "hwlab-agent-mgr");
assert.equal(cleanup.agentSessionId, agentSessionId);
assert.equal(cleanup.status, "ok");
assert.equal(cleanup.cleanup.completed, true);
assert.equal(cleanup.workspace.existsAfterCleanup, false);
logOk("agent-mgr health/sample local contract");
} finally {
if (agentMgr) {
await stopNode(agentMgr);
}
await rm(stateDir, { recursive: true, force: true });
}
}
try {
await smokeCloudApi();
await smokePatchPanelAndSimulators();
await smokeAgentMgrLocalContract();
process.stdout.write("[m1-smoke] passed\n");
} finally {
await Promise.allSettled(children.map(stopNode));
}
-299
View File
@@ -1,299 +0,0 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { mkdtemp, readFile, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
AGENT_MGR_SERVICE_ID,
AGENT_SESSION_STATUSES,
AGENT_SKILLS_SERVICE_ID,
AGENT_WORKER_SERVICE_ID,
WORKER_SESSION_STATUSES,
createAgentRunPlan,
createSkillsManifest,
finalizeAgentRun
} from "../internal/agent/index.mjs";
import {
createLocalAgentSession,
getLocalAgentEvidence,
getLocalAgentStatus,
getLocalAgentTrace,
runLocalWorkerDryRun
} from "../internal/agent/runtime.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const fixedNow = () => "2026-05-21T00:00:00.000Z";
async function readJSON(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
}
async function readText(relativePath) {
return readFile(path.join(repoRoot, relativePath), "utf8");
}
function sha256Hex(value) {
return createHash("sha256").update(String(value)).digest("hex");
}
function expectLifecycleConstants() {
assert.deepEqual(AGENT_SESSION_STATUSES, ["create", "start", "trace", "finish", "cleanup"]);
assert.deepEqual(WORKER_SESSION_STATUSES, ["create", "start", "trace", "finish", "cleanup"]);
}
function expectRunPlan(plan, fixture) {
const workerSessionId = `wkr_${fixture.agentSessionId}`;
const traceId = `trc_${fixture.agentSessionId}`;
const operationId = `op_${fixture.agentSessionId}`;
const evidenceId = `evi_${fixture.agentSessionId}`;
const workspaceVolumeId = `vol_${fixture.agentSessionId}`;
const mountPath = `/workspace/${fixture.projectId}/${fixture.agentSessionId}`;
const evidenceUri = `file://${path.posix.join(mountPath, "evidence.txt")}`;
assert.equal(plan.skillCommitId, fixture.skillCommitId);
assert.equal(plan.skillVersion, fixture.skillVersion);
assert.equal(plan.agentSession.serviceId, AGENT_MGR_SERVICE_ID);
assert.equal(plan.workerSession.serviceId, AGENT_WORKER_SERVICE_ID);
assert.equal(plan.agentSession.agentSessionId, fixture.agentSessionId);
assert.equal(plan.agentSession.projectId, fixture.projectId);
assert.equal(plan.agentSession.environment, fixture.environment);
assert.equal(plan.agentSession.status, "start");
assert.equal(plan.agentSession.startedAt, fixedNow());
assert.equal(plan.agentSession.updatedAt, fixedNow());
assert.equal(plan.agentSession.metadata.currentState, "start");
assert.equal(plan.agentSession.metadata.workerSessionId, workerSessionId);
assert.equal(plan.agentSession.metadata.workspaceVolumeId, workspaceVolumeId);
assert.equal(plan.workerSession.workerSessionId, workerSessionId);
assert.equal(plan.workerSession.agentSessionId, fixture.agentSessionId);
assert.equal(plan.workerSession.projectId, fixture.projectId);
assert.equal(plan.workerSession.environment, fixture.environment);
assert.equal(plan.workerSession.status, "start");
assert.equal(plan.workerSession.startedAt, fixedNow());
assert.equal(plan.workerSession.updatedAt, fixedNow());
assert.equal(plan.workerSession.metadata.currentState, "start");
assert.equal(plan.workerSession.metadata.workspaceVolumeId, workspaceVolumeId);
assert.equal(plan.workspaceVolume.workspaceVolumeId, workspaceVolumeId);
assert.equal(plan.workspaceVolume.mountPath, mountPath);
assert.equal(plan.workspaceVolume.serviceId, AGENT_WORKER_SERVICE_ID);
assert.equal(plan.workspaceVolume.lifecycle, "create");
assert.equal(plan.workspaceVolume.createdAt, fixedNow());
assert.equal(plan.workspaceVolume.updatedAt, fixedNow());
assert.deepEqual(
plan.events.map((event) => event.state),
["create", "start", "trace", "finish", "cleanup"]
);
assert.equal(plan.events[0].actor, AGENT_MGR_SERVICE_ID);
assert.equal(plan.events[0].agentSessionId, fixture.agentSessionId);
assert.equal(plan.events[1].actor, AGENT_WORKER_SERVICE_ID);
assert.equal(plan.events[1].workerSessionId, workerSessionId);
assert.equal(plan.events[2].actor, AGENT_MGR_SERVICE_ID);
assert.equal(plan.events[2].traceEvent.traceEventId, `tev_${fixture.agentSessionId}`);
assert.equal(plan.events[2].traceEvent.traceId, traceId);
assert.equal(plan.events[2].traceEvent.projectId, fixture.projectId);
assert.equal(plan.events[2].traceEvent.serviceId, AGENT_MGR_SERVICE_ID);
assert.equal(plan.events[2].traceEvent.level, "info");
assert.equal(plan.events[2].traceEvent.message, fixture.prompt);
assert.equal(plan.events[2].traceEvent.environment, fixture.environment);
assert.equal(plan.events[2].traceEvent.agentSessionId, fixture.agentSessionId);
assert.equal(plan.events[2].traceEvent.workerSessionId, workerSessionId);
assert.equal(plan.events[2].traceEvent.operationId, operationId);
assert.deepEqual(plan.events[2].traceEvent.metadata, {});
assert.equal(plan.traceEvent.traceId, traceId);
assert.equal(plan.traceEvent.message, fixture.prompt);
assert.equal(plan.traceEvent.occurredAt, fixedNow());
assert.equal(plan.events[3].actor, AGENT_WORKER_SERVICE_ID);
assert.equal(plan.events[3].evidenceRecord.evidenceId, evidenceId);
assert.equal(plan.events[4].actor, AGENT_WORKER_SERVICE_ID);
assert.equal(plan.events[4].workspaceVolumeId, workspaceVolumeId);
assert.equal(plan.evidenceRecord.evidenceId, evidenceId);
assert.equal(plan.evidenceRecord.projectId, fixture.projectId);
assert.equal(plan.evidenceRecord.operationId, operationId);
assert.equal(plan.evidenceRecord.agentSessionId, fixture.agentSessionId);
assert.equal(plan.evidenceRecord.workerSessionId, workerSessionId);
assert.equal(plan.evidenceRecord.kind, "artifact");
assert.equal(plan.evidenceRecord.uri, evidenceUri);
assert.equal(plan.evidenceRecord.mimeType, "text/plain");
assert.equal(plan.evidenceRecord.sha256, sha256Hex(evidenceUri));
assert.equal(plan.evidenceRecord.sizeBytes, 0);
assert.equal(plan.evidenceRecord.serviceId, AGENT_WORKER_SERVICE_ID);
assert.equal(plan.evidenceRecord.environment, fixture.environment);
assert.equal(plan.evidenceRecord.createdAt, fixedNow());
assert.equal(plan.evidenceRecord.metadata.skillCommitId, fixture.skillCommitId);
assert.equal(plan.evidenceRecord.metadata.skillVersion, fixture.skillVersion);
assert.equal(plan.evidenceRecord.metadata.prompt, fixture.prompt);
assert.equal(plan.evidenceRecord.metadata.goal, fixture.goal);
assert.equal(plan.evidenceRecord.metadata.workspaceVolumeId, workspaceVolumeId);
}
function expectFinishedRun(finished, originalPlan) {
assert.equal(finished.agentSession.status, "cleanup");
assert.equal(finished.workerSession.status, "cleanup");
assert.equal(finished.agentSession.completedAt, fixedNow());
assert.equal(finished.workerSession.completedAt, fixedNow());
assert.equal(finished.agentSession.metadata.cleanedUp, true);
assert.equal(finished.workerSession.metadata.cleanedUp, true);
assert.equal(finished.cleanup.workspaceVolumeId, originalPlan.workspaceVolume.workspaceVolumeId);
assert.equal(finished.cleanup.removed, true);
assert.equal(finished.evidenceRecord.sha256, originalPlan.evidenceRecord.sha256);
assert.equal(originalPlan.agentSession.status, "start");
assert.equal(originalPlan.workerSession.status, "start");
}
function expectSkillsManifest(manifest, fixture) {
assert.equal(manifest.manifestVersion, "v1");
assert.equal(manifest.environment, fixture.environment);
assert.equal(manifest.commitId, fixture.skillCommitId);
assert.equal(manifest.version, fixture.skillVersion);
assert.equal(manifest.namespace, "hwlab");
assert.equal(manifest.endpoint, "http://74.48.78.17:16667");
assert.equal(manifest.profiles.dev.enabled, true);
assert.equal(manifest.profiles.dev.namespace, "hwlab-dev");
assert.equal(manifest.services[0].serviceId, AGENT_SKILLS_SERVICE_ID);
assert.equal(manifest.services[0].image, `hwlab-agent-runtime:${fixture.skillCommitId.slice(0, 7)}`);
assert.equal(manifest.services[0].env.HWLAB_SKILLS_COMMIT_ID, fixture.skillCommitId);
assert.equal(manifest.services[0].env.HWLAB_SKILLS_VERSION, fixture.skillVersion);
assert.deepEqual(manifest.skills, []);
}
function expectWorkspaceIsolation(primaryPlan, secondaryPlan, fixture) {
const primaryVolumeId = primaryPlan.workspaceVolume.workspaceVolumeId;
const secondaryVolumeId = secondaryPlan.workspaceVolume.workspaceVolumeId;
const secondaryMountPath = `/workspace/${fixture.workspaceIsolation.projectId}/${fixture.workspaceIsolation.agentSessionId}`;
const secondaryEvidenceUri = `file://${path.posix.join(secondaryMountPath, "evidence.txt")}`;
assert.notEqual(primaryVolumeId, secondaryVolumeId);
assert.notEqual(primaryPlan.workspaceVolume.mountPath, secondaryPlan.workspaceVolume.mountPath);
assert.equal(secondaryPlan.workspaceVolume.mountPath, secondaryMountPath);
assert.equal(secondaryPlan.evidenceRecord.uri, secondaryEvidenceUri);
assert.equal(secondaryPlan.evidenceRecord.metadata.workspaceVolumeId, secondaryVolumeId);
assert.equal(secondaryPlan.traceEvent.traceId, `trc_${fixture.workspaceIsolation.agentSessionId}`);
}
function expectEvidenceFixture(text, fixture) {
assert.match(text, /^# M4 Agent Automation Loop Evidence Plan/m);
assert.ok(text.includes(`- Scenario: ${fixture.scenario}`));
assert.ok(text.includes(`- Environment: ${fixture.environment}`));
assert.ok(text.includes(`- Project: ${fixture.projectId}`));
assert.ok(text.includes(`- Skill commitId: ${fixture.skillCommitId}`));
assert.ok(text.includes(`- Skill version: ${fixture.skillVersion}`));
assert.ok(text.includes("no secrets"));
assert.ok(text.includes("no live DEV agent run"));
}
async function expectLocalRuntimeClosedLoop(fixture) {
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-m4-agent-loop-"));
try {
const created = await createLocalAgentSession({
stateDir,
agentSessionId: fixture.agentSessionId,
projectId: fixture.projectId,
goal: fixture.goal,
prompt: fixture.prompt,
skillCommitId: fixture.skillCommitId,
skillVersion: fixture.skillVersion,
now: fixedNow
});
assert.equal(created.health.status, "ok");
assert.equal(created.task.status, "queued");
assert.equal(created.traceCount, 1);
const worker = await runLocalWorkerDryRun({
stateDir,
agentSessionId: fixture.agentSessionId,
skillCommitId: fixture.skillCommitId,
skillVersion: fixture.skillVersion,
now: fixedNow
});
assert.equal(worker.health.status, "ok");
assert.equal(worker.task.status, "completed");
assert.equal(worker.agentSession.status, "cleanup");
assert.equal(worker.workerSession.status, "cleanup");
assert.equal(worker.cleanup.removed, true);
assert.equal(worker.workspace.existsAfterCleanup, false);
assert.equal(worker.evidenceRecord.metadata.health.status, "ok");
assert.equal(worker.evidenceRecord.metadata.skills.manager.commitId, fixture.skillCommitId);
assert.equal(worker.evidenceRecord.metadata.skills.worker.version, fixture.skillVersion);
const status = await getLocalAgentStatus({
stateDir,
agentSessionId: fixture.agentSessionId
});
assert.equal(status.evidenceCount, 1);
assert.equal(status.cleanup.removed, true);
const trace = await getLocalAgentTrace({
stateDir,
agentSessionId: fixture.agentSessionId
});
assert.deepEqual(
trace.traceEvents.map((event) => event.metadata.state),
["create", "start", "trace", "finish", "cleanup"]
);
const evidence = await getLocalAgentEvidence({
stateDir,
agentSessionId: fixture.agentSessionId
});
assert.equal(evidence.status, "ok");
assert.equal(evidence.evidenceRecords.length, 1);
assert.equal(evidence.evidenceRecords[0].metadata.traceId, `trc_${fixture.agentSessionId}`);
} finally {
await rm(stateDir, { recursive: true, force: true });
}
}
const fixture = await readJSON("fixtures/mvp/m4-agent-loop/runtime.json");
const evidenceFixture = await readText(fixture.evidencePath);
assert.equal(fixture.environment, "dev");
assert.equal(fixture.projectId.startsWith("prj_"), true);
assert.equal(fixture.agentSessionId.startsWith("agt_"), true);
assert.equal(fixture.skillCommitId.length >= 7, true);
assert.equal(fixture.skillVersion.length > 0, true);
assert.equal(fixture.workspaceIsolation.projectId.startsWith("prj_"), true);
assert.equal(fixture.workspaceIsolation.agentSessionId.startsWith("agt_"), true);
expectLifecycleConstants();
expectEvidenceFixture(evidenceFixture, fixture);
const plan = createAgentRunPlan({
agentSessionId: fixture.agentSessionId,
projectId: fixture.projectId,
goal: fixture.goal,
prompt: fixture.prompt,
skillCommitId: fixture.skillCommitId,
skillVersion: fixture.skillVersion,
now: fixedNow
});
expectRunPlan(plan, fixture);
const finished = finalizeAgentRun(plan, { now: fixedNow });
expectFinishedRun(finished, plan);
const manifest = createSkillsManifest({
commitId: fixture.skillCommitId,
version: fixture.skillVersion
});
expectSkillsManifest(manifest, fixture);
const isolationPlan = createAgentRunPlan({
agentSessionId: fixture.workspaceIsolation.agentSessionId,
projectId: fixture.workspaceIsolation.projectId,
goal: fixture.goal,
prompt: fixture.prompt,
skillCommitId: fixture.skillCommitId,
skillVersion: fixture.skillVersion,
now: fixedNow
});
expectWorkspaceIsolation(plan, isolationPlan, fixture);
await expectLocalRuntimeClosedLoop(fixture);
console.log(`[m4-smoke] ok ${fixture.scenario}`);
-2
View File
@@ -29,8 +29,6 @@ const v02EnvReuseRuntimeMode = "env-reuse-git-mirror-checkout";
const v02RuntimeServiceIds = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
@@ -13,8 +13,6 @@ const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), "
const V02_SERVICE_IDS = Object.freeze([
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-device-pod",
"hwlab-gateway",
"hwlab-edge-proxy",
@@ -1236,7 +1236,6 @@ function nextCommands({ canPublish, canApply, blockedReasons }) {
return [
"node scripts/dev-edge-health-smoke.mjs --live",
"node scripts/dev-m3-hardware-loop-smoke.mjs --dry-run",
"node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production",
guardCommand
];
}
+2 -4
View File
@@ -30,8 +30,7 @@ export const checkProfiles = Object.freeze({
{ id: "check-008-repo-report-paths", group: "repo", command: ["node","--check","scripts/src/report-paths.mjs"] },
{ id: "check-009-repo-index", group: "repo", command: ["node","--check","internal/protocol/index.mjs"] },
{ id: "check-010-repo-build-metadata", group: "repo", command: ["node","--check","internal/build-metadata.mjs"] },
{ id: "check-011-repo-index", group: "repo", command: ["node","--check","internal/agent/index.mjs"] },
{ id: "check-012-repo-runtime", group: "repo", command: ["node","--check","internal/agent/runtime.mjs"] },
{ id: "check-011-repo-agentrun-dispatch", group: "repo", command: ["node","--check","internal/agent/agentrun-dispatch.mjs"] },
{ id: "check-013-repo-index", group: "repo", command: ["node","--check","internal/audit/index.mjs"] },
{ id: "check-014-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/db/runtime-store.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-015-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","internal/db/runtime-store.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
@@ -69,7 +68,7 @@ export const checkProfiles = Object.freeze({
{ id: "check-045-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","cmd/hwlab-cloud-api/main.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-046-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","cmd/hwlab-cloud-api/provision.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-047-cloud-api-run-bun", group: "cloud-api", command: ["node","scripts/run-bun.mjs","build","cmd/hwlab-cloud-api/migrate.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-048-runtime-services-run-bun", group: "runtime-services", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-edge-proxy/main.test.ts","cmd/hwlab-device-pod/main.test.ts","cmd/hwlab-gateway/main.test.ts","cmd/hwlab-agent-mgr/main.test.ts","cmd/hwlab-agent-worker/main.test.ts","cmd/hwlab-codex-api-responses-forwarder/main.test.ts","cmd/hwlab-deepseek-responses-bridge/main.test.ts"] },
{ id: "check-048-runtime-services-run-bun", group: "runtime-services", command: ["node","scripts/run-bun.mjs","test","cmd/hwlab-edge-proxy/main.test.ts","cmd/hwlab-device-pod/main.test.ts","cmd/hwlab-gateway/main.test.ts","cmd/hwlab-codex-api-responses-forwarder/main.test.ts","cmd/hwlab-deepseek-responses-bridge/main.test.ts"] },
{ id: "check-056-repo-main", group: "repo", command: ["node","scripts/run-bun.mjs","build","cmd/hwlab-gateway/main.ts","cmd/hwlab-gateway/main.test.ts","--target=bun","--packages=external","--outdir=/tmp/hwlab-ts-check"] },
{ id: "check-058-tools-hwlab-gateway-shell", group: "tools", command: ["node","--check","tools/hwlab-gateway-shell.mjs"] },
{ id: "check-059-tools-hwlab-gateway-tran", group: "tools", command: ["node","--check","tools/hwlab-gateway-tran.mjs"] },
@@ -116,7 +115,6 @@ export const checkProfiles = Object.freeze({
{ id: "check-099-smoke-dev-evidence-blocker-aggregator", group: "smoke", command: ["node","--check","scripts/dev-evidence-blocker-aggregator.mjs"] },
{ id: "check-100-smoke-dev-evidence-blocker-aggregator", group: "smoke", command: ["node","--check","scripts/src/dev-evidence-blocker-aggregator.mjs"] },
{ id: "check-101-smoke-dev-evidence-blocker-aggregator-test", group: "smoke", command: ["node","--check","scripts/src/dev-evidence-blocker-aggregator.test.mjs"] },
{ id: "check-102-smoke-dev-m4-agent-loop-smoke-lib-test", group: "smoke", command: ["node","--check","scripts/src/dev-m4-agent-loop-smoke-lib.test.mjs"] },
{ id: "check-103-repo-d601-k3s-readonly-observability", group: "repo", command: ["node","--check","scripts/d601-k3s-readonly-observability.mjs"] },
{ id: "check-104-repo-d601-k3s-readonly-observability", group: "repo", command: ["node","--check","scripts/src/d601-k3s-readonly-observability.mjs"] },
{ id: "check-105-dev-runtime-dev-runtime-provisioning", group: "dev-runtime", command: ["node","--check","scripts/dev-runtime-provisioning.mjs"] },
+11 -11
View File
@@ -3103,7 +3103,7 @@ async function inspectLiveDom(url, options = {}) {
dom.rootAfterScrollAttempt.bodyScrollTop === 0 &&
dom.liveBuildDefaultVisible &&
/最新镜像构建时间:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} 北京时间/u.test(dom.liveBuildLatest) &&
/hwlab-agent-mgr/u.test(dom.liveBuildLatest) &&
/hwlab-agent-skills/u.test(dom.liveBuildLatest) &&
/tag f09ad05/u.test(dom.liveBuildLatest) &&
/commit f09ad05/u.test(dom.liveBuildLatest) &&
/revision f09ad05/u.test(dom.liveBuildLatest) &&
@@ -3118,7 +3118,7 @@ async function inspectLiveDom(url, options = {}) {
dom.liveBuildDetailWhenOpen.overflowX <= 1 &&
/构建版本明细/u.test(dom.liveBuildDetailWhenOpen.title) &&
/hwlab-cloud-api/u.test(dom.liveBuildDetailWhenOpen.text) &&
/hwlab-agent-worker/u.test(dom.liveBuildDetailWhenOpen.text) &&
/hwlab-device-pod/u.test(dom.liveBuildDetailWhenOpen.text) &&
/构建时间不可用/u.test(dom.liveBuildDetailWhenOpen.text) &&
/外部镜像或非 HWLAB 构建产物/u.test(dom.liveBuildDetailWhenOpen.text) &&
/来源 live health/u.test(dom.liveBuildDetailWhenOpen.text) &&
@@ -5516,8 +5516,8 @@ function liveBuildsFixturePayload() {
healthPath: "/health/live"
},
latest: {
serviceId: "hwlab-agent-mgr",
name: "hwlab-agent-mgr",
serviceId: "hwlab-agent-skills",
name: "hwlab-agent-skills",
kind: "hwlab",
status: "ok",
build: {
@@ -5526,7 +5526,7 @@ function liveBuildsFixturePayload() {
liveHealthMissingReason: "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用与 live tag/revision 匹配的 catalog metadata"
},
image: {
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:f09ad05",
reference: "127.0.0.1:5000/hwlab/hwlab-agent-skills:f09ad05",
tag: "f09ad05",
digest: "sha256:" + "1".repeat(64)
},
@@ -5565,8 +5565,8 @@ function liveBuildsFixturePayload() {
revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4"
},
{
serviceId: "hwlab-agent-mgr",
name: "hwlab-agent-mgr",
serviceId: "hwlab-agent-skills",
name: "hwlab-agent-skills",
kind: "hwlab",
status: "ok",
build: {
@@ -5574,7 +5574,7 @@ function liveBuildsFixturePayload() {
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
},
image: {
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:f09ad05",
reference: "127.0.0.1:5000/hwlab/hwlab-agent-skills:f09ad05",
tag: "f09ad05",
digest: "sha256:" + "1".repeat(64)
},
@@ -5606,14 +5606,14 @@ function liveBuildsFixturePayload() {
revision: "unknown"
},
{
serviceId: "hwlab-agent-worker",
name: "hwlab-agent-worker",
serviceId: "hwlab-device-pod",
name: "hwlab-device-pod",
kind: "hwlab",
status: "unavailable",
build: {
createdAt: null,
metadataSource: "unavailable",
unavailableReason: "构建时间不可用:hwlab-agent-worker 是 suspended Job template,当前 live desired replicas=0"
unavailableReason: "构建时间不可用:当前 live health 不可用"
},
image: {
reference: "unknown",
+1 -3
View File
@@ -183,9 +183,7 @@ async function createDevGateReport(edgeHealth) {
],
localSmoke: {
status: "pass",
commands: [
"node scripts/m1-contract-smoke.mjs"
],
commands: [],
evidence: [
"npm run check includes internal/cloud/server-health.test.ts and validates /health plus /health/live."
],
@@ -317,9 +317,9 @@ function buildEvidence() {
category: "local-smoke",
level: "LOCAL",
status: "pass",
sources: ["scripts/m1-contract-smoke.mjs"],
commands: ["node scripts/m1-contract-smoke.mjs"],
summary: "Local skeleton smoke remains local-only evidence."
sources: ["scripts/validate-contract.mjs"],
commands: ["node scripts/validate-contract.mjs"],
summary: "Contract validation remains local-only source evidence."
}),
sourceEntry({
milestone: "M5",
-747
View File
@@ -1,747 +0,0 @@
import { execFileSync } from "node:child_process";
import { request as httpRequest } from "node:http";
import { request as httpsRequest } from "node:https";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
AGENT_MGR_SERVICE_ID,
AGENT_SKILLS_SERVICE_ID,
AGENT_WORKER_SERVICE_ID
} from "../../internal/agent/index.mjs";
import {
RUNTIME_DURABLE_ADAPTER_MISSING,
RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
RUNTIME_STORE_KIND_POSTGRES
} from "../../internal/db/runtime-store.ts";
import { CLOUD_CORE_MIGRATION_ID } from "../../internal/db/schema.ts";
import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export const d601Kubeconfig = "/etc/rancher/k3s/k3s.yaml";
export const devNamespace = "hwlab-dev";
export const d601K3sReadonlyCommand =
"KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev get pods,svc,deploy,job,cm -o wide";
export const workerServerDryRunCommand =
"KUBECONFIG=/etc/rancher/k3s/k3s.yaml kubectl -n hwlab-dev create -f - --dry-run=server -o json";
export function isDbLiveReady(body) {
const db = body?.db;
return Boolean(db?.ready && db?.connected && db?.liveConnected !== false && db?.liveDbEvidence);
}
export function isDbLiveBlocked(body) {
const db = body?.db;
if (!db || typeof db !== "object") {
return false;
}
if (isDbLiveReady(body)) {
return false;
}
return db.connected === false ||
db.ready === false ||
db.liveConnected === false ||
db.liveDbEvidence === false ||
["blocked", "degraded", "failed"].includes(db.status);
}
export function summarizeDbBlocker(body) {
const db = body?.db ?? {};
const missing = Array.isArray(db.missingEnv) && db.missingEnv.length > 0
? ` missing ${db.missingEnv.join(", ")}`
: "";
return `cloud-api /health/live reports DB ${db.status ?? "not ready"}; connected=${db.connected ?? "unknown"}; ready=${db.ready ?? "unknown"}; liveDbEvidence=${db.liveDbEvidence ?? "unknown"}.${missing}`;
}
export function runtimeDurabilityFromHealth(body) {
const runtime = body?.runtime ?? {};
const durability = body?.readiness?.durability ?? {};
const migration = runtime.migration ?? {};
const migrationGate = runtime.gates?.migration ?? durability.gates?.migration ?? {};
const blocker = runtime.blocker ??
durability.blocker ??
(Array.isArray(body?.blockerCodes)
? body.blockerCodes.find((code) => String(code).startsWith("runtime_durable_adapter_"))
: null) ??
RUNTIME_DURABLE_ADAPTER_MISSING;
const durableRequested = Boolean(
runtime.durableRequested ||
durability.durableRequested ||
runtime.adapter === RUNTIME_STORE_KIND_POSTGRES ||
durability.adapter === RUNTIME_STORE_KIND_POSTGRES
);
const ready = Boolean(
runtime.durable === true &&
runtime.ready === true &&
runtime.liveRuntimeEvidence === true &&
durability.ready !== false
);
return {
adapter: runtime.adapter ?? durability.adapter ?? "unknown",
durable: Boolean(runtime.durable),
durableRequested,
ready,
runtimeStatus: runtime.status ?? durability.runtimeStatus ?? "unknown",
blocker,
blockedLayer: runtime.durabilityContract?.blockedLayer ?? durability.blockedLayer ?? null,
requiredEvidence: runtime.durabilityContract?.requiredEvidence ??
durability.requiredEvidence ??
RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
migration: {
checked: migration.checked ?? migrationGate.checked ?? false,
ready: migration.ready ?? migrationGate.ready ?? false,
missing: migration.missing ?? (migration.ready === false ? true : null),
requiredMigrationId: migration.requiredMigrationId ?? CLOUD_CORE_MIGRATION_ID,
appliedMigrationId: migration.appliedMigrationId ?? null
},
queryAttempted: Boolean(runtime.connection?.queryAttempted ?? durability.queryAttempted),
queryResult: runtime.connection?.queryResult ?? durability.queryResult ?? "unknown"
};
}
export function isRuntimeDurableAdapterBlocked(body) {
const runtime = runtimeDurabilityFromHealth(body);
if (!runtime.durableRequested) {
return false;
}
return !runtime.ready ||
["blocked", "degraded", "failed"].includes(runtime.runtimeStatus) ||
String(runtime.blocker).startsWith("runtime_durable_adapter_");
}
export function summarizeRuntimeDurableBlocker(body) {
const runtime = runtimeDurabilityFromHealth(body);
const migration = runtime.migration;
return `cloud-api durable runtime adapter is blocked: ${runtime.blocker}; adapter=${runtime.adapter}; durable=${runtime.durable}; ready=${runtime.ready}; migration=${migration.requiredMigrationId} checked=${migration.checked} ready=${migration.ready} missing=${migration.missing ?? "unknown"}; queryResult=${runtime.queryResult}.`;
}
export function publicDbLiveSummary(body) {
const db = body?.db ?? null;
if (!db || typeof db !== "object") {
return null;
}
return {
status: db.status ?? null,
connected: db.connected ?? null,
liveConnected: db.liveConnected ?? null,
ready: db.ready ?? null,
configReady: db.configReady ?? null,
connectionChecked: db.connectionChecked ?? null,
connectionAttempted: db.connectionAttempted ?? null,
connectionResult: db.connectionResult ?? null,
endpointSource: db.endpointSource ?? db.connection?.endpointSource ?? null,
liveDbEvidence: db.liveDbEvidence ?? null,
evidence: db.evidence ?? null
};
}
export function publicRuntimeDurabilitySummary(body) {
if (!body?.runtime && !body?.readiness?.durability) {
return null;
}
const runtime = runtimeDurabilityFromHealth(body);
return {
adapter: runtime.adapter,
durable: runtime.durable,
durableRequested: runtime.durableRequested,
ready: runtime.ready,
status: runtime.runtimeStatus,
blocker: runtime.blocker,
blockedLayer: runtime.blockedLayer,
requiredEvidence: runtime.requiredEvidence,
liveRuntimeEvidence: Boolean(runtime.ready),
migration: runtime.migration,
queryAttempted: runtime.queryAttempted,
queryResult: runtime.queryResult
};
}
export function classifyCloudApiLiveReadiness(body) {
if (isDbLiveBlocked(body)) {
return {
blocked: true,
blockerType: "runtime_blocker",
blockerScope: "db-live",
sourceIssue: "pikasTech/HWLAB#49",
blockedClassification: "DB live",
blockerSummary: summarizeDbBlocker(body),
summary: "Blocked at DB live readiness before scheduling a DEV agent task.",
evidenceSuffix: "db-blocked",
dbLiveReady: false,
runtimeDurableReady: false
};
}
if (isRuntimeDurableAdapterBlocked(body)) {
const runtime = runtimeDurabilityFromHealth(body);
return {
blocked: true,
blockerType: "runtime_blocker",
blockerScope: "runtime-durable-adapter",
sourceIssue: "pikasTech/HWLAB#164",
blockedClassification: runtime.blocker,
blockerSummary: summarizeRuntimeDurableBlocker(body),
summary: "Blocked at runtime durable adapter readiness before scheduling a DEV agent task.",
evidenceSuffix: `runtime-durable-adapter-blocked:${runtime.blocker}`,
dbLiveReady: isDbLiveReady(body),
runtimeDurableReady: false
};
}
return {
blocked: false,
blockerType: null,
blockerScope: null,
blockedClassification: "none",
blockerSummary: "",
summary: "Live DEV preflight observed DB live and durable runtime adapter readiness.",
evidenceSuffix: null,
dbLiveReady: isDbLiveReady(body),
runtimeDurableReady: true
};
}
export function requestJson(urlString, { method = "GET", headers = {}, body, timeoutMs = 5000 } = {}) {
const url = new URL(urlString);
const client = url.protocol === "https:" ? httpsRequest : httpRequest;
const payload = body === undefined ? null : Buffer.from(body);
return new Promise((resolve, reject) => {
const request = client(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
method,
headers
},
(response) => {
let raw = "";
response.setEncoding("utf8");
response.on("data", (chunk) => {
raw += chunk;
});
response.on("end", () => {
let bodyValue = null;
if (raw.length > 0) {
try {
bodyValue = JSON.parse(raw);
} catch (error) {
reject(new Error(`invalid JSON response from ${urlString}: ${error.message}`));
return;
}
}
resolve({
statusCode: response.statusCode ?? 0,
headers: response.headers,
body: bodyValue,
raw
});
});
}
);
request.on("error", reject);
request.setTimeout(timeoutMs, () => {
request.destroy(new Error(`request to ${urlString} timed out after ${timeoutMs}ms`));
});
if (payload) request.write(payload);
request.end();
});
}
function requestHead(urlString, { timeoutMs = 5000 } = {}) {
const url = new URL(urlString);
const client = url.protocol === "https:" ? httpsRequest : httpRequest;
return new Promise((resolve, reject) => {
const request = client(
{
protocol: url.protocol,
hostname: url.hostname,
port: url.port,
path: `${url.pathname}${url.search}`,
method: "HEAD"
},
(response) => {
response.resume();
response.on("end", () => {
resolve({
statusCode: response.statusCode ?? 0,
headers: response.headers
});
});
}
);
request.on("error", reject);
request.setTimeout(timeoutMs, () => {
request.destroy(new Error(`request to ${urlString} timed out after ${timeoutMs}ms`));
});
request.end();
});
}
function runKubectl(args, { input, timeoutMs = 5000 } = {}) {
try {
return {
ok: true,
command: `KUBECONFIG=${d601Kubeconfig} kubectl ${args.join(" ")}`,
exitCode: 0,
stdout: execFileSync("kubectl", args, {
cwd: repoRoot,
encoding: "utf8",
env: { ...process.env, KUBECONFIG: d601Kubeconfig },
input,
stdio: ["pipe", "pipe", "pipe"],
timeout: timeoutMs
})
};
} catch (error) {
return {
ok: false,
command: `KUBECONFIG=${d601Kubeconfig} kubectl ${args.join(" ")}`,
exitCode: typeof error.status === "number" ? error.status : 1,
stdout: String(error.stdout ?? ""),
stderr: String(error.stderr ?? ""),
error: error instanceof Error ? error.message : String(error)
};
}
}
function parseProbeJson(probe) {
if (!probe.ok || !probe.stdout.trim()) return null;
try {
return JSON.parse(probe.stdout);
} catch {
return null;
}
}
function compactError(probe) {
return (probe.stderr || probe.error || probe.stdout || "unknown error")
.replace(/\s+/g, " ")
.trim()
.slice(0, 400);
}
function items(document) {
if (!document) return [];
return Array.isArray(document.items) ? document.items : [document];
}
function named(document, name) {
return items(document).find((item) => item?.metadata?.name === name) ?? null;
}
function firstContainer(workload) {
return workload?.spec?.template?.spec?.containers?.[0] ?? null;
}
function envMap(container) {
const values = {};
const valueFrom = [];
for (const entry of container?.env ?? []) {
if (Object.hasOwn(entry, "value")) values[entry.name] = entry.value;
else if (entry.valueFrom) valueFrom.push(entry.name);
}
return { values, valueFrom };
}
function condition(workload, type) {
return workload?.status?.conditions?.find((entry) => entry.type === type)?.status ?? "Unknown";
}
function deploymentSummary(deployment) {
const container = firstContainer(deployment);
const replicas = deployment?.status?.replicas ?? deployment?.spec?.replicas ?? 0;
const readyReplicas = deployment?.status?.readyReplicas ?? 0;
return {
name: deployment?.metadata?.name ?? null,
image: container?.image ?? null,
readyReplicas,
replicas,
available: condition(deployment, "Available"),
ready: replicas > 0 && readyReplicas >= replicas && condition(deployment, "Available") === "True",
env: envMap(container)
};
}
function workerTemplateSummary(job) {
const container = firstContainer(job);
return {
name: job?.metadata?.name ?? null,
suspend: job?.spec?.suspend ?? null,
image: container?.image ?? null,
restartPolicy: job?.spec?.template?.spec?.restartPolicy ?? null,
env: envMap(container)
};
}
function serviceUrl(service, fallbackPort) {
const clusterIP = service?.spec?.clusterIP;
if (!clusterIP || clusterIP === "None") return null;
return `http://${clusterIP}:${service?.spec?.ports?.[0]?.port ?? fallbackPort}`;
}
function endpointCount(endpoint) {
return (endpoint?.subsets ?? []).reduce((total, subset) => total + (subset.addresses?.length ?? 0), 0);
}
function buildWorkerDryRunJob(templateJob, fixture, liveSkillsEnv) {
const templateContainer = firstContainer(templateJob);
const labels = {
"app.kubernetes.io/name": AGENT_WORKER_SERVICE_ID,
"hwlab.pikastech.local/environment": ENVIRONMENT_DEV,
"hwlab.pikastech.local/profile": ENVIRONMENT_DEV,
"hwlab.pikastech.local/service-id": AGENT_WORKER_SERVICE_ID,
"hwlab.pikastech.local/smoke": "dev-m4-agent-loop"
};
const reservedEnv = new Set([
"HWLAB_AGENT_SESSION_ID",
"HWLAB_PROJECT_ID",
"HWLAB_SKILL_COMMIT_ID",
"HWLAB_SKILL_VERSION",
"HWLAB_WORKSPACE_ROOT"
]);
return {
apiVersion: "batch/v1",
kind: "Job",
metadata: {
name: "hwlab-agent-worker-smoke-dryrun-20260522",
namespace: devNamespace,
labels
},
spec: {
suspend: true,
backoffLimit: 0,
template: {
metadata: { labels },
spec: {
restartPolicy: templateJob?.spec?.template?.spec?.restartPolicy ?? "Never",
containers: [
{
name: templateContainer?.name ?? AGENT_WORKER_SERVICE_ID,
image: templateContainer?.image ?? "unknown",
env: [
...(templateContainer?.env ?? []).filter((entry) => !reservedEnv.has(entry.name)),
{ name: "HWLAB_AGENT_SESSION_ID", value: fixture.agentSessionId },
{ name: "HWLAB_PROJECT_ID", value: fixture.projectId },
{ name: "HWLAB_SKILL_COMMIT_ID", value: liveSkillsEnv.HWLAB_SKILLS_COMMIT_ID ?? "" },
{ name: "HWLAB_SKILL_VERSION", value: liveSkillsEnv.HWLAB_SKILLS_VERSION ?? "" },
{ name: "HWLAB_WORKSPACE_ROOT", value: `/workspace/${fixture.projectId}/${fixture.agentSessionId}` }
]
}
]
}
}
}
};
}
function k3sBlockers(report) {
const blockers = [];
const push = (type, scope, summary, classification, nextFix) => {
blockers.push({ type, scope, status: "open", summary, classification, nextFix });
};
if (!report.clusterReadable) {
push(
"environment_blocker",
"d601-k3s-readonly",
"D601 native k3s hwlab-dev resources were not readable with /etc/rancher/k3s/k3s.yaml.",
"k3s readonly",
"Mount or authorize read-only hwlab-dev access through the D601 native k3s kubeconfig."
);
return blockers;
}
if (report.agentManager.health.status !== "ok") {
push(
"agent_blocker",
"agent-mgr-health",
`hwlab-agent-mgr /health/live is ${report.agentManager.health.status}.`,
"agent runtime",
"Inject explicit skill commit and version into agent-mgr health configuration, then redeploy DEV."
);
}
if (report.skillsInjection.status !== "pass") {
push(
"agent_blocker",
"skills-commit-version-injection",
`DEV skills injection is incomplete: missing ${report.skillsInjection.missing.join(", ")}.`,
"skills injection",
"Add HWLAB_SKILLS_VERSION to hwlab-agent-skills and ensure scheduled worker jobs receive HWLAB_SKILL_COMMIT_ID plus HWLAB_SKILL_VERSION."
);
}
if (report.workerJob.serverDryRun.status !== "pass") {
push(
"agent_blocker",
"worker-job-admission",
report.workerJob.serverDryRun.summary,
"worker job",
"Fix the hwlab-agent-worker-template image/env/job spec until server-side dry-run admission succeeds."
);
}
return blockers;
}
export async function collectPublicEntrypoints() {
const apiHealth = await requestJson(`${DEV_ENDPOINT}/health`).catch((error) => ({
statusCode: 0,
body: null,
error: error instanceof Error ? error.message : String(error)
}));
const apiLive = await requestJson(`${DEV_ENDPOINT}/health/live`).catch((error) => ({
statusCode: 0,
body: null,
error: error instanceof Error ? error.message : String(error)
}));
const frontend = await requestHead(`${DEV_FRONTEND_ENDPOINT}/`).catch((error) => ({
statusCode: 0,
headers: {},
error: error instanceof Error ? error.message : String(error)
}));
return {
status: apiHealth.statusCode >= 200 &&
apiHealth.statusCode < 300 &&
apiLive.statusCode >= 200 &&
apiLive.statusCode < 300 &&
frontend.statusCode >= 200 &&
frontend.statusCode < 400
? "pass"
: "blocked",
commands: [
`curl -fsS --max-time 10 ${DEV_ENDPOINT}/health`,
`curl -fsS --max-time 10 ${DEV_ENDPOINT}/health/live`,
`curl -fsS -I --max-time 10 ${DEV_FRONTEND_ENDPOINT}/`
],
api: {
endpoint: DEV_ENDPOINT,
health: {
statusCode: apiHealth.statusCode,
serviceId: apiHealth.body?.serviceId ?? null,
environment: apiHealth.body?.environment ?? null,
status: apiHealth.body?.status ?? null,
error: apiHealth.error ?? null
},
live: {
statusCode: apiLive.statusCode,
serviceId: apiLive.body?.serviceId ?? null,
environment: apiLive.body?.environment ?? null,
status: apiLive.body?.status ?? null,
db: publicDbLiveSummary(apiLive.body),
runtime: publicRuntimeDurabilitySummary(apiLive.body),
blockerCodes: Array.isArray(apiLive.body?.blockerCodes) ? apiLive.body.blockerCodes : [],
error: apiLive.error ?? null
}
},
frontend: {
endpoint: DEV_FRONTEND_ENDPOINT,
statusCode: frontend.statusCode,
contentType: frontend.headers?.["content-type"] ?? null,
contentLength: frontend.headers?.["content-length"] ?? null,
error: frontend.error ?? null
},
summary: `Public DEV API ${DEV_ENDPOINT} health=${apiHealth.statusCode} live=${apiLive.statusCode}; frontend ${DEV_FRONTEND_ENDPOINT}/=${frontend.statusCode}.`
};
}
export async function collectD601K3sNativeEvidence(fixture) {
const namespaceProbe = runKubectl(["get", "namespace", devNamespace, "-o", "json"]);
const deploymentsProbe = runKubectl([
"-n",
devNamespace,
"get",
"deploy",
AGENT_MGR_SERVICE_ID,
AGENT_SKILLS_SERVICE_ID,
"-o",
"json"
]);
const workerTemplateProbe = runKubectl([
"-n",
devNamespace,
"get",
"job",
"hwlab-agent-worker-template",
"-o",
"json"
]);
const servicesProbe = runKubectl([
"-n",
devNamespace,
"get",
"svc",
AGENT_MGR_SERVICE_ID,
AGENT_SKILLS_SERVICE_ID,
"-o",
"json"
]);
const endpointsProbe = runKubectl([
"-n",
devNamespace,
"get",
"endpoints",
AGENT_MGR_SERVICE_ID,
AGENT_SKILLS_SERVICE_ID,
"-o",
"json"
]);
const canCreateJobs = runKubectl(["auth", "can-i", "create", "jobs", "-n", devNamespace]);
const canDeleteJobs = runKubectl(["auth", "can-i", "delete", "jobs", "-n", devNamespace]);
const canGetSecrets = runKubectl(["auth", "can-i", "get", "secrets", "-n", devNamespace]);
const deployments = parseProbeJson(deploymentsProbe);
const services = parseProbeJson(servicesProbe);
const endpoints = parseProbeJson(endpointsProbe);
const workerTemplate = parseProbeJson(workerTemplateProbe);
const agentMgrDeployment = named(deployments, AGENT_MGR_SERVICE_ID);
const skillsDeployment = named(deployments, AGENT_SKILLS_SERVICE_ID);
const agentMgrService = named(services, AGENT_MGR_SERVICE_ID);
const skillsService = named(services, AGENT_SKILLS_SERVICE_ID);
const agentMgrEndpoint = named(endpoints, AGENT_MGR_SERVICE_ID);
const skillsEndpoint = named(endpoints, AGENT_SKILLS_SERVICE_ID);
const agentMgrSummary = deploymentSummary(agentMgrDeployment);
const skillsSummary = deploymentSummary(skillsDeployment);
const workerSummary = workerTemplateSummary(workerTemplate);
const liveSkillsEnv = skillsSummary.env.values;
const agentMgrUrl = serviceUrl(agentMgrService, 7410);
const skillsUrl = serviceUrl(skillsService, 7430);
const agentMgrHealth = agentMgrUrl ? await requestJson(`${agentMgrUrl}/health/live`).catch((error) => ({
statusCode: 0,
body: null,
error: error instanceof Error ? error.message : String(error)
})) : null;
const skillsHealth = skillsUrl ? await requestJson(`${skillsUrl}/health/live`).catch((error) => ({
statusCode: 0,
body: null,
error: error instanceof Error ? error.message : String(error)
})) : null;
const workerDryRunManifest = workerTemplate ? buildWorkerDryRunJob(workerTemplate, fixture, liveSkillsEnv) : null;
const workerDryRunProbe = workerDryRunManifest
? runKubectl(["-n", devNamespace, "create", "-f", "-", "--dry-run=server", "-o", "json"], {
input: `${JSON.stringify(workerDryRunManifest)}\n`
})
: { ok: false, stdout: "", stderr: "worker template was not readable" };
const workerDryRun = parseProbeJson(workerDryRunProbe);
const workerInjectedEnv = envMap(workerDryRunManifest?.spec?.template?.spec?.containers?.[0] ?? null).values;
const missingSkills = [];
if (!liveSkillsEnv.HWLAB_SKILLS_COMMIT_ID) missingSkills.push("hwlab-agent-skills.HWLAB_SKILLS_COMMIT_ID");
if (!liveSkillsEnv.HWLAB_SKILLS_VERSION) missingSkills.push("hwlab-agent-skills.HWLAB_SKILLS_VERSION");
if (!workerInjectedEnv.HWLAB_SKILL_COMMIT_ID) missingSkills.push("worker-dry-run.HWLAB_SKILL_COMMIT_ID");
if (!workerInjectedEnv.HWLAB_SKILL_VERSION) missingSkills.push("worker-dry-run.HWLAB_SKILL_VERSION_FROM_DEV");
const clusterReadable = namespaceProbe.ok && deploymentsProbe.ok && workerTemplateProbe.ok && servicesProbe.ok;
const evidence = [
`k3s:namespace=${devNamespace}:read=${namespaceProbe.ok}`,
`k3s:deploy/${AGENT_MGR_SERVICE_ID}:ready=${agentMgrSummary.readyReplicas}/${agentMgrSummary.replicas}:available=${agentMgrSummary.available}`,
`k3s:deploy/${AGENT_SKILLS_SERVICE_ID}:ready=${skillsSummary.readyReplicas}/${skillsSummary.replicas}:available=${skillsSummary.available}`,
`k3s:svc/${AGENT_MGR_SERVICE_ID}:endpoints=${endpointCount(agentMgrEndpoint)}`,
`k3s:svc/${AGENT_SKILLS_SERVICE_ID}:endpoints=${endpointCount(skillsEndpoint)}`,
`agent-mgr:/health/live:${agentMgrHealth?.body?.health?.status ?? agentMgrHealth?.body?.status ?? "unreachable"}`,
`agent-skills:/health/live:${skillsHealth?.body?.status ?? "unreachable"}:${skillsHealth?.body?.revision ?? "unknown"}`,
`worker-template:suspend=${workerSummary.suspend}:image=${workerSummary.image ?? "unknown"}`,
`worker-job:server-dry-run=${workerDryRunProbe.ok}:persisted=false`,
`skills-injection:commit=${liveSkillsEnv.HWLAB_SKILLS_COMMIT_ID ?? "missing"}:version=${liveSkillsEnv.HWLAB_SKILLS_VERSION ?? "missing"}`,
`secret-rbac:get=${canGetSecrets.stdout.trim() || "unknown"}:secretResourcesRead=false`
];
const report = {
status: "pass",
mode: "dev-read-only-plus-server-dry-run",
generatedAt: new Date().toISOString(),
commands: [
`KUBECONFIG=${d601Kubeconfig} kubectl get namespace ${devNamespace} -o json`,
d601K3sReadonlyCommand,
`KUBECONFIG=${d601Kubeconfig} kubectl -n ${devNamespace} get deploy ${AGENT_MGR_SERVICE_ID} ${AGENT_SKILLS_SERVICE_ID} -o json`,
`KUBECONFIG=${d601Kubeconfig} kubectl -n ${devNamespace} get job hwlab-agent-worker-template -o json`,
`KUBECONFIG=${d601Kubeconfig} kubectl -n ${devNamespace} get svc ${AGENT_MGR_SERVICE_ID} ${AGENT_SKILLS_SERVICE_ID} -o json`,
`KUBECONFIG=${d601Kubeconfig} kubectl -n ${devNamespace} get endpoints ${AGENT_MGR_SERVICE_ID} ${AGENT_SKILLS_SERVICE_ID} -o json`,
`KUBECONFIG=${d601Kubeconfig} kubectl auth can-i create jobs -n ${devNamespace}`,
`KUBECONFIG=${d601Kubeconfig} kubectl auth can-i delete jobs -n ${devNamespace}`,
`KUBECONFIG=${d601Kubeconfig} kubectl auth can-i get secrets -n ${devNamespace}`,
workerServerDryRunCommand
],
safety: {
namespace: devNamespace,
kubeconfig: d601Kubeconfig,
readOnly: true,
prodTouched: false,
secretResourcesRead: false,
secretMaterialPrinted: false,
servicesRestarted: false,
workerJobPersisted: false,
longRunningAgentStarted: false,
note: "The smoke checks Secret RBAC only with kubectl auth can-i; it never reads Secret resources or kubeconfig contents."
},
clusterReadable,
namespace: {
name: devNamespace,
readable: namespaceProbe.ok,
error: namespaceProbe.ok ? null : compactError(namespaceProbe)
},
rbac: {
createJobs: canCreateJobs.stdout.trim() === "yes",
deleteJobs: canDeleteJobs.stdout.trim() === "yes",
getSecrets: canGetSecrets.stdout.trim() === "yes",
secretReadNotPerformed: true
},
agentManager: {
deployment: agentMgrSummary,
endpointCount: endpointCount(agentMgrEndpoint),
health: {
statusCode: agentMgrHealth?.statusCode ?? 0,
status: agentMgrHealth?.body?.health?.status ?? agentMgrHealth?.body?.status ?? "unreachable",
serviceId: agentMgrHealth?.body?.serviceId ?? null,
missingSkills: agentMgrHealth?.body?.health?.skills?.missing ?? [],
error: agentMgrHealth?.error ?? null
}
},
agentSkills: {
deployment: skillsSummary,
endpointCount: endpointCount(skillsEndpoint),
health: {
statusCode: skillsHealth?.statusCode ?? 0,
status: skillsHealth?.body?.status ?? "unreachable",
serviceId: skillsHealth?.body?.serviceId ?? null,
revision: skillsHealth?.body?.revision ?? null,
error: skillsHealth?.error ?? null
}
},
workerJob: {
template: workerSummary,
serverDryRun: {
status: workerDryRunProbe.ok ? "pass" : "blocked",
accepted: workerDryRunProbe.ok,
command: workerServerDryRunCommand,
persisted: false,
generatedName: workerDryRunManifest?.metadata?.name ?? null,
injectedEnvNames: Object.keys(workerInjectedEnv).sort(),
dryRunKind: workerDryRun?.kind ?? null,
dryRunSuspend: workerDryRun?.spec?.suspend ?? null,
summary: workerDryRunProbe.ok
? "Server-side admission accepted a suspended worker Job generated from the template with session/workspace/skills env injected; no Job was persisted."
: compactError(workerDryRunProbe)
}
},
skillsInjection: {
status: missingSkills.length === 0 ? "pass" : "blocked",
source: "k3s deployment env plus generated worker dry-run manifest",
serviceCommitId: liveSkillsEnv.HWLAB_SKILLS_COMMIT_ID ?? null,
serviceVersion: liveSkillsEnv.HWLAB_SKILLS_VERSION ?? null,
workerDryRunCommitId: workerInjectedEnv.HWLAB_SKILL_COMMIT_ID ?? null,
workerDryRunVersion: workerInjectedEnv.HWLAB_SKILL_VERSION ?? null,
workerDryRunSource: "derived-from-live-dev-skills-env",
missing: missingSkills
},
traceEvidenceCleanup: {
status: "dry-run-only",
summary: "Live worker execution was not started. Trace/evidence/cleanup closure is covered by the bounded local dry-run and remains explicitly non-M3 DEV-LIVE."
},
evidence
};
report.blockers = k3sBlockers(report);
report.status = report.blockers.length === 0 ? "pass" : "blocked";
report.minimumFixRecommendations = report.blockers.map((blocker) => blocker.nextFix).filter(Boolean);
return report;
}
@@ -1,157 +0,0 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
classifyCloudApiLiveReadiness,
publicDbLiveSummary,
publicRuntimeDurabilitySummary,
runtimeDurabilityFromHealth
} from "./dev-m4-agent-loop-smoke-lib.mjs";
test("M4 live readiness classifies durable adapter query blocker after DB live is ready", () => {
const payload = {
db: {
ready: true,
connected: true,
liveConnected: true,
liveDbEvidence: true,
status: "ok"
},
runtime: {
adapter: "postgres",
durable: false,
durableRequested: true,
ready: false,
status: "blocked",
blocker: "runtime_durable_adapter_query_blocked",
reason: "durable runtime read query is blocked",
liveRuntimeEvidence: false,
connection: {
queryAttempted: true,
queryResult: "query_blocked"
},
migration: {
checked: true,
ready: false,
missing: true,
requiredMigrationId: "0001_cloud_core_skeleton"
}
},
readiness: {
durability: {
blockedLayer: "durability_query",
requiredEvidence: "runtime_adapter_schema_migration_read_query"
}
}
};
const classification = classifyCloudApiLiveReadiness(payload);
assert.equal(classification.blocked, true);
assert.equal(classification.blockerScope, "runtime-durable-adapter");
assert.equal(classification.blockedClassification, "runtime_durable_adapter_query_blocked");
assert.match(classification.blockerSummary, /0001_cloud_core_skeleton/u);
assert.match(classification.evidenceSuffix, /runtime_durable_adapter_query_blocked/u);
const runtime = runtimeDurabilityFromHealth(payload);
assert.equal(runtime.blocker, "runtime_durable_adapter_query_blocked");
assert.equal(runtime.migration.checked, true);
assert.equal(runtime.migration.ready, false);
assert.equal(runtime.queryResult, "query_blocked");
});
test("M4 live readiness keeps DB live blocker when DB is not connected", () => {
const classification = classifyCloudApiLiveReadiness({
db: {
ready: false,
connected: false,
liveDbEvidence: false,
status: "degraded"
},
runtime: {
adapter: "postgres",
durableRequested: true,
blocker: "runtime_durable_adapter_query_blocked"
}
});
assert.equal(classification.blockerScope, "db-live");
assert.equal(classification.blockedClassification, "DB live");
});
test("public DB live summary preserves DB pass fields apart from durable adapter blocker", () => {
const summary = publicDbLiveSummary({
db: {
status: "ok",
connected: true,
liveConnected: true,
ready: true,
configReady: true,
connectionChecked: true,
endpointSource: "secret-url-host",
liveDbEvidence: true,
evidence: "live_db_tcp_connection_ready"
},
runtime: {
adapter: "postgres",
durable: false,
ready: false,
blocker: "runtime_durable_adapter_query_blocked"
}
});
assert.equal(summary.status, "ok");
assert.equal(summary.connected, true);
assert.equal(summary.liveConnected, true);
assert.equal(summary.ready, true);
assert.equal(summary.configReady, true);
assert.equal(summary.connectionChecked, true);
assert.equal(summary.endpointSource, "secret-url-host");
assert.equal(summary.liveDbEvidence, true);
assert.equal(summary.evidence, "live_db_tcp_connection_ready");
});
test("public runtime durability summary preserves durable adapter fields separately from DB live", () => {
const summary = publicRuntimeDurabilitySummary({
db: {
ready: true,
connected: true,
liveConnected: true,
liveDbEvidence: true
},
runtime: {
adapter: "postgres",
durable: false,
durableRequested: true,
ready: false,
status: "blocked",
blocker: "runtime_durable_adapter_query_blocked",
liveRuntimeEvidence: false,
connection: {
queryAttempted: true,
queryResult: "query_blocked"
},
migration: {
checked: false,
ready: false,
missing: true,
requiredMigrationId: "0001_cloud_core_skeleton"
},
durabilityContract: {
blockedLayer: "durability_query",
requiredEvidence: "runtime_adapter_schema_migration_read_query"
}
}
});
assert.equal(summary.adapter, "postgres");
assert.equal(summary.durableRequested, true);
assert.equal(summary.durable, false);
assert.equal(summary.ready, false);
assert.equal(summary.liveRuntimeEvidence, false);
assert.equal(summary.blocker, "runtime_durable_adapter_query_blocked");
assert.equal(summary.blockedLayer, "durability_query");
assert.equal(summary.requiredEvidence, "runtime_adapter_schema_migration_read_query");
assert.equal(summary.queryAttempted, true);
assert.equal(summary.queryResult, "query_blocked");
assert.equal(summary.migration.requiredMigrationId, "0001_cloud_core_skeleton");
});
+18 -9
View File
@@ -81,12 +81,6 @@ const serviceSpecificPaths = Object.freeze({
"internal/dev-entrypoint/cloud-web-proxy.mjs",
"internal/dev-entrypoint/cloud-web-routes.mjs"
],
"hwlab-agent-mgr": ["cmd/hwlab-agent-mgr/", "internal/agent/", "internal/db/", "internal/audit/"],
"hwlab-agent-worker": [
"cmd/hwlab-agent-worker/",
"internal/agent/",
"skills/hwlab-agent-runtime/"
],
"hwlab-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"],
"hwlab-gateway": ["cmd/hwlab-gateway/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
@@ -94,8 +88,6 @@ const serviceSpecificPaths = Object.freeze({
});
const bunCommandServices = new Set([
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-cloud-api",
"hwlab-codex-api-responses-forwarder",
"hwlab-deepseek-responses-bridge",
@@ -485,7 +477,13 @@ export async function lastCommitForPaths(repoRoot, targetRef, paths) {
function resolveServiceIds({ deployJson, options }) {
if (Array.isArray(options.services) && options.services.length > 0) {
return { source: "cli-services", serviceIds: uniqueSorted(options.services.map(String)) };
const serviceIds = uniqueSorted(options.services.map(String));
const allowedServiceIds = knownServiceIds(deployJson);
const unknownServiceIds = serviceIds.filter((serviceId) => !allowedServiceIds.has(serviceId));
if (unknownServiceIds.length > 0) {
throw new Error(`unknown service IDs for G14 CI plan: ${unknownServiceIds.join(", ")}`);
}
return { source: "cli-services", serviceIds };
}
const deployServices = Array.isArray(deployJson?.services) ? deployJson.services.map((service) => service?.serviceId).filter(Boolean) : [];
if (deployServices.length > 0) return { source: "deploy.services", serviceIds: uniquePreserveOrder(deployServices) };
@@ -496,6 +494,17 @@ function resolveServiceIds({ deployJson, options }) {
return { source: "internal.protocol.SERVICE_IDS", serviceIds: [...SERVICE_IDS] };
}
function knownServiceIds(deployJson) {
const values = new Set(SERVICE_IDS);
for (const service of deployJson?.services ?? []) {
if (service?.serviceId) values.add(service.serviceId);
}
for (const service of deployJson?.k3s?.serviceMappings ?? []) {
if (service?.serviceId) values.add(service.serviceId);
}
return values;
}
async function readJsonIfPresent(repoRoot, relativePath, fallback) {
try {
const filePath = path.isAbsolute(relativePath) ? relativePath : path.join(repoRoot, relativePath);
@@ -543,8 +543,6 @@ function fixtureArtifactJson() {
const serviceIds = [
"hwlab-cloud-api",
"hwlab-cloud-web",
"hwlab-agent-mgr",
"hwlab-agent-worker",
"hwlab-gateway",
"hwlab-gateway-simu",
"hwlab-box-simu",
+3 -5
View File
@@ -127,7 +127,7 @@ function assertEnvelopeValidation() {
},
meta: {
traceId: "trc_01J00000000000000000000000",
serviceId: "hwlab-agent-mgr",
serviceId: "hwlab-cloud-api",
environment: "dev"
}
});
@@ -141,7 +141,7 @@ function assertEnvelopeValidation() {
error: { code: -32000, message: "bad" },
meta: {
traceId: "trc_01J00000000000000000000000",
serviceId: "hwlab-agent-mgr",
serviceId: "hwlab-cloud-api",
environment: "dev"
}
}),
@@ -164,10 +164,8 @@ assertCommonSchema(docsByName.get("common.json"));
assertDeploySchema(docsByName.get("deploy.schema.json"));
assertEnvelopeValidation();
const deployServicesById = new Map(deployManifest.services.map((service) => [service.serviceId, service]));
assert.equal(deployServicesById.has("hwlab-agent-mgr"), true);
assert.equal(deployServicesById.has("hwlab-agent-worker"), true);
assert.equal(deployServicesById.has("hwlab-agent-skills"), true);
for (const removedServiceId of ["hwlab-gateway-simu", "hwlab-box-simu", "hwlab-patch-panel", "hwlab-router", "hwlab-tunnel-client"]) {
for (const removedServiceId of ["hwlab-agent-mgr", "hwlab-agent-worker", "hwlab-gateway-simu", "hwlab-box-simu", "hwlab-patch-panel", "hwlab-router", "hwlab-tunnel-client"]) {
assert.equal(deployServicesById.has(removedServiceId), false, `${removedServiceId} is removed from default deploy services`);
assert.equal(getWorkload(k8sWorkloads, removedServiceId), null, `${removedServiceId} is removed from default workload manifests`);
assert.equal(listItems(k8sServices).some((item) => item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] === removedServiceId), false, `${removedServiceId} is removed from default service manifests`);
-262
View File
@@ -1,262 +0,0 @@
#!/usr/bin/env node
import assert from "node:assert/strict";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DEV_ENDPOINT,
ENVIRONMENT_DEV,
ERROR_CODES,
SERVICE_IDS,
validateRequest,
validateResponse
} from "../internal/protocol/index.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const examplesDir = "protocol/examples/m0-contract";
const auditDocPath = "docs/reference/spec-v02-documentation-governance.md";
const timestampPattern = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
const idPattern = /^[a-z][a-z0-9]*_[A-Za-z0-9._:-]+$/;
const actionPattern = /^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)+$/;
async function readJSON(relativePath) {
const raw = await readFile(path.join(repoRoot, relativePath), "utf8");
return JSON.parse(raw);
}
async function loadExamples() {
const entries = await readdir(path.join(repoRoot, examplesDir), { withFileTypes: true });
const examples = new Map();
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json")) {
continue;
}
const relativePath = path.join(examplesDir, entry.name);
examples.set(entry.name, await readJSON(relativePath));
}
return examples;
}
function assertUnique(name, values) {
assert.equal(new Set(values).size, values.length, `${name} must be unique`);
}
function assertServiceId(serviceId, context) {
assert.ok(SERVICE_IDS.includes(serviceId), `${context} uses unknown serviceId ${serviceId}`);
}
function assertDevEnvironment(value, context) {
assert.equal(value, ENVIRONMENT_DEV, `${context} must be dev`);
}
function assertId(value, context) {
assert.equal(typeof value, "string", `${context} must be a string id`);
assert.match(value, idPattern, `${context} must match HWLAB id pattern`);
}
function assertTimestamp(value, context) {
assert.equal(typeof value, "string", `${context} must be a timestamp`);
assert.match(value, timestampPattern, `${context} must be an RFC 3339 UTC timestamp`);
}
function assertServiceIdsExample(doc) {
assert.equal(doc.contract, "m0-service-ids");
assertDevEnvironment(doc.environment, "service-ids environment");
assert.deepEqual(doc.serviceIds, SERVICE_IDS, "example service ids must match frozen runtime service ids");
assertUnique("example service ids", doc.serviceIds);
}
function assertJsonRpcExamples(request, response, errorEnvelope) {
validateRequest(request);
validateResponse(response);
validateResponse(errorEnvelope);
assert.equal(response.id, request.id, "JSON-RPC response must echo request id");
assert.equal(errorEnvelope.id, request.id, "JSON-RPC error must echo request id");
assert.equal(response.meta.traceId, request.meta.traceId, "response must keep request traceId");
assert.equal(errorEnvelope.meta.traceId, request.meta.traceId, "error must keep request traceId");
assertServiceId(request.meta.serviceId, "request meta");
assertServiceId(response.meta.serviceId, "response meta");
assertServiceId(errorEnvelope.meta.serviceId, "error meta");
assert.ok(
Object.values(ERROR_CODES).includes(errorEnvelope.error.code),
"error example must use a reserved JSON-RPC or HWLAB error code"
);
for (const key of ["projectId", "gatewaySessionId", "resourceId", "capabilityId", "reason"]) {
assert.ok(Object.hasOwn(errorEnvelope.error.data, key), `error.data must include ${key}`);
}
}
function assertAuditEvent(doc) {
for (const key of [
"auditId",
"traceId",
"actorType",
"actorId",
"action",
"targetType",
"targetId",
"serviceId",
"environment",
"occurredAt"
]) {
assert.ok(Object.hasOwn(doc, key), `audit event missing ${key}`);
}
for (const key of ["auditId", "traceId", "actorId", "targetId", "projectId", "gatewaySessionId", "workerSessionId", "operationId"]) {
assertId(doc[key], `audit event ${key}`);
}
assert.match(doc.action, actionPattern, "audit action must be dotted lower-case");
assert.equal(doc.targetId, doc.operationId, "operation audit targetId must equal operationId");
assertServiceId(doc.serviceId, "audit event");
assertDevEnvironment(doc.environment, "audit event environment");
assertTimestamp(doc.occurredAt, "audit occurredAt");
}
function assertCapabilityTopology(doc) {
assert.equal(doc.contract, "m0-capability-topology");
assertDevEnvironment(doc.project.environment, "project environment");
assertId(doc.project.projectId, "projectId");
assert.equal(doc.gatewaySession.projectId, doc.project.projectId, "gateway session projectId must match project");
assert.ok(["hwlab-gateway", "hwlab-gateway-simu"].includes(doc.gatewaySession.serviceId), "gateway service must be gateway");
assertDevEnvironment(doc.gatewaySession.environment, "gateway session environment");
const resourceIds = new Set();
for (const resource of doc.resources) {
assertId(resource.resourceId, "resourceId");
assert.equal(resource.projectId, doc.project.projectId, "resource projectId must match project");
assert.equal(resource.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "resource gatewaySessionId must match gateway");
assertDevEnvironment(resource.environment, `resource ${resource.resourceId} environment`);
resourceIds.add(resource.resourceId);
}
assert.ok(resourceIds.size >= 2, "topology example must include at least two resources");
const capabilityIds = new Set();
for (const capability of doc.capabilities) {
assertId(capability.capabilityId, "capabilityId");
assert.ok(resourceIds.has(capability.resourceId), `capability ${capability.capabilityId} must reference a known resource`);
assert.equal(capability.projectId, doc.project.projectId, "capability projectId must match project");
assert.match(capability.name, actionPattern, "capability name must be dotted lower-case");
assert.equal(typeof capability.mutatesState, "boolean", "capability mutatesState must be boolean");
capabilityIds.add(capability.capabilityId);
}
assert.ok(capabilityIds.size >= 2, "topology example must include multiple capabilities");
const wiring = doc.wiringConfig;
assert.equal(wiring.projectId, doc.project.projectId, "wiring projectId must match project");
assert.equal(wiring.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "wiring gatewaySessionId must match gateway");
assert.equal(wiring.status, "active", "wiring example should show an active config");
for (const connection of wiring.connections) {
assert.ok(resourceIds.has(connection.from.resourceId), "wiring from.resourceId must exist");
assert.ok(resourceIds.has(connection.to.resourceId), "wiring to.resourceId must exist");
if (connection.from.capabilityId) {
assert.ok(capabilityIds.has(connection.from.capabilityId), "wiring from.capabilityId must exist");
}
if (connection.to.capabilityId) {
assert.ok(capabilityIds.has(connection.to.capabilityId), "wiring to.capabilityId must exist");
}
}
const patch = doc.patchPanelStatus;
assert.equal(patch.serviceId, "hwlab-patch-panel");
assert.equal(patch.projectId, doc.project.projectId, "patch panel projectId must match project");
assert.equal(patch.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "patch panel gatewaySessionId must match gateway");
assert.equal(patch.wiringConfigId, wiring.wiringConfigId, "patch panel wiringConfigId must match wiring");
assertDevEnvironment(patch.environment, "patch panel environment");
for (const active of patch.activeConnections) {
assert.ok(resourceIds.has(active.fromResourceId), "active fromResourceId must exist");
assert.ok(resourceIds.has(active.toResourceId), "active toResourceId must exist");
}
assert.equal(doc.agentSession.projectId, doc.project.projectId, "agent session projectId must match project");
assert.equal(doc.agentSession.serviceId, "hwlab-agent-mgr");
assertDevEnvironment(doc.agentSession.environment, "agent session environment");
assert.equal(doc.workerSession.projectId, doc.project.projectId, "worker session projectId must match project");
assert.equal(doc.workerSession.agentSessionId, doc.agentSession.agentSessionId, "worker must reference agent session");
assert.equal(doc.workerSession.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "worker must reference gateway session");
assert.equal(doc.workerSession.serviceId, "hwlab-agent-worker");
assertDevEnvironment(doc.workerSession.environment, "worker session environment");
const operation = doc.hardwareOperation;
assert.equal(operation.projectId, doc.project.projectId, "operation projectId must match project");
assert.equal(operation.gatewaySessionId, doc.gatewaySession.gatewaySessionId, "operation gatewaySessionId must match gateway");
assert.equal(operation.agentSessionId, doc.agentSession.agentSessionId, "operation must reference agent session");
assert.equal(operation.workerSessionId, doc.workerSession.workerSessionId, "operation must reference worker session");
assert.ok(resourceIds.has(operation.resourceId), "operation resourceId must exist");
assert.ok(capabilityIds.has(operation.capabilityId), "operation capabilityId must exist");
assertDevEnvironment(operation.environment, "operation environment");
for (const traceEvent of doc.traceEvents) {
assert.equal(traceEvent.projectId, doc.project.projectId, "trace projectId must match project");
assert.equal(traceEvent.operationId, operation.operationId, "trace operationId must match operation");
assertServiceId(traceEvent.serviceId, "trace event");
assertDevEnvironment(traceEvent.environment, "trace environment");
}
const evidence = doc.evidenceRecord;
assert.equal(evidence.projectId, doc.project.projectId, "evidence projectId must match project");
assert.equal(evidence.operationId, operation.operationId, "evidence operationId must match operation");
assert.equal(evidence.agentSessionId, doc.agentSession.agentSessionId, "evidence must reference agent session");
assert.equal(evidence.workerSessionId, doc.workerSession.workerSessionId, "evidence must reference worker session");
assert.match(evidence.sha256, /^[a-f0-9]{64}$/, "evidence sha256 must be lowercase hex");
assertServiceId(evidence.serviceId, "evidence");
assertDevEnvironment(evidence.environment, "evidence environment");
}
function assertDeployManifest(doc) {
assert.equal(doc.manifestVersion, "v1");
assertDevEnvironment(doc.environment, "deploy manifest environment");
assert.equal(doc.endpoint, DEV_ENDPOINT, "deploy manifest endpoint must be the frozen DEV endpoint");
assert.ok(doc.profiles.dev.enabled, "deploy manifest dev profile must be enabled");
assert.equal(doc.profiles.dev.endpoint, DEV_ENDPOINT, "deploy manifest dev profile endpoint must be frozen");
assert.ok(!doc.profiles.prod, "M0 example must not include a PROD profile");
const serviceIds = doc.services.map((service) => service.serviceId);
assert.deepEqual(serviceIds, SERVICE_IDS, "deploy manifest example must cover every frozen service id in order");
assertUnique("deploy service ids", serviceIds);
for (const service of doc.services) {
assertServiceId(service.serviceId, "deploy service");
assert.equal(service.profile, "dev", `${service.serviceId} must use dev profile in M0 example`);
assert.equal(service.namespace, doc.namespace, `${service.serviceId} namespace must match manifest namespace`);
assert.ok(service.healthPath.startsWith("/"), `${service.serviceId} healthPath must be absolute`);
}
}
async function assertAuditMarkdown() {
const doc = await readFile(path.join(repoRoot, auditDocPath), "utf8");
for (const needle of [
"# v0.2 文档治理规格",
"docs/m0-*",
"旧里程碑",
"#532"
]) {
assert.ok(doc.includes(needle), `${auditDocPath} missing ${needle}`);
}
}
const examples = await loadExamples();
for (const required of [
"service-ids.json",
"json-rpc-request.json",
"json-rpc-response.json",
"json-rpc-error.json",
"audit-event.json",
"capability-topology.json",
"deploy-manifest.dev.json"
]) {
assert.ok(examples.has(required), `${examplesDir} missing ${required}`);
}
assertServiceIdsExample(examples.get("service-ids.json"));
assertJsonRpcExamples(
examples.get("json-rpc-request.json"),
examples.get("json-rpc-response.json"),
examples.get("json-rpc-error.json")
);
assertAuditEvent(examples.get("audit-event.json"));
assertCapabilityTopology(examples.get("capability-topology.json"));
assertDeployManifest(examples.get("deploy-manifest.dev.json"));
await assertAuditMarkdown();
console.log(`validated M0 contract examples: ${examples.size} JSON files, ${SERVICE_IDS.length} service ids`);
-4
View File
@@ -156,10 +156,6 @@ function assertHwlabImage(image, serviceId, label) {
}
function assertHealthProbe(container, serviceId, label) {
if (serviceId === "hwlab-agent-worker") {
return;
}
for (const probeName of ["readinessProbe", "livenessProbe"]) {
assert.equal(
container[probeName]?.httpGet?.path,
+1 -1
View File
@@ -389,7 +389,7 @@ function runtimeRouteSummary(value: any) {
}
function defaultRuntimeContainer(serviceId: string) {
if (["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-mgr", "hwlab-agent-skills"].includes(serviceId)) return serviceId;
if (["hwlab-cloud-api", "hwlab-cloud-web", "hwlab-device-pod", "hwlab-agent-skills"].includes(serviceId)) return serviceId;
return "";
}