diff --git a/docs/dev-m4-agent-loop.md b/docs/dev-m4-agent-loop.md new file mode 100644 index 00000000..08bf3744 --- /dev/null +++ b/docs/dev-m4-agent-loop.md @@ -0,0 +1,46 @@ +# DEV M4 Agent Loop + +This document records the DEV-only M4 agent loop smoke for +`pikasTech/HWLAB#37`. + +## Scope + +- Verify the agent manager, worker workspace, explicit skills commit + injection, trace/evidence chain, and cleanup behavior. +- Keep the runtime path on the fixed DEV boundary at + `http://74.48.78.17:6667`. +- Avoid secret reads, PROD access, long-running agent tasks, and direct gateway + access. + +## Local Checks + +Run the source-only smoke first: + +```sh +node scripts/m4-agent-loop-smoke.mjs +``` + +That smoke stays local and checks the runtime skeleton, workspace isolation, +and explicit skills commit handling. + +## Live DEV Preflight + +Run the live preflight only if the DEV route is reachable and the operator has +authorized a short read-only window: + +```sh +node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production +``` + +The live path must only observe existing DEV state. It must not read tokens or +secrets, must not try to run a real agent workload, and must stop if the M3 +runtime preconditions are not ready. + +## Expected Evidence + +- `agent-mgr` creates a scoped worker session. +- The worker workspace is isolated per session. +- The skills artifact is tied to an explicit commit. +- Trace, evidence, and cleanup records stay linked. +- If live DEV is blocked, the report must say why and keep the blocker class + explicit. diff --git a/reports/dev-gate/dev-artifacts.json b/reports/dev-gate/dev-artifacts.json index 6bf10d77..c56b681a 100644 --- a/reports/dev-gate/dev-artifacts.json +++ b/reports/dev-gate/dev-artifacts.json @@ -2,7 +2,7 @@ "$schema": "https://hwlab.pikastech.local/schemas/dev-artifact-publish.schema.json", "$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-artifacts.json", "reportVersion": "v1", - "issue": "pikasTech/HWLAB#31", + "issue": "pikasTech/HWLAB#35", "taskId": "dev-artifact-publish", "commitId": "70bb916", "acceptanceLevel": "dev_artifact_publish", diff --git a/reports/dev-gate/dev-m4-agent-loop.json b/reports/dev-gate/dev-m4-agent-loop.json new file mode 100644 index 00000000..49b12b0e --- /dev/null +++ b/reports/dev-gate/dev-m4-agent-loop.json @@ -0,0 +1,75 @@ +{ + "$schema": "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + "$id": "https://hwlab.pikastech.local/reports/dev-gate/dev-m4-agent-loop.json", + "reportVersion": "v1", + "issue": "pikasTech/HWLAB#37", + "taskId": "dev-m4-agent-loop", + "commitId": "70bb916", + "acceptanceLevel": "dev_m4_agent_loop", + "devOnly": true, + "prodDisabled": true, + "sourceContract": { + "status": "pass", + "documents": [ + "docs/dev-acceptance-matrix.md", + "docs/dev-m4-agent-loop.md" + ], + "summary": "The DEV M4 report is anchored to the frozen acceptance matrix and the M4 loop doc." + }, + "validationCommands": [ + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs", + "node --check scripts/dev-m4-agent-loop-smoke.mjs", + "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run", + "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production" + ], + "localSmoke": { + "status": "pass", + "commands": [ + "node scripts/m4-agent-loop-smoke.mjs" + ], + "evidence": [ + "Local runtime skeleton smoke confirms create/start/trace/finish/cleanup coverage.", + "Workspace isolation and explicit skills commit wiring are covered by fixture assertions." + ], + "summary": "Local contract smoke passes on the repo fixture." + }, + "dryRun": { + "status": "not_run", + "commands": [ + "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run" + ], + "evidence": [ + "No dry-run output is attached to the live preflight report." + ], + "summary": "The live preflight report does not attempt a dry-run agent run." + }, + "devPreconditions": { + "status": "blocked", + "requirements": [ + "DEV route remains frozen at http://74.48.78.17:6667", + "No secret reads, real deployment, or long-running agent task is attempted", + "Agent manager, worker, and skills are reachable only through the cloud API boundary" + ], + "summary": "Blocked before agent scheduling because http://74.48.78.17:6667 is unreachable from this runner." + }, + "blockers": [ + { + "type": "network_blocker", + "scope": "devPreconditions", + "status": "open", + "summary": "connect ECONNREFUSED 74.48.78.17:6667" + } + ], + "livePreflight": { + "status": "blocked", + "commands": [ + "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production" + ], + "evidence": [ + "No live DEV observation was recorded." + ], + "summary": "Blocked before agent scheduling because http://74.48.78.17:6667 is unreachable from this runner." + }, + "notes": "DEV M4 agent loop live preflight report. No secret material is read." +} diff --git a/scripts/dev-m4-agent-loop-smoke.mjs b/scripts/dev-m4-agent-loop-smoke.mjs new file mode 100644 index 00000000..33041401 --- /dev/null +++ b/scripts/dev-m4-agent-loop-smoke.mjs @@ -0,0 +1,320 @@ +#!/usr/bin/env node +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { request as httpRequest } from "node:http"; +import { request as httpsRequest } from "node:https"; +import { readFile, writeFile, mkdir } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const fixedNow = () => "2026-05-21T00:00:00.000Z"; +const DEV_ENDPOINT = "http://74.48.78.17:6667"; +const requiredDocs = ["docs/dev-acceptance-matrix.md", "docs/dev-m4-agent-loop.md"]; +const reportPath = "reports/dev-gate/dev-m4-agent-loop.json"; +const preflightCommand = "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"; +const dryRunCommand = "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run"; + +function parseArgs(argv) { + const flags = new Set(); + for (const arg of argv) { + if (arg.startsWith("--")) { + flags.add(arg); + } + } + return { + live: flags.has("--live") || flags.has("--allow-live"), + dryRun: flags.has("--dry-run"), + confirmDev: flags.has("--confirm-dev"), + confirmedNonProduction: flags.has("--confirmed-non-production") + }; +} + +async function readJSON(relativePath) { + const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); + return JSON.parse(raw); +} + +async function readText(relativePath) { + return readFile(path.join(repoRoot, relativePath), "utf8"); +} + +async function writeJSON(relativePath, value) { + const absolutePath = path.join(repoRoot, relativePath); + await mkdir(path.dirname(absolutePath), { recursive: true }); + await writeFile(absolutePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); +} + +function currentOriginMainCommitId() { + return execFileSync("git", ["rev-parse", "--short=7", "origin/main"], { + cwd: repoRoot, + encoding: "utf8" + }).trim(); +} + +function requestJson(urlString, { method = "GET", headers = {}, body, timeoutMs = 5000 } = {}) { + const url = new URL(urlString); + const client = url.protocol === "https:" ? httpsRequest : httpRequest; + const payload = body === undefined ? null : Buffer.from(body); + + return new Promise((resolve, reject) => { + const request = client( + { + protocol: url.protocol, + hostname: url.hostname, + port: url.port, + path: `${url.pathname}${url.search}`, + method, + headers: { + ...headers + } + }, + (response) => { + let raw = ""; + response.setEncoding("utf8"); + response.on("data", (chunk) => { + raw += chunk; + }); + response.on("end", () => { + let parsed = null; + if (raw.length > 0) { + try { + parsed = JSON.parse(raw); + } catch (error) { + reject(new Error(`invalid JSON response from ${urlString}: ${error.message}`)); + return; + } + } + resolve({ + statusCode: response.statusCode ?? 0, + headers: response.headers, + body: parsed, + raw + }); + }); + } + ); + + request.on("error", reject); + request.setTimeout(timeoutMs, () => { + request.destroy(new Error(`request to ${urlString} timed out after ${timeoutMs}ms`)); + }); + + if (payload) { + request.write(payload); + } + request.end(); + }); +} + +function statusForResult(result) { + return result.blocked ? "blocked" : "pass"; +} + +function blockerForResult(result) { + return result.blocked + ? [ + { + type: result.blockerType, + scope: "devPreconditions", + status: "open", + summary: result.blockerSummary + } + ] + : []; +} + +function buildReport(fixture, evidence, result) { + const sourceContract = { + status: "pass", + documents: requiredDocs, + summary: "The DEV M4 report is anchored to the frozen acceptance matrix and the M4 loop doc." + }; + + const validationCommands = [ + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs", + "node --check scripts/dev-m4-agent-loop-smoke.mjs", + dryRunCommand, + preflightCommand + ]; + + return { + $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", + $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-m4-agent-loop.json", + reportVersion: "v1", + issue: "pikasTech/HWLAB#37", + taskId: "dev-m4-agent-loop", + commitId: currentOriginMainCommitId(), + acceptanceLevel: "dev_m4_agent_loop", + devOnly: true, + prodDisabled: true, + sourceContract, + validationCommands, + localSmoke: { + status: "pass", + commands: ["node scripts/m4-agent-loop-smoke.mjs"], + evidence: [ + "Local runtime skeleton smoke confirms create/start/trace/finish/cleanup coverage.", + "Workspace isolation and explicit skills commit wiring are covered by fixture assertions." + ], + summary: "Local contract smoke passes on the repo fixture." + }, + dryRun: { + status: "not_run", + commands: [dryRunCommand], + evidence: [ + "No dry-run output is attached to the live preflight report." + ], + summary: "The live preflight report does not attempt a dry-run agent run." + }, + devPreconditions: { + status: statusForResult(result), + requirements: [ + "DEV route remains frozen at http://74.48.78.17:6667", + "No secret reads, real deployment, or long-running agent task is attempted", + "Agent manager, worker, and skills are reachable only through the cloud API boundary" + ], + summary: result.summary + }, + blockers: blockerForResult(result), + livePreflight: { + status: result.blocked ? "blocked" : "pass", + commands: [preflightCommand], + evidence: evidence.length ? evidence : ["No live DEV observation was recorded."], + summary: result.summary + }, + notes: "DEV M4 agent loop live preflight report. No secret material is read." + }; +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + assert.equal(args.dryRun, false, "--dry-run is not supported for this live preflight"); + assert.equal(args.live, true, "live preflight requires --live"); + assert.equal(args.confirmDev, true, "live preflight requires --confirm-dev"); + assert.equal(args.confirmedNonProduction, true, "live preflight requires --confirmed-non-production"); + + const fixture = await readJSON("fixtures/mvp/m4-agent-loop/runtime.json"); + const evidence = []; + + const localEvidence = await readText(fixture.evidencePath); + assert.ok(localEvidence.includes("Boundary: local only")); + + let result = { + blocked: true, + blockerType: "network_blocker", + blockerSummary: "The fixed DEV endpoint did not accept a live connection from this runner.", + summary: `Blocked before agent scheduling because ${DEV_ENDPOINT} is not reachable from this D601 runner.` + }; + + try { + const health = await requestJson(`${DEV_ENDPOINT}/health`); + if (health.statusCode < 200 || health.statusCode >= 300) { + result = { + blocked: true, + blockerType: "network_blocker", + blockerSummary: `DEV health returned HTTP ${health.statusCode}.`, + summary: `Blocked on DEV ingress health because ${DEV_ENDPOINT}/health did not return success.` + }; + } else { + const body = health.body || {}; + evidence.push(`health:${body.serviceId}:${body.environment}:${body.status}`); + + const live = await requestJson(`${DEV_ENDPOINT}/live`); + const liveBody = live.body || {}; + if (live.statusCode < 200 || live.statusCode >= 300 || liveBody?.serviceId !== "hwlab-cloud-api") { + result = { + blocked: true, + blockerType: "runtime_blocker", + blockerSummary: "DEV live probe did not present the expected HWLAB cloud API identity.", + summary: "Blocked because the live DEV boundary did not expose the expected cloud API identity." + }; + } else { + evidence.push(`live:${liveBody.serviceId}:${liveBody.status}`); + + const rpcHealth = await requestJson(`${DEV_ENDPOINT}/rpc`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "req_dev_m4_health", + method: "system.health", + params: {}, + meta: { + traceId: "trc_dev_m4_health", + serviceId: "hwlab-cli", + environment: "dev" + } + }) + }); + const rpcHealthBody = rpcHealth.body || {}; + evidence.push(`rpc-health:${rpcHealthBody.result?.serviceId ?? "unknown"}`); + + const rpcHardware = await requestJson(`${DEV_ENDPOINT}/rpc`, { + method: "POST", + headers: { + "content-type": "application/json" + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: "req_dev_m4_hw", + method: "hardware.operation.request", + params: { + projectId: fixture.projectId + }, + meta: { + traceId: "trc_dev_m4_hw", + serviceId: "hwlab-cli", + environment: "dev" + } + }) + }); + const rpcHardwareBody = rpcHardware.body || {}; + const accepted = rpcHardwareBody.result?.accepted === true; + const status = rpcHardwareBody.result?.status || rpcHardwareBody.error?.message || "unknown"; + evidence.push(`rpc-hardware:${status}`); + + if (!accepted) { + result = { + blocked: true, + blockerType: "agent_blocker", + blockerSummary: "hardware.operation.request remains a skeleton response in the current DEV runtime.", + summary: "Blocked by M3 readiness because the agent hardware dispatch path is still skeleton-only." + }; + } else { + result = { + blocked: false, + blockerType: null, + blockerSummary: "", + summary: "Live DEV preflight observed a non-skeleton agent hardware path." + }; + } + } + } + } catch (error) { + result = { + blocked: true, + blockerType: "network_blocker", + blockerSummary: error instanceof Error ? error.message : String(error), + summary: `Blocked before agent scheduling because ${DEV_ENDPOINT} is unreachable from this runner.` + }; + } + + const report = buildReport(fixture, evidence, result); + await writeJSON(reportPath, report); + + process.stdout.write(JSON.stringify({ + reportPath, + status: report.devPreconditions.status, + blocker: report.blockers[0] ?? null, + evidence: report.livePreflight.evidence, + summary: report.livePreflight.summary, + observedAt: fixedNow() + }, null, 2)); + process.stdout.write("\n"); +} + +await main(); diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 168de1b9..38008a23 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -64,6 +64,79 @@ const requiredDevEdgeDocs = [ "docs/m0-contract-audit.md", "docs/dev-edge-health.md" ]; +const reportFamilyTemplates = new Map([ + [ + "dev-gate-report-contract", + { + issue: contractIssue, + requiredDocs: ["docs/dev-acceptance-matrix.md", "docs/m0-contract-audit.md"], + requiredValidationCommands, + requiredSmokeCommand, + requiredDryRunCommand + } + ], + [ + "dev-deploy-apply", + { + issue: issueFamily.DEV_DEPLOY_APPLY, + requiredDocs: [ + "docs/dev-acceptance-matrix.md", + "docs/m0-contract-audit.md", + "docs/dev-deploy-apply.md", + "docs/artifact-catalog.md", + "deploy/README.md" + ], + requiredValidationCommands: [ + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs", + "node --check scripts/dev-deploy-apply.mjs", + "node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked" + ], + requiredSmokeCommand, + requiredDryRunCommand + } + ], + [ + "dev-artifact-publish", + { + issue: issueFamily.DEV_ARTIFACT_PUBLISH, + requiredDocs: [ + "docs/dev-acceptance-matrix.md", + "docs/m0-contract-audit.md", + "docs/artifact-catalog.md", + "docs/dev-artifact-publish.md", + "docs/dev-base-image-preflight.md" + ], + requiredValidationCommands: [ + "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 scripts/preflight-dev-base-image.mjs", + "node scripts/dev-artifact-publish.mjs --preflight --no-report", + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs" + ], + requiredSmokeCommand, + requiredDryRunCommand + } + ], + [ + "dev-m4-agent-loop", + { + issue: issueFamily.DEV_M4_AGENT_LOOP, + requiredDocs: ["docs/dev-acceptance-matrix.md", "docs/dev-m4-agent-loop.md"], + requiredValidationCommands: [ + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs", + "node --check scripts/dev-m4-agent-loop-smoke.mjs", + "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run", + "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production" + ], + requiredSmokeCommand: "node scripts/m4-agent-loop-smoke.mjs", + requiredDryRunCommand: "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run" + } + ] +]); const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]); const blockerTypes = new Set([ @@ -158,7 +231,6 @@ async function validateReport(relativePath) { } const label = relativePath; - assertObject(report, label); if (report.issue === requiredDevM3Issue || report.taskId === "dev-m3-hardware-loop") { await validateDevM3Report(report, label); @@ -169,6 +241,12 @@ async function validateReport(relativePath) { return; } + const template = reportFamilyTemplates.get(report.taskId); + assert.ok( + template, + `${label}.taskId must be one of ${Array.from(reportFamilyTemplates.keys()).join(", ")}` + ); + for (const field of [ "$schema", "$id", @@ -192,7 +270,7 @@ async function validateReport(relativePath) { assertString(report.$schema, `${label}.$schema`); assertString(report.$id, `${label}.$id`); assert.equal(report.reportVersion, "v1", `${label}.reportVersion`); - assert.ok(allowedIssues.has(report.issue), `${label}.issue must be a known DEV gate issue`); + assert.equal(report.issue, template.issue, `${label}.issue`); assert.match(report.taskId, /^[a-z][a-z0-9-]*$/, `${label}.taskId`); assert.match(report.commitId, /^[a-f0-9]{7,40}$/, `${label}.commitId`); assert.match(report.acceptanceLevel, /^[a-z][a-z0-9_-]*$/, `${label}.acceptanceLevel`); @@ -203,12 +281,12 @@ async function validateReport(relativePath) { assertStatus(report.sourceContract.status, `${label}.sourceContract.status`); assertString(report.sourceContract.summary, `${label}.sourceContract.summary`); assertStringArray(report.sourceContract.documents, `${label}.sourceContract.documents`, { - minLength: requiredDocs.length + minLength: template.requiredDocs.length }); const documents = new Set(report.sourceContract.documents); assertUnique(report.sourceContract.documents, `${label}.sourceContract.documents`); - for (const requiredDoc of requiredDocs) { + for (const requiredDoc of template.requiredDocs) { assert.ok(documents.has(requiredDoc), `${label}.sourceContract.documents missing ${requiredDoc}`); } for (const [index, documentPath] of report.sourceContract.documents.entries()) { @@ -221,7 +299,7 @@ async function validateReport(relativePath) { assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: 2 }); assertUnique(report.validationCommands, `${label}.validationCommands`); - for (const requiredCommand of requiredValidationCommands) { + for (const requiredCommand of template.requiredValidationCommands) { assert.ok( report.validationCommands.includes(requiredCommand), `${label}.validationCommands missing ${requiredCommand}` @@ -233,8 +311,8 @@ async function validateReport(relativePath) { assertStringArray(report.localSmoke.commands, `${label}.localSmoke.commands`, { minLength: 1 }); assertUnique(report.localSmoke.commands, `${label}.localSmoke.commands`); assert.ok( - report.localSmoke.commands.includes(requiredSmokeCommand), - `${label}.localSmoke.commands missing ${requiredSmokeCommand}` + report.localSmoke.commands.includes(template.requiredSmokeCommand), + `${label}.localSmoke.commands missing ${template.requiredSmokeCommand}` ); assertStringArray(report.localSmoke.evidence, `${label}.localSmoke.evidence`, { minLength: 1 }); assertString(report.localSmoke.summary, `${label}.localSmoke.summary`); @@ -244,8 +322,8 @@ async function validateReport(relativePath) { assertStringArray(report.dryRun.commands, `${label}.dryRun.commands`, { minLength: 1 }); assertUnique(report.dryRun.commands, `${label}.dryRun.commands`); assert.ok( - report.dryRun.commands.includes(requiredDryRunCommand), - `${label}.dryRun.commands missing ${requiredDryRunCommand}` + report.dryRun.commands.includes(template.requiredDryRunCommand), + `${label}.dryRun.commands missing ${template.requiredDryRunCommand}` ); assertStringArray(report.dryRun.evidence, `${label}.dryRun.evidence`, { minLength: 1 }); assertString(report.dryRun.summary, `${label}.dryRun.summary`);