fix(v02): remove legacy code agent control plane
This commit is contained in:
@@ -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)。
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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 "$@"
|
||||
@@ -95,7 +95,7 @@ G14 CI/CD 加速的第一判定标准不是 PipelineRun 总耗时单点变短,
|
||||
- 并发 fan-out:per-service TaskRun 应由 Tekton/k8s scheduler 同时调度,多个 service 的 build/reuse 窗口应接近“最慢单个服务任务耗时”,而不是所有服务串行相加。reuse-only 场景的 fan-out 窗口当前基线约为十几秒;真实组件构建场景仍需按 changed service 单独记录 BuildKit 耗时和 cache hit 情况。
|
||||
- CD rollout:unchanged service 必须复用 `deploy/artifact-catalog.dev.json` 的 image/digest,pod 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 template;AgentRun v0.1 作为外部共享执行基础设施接入。`hwlab-cli` 不创建镜像、Service 或 Job template,只在固定 repo 内短连接执行。真正发布验收仍以长驻 workload ready、公网 health 和失败 Pod 清单为准。
|
||||
|
||||
当前仍然慢的主要位置:
|
||||
|
||||
|
||||
@@ -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。 |
|
||||
|
||||
@@ -122,7 +122,7 @@ devops-infra git mirror 仍是 PipelineRun 和 Argo CD 的集群内读写源。`
|
||||
7. affected service 通过 BuildKit 发布到 G14 本地 registry;reused 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 image:worker 代码变化只能更新 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 CLI;code-only 变更复用 env image。 |
|
||||
| session-scoped evidence 写入 | 已实现 | 本地 dry-run 覆盖 trace/evidence。 |
|
||||
| 长驻 HTTP API | 不适用 | worker 不是长驻服务。 |
|
||||
| 生产调度与真实执行 | 未完全实现 | 当前仍是 skeleton/dry-run 模式。 |
|
||||
|
||||
@@ -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,不拥有业务 authority;CLI 可以旁路 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 必需依赖,应删除旧口径或交叉引用本文。
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"$ref": "common.json#/$defs/id"
|
||||
},
|
||||
"serviceId": {
|
||||
"const": "hwlab-agent-mgr"
|
||||
"const": "hwlab-cloud-api"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"$ref": "common.json#/$defs/id"
|
||||
},
|
||||
"serviceId": {
|
||||
"const": "hwlab-agent-worker"
|
||||
"const": "hwlab-cloud-api"
|
||||
},
|
||||
"gatewaySessionId": {
|
||||
"$ref": "common.json#/$defs/id"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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."
|
||||
],
|
||||
|
||||
@@ -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
@@ -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`
|
||||
} : {})
|
||||
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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}`);
|
||||
@@ -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
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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"] },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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`);
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user