621 lines
25 KiB
JavaScript
621 lines
25 KiB
JavaScript
#!/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();
|