diff --git a/docs/m1-local-smoke.md b/docs/m1-local-smoke.md index ab8f6faa..6ab741a8 100644 --- a/docs/m1-local-smoke.md +++ b/docs/m1-local-smoke.md @@ -1,8 +1,9 @@ -# M1 Local Contract Smoke +# M1 SOURCE/LOCAL Contract Smoke The M1 local smoke is a fast contract check for the current HWLAB MVP runtime. It runs only on the local machine, uses short-lived localhost -processes, and does not deploy or call the frozen DEV endpoint. +processes, and does not deploy or call the frozen DEV endpoint. It is +SOURCE/LOCAL evidence only, not DEV-LIVE evidence. Run it from the repository root: @@ -29,6 +30,8 @@ npm run m1:smoke - Checks simulator health and status payloads, confirms cross-device propagation is `patch-panel-only`, writes a local box port value, and routes a signal through the patch panel fixture. +- Checks `hwlab-agent-mgr` local health and sample manager lifecycle JSON + (`health`, `create`, `status`, `cleanup`) in a temporary state directory. - Parses `fixtures/mvp/runtime.json` through the CLI dry-run path and verifies the dry-run output names the MVP route, closed loops, evidence fixture, and the no-DEV/PROD-change disclaimer. @@ -45,10 +48,16 @@ This smoke is not the future DEV e2e acceptance test. It intentionally does not: behavior; - replace the L5 deploy manifest work. -The check is scoped to local M1 contract readiness: existing cloud-api, -patch-panel, simulator, and CLI dry-run runtime paths can be started or parsed -and still agree on the frozen service IDs, DEV environment, JSON-RPC surface, -DB degradation contract, and patch-panel-only topology assumptions. +The check is scoped to the local M1 contract boundary: existing cloud-api, +patch-panel, simulator, agent-mgr, Code Agent chat schema, and CLI dry-run +runtime paths can be started or parsed and still agree on the frozen service +IDs, DEV environment, JSON-RPC/REST surface, DB degradation contract, local +manager lifecycle shape, and patch-panel-only topology assumptions. + +The Code Agent chat check intentionally exercises only SOURCE/LOCAL contract +shape. User workbench backend routing may be present in the codebase, but a +real assistant reply and provider-backed completion must be validated by separate +DEV-LIVE work. ## Follow-On DEV e2e diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index 18540c77..4eb46726 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -1,8 +1,9 @@ #!/usr/bin/env node import assert from "node:assert/strict"; import { spawn } from "node:child_process"; -import { readFile } from "node:fs/promises"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; import net from "node:net"; +import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -63,6 +64,42 @@ function startNode(relativeScript, env = {}) { return record; } +function runNodeJson(relativeScript, args = [], env = {}) { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, [relativeScript, ...args], { + cwd: repoRoot, + env: { + ...process.env, + ...env + }, + stdio: ["ignore", "pipe", "pipe"] + }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`${relativeScript} ${args.join(" ")} exited with ${code}\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + return; + } + try { + resolve(JSON.parse(stdout)); + } catch (error) { + reject(new Error(`${relativeScript} ${args.join(" ")} did not return JSON: ${error.message}\nstdout:\n${stdout}\nstderr:\n${stderr}`)); + } + }); + }); +} + async function stopNode(record) { const { child } = record; if (child.exitCode !== null || child.signalCode !== null) { @@ -354,7 +391,8 @@ async function smokePatchPanelAndSimulators() { HWLAB_BOX_ID: "boxsim_alpha" }); const patchPanel = startNode("cmd/hwlab-patch-panel/main.mjs", { - PORT: String(patchPort) + PORT: String(patchPort), + HWLAB_WIRING_CONFIG_PATH: path.join(repoRoot, "fixtures/mvp-topology/topology.json") }); const gatewayBase = `http://127.0.0.1:${gatewayPort}`; @@ -467,9 +505,122 @@ async function smokeCliDryRunFixture() { logOk("CLI dry-run fixture parse"); } +async function smokeAgentMgrLocalContract() { + const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-m1-agent-mgr-")); + const agentSessionId = "agt_m1_local_contract"; + const agentMgrPort = await freePort(); + let agentMgr; + + try { + agentMgr = startNode("cmd/hwlab-agent-mgr/main.mjs", { + PORT: String(agentMgrPort), + HWLAB_AGENT_RUNTIME_STATE_DIR: stateDir, + HWLAB_ENVIRONMENT: ENVIRONMENT_DEV + }); + const baseUrl = `http://127.0.0.1:${agentMgrPort}`; + await waitForHttp(`${baseUrl}/health/live`, agentMgr); + + const healthLive = await requestJson(`${baseUrl}/health/live`); + assert.equal(healthLive.response.status, 200); + assert.equal(healthLive.body.serviceId, "hwlab-agent-mgr"); + assert.equal(healthLive.body.ok, true); + assert.equal(healthLive.body.environment, ENVIRONMENT_DEV); + assert.equal(healthLive.body.health.serviceId, "hwlab-agent-mgr"); + assert.equal(healthLive.body.health.status, "degraded"); + assert.equal(healthLive.body.health.runtimeMode, "local-dry-run"); + assert.ok(healthLive.body.health.skills.missing.includes("manager.skillCommitId")); + assert.ok(healthLive.body.health.skills.missing.includes("manager.skillVersion")); + assert.equal(healthLive.body.skillsManifest, null); + + const health = await runNodeJson("cmd/hwlab-agent-mgr/main.mjs", [ + "health", + "--state-dir", + stateDir, + "--skill-commit-id", + "local-contract", + "--skill-version", + "m1-local" + ]); + assert.equal(health.serviceId, "hwlab-agent-mgr"); + assert.equal(health.ok, true); + assert.equal(health.health.serviceId, "hwlab-agent-mgr"); + assert.equal(health.health.status, "ok"); + assert.equal(health.health.environment, ENVIRONMENT_DEV); + assert.equal(health.health.runtimeMode, "local-dry-run"); + assert.equal(health.health.workspaceCleaned, false); + assert.equal(health.health.skills.manager.status, "ready"); + assert.equal(health.skillsManifest.services[0].serviceId, "hwlab-agent-skills"); + assert.equal(health.skillsManifest.environment, ENVIRONMENT_DEV); + + const created = await runNodeJson("cmd/hwlab-agent-mgr/main.mjs", [ + "create", + "--state-dir", + stateDir, + "--agent-session-id", + agentSessionId, + "--project-id", + "prj_m1_local_contract", + "--goal", + "verify M1 local agent manager contract", + "--prompt", + "local contract sample", + "--skill-commit-id", + "local-contract", + "--skill-version", + "m1-local" + ]); + assert.equal(created.ok, true); + assert.equal(created.serviceId, "hwlab-agent-mgr"); + assert.equal(created.agentSessionId, agentSessionId); + assert.equal(created.status, "ok"); + assert.equal(created.environment, ENVIRONMENT_DEV); + assert.equal(created.runtimeMode, "local-dry-run"); + assert.equal(created.agentSession.serviceId, "hwlab-agent-mgr"); + assert.equal(created.agentSession.projectId, "prj_m1_local_contract"); + assert.equal(created.task.status, "queued"); + assert.equal(created.traceCount, 1); + assert.equal(created.evidenceCount, 0); + + const status = await runNodeJson("cmd/hwlab-agent-mgr/main.mjs", [ + "status", + "--state-dir", + stateDir, + "--agent-session-id", + agentSessionId + ]); + assert.equal(status.ok, true); + assert.equal(status.serviceId, "hwlab-agent-mgr"); + assert.equal(status.agentSessionId, agentSessionId); + assert.equal(status.status, "ok"); + assert.equal(status.task.taskId, `tsk_${agentSessionId}`); + + const cleanup = await runNodeJson("cmd/hwlab-agent-mgr/main.mjs", [ + "cleanup", + "--state-dir", + stateDir, + "--agent-session-id", + agentSessionId + ]); + assert.equal(cleanup.ok, true); + assert.equal(cleanup.serviceId, "hwlab-agent-mgr"); + assert.equal(cleanup.agentSessionId, agentSessionId); + assert.equal(cleanup.status, "ok"); + assert.equal(cleanup.cleanup.completed, true); + assert.equal(cleanup.workspace.existsAfterCleanup, false); + + logOk("agent-mgr health/sample local contract"); + } finally { + if (agentMgr) { + await stopNode(agentMgr); + } + await rm(stateDir, { recursive: true, force: true }); + } +} + try { await smokeCloudApi(); await smokePatchPanelAndSimulators(); + await smokeAgentMgrLocalContract(); await smokeCliDryRunFixture(); process.stdout.write("[m1-smoke] passed\n"); } finally {