feat: add local agent runtime loop
This commit is contained in:
@@ -35,3 +35,4 @@ yarn-error.log*
|
||||
|
||||
# Deploy/runtime state
|
||||
*.local.json
|
||||
.state/
|
||||
|
||||
+165
-44
@@ -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`);
|
||||
}
|
||||
|
||||
+100
-44
@@ -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`);
|
||||
}
|
||||
|
||||
+29
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
],
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user