321 lines
11 KiB
JavaScript
321 lines
11 KiB
JavaScript
#!/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:16667";
|
|
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:16667",
|
|
"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();
|