#!/usr/bin/env node import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; import { mkdtemp, readFile, writeFile, mkdir, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { AGENT_MGR_SERVICE_ID, AGENT_SKILLS_SERVICE_ID, AGENT_WORKER_SERVICE_ID } from "../internal/agent/index.mjs"; import { createLocalAgentSession, getLocalAgentEvidence, getLocalAgentStatus, getLocalAgentTrace, runLocalWorkerDryRun } from "../internal/agent/runtime.mjs"; import { activeReportLifecycle } from "../internal/dev-report-lifecycle.mjs"; import { DEV_ENDPOINT, DEV_FRONTEND_ENDPOINT, ENVIRONMENT_DEV } from "../internal/protocol/index.mjs"; import { ensureNotRepoReportsPath, tempReportPath } from "./src/report-paths.mjs"; import { collectD601K3sNativeEvidence, collectPublicEntrypoints, classifyCloudApiLiveReadiness, d601K3sReadonlyCommand, d601Kubeconfig, devNamespace, requestJson, runtimeDurabilityFromHealth, workerServerDryRunCommand } from "./src/dev-m4-agent-loop-smoke-lib.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const fixedNow = () => "2026-05-22T00:00:00.000Z"; const requiredDocs = ["docs/reference/MVP-e2e-acceptance.md", "docs/reference/code-agent-chat-readiness.md"]; const reportPath = tempReportPath("dev-m4-agent-loop.json"); const preflightCommand = "node scripts/dev-m4-agent-loop-smoke.mjs --live --confirm-dev --confirmed-non-production"; const dryRunCommand = "node scripts/dev-m4-agent-loop-smoke.mjs --dry-run"; function parseArgs(argv) { const flags = new Set(); for (const arg of argv) { if (arg.startsWith("--")) { flags.add(arg); } } return { live: flags.has("--live") || flags.has("--allow-live"), dryRun: flags.has("--dry-run"), confirmDev: flags.has("--confirm-dev"), confirmedNonProduction: flags.has("--confirmed-non-production") }; } async function readJSON(relativePath) { const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); return JSON.parse(raw); } async function readText(relativePath) { return readFile(path.join(repoRoot, relativePath), "utf8"); } async function writeJSON(relativePath, value) { const absolutePath = ensureNotRepoReportsPath(repoRoot, relativePath, "--report"); await mkdir(path.dirname(absolutePath), { recursive: true }); await writeFile(absolutePath, `${JSON.stringify(value, null, 2)}\n`, "utf8"); } function currentOriginMainCommitId() { return execFileSync("git", ["rev-parse", "--short=7", "origin/main"], { cwd: repoRoot, encoding: "utf8" }).trim(); } function statusForResult(result) { return result.blocked || (result.additionalBlockers?.length ?? 0) > 0 ? "blocked" : "pass"; } function dbLiveReadyForResult(result) { return result.dbLiveReady === true || (!result.blocked && result.dbLiveReady !== false); } function runtimeDurableReadyForResult(result) { return result.runtimeDurableReady === true || (!result.blocked && result.runtimeDurableReady !== false); } function blockerForResult(result) { const blockers = []; if (result.blocked && result.blockerType) { blockers.push({ type: result.blockerType, scope: result.blockerScope ?? "devPreconditions", status: "open", summary: result.blockerSummary, classification: result.blockedClassification ?? "other", ...(result.sourceIssue ? { sourceIssue: result.sourceIssue } : {}) }); } for (const blocker of result.additionalBlockers ?? []) { blockers.push({ status: "open", ...blocker }); } const seen = new Set(); return blockers.filter((blocker) => { const key = `${blocker.type}:${blocker.scope}`; if (seen.has(key)) return false; seen.add(key); return true; }); } function classificationForResult(result) { return result.blockedClassification ?? result.additionalBlockers?.[0]?.classification ?? "none"; } function buildDryRunEvidence({ status = "not_run", details = [] } = {}) { return { status, commands: [dryRunCommand], evidence: details.length ? details : ["No dry-run output is attached to this live preflight report."], summary: status === "pass" ? "Local dry-run covered agent manager, worker, workspace isolation, skills commit, trace, evidence, and cleanup without live DEV mutation." : "The live preflight report did not attach a dry-run agent run." }; } function buildComponentEvidence(fixture, dryRun, result) { const dbLiveReady = dbLiveReadyForResult(result); const runtimeDurableReady = runtimeDurableReadyForResult(result); const dbLiveStatus = result.blockedClassification === "DB live" ? "blocked" : dbLiveReady ? "pass" : "not_run"; const m3DependencyStatus = result.blockedClassification === "hardware loop" ? "blocked" : result.blocked ? "not_run" : "pass"; return { agentManagerWorker: { level: dryRun.status === "pass" ? "DRY-RUN" : "LOCAL", status: dryRun.status === "pass" ? "pass" : "not_run", services: [AGENT_MGR_SERVICE_ID, AGENT_WORKER_SERVICE_ID], summary: dryRun.status === "pass" ? "Manager created a scoped local session and worker completed the bounded dry-run task." : "Manager/worker live scheduling was not attempted before preflight blockers were classified." }, workspaceIsolation: { level: dryRun.status === "pass" ? "DRY-RUN" : "LOCAL", status: "pass", evidence: [ `primary=${fixture.projectId}/${fixture.agentSessionId}`, `isolation=${fixture.workspaceIsolation.projectId}/${fixture.workspaceIsolation.agentSessionId}` ], summary: "Workspace identity is per agent session and fixture isolation remains source/local evidence only." }, skillsCommit: { level: dryRun.status === "pass" ? "DRY-RUN" : "LOCAL", status: "pass", serviceId: AGENT_SKILLS_SERVICE_ID, commitId: fixture.skillCommitId, version: fixture.skillVersion, summary: "Agent skills are accepted only with explicit commitId and version wiring." }, traceEvidenceCleanup: { level: dryRun.status === "pass" ? "DRY-RUN" : "LOCAL", status: dryRun.status === "pass" ? "pass" : "not_run", summary: dryRun.status === "pass" ? "Dry-run produced trace/evidence records and removed the temporary worker workspace." : "Trace/evidence/cleanup live closure was not attempted before preflight blockers were classified." }, dbLive: { level: dbLiveStatus === "pass" ? "DEV-LIVE" : "BLOCKED", status: dbLiveStatus, summary: dbLiveStatus === "blocked" ? "Live scheduling is stopped at cloud-api DB readiness; DB health is classified separately from local dry-run evidence." : dbLiveStatus === "pass" ? "Current read-only /health/live evidence reports DB ready=true, connected=true, and liveDbEvidence=true; DB live is not the active M4 blocker." : "Live cloud-api DB readiness did not block before the current M4 classification point." }, runtimeDurableAdapter: { level: runtimeDurableReady ? "DEV-LIVE" : "BLOCKED", status: result.blockerScope === "runtime-durable-adapter" ? "blocked" : runtimeDurableReady ? "pass" : "not_run", summary: result.blockerScope === "runtime-durable-adapter" ? "Live scheduling is stopped at runtime durable adapter readiness; DB live evidence alone is not runtime durability evidence." : runtimeDurableReady ? "Current read-only /health/live evidence reports runtime.adapter=postgres, durable=true, ready=true, and liveRuntimeEvidence=true." : "Runtime durable adapter readiness did not produce the active M4 blocker." }, m3HardwareLoopDependency: { level: "BLOCKED", status: m3DependencyStatus, requiredPath: "DO1 -> hwlab-patch-panel -> DI1", summary: m3DependencyStatus === "blocked" ? "M4 reached the hardware dispatch dependency, but the M3 patch-panel trusted loop is not accepted as live." : "M4 can only support M3 through cloud-api/hardware API and the patch-panel trusted path; no direct box/gateway path is used." } }; } function buildDependencyState(result) { const dbLiveReady = dbLiveReadyForResult(result); const runtimeDurableReady = runtimeDurableReadyForResult(result); return { dbLive: { status: result.blockedClassification === "DB live" ? "blocked" : dbLiveReady ? "pass" : "not_run", summary: result.blockedClassification === "DB live" ? result.blockerSummary : dbLiveReady ? "cloud-api /health/live reports DB ready=true, connected=true, and liveDbEvidence=true." : "DB live readiness did not produce the active M4 blocker." }, runtimeDurableAdapter: { status: result.blockerScope === "runtime-durable-adapter" ? "blocked" : runtimeDurableReady ? "pass" : "not_run", requiredEvidence: "Postgres runtime adapter schema, migration ledger, and read-query readiness with liveRuntimeEvidence=true.", summary: result.blockerScope === "runtime-durable-adapter" ? result.blockerSummary : runtimeDurableReady ? "cloud-api /health/live reports runtime.adapter=postgres, durable=true, ready=true, and liveRuntimeEvidence=true." : "Runtime durable adapter readiness did not produce the active M4 blocker." }, m3HardwareLoop: { status: result.blockedClassification === "hardware loop" ? "blocked" : result.blocked ? "not_run" : "pass", requiredEvidence: "Traceable DO1 -> hwlab-patch-panel -> DI1 operation with auditId and evidenceId.", summary: result.blockedClassification === "hardware loop" ? result.blockerSummary : "M3 hardware loop was not reached while an earlier M4 precondition remained blocked." }, hardwareDispatchBoundary: { status: "pass", allowedPath: "cloud-api /rpc hardware.operation.request -> hwlab-patch-panel trusted route", forbiddenPaths: [ "direct hwlab-box-simu access", "direct hwlab-gateway-simu access", "agent-side fixture promotion to DEV-LIVE" ] } }; } function buildReport( fixture, liveEvidence, result, { dryRun = buildDryRunEvidence(), d601K3s = null, publicEntrypoints = null } = {} ) { const sourceContract = { status: "pass", documents: requiredDocs, summary: "The DEV M4 report is anchored to the frozen acceptance matrix and the M4 loop doc." }; const validationCommands = [ "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs", "node --check scripts/dev-m4-agent-loop-smoke.mjs", "node --check scripts/src/dev-m4-agent-loop-smoke-lib.mjs", dryRunCommand, preflightCommand, d601K3sReadonlyCommand, workerServerDryRunCommand ]; const blockers = blockerForResult(result); return { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", $id: "https://hwlab.pikastech.local/dev-m4-agent-loop.json", reportVersion: "v1", issue: "pikasTech/HWLAB#37", taskId: "dev-m4-agent-loop", commitId: currentOriginMainCommitId(), acceptanceLevel: "dev_m4_agent_loop", devOnly: true, prodDisabled: true, reportLifecycle: activeReportLifecycle("Current DEV M4 agent-loop preflight report; it does not substitute for M3 DEV-LIVE acceptance."), sourceContract, validationCommands, localSmoke: { status: "pass", commands: ["node scripts/m4-agent-loop-smoke.mjs"], evidence: [ "Local runtime skeleton smoke confirms create/start/trace/finish/cleanup coverage.", "Workspace isolation and explicit skills commit wiring are covered by fixture assertions." ], summary: "Local contract smoke passes on the repo fixture." }, dryRun, componentEvidence: buildComponentEvidence(fixture, dryRun, result), dependencyState: buildDependencyState(result), d601K3sNative: d601K3s, publicEntrypoints, safetyEvidence: { devOnly: true, namespace: devNamespace, kubeconfig: d601Kubeconfig, prodTouched: false, prodNamespaceTouched: false, secretMaterialRead: false, secretResourcesRead: false, servicesRestarted: false, longRunningAgentStarted: false, workerJobPersisted: false, notes: [ "kubectl commands were scoped to hwlab-dev and /etc/rancher/k3s/k3s.yaml.", "Secret RBAC was checked with kubectl auth can-i only; no Secret resource or secret value was read.", "Worker createability used server-side dry-run with suspend=true and did not persist a Job." ] }, devPreconditions: { status: statusForResult(result), classification: classificationForResult(result), requirements: [ `DEV route remains frozen at ${DEV_ENDPOINT}`, `DEV browser route remains frozen at ${DEV_FRONTEND_ENDPOINT}`, `D601 native k3s access uses KUBECONFIG=${d601Kubeconfig} and namespace ${devNamespace}`, "No secret reads, real deployment, or long-running agent task is attempted", "DB live readiness and runtime durable adapter readiness are separate gates; DB live alone cannot promote M4", "Agent manager, worker, and skills are reachable only through the cloud API boundary", "M4 hardware assistance must go through cloud-api/hardware API and patch-panel; direct box/gateway access is forbidden" ], summary: result.summary }, blockers, livePreflight: { status: blockers.length > 0 ? "blocked" : "pass", classification: classificationForResult(result), commands: [preflightCommand], evidence: liveEvidence.length ? liveEvidence : ["No live DEV observation was recorded."], summary: result.summary }, notes: "DEV M4 agent loop report. SOURCE/LOCAL/DRY-RUN evidence is not DEV-LIVE, and no secret material is read." }; } async function loadFixture() { const fixture = await readJSON("fixtures/mvp/m4-agent-loop/runtime.json"); const localEvidence = await readText(fixture.evidencePath); assert.ok(localEvidence.includes("Boundary: local only")); return fixture; } async function runDryRun(fixture) { const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-dev-m4-agent-loop-")); try { const created = await createLocalAgentSession({ stateDir, agentSessionId: fixture.agentSessionId, projectId: fixture.projectId, goal: fixture.goal, prompt: fixture.prompt, skillCommitId: fixture.skillCommitId, skillVersion: fixture.skillVersion, now: fixedNow }); assert.equal(created.status, "ok"); assert.equal(created.environment, ENVIRONMENT_DEV); const worker = await runLocalWorkerDryRun({ stateDir, agentSessionId: fixture.agentSessionId, skillCommitId: fixture.skillCommitId, skillVersion: fixture.skillVersion, now: fixedNow }); assert.equal(worker.dryRun, true); assert.equal(worker.health.status, "ok"); assert.equal(worker.agentSession.status, "cleanup"); assert.equal(worker.workerSession.status, "cleanup"); assert.equal(worker.workspace.cleaned, true); assert.equal(worker.workspace.existsAfterCleanup, false); const status = await getLocalAgentStatus({ stateDir, agentSessionId: fixture.agentSessionId }); const trace = await getLocalAgentTrace({ stateDir, agentSessionId: fixture.agentSessionId }); const evidence = await getLocalAgentEvidence({ stateDir, agentSessionId: fixture.agentSessionId }); assert.equal(status.evidenceCount, 1); assert.equal(trace.traceEvents.length, 5); assert.equal(evidence.evidenceRecords.length, 1); return buildDryRunEvidence({ status: "pass", details: [ `${AGENT_MGR_SERVICE_ID}:session=${fixture.agentSessionId}:created=${created.task.status}:final=${worker.agentSession.status}`, `${AGENT_WORKER_SERVICE_ID}:session=${worker.workerSession.workerSessionId}:status=${worker.workerSession.status}`, `workspace:${worker.workspace.workspaceVolumeId}:cleaned=${worker.workspace.cleaned}:existsAfterCleanup=${worker.workspace.existsAfterCleanup}`, `${AGENT_SKILLS_SERVICE_ID}:commit=${fixture.skillCommitId}:version=${fixture.skillVersion}`, `trace:${trace.traceId}:events=${trace.traceEvents.length}`, `evidence:${evidence.evidenceRecords[0].evidenceId}:kind=${evidence.evidenceRecords[0].kind}`, `cleanup:${worker.cleanup.cleanupId}:removed=${worker.cleanup.removed}` ] }); } finally { await rm(stateDir, { recursive: true, force: true }); } } async function runLivePreflight(fixture, dryRun) { const liveEvidence = []; const [d601K3s, publicEntrypoints] = await Promise.all([ collectD601K3sNativeEvidence(fixture), collectPublicEntrypoints() ]); liveEvidence.push(...d601K3s.evidence); liveEvidence.push(publicEntrypoints.summary); let result = { blocked: true, blockerType: "network_blocker", blockerScope: "frp", blockedClassification: "frp", blockerSummary: "The fixed DEV endpoint did not accept a live connection from this runner.", summary: `Blocked before agent scheduling because ${DEV_ENDPOINT} is not reachable from this D601 runner.` }; try { const health = await requestJson(`${DEV_ENDPOINT}/health`); if (health.statusCode < 200 || health.statusCode >= 300) { result = { blocked: true, blockerType: "network_blocker", blockerScope: "frp", blockedClassification: "frp", blockerSummary: `DEV health returned HTTP ${health.statusCode}.`, summary: `Blocked on DEV ingress health because ${DEV_ENDPOINT}/health did not return success.` }; } else { const body = health.body || {}; liveEvidence.push(`health:${body.serviceId}:${body.environment}:${body.status}`); const live = await requestJson(`${DEV_ENDPOINT}/health/live`); const liveBody = live.body || {}; if (live.statusCode < 200 || live.statusCode >= 300 || liveBody?.serviceId !== "hwlab-cloud-api") { result = { blocked: true, blockerType: "network_blocker", blockerScope: "frp", blockedClassification: "frp", blockerSummary: "DEV live probe did not present the expected HWLAB cloud API identity.", summary: "Blocked because the live DEV boundary did not expose the expected cloud API identity." }; } else { const liveReadiness = classifyCloudApiLiveReadiness(liveBody); if (liveReadiness.blocked) { liveEvidence.push(`live:${liveBody.serviceId}:${liveBody.status}:${liveReadiness.evidenceSuffix}`); const runtime = runtimeDurabilityFromHealth(liveBody); liveEvidence.push(`runtime-durable-adapter:adapter=${runtime.adapter}:blocker=${runtime.blocker}:migration=${runtime.migration.requiredMigrationId}:migrationChecked=${runtime.migration.checked}:migrationReady=${runtime.migration.ready}:queryResult=${runtime.queryResult}`); result = liveReadiness; } else { liveEvidence.push(`live:${liveBody.serviceId}:${liveBody.status}`); const runtime = runtimeDurabilityFromHealth(liveBody); liveEvidence.push(`runtime-durable-adapter:adapter=${runtime.adapter}:ready=${runtime.ready}:migration=${runtime.migration.requiredMigrationId}:queryResult=${runtime.queryResult}`); const rpcHealth = await requestJson(`${DEV_ENDPOINT}/rpc`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: "req_dev_m4_health", method: "system.health", params: {}, meta: { traceId: "trc_dev_m4_health", serviceId: "hwlab-cloud-api", environment: ENVIRONMENT_DEV } }) }); const rpcHealthBody = rpcHealth.body || {}; liveEvidence.push(`rpc-health:${rpcHealthBody.result?.serviceId ?? "unknown"}`); if (rpcHealth.statusCode < 200 || rpcHealth.statusCode >= 300 || rpcHealthBody.error) { result = { blocked: true, blockerType: "agent_blocker", blockerScope: "agent-runtime", blockedClassification: "agent runtime", blockerSummary: rpcHealthBody.error?.message ?? `system.health returned HTTP ${rpcHealth.statusCode}.`, summary: "Blocked at agent runtime health before attempting hardware dispatch." }; } else { const rpcHardware = await requestJson(`${DEV_ENDPOINT}/rpc`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ jsonrpc: "2.0", id: "req_dev_m4_hw", method: "hardware.operation.request", params: { projectId: fixture.projectId, input: { requestedPath: "DO1 -> hwlab-patch-panel -> DI1", directBoxGatewayAccess: false, source: "dev-m4-agent-loop-preflight" } }, meta: { traceId: "trc_dev_m4_hw", serviceId: "hwlab-cloud-api", environment: ENVIRONMENT_DEV } }) }); const rpcHardwareBody = rpcHardware.body || {}; const accepted = rpcHardwareBody.result?.accepted === true; const status = rpcHardwareBody.result?.status || rpcHardwareBody.error?.message || "unknown"; liveEvidence.push(`rpc-hardware:${status}`); if (!accepted) { result = { blocked: true, blockerType: "agent_blocker", blockerScope: "hardware-loop", blockedClassification: "hardware loop", blockerSummary: "hardware.operation.request did not accept the patch-panel-bounded M3 request.", summary: "Blocked by M3 readiness because M4 did not observe an accepted DO1 -> patch-panel -> DI1 hardware request." }; } else if (!rpcHardwareBody.result?.auditId || !rpcHardwareBody.result?.evidenceId) { result = { blocked: true, blockerType: "observability_blocker", blockerScope: "evidence-audit", blockedClassification: "evidence/audit", blockerSummary: "hardware.operation.request accepted but did not return audit/evidence identifiers.", summary: "Blocked because accepted DEV agent hardware dispatch lacks audit/evidence closure." }; } else { result = { blocked: false, blockerType: null, blockerScope: null, blockedClassification: "none", blockerSummary: "", summary: "Live DEV preflight observed a non-skeleton agent hardware path." }; } } } } } } catch (error) { result = { blocked: true, blockerType: "network_blocker", blockerScope: "frp", blockedClassification: "frp", blockerSummary: error instanceof Error ? error.message : String(error), summary: `Blocked before agent scheduling because ${DEV_ENDPOINT} is unreachable from this runner.` }; } result.additionalBlockers = d601K3s.blockers; if (!result.blocked && result.additionalBlockers.length > 0) { result.summary = "D601 native k3s agent evidence is blocked even though the public preflight path did not produce an earlier blocker."; } const report = buildReport(fixture, liveEvidence, result, { dryRun, d601K3s, publicEntrypoints }); await writeJSON(reportPath, report); process.stdout.write(JSON.stringify({ reportPath, status: report.devPreconditions.status, blocker: report.blockers[0] ?? null, classification: report.devPreconditions.classification, evidence: report.livePreflight.evidence, summary: report.livePreflight.summary, observedAt: fixedNow() }, null, 2)); process.stdout.write("\n"); } async function main() { const args = parseArgs(process.argv.slice(2)); const fixture = await loadFixture(); if (args.dryRun) { assert.equal(args.live, false, "--dry-run cannot be combined with --live"); const dryRun = await runDryRun(fixture); process.stdout.write(JSON.stringify({ reportPath, status: dryRun.status, evidence: dryRun.evidence, summary: dryRun.summary, observedAt: fixedNow() }, null, 2)); process.stdout.write("\n"); return; } assert.equal(args.live, true, "live preflight requires --live"); assert.equal(args.confirmDev, true, "live preflight requires --confirm-dev"); assert.equal(args.confirmedNonProduction, true, "live preflight requires --confirmed-non-production"); await runLivePreflight(fixture, await runDryRun(fixture)); } await main();