diff --git a/.gitignore b/.gitignore index 44450299..316c5f3a 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ yarn-error.log* # Deploy/runtime state *.local.json +.state/ diff --git a/cmd/hwlab-agent-mgr/main.mjs b/cmd/hwlab-agent-mgr/main.mjs index ec14406f..4ac2c253 100644 --- a/cmd/hwlab-agent-mgr/main.mjs +++ b/cmd/hwlab-agent-mgr/main.mjs @@ -1,58 +1,179 @@ #!/usr/bin/env node -import { createAgentRunPlan, createSkillsManifest, finalizeAgentRun } from "../../internal/agent/index.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"; -function parseArgs(argv) { - const args = { - agentSessionId: "agt_local", - projectId: "prj_local", - goal: "dry-run agent runtime skeleton", - prompt: "dry-run", - skillCommitId: "" +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_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") { + 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, + health, + skillsManifest: managerSkills.status === "ready" + ? createSkillsManifest({ commitId: managerSkills.commitId, version: managerSkills.version }) + : null + }; + } + + 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") args.agentSessionId = argv[++i] || args.agentSessionId; - else if (arg === "--project-id") args.projectId = argv[++i] || args.projectId; - else if (arg === "--goal") args.goal = argv[++i] || args.goal; - else if (arg === "--prompt") args.prompt = argv[++i] || args.prompt; - else if (arg === "--skill-commit-id") args.skillCommitId = argv[++i] || args.skillCommitId; - else if (arg === "--help" || arg === "-h") args.help = true; + 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)}`); } - return args; + if (opts.stateDir) { + opts.stateDir = resolveAgentRuntimeStateDir(opts.stateDir); + } + + return opts; } -function printHelp() { - console.log("usage: node cmd/hwlab-agent-mgr/main.mjs [--agent-session-id ID] [--project-id ID] [--goal TEXT] [--prompt TEXT] [--skill-commit-id COMMIT]"); +function requireOption(opts, key, flag) { + if (!opts[key]) { + throw new Error(`${flag} is required`); + } + return opts[key]; } -const args = parseArgs(process.argv.slice(2)); - -if (args.help) { - printHelp(); - process.exit(0); +function help() { + return { + serviceId: AGENT_MGR_SERVICE_ID, + ok: true, + usage: [ + "node cmd/hwlab-agent-mgr/main.mjs health [--skill-commit-id COMMIT --skill-version VERSION] [--state-dir DIR]", + "node cmd/hwlab-agent-mgr/main.mjs create --agent-session-id ID --project-id ID [--goal TEXT --prompt TEXT] [--skill-commit-id COMMIT --skill-version VERSION] [--state-dir DIR]", + "node cmd/hwlab-agent-mgr/main.mjs status --agent-session-id ID [--state-dir DIR]", + "node cmd/hwlab-agent-mgr/main.mjs trace --agent-session-id ID [--state-dir DIR]", + "node cmd/hwlab-agent-mgr/main.mjs evidence --agent-session-id ID [--state-dir DIR]", + "node cmd/hwlab-agent-mgr/main.mjs cleanup --agent-session-id ID [--state-dir DIR]" + ], + contract: { + stateDir: resolveAgentRuntimeStateDir(), + output: "json", + runtimeMode: "local-dry-run", + prodDisabled: true + } + }; } -const plan = createAgentRunPlan({ - agentSessionId: args.agentSessionId, - projectId: args.projectId, - goal: args.goal, - prompt: args.prompt, - skillCommitId: args.skillCommitId || null -}); -const finished = finalizeAgentRun(plan); - -const result = { - serviceId: "hwlab-agent-mgr", - dryRun: true, - agentSession: finished.agentSession, - workerSession: finished.workerSession, - workspaceVolume: finished.workspaceVolume, - traceEvent: finished.traceEvent, - evidenceRecord: finished.evidenceRecord, - cleanup: finished.cleanup, - skillsManifest: args.skillCommitId ? createSkillsManifest({ commitId: args.skillCommitId }) : null -}; - -console.log(JSON.stringify(result, null, 2)); +function printJSON(value, { stderr = false } = {}) { + const target = stderr ? process.stderr : process.stdout; + target.write(`${JSON.stringify(value, null, 2)}\n`); +} diff --git a/cmd/hwlab-agent-worker/main.mjs b/cmd/hwlab-agent-worker/main.mjs index b8341e0f..dbf53465 100644 --- a/cmd/hwlab-agent-worker/main.mjs +++ b/cmd/hwlab-agent-worker/main.mjs @@ -1,58 +1,114 @@ #!/usr/bin/env node -import { createAgentRunPlan, finalizeAgentRun } from "../../internal/agent/index.mjs"; +import { AGENT_WORKER_SERVICE_ID } from "../../internal/agent/index.mjs"; +import { + assessSkillsInjection, + resolveAgentRuntimeStateDir, + runLocalWorkerDryRun +} from "../../internal/agent/runtime.mjs"; -function parseArgs(argv) { - const args = { - agentSessionId: process.env.HWLAB_AGENT_SESSION_ID || "agt_local", - projectId: process.env.HWLAB_PROJECT_ID || "prj_local", - prompt: "worker dry-run", - goal: "agent worker session skeleton", - skillCommitId: process.env.HWLAB_SKILL_COMMIT_ID || "" +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, + 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") args.agentSessionId = argv[++i] || args.agentSessionId; - else if (arg === "--project-id") args.projectId = argv[++i] || args.projectId; - else if (arg === "--prompt") args.prompt = argv[++i] || args.prompt; - else if (arg === "--goal") args.goal = argv[++i] || args.goal; - else if (arg === "--skill-commit-id") args.skillCommitId = argv[++i] || args.skillCommitId; - else if (arg === "--help" || arg === "-h") args.help = true; + 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)}`); } - return args; + if (opts.stateDir) { + opts.stateDir = resolveAgentRuntimeStateDir(opts.stateDir); + } + + return opts; } -function printHelp() { - console.log("usage: node cmd/hwlab-agent-worker/main.mjs [--agent-session-id ID] [--project-id ID] [--goal TEXT] [--prompt TEXT] [--skill-commit-id COMMIT]"); +function requireOption(opts, key, flag) { + if (!opts[key]) { + throw new Error(`${flag} is required`); + } + return opts[key]; } -const args = parseArgs(process.argv.slice(2)); - -if (args.help) { - printHelp(); - process.exit(0); +function help() { + return { + serviceId: AGENT_WORKER_SERVICE_ID, + ok: true, + usage: [ + "node cmd/hwlab-agent-worker/main.mjs health [--skill-commit-id COMMIT --skill-version VERSION]", + "node cmd/hwlab-agent-worker/main.mjs 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" + } + }; } -const plan = finalizeAgentRun( - createAgentRunPlan({ - agentSessionId: args.agentSessionId, - projectId: args.projectId, - goal: args.goal, - prompt: args.prompt, - skillCommitId: args.skillCommitId || null - }) -); - -const result = { - serviceId: "hwlab-agent-worker", - dryRun: true, - agentSession: plan.agentSession, - workerSession: plan.workerSession, - workspaceVolume: plan.workspaceVolume, - traceEvent: plan.traceEvent, - evidenceRecord: plan.evidenceRecord, - cleanup: plan.cleanup -}; - -console.log(JSON.stringify(result, null, 2)); +function printJSON(value, { stderr = false } = {}) { + const target = stderr ? process.stderr : process.stdout; + target.write(`${JSON.stringify(value, null, 2)}\n`); +} diff --git a/docs/m4-agent-loop.md b/docs/m4-agent-loop.md index 82680fc2..86cd72e6 100644 --- a/docs/m4-agent-loop.md +++ b/docs/m4-agent-loop.md @@ -10,11 +10,39 @@ This check stays local. It uses the `internal/agent` runtime skeleton and a repo fixture to verify: - lifecycle coverage for `create`, `start`, `trace`, `finish`, and `cleanup`; +- manager `create/status/trace/evidence/cleanup` contract behavior through the + local file-system runtime; +- worker `dry-run` task claim, trace write, evidence write, and temporary + workspace cleanup; - workspace isolation per agent session; -- explicit skills `commitId` wiring; +- explicit skills `commitId` and `version` wiring; - evidence record fields, URI shape, and SHA-256 digest; - the fixture evidence note that marks the run as local-only. +The local CLI contract is JSON-only and short-lived: + +```sh +STATE_DIR="$(mktemp -d)" +node cmd/hwlab-agent-mgr/main.mjs create --state-dir "$STATE_DIR" \ + --agent-session-id agt_local_m4 --project-id prj_local_m4 \ + --goal "verify local agent loop" --prompt "dry-run" \ + --skill-commit-id 6509a35 --skill-version v1 +node cmd/hwlab-agent-worker/main.mjs dry-run --state-dir "$STATE_DIR" \ + --agent-session-id agt_local_m4 \ + --skill-commit-id 6509a35 --skill-version v1 +node cmd/hwlab-agent-mgr/main.mjs status --state-dir "$STATE_DIR" \ + --agent-session-id agt_local_m4 +node cmd/hwlab-agent-mgr/main.mjs trace --state-dir "$STATE_DIR" \ + --agent-session-id agt_local_m4 +node cmd/hwlab-agent-mgr/main.mjs evidence --state-dir "$STATE_DIR" \ + --agent-session-id agt_local_m4 +node cmd/hwlab-agent-mgr/main.mjs cleanup --state-dir "$STATE_DIR" \ + --agent-session-id agt_local_m4 +``` + +If either skills `commitId` or `version` is omitted, health and evidence are +reported as `degraded`; no fallback commit or external skill source is inferred. + ## Real DEV Preconditions A real DEV M4 run is separate from this smoke. It would need a live DEV diff --git a/fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md b/fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md index 18810a97..e6226876 100644 --- a/fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md +++ b/fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md @@ -8,4 +8,5 @@ - Operation: op_agt_m4_agent_loop_01 - Evidence: evi_agt_m4_agent_loop_01 - Skill commitId: 6509a35 +- Skill version: v1 - Boundary: local only; no secrets, no live DEV agent run, no long-running task diff --git a/fixtures/mvp/m4-agent-loop/runtime.json b/fixtures/mvp/m4-agent-loop/runtime.json index 3b6923b5..976e6bff 100644 --- a/fixtures/mvp/m4-agent-loop/runtime.json +++ b/fixtures/mvp/m4-agent-loop/runtime.json @@ -6,6 +6,7 @@ "goal": "verify M4 agent automation loop skeleton", "prompt": "local contract smoke for agent automation loop", "skillCommitId": "6509a35", + "skillVersion": "v1", "evidencePath": "fixtures/mvp/m4-agent-loop/evidence/m4-agent-loop-plan.md", "workspaceIsolation": { "projectId": "prj_m4_agent_loop_alt", diff --git a/internal/agent/index.mjs b/internal/agent/index.mjs index 8db6e59a..c96daf3e 100644 --- a/internal/agent/index.mjs +++ b/internal/agent/index.mjs @@ -200,6 +200,7 @@ export function createAgentRunPlan({ goal = "", prompt = "", skillCommitId = null, + skillVersion = null, now = () => new Date().toISOString() }) { const traceId = `trc_${agentSessionId}`; @@ -239,6 +240,7 @@ export function createAgentRunPlan({ workerSessionId, metadata: { skillCommitId, + skillVersion, prompt, goal, workspaceVolumeId: workspaceVolume.workspaceVolumeId @@ -270,7 +272,8 @@ export function createAgentRunPlan({ ], traceEvent, evidenceRecord, - skillCommitId + skillCommitId, + skillVersion }; } @@ -296,6 +299,7 @@ export function finalizeAgentRun(plan, { now = () => new Date().toISOString() } export function createSkillsManifest({ commitId, + version, skillName = "hwlab-agent-runtime", serviceId = AGENT_SKILLS_SERVICE_ID, environment = ENVIRONMENT_DEV, @@ -304,10 +308,14 @@ export function createSkillsManifest({ 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:6667", profiles: { @@ -327,7 +335,8 @@ export function createSkillsManifest({ healthPath: "/health", profile: "dev", env: { - HWLAB_SKILLS_COMMIT_ID: commitId + HWLAB_SKILLS_COMMIT_ID: commitId, + HWLAB_SKILLS_VERSION: version } } ], diff --git a/internal/agent/index.test.mjs b/internal/agent/index.test.mjs index 29a15076..d1c6f5b6 100644 --- a/internal/agent/index.test.mjs +++ b/internal/agent/index.test.mjs @@ -1,4 +1,7 @@ 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 { @@ -10,6 +13,13 @@ import { 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({ @@ -45,10 +55,13 @@ test("finalizeAgentRun marks cleanup and keeps evidence visible", () => { test("skills manifest requires explicit commitId", () => { assert.throws(() => createSkillsManifest({}), /explicit commitId/); + assert.throws(() => createSkillsManifest({ commitId: "6509a35" }), /explicit version/); - const manifest = createSkillsManifest({ commitId: "6509a35" }); + 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", () => { @@ -66,3 +79,100 @@ test("advance helpers preserve completedAt on non-finish states", () => { 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 }); + } +}); diff --git a/internal/agent/runtime.mjs b/internal/agent/runtime.mjs new file mode 100644 index 00000000..ed48bb67 --- /dev/null +++ b/internal/agent/runtime.mjs @@ -0,0 +1,657 @@ +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"); +} diff --git a/package.json b/package.json index bc9a5d52..abc3a192 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", "dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty", diff --git a/scripts/m4-agent-loop-smoke.mjs b/scripts/m4-agent-loop-smoke.mjs index 27315f84..ec1a2581 100644 --- a/scripts/m4-agent-loop-smoke.mjs +++ b/scripts/m4-agent-loop-smoke.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node import assert from "node:assert/strict"; import { createHash } from "node:crypto"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -15,6 +16,13 @@ import { 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"; @@ -47,6 +55,7 @@ function expectRunPlan(plan, fixture) { 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); @@ -114,6 +123,7 @@ function expectRunPlan(plan, fixture) { 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); @@ -137,6 +147,7 @@ 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:6667"); assert.equal(manifest.profiles.dev.enabled, true); @@ -144,6 +155,7 @@ function expectSkillsManifest(manifest, fixture) { 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, []); } @@ -167,10 +179,73 @@ function expectEvidenceFixture(text, fixture) { 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); @@ -178,6 +253,7 @@ 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); @@ -190,6 +266,7 @@ const plan = createAgentRunPlan({ goal: fixture.goal, prompt: fixture.prompt, skillCommitId: fixture.skillCommitId, + skillVersion: fixture.skillVersion, now: fixedNow }); @@ -200,7 +277,8 @@ const finished = finalizeAgentRun(plan, { now: fixedNow }); expectFinishedRun(finished, plan); const manifest = createSkillsManifest({ - commitId: fixture.skillCommitId + commitId: fixture.skillCommitId, + version: fixture.skillVersion }); expectSkillsManifest(manifest, fixture); @@ -211,9 +289,11 @@ const isolationPlan = createAgentRunPlan({ 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}`); diff --git a/skills/hwlab-agent-runtime/SKILL.md b/skills/hwlab-agent-runtime/SKILL.md index e78f5236..847cc187 100644 --- a/skills/hwlab-agent-runtime/SKILL.md +++ b/skills/hwlab-agent-runtime/SKILL.md @@ -10,5 +10,25 @@ Use this skill when working on HWLAB agent runtime skeletons. - Keep the control plane long-lived and worker execution session-scoped. - Model the lifecycle as create, start, trace, finish, cleanup. - Always carry explicit evidence record fields through the run plan. -- Use repo-local skills artifacts only when a commitId is supplied explicitly. +- Use repo-local skills artifacts only when `commitId` and `version` are + supplied explicitly. - Do not infer or fall back to external skill sources. + +## Local CLI Contract + +All commands emit JSON and stay local: + +- `node cmd/hwlab-agent-mgr/main.mjs health` +- `node cmd/hwlab-agent-mgr/main.mjs create --agent-session-id ID --project-id ID` +- `node cmd/hwlab-agent-mgr/main.mjs status --agent-session-id ID` +- `node cmd/hwlab-agent-mgr/main.mjs trace --agent-session-id ID` +- `node cmd/hwlab-agent-mgr/main.mjs evidence --agent-session-id ID` +- `node cmd/hwlab-agent-mgr/main.mjs cleanup --agent-session-id ID` +- `node cmd/hwlab-agent-worker/main.mjs dry-run --agent-session-id ID` + +Use `--state-dir DIR` to isolate local state. The default state directory is +`.state/agent-runtime`, which is intended for local runtime state only. + +Provide both `--skill-commit-id COMMIT` and `--skill-version VERSION` for a +ready health result. Missing fields must produce `degraded` health and degraded +evidence metadata; there is no silent fallback.