From 9296c28bff8fd0785af29bfe12be36e0b580f952 Mon Sep 17 00:00:00 2001 From: unidesk-code-queue-runner Date: Sat, 23 May 2026 15:21:55 +0000 Subject: [PATCH] feat: expose M3 Code Agent route evidence --- internal/cloud/code-agent-chat.mjs | 22 ++++ .../code-agent-session-registry.test.mjs | 114 ++++++++++++++++++ internal/cloud/server.test.mjs | 8 ++ scripts/code-agent-chat-smoke.mjs | 7 ++ scripts/src/rpt004-mvp-e2e-harness.mjs | 35 +++++- scripts/src/rpt004-mvp-e2e-harness.test.mjs | 48 +++++++- .../scripts/m3-io-skill-client.test.mjs | 7 ++ .../scripts/src/m3-io-skill-client.mjs | 22 ++++ web/hwlab-cloud-web/code-agent-facts.mjs | 8 +- web/hwlab-cloud-web/code-agent-facts.test.mjs | 30 ++++- 10 files changed, 295 insertions(+), 6 deletions(-) diff --git a/internal/cloud/code-agent-chat.mjs b/internal/cloud/code-agent-chat.mjs index 5fd1e846..e2cd8f1b 100644 --- a/internal/cloud/code-agent-chat.mjs +++ b/internal/cloud/code-agent-chat.mjs @@ -1247,29 +1247,39 @@ function m3IoSkillRunnerResult({ exitCode: skillResult.ok ? 0 : 2, stdout: boundToolOutput(JSON.stringify({ route: skillResult.route, + method: skillResult.method ?? "POST", status: skillResult.status, accepted: skillResult.accepted, traceId: skillResult.traceId, operationId: skillResult.operationId, + auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, + evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, audit: skillResult.audit, evidence: skillResult.evidence, durable: skillResult.durable, blocker: skillResult.blocker, result: skillResult.result, + readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, safety: skillResult.safety })).text, stderrSummary: skillResult.blocker?.code ?? "", outputTruncated: false, route: HWLAB_M3_IO_API_ROUTE, + method: skillResult.method ?? "POST", hwlabApi: skillResult.hwlabApi, capabilityLevel, controlReady: skillResult.controlReady === true, accepted: skillResult.accepted, + operationStatus: skillResult.status, + apiStatus: skillResult.status, operationId: skillResult.operationId, traceId: skillResult.traceId, + auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, + evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, audit: skillResult.audit, evidence: skillResult.evidence, durable: skillResult.durable, + readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, blocker: skillResult.blocker, capabilityBlocker: skillResult.capabilityBlocker ?? null, trustBlocker: skillResult.trustBlocker ?? null, @@ -1342,8 +1352,12 @@ function m3IoSkillRunnerResult({ runnerKind: M3_IO_SKILL_RUNNER_KIND, skill: HWLAB_M3_IO_SKILL_NAME, route: HWLAB_M3_IO_API_ROUTE, + method: skillResult.method ?? "POST", traceId: skillResult.traceId, operationId: skillResult.operationId, + auditId: skillResult.auditId ?? skillResult.audit?.auditId ?? null, + evidenceId: skillResult.evidenceId ?? skillResult.evidence?.evidenceId ?? null, + readback: skillResult.readback ?? skillResult.result?.targetReadback ?? null, capabilityLevel, controlReady: skillResult.controlReady === true, accepted: skillResult.accepted, @@ -1359,6 +1373,7 @@ function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, + method: "POST", hwlabApi: { route: HWLAB_M3_IO_API_ROUTE, redactedUrl: null, @@ -1384,6 +1399,8 @@ function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker requestId, actorId, operationId: null, + auditId: null, + evidenceId: null, audit: { auditId: null, status: "not_written", @@ -1422,6 +1439,7 @@ function m3IoSkillMissingApiBaseUrlResult({ traceId, requestId, actorId, blocker value: null, targetReadback: null }, + readback: null, controlPath: { cloudApi: false, gatewaySimu: false, @@ -2257,10 +2275,14 @@ function m3IoSkillRunnerTrace({ traceId, events, startedAt, finishedAt = started finishedAt, events, route: HWLAB_M3_IO_API_ROUTE, + method: skillResult?.method ?? "POST", skill: HWLAB_M3_IO_SKILL_NAME, capabilityLevel, controlReady: skillResult?.controlReady === true, operationId: skillResult?.operationId ?? null, + auditId: skillResult?.auditId ?? skillResult?.audit?.auditId ?? null, + evidenceId: skillResult?.evidenceId ?? skillResult?.evidence?.evidenceId ?? null, + readback: skillResult?.readback ?? skillResult?.result?.targetReadback ?? null, accepted: skillResult?.accepted ?? false, status: skillResult?.status ?? "blocked", blocker: skillResult?.blocker ?? null, diff --git a/internal/cloud/code-agent-session-registry.test.mjs b/internal/cloud/code-agent-session-registry.test.mjs index 423e47f0..944f10c6 100644 --- a/internal/cloud/code-agent-session-registry.test.mjs +++ b/internal/cloud/code-agent-session-registry.test.mjs @@ -391,12 +391,21 @@ test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", a assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); assert.equal(payload.toolCalls[0].status, "completed"); assert.equal(payload.toolCalls[0].route, HWLAB_M3_IO_API_ROUTE); + assert.equal(payload.toolCalls[0].method, "POST"); assert.equal(payload.toolCalls[0].capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); assert.equal(payload.toolCalls[0].controlReady, true); assert.equal(payload.toolCalls[0].accepted, true); assert.equal(payload.toolCalls[0].operationId, "op_m3_do_write_skill"); + assert.equal(payload.toolCalls[0].traceId, "trc_m3_skill_write"); + assert.equal(payload.toolCalls[0].auditId, "aud_m3_do_write_skill_succeeded"); + assert.equal(payload.toolCalls[0].evidenceId, "evd_m3_do_write_skill_succeeded"); + assert.equal(payload.toolCalls[0].readback.value, true); assert.equal(payload.runnerTrace.route, HWLAB_M3_IO_API_ROUTE); + assert.equal(payload.runnerTrace.method, "POST"); assert.equal(payload.runnerTrace.accepted, true); + assert.equal(payload.runnerTrace.auditId, "aud_m3_do_write_skill_succeeded"); + assert.equal(payload.runnerTrace.evidenceId, "evd_m3_do_write_skill_succeeded"); + assert.equal(payload.runnerTrace.readback.value, true); assert.equal(payload.runnerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); assert.equal(payload.runnerTrace.controlReady, true); assert.equal(payload.runner.writeCapable, true); @@ -405,6 +414,10 @@ test("Code Agent M3 DO write uses Skill CLI to call only HWLAB API /v1/m3/io", a assert.equal(payload.runner.safety.directBoxSimuCallsAllowed, false); assert.equal(payload.runner.safety.directPatchPanelCallsAllowed, false); assert.equal(payload.providerTrace.fallbackUsed, false); + assert.equal(payload.providerTrace.method, "POST"); + assert.equal(payload.providerTrace.auditId, "aud_m3_do_write_skill_succeeded"); + assert.equal(payload.providerTrace.evidenceId, "evd_m3_do_write_skill_succeeded"); + assert.equal(payload.providerTrace.readback.value, true); assert.equal(payload.providerTrace.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); assert.equal(payload.providerTrace.controlReady, true); assert.equal(payload.skills.blockers.some((blocker) => blocker.code === "runtime_durable_not_green"), true); @@ -537,6 +550,7 @@ test("Code Agent M3 Skill CLI missing API base returns structured config blocker assert.equal(payload.provider, "hwlab-skill-cli"); assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); assert.equal(payload.blocker.code, "m3_readiness_blocked"); + assert.equal(payload.toolCalls[0].method, "POST"); assert.equal(payload.toolCalls[0].blocker.code, "skill_cli_api_base_missing"); assert.equal(payload.toolCalls[0].blocker.layer, "skill-cli-config"); assert.equal(payload.toolCalls[0].blocker.retryable, false); @@ -579,10 +593,110 @@ test("Code Agent M3 Skill CLI HWLAB API unavailable returns retryable structured assert.equal(payload.blocker.layer, "hwlab-api"); assert.equal(payload.blocker.retryable, true); assert.equal(payload.blocker.route, HWLAB_M3_IO_API_ROUTE); + assert.equal(payload.toolCalls[0].method, "POST"); assert.equal(payload.toolCalls[0].blocker.code, "hwlab_api_unavailable"); assert.equal(payload.toolCalls[0].blocker.retryable, true); }); +test("Code Agent M3 DO false write exposes readback and identifier contract", async () => { + const calls = []; + const payload = await handleCodeAgentChat( + { + conversationId: "cnv_m3_skill_write_false", + traceId: "trc_m3_skill_write_false", + message: "通过 HWLAB API 把 res_boxsimu_1 DO1 写成 false 并读取 res_boxsimu_2 DI1" + }, + { + now: () => "2026-05-23T00:06:30.000Z", + env: { + PATH: process.env.PATH, + HWLAB_CODE_AGENT_WORKSPACE: process.cwd(), + HWLAB_CODE_AGENT_HWLAB_API_BASE_URL: "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", + OPENAI_API_KEY: "must-not-be-used" + }, + callProvider: async () => { + throw new Error("OpenAI fallback must not be used for M3 IO control"); + }, + m3IoSkillRequestJson: async (url, request) => { + calls.push({ url, request }); + return { + ok: true, + status: 200, + body: { + serviceId: "hwlab-cloud-api", + contractVersion: "m3-io-control-v1", + status: "succeeded", + accepted: true, + action: "do.write", + traceId: "trc_m3_skill_write_false", + operationId: "op_m3_do_write_skill_false", + auditId: "aud_m3_do_write_skill_false_succeeded", + evidenceId: "evd_m3_do_write_skill_false_succeeded", + auditState: { + status: "written_non_durable" + }, + evidenceState: { + status: "blocked", + sourceKind: "BLOCKED", + blocker: "runtime_durable_not_green", + writeStatus: "written_non_durable" + }, + durableStatus: { + status: "degraded", + durable: false, + blocker: "runtime_durable_not_green" + }, + result: { + value: false, + targetReadback: { + status: "succeeded", + value: false, + resourceId: "res_boxsimu_2", + port: "DI1" + } + }, + controlPath: { + status: "succeeded", + cloudApi: true, + gatewaySimu: true, + boxSimu: true, + patchPanel: true, + frontendBypass: false + } + } + }; + } + } + ); + + validateCodeAgentChatSchema(payload); + assert.equal(payload.status, "completed"); + assert.equal(payload.provider, "hwlab-skill-cli"); + assert.equal(payload.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); + const tool = payload.toolCalls[0]; + assert.equal(tool.route, HWLAB_M3_IO_API_ROUTE); + assert.equal(tool.method, "POST"); + assert.equal(tool.accepted, true); + assert.equal(tool.status, "completed"); + assert.equal(tool.operationId, "op_m3_do_write_skill_false"); + assert.equal(tool.traceId, "trc_m3_skill_write_false"); + assert.equal(tool.auditId, "aud_m3_do_write_skill_false_succeeded"); + assert.equal(tool.evidenceId, "evd_m3_do_write_skill_false_succeeded"); + assert.equal(tool.readback.value, false); + assert.equal(tool.readback.resourceId, "res_boxsimu_2"); + assert.equal(tool.readback.port, "DI1"); + assert.equal(payload.runnerTrace.method, "POST"); + assert.equal(payload.runnerTrace.readback.value, false); + assert.equal(payload.providerTrace.readback.value, false); + assert.equal(calls.length, 1); + assert.equal(calls[0].url, `http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667${HWLAB_M3_IO_API_ROUTE}`); + assert.equal(calls[0].request.body.action, "do.write"); + assert.equal(calls[0].request.body.value, false); + assert.equal(calls[0].url.includes("gateway"), false); + assert.equal(calls[0].url.includes("box-simu"), false); + assert.equal(calls[0].url.includes("patch-panel"), false); +}); + test("Code Agent blocks direct gateway or patch-panel requests instead of using M3 Skill CLI", async () => { const direct = await handleCodeAgentChat( { diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index a6c4c6eb..fe1e0165 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -1142,8 +1142,16 @@ test("cloud api /v1/agent/chat routes M3 IO through Skill CLI to /v1/m3/io only" assert.equal(payload.backend, "hwlab-cloud-api/hwlab-agent-runtime-skill-cli"); assert.equal(payload.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); assert.equal(payload.toolCalls[0].route, "/v1/m3/io"); + assert.equal(payload.toolCalls[0].method, "POST"); assert.equal(payload.toolCalls[0].accepted, true); + assert.equal(payload.toolCalls[0].operationId, "op_m3_do_write_server_skill"); + assert.equal(payload.toolCalls[0].traceId, "trc_server-test-m3-skill"); + assert.equal(payload.toolCalls[0].auditId, "aud_m3_do_write_server_skill_succeeded"); + assert.equal(payload.toolCalls[0].evidenceId, "evd_m3_do_write_server_skill_succeeded"); + assert.equal(payload.toolCalls[0].readback.value, false); assert.equal(payload.providerTrace.fallbackUsed, false); + assert.equal(payload.providerTrace.method, "POST"); + assert.equal(payload.providerTrace.readback.value, false); assert.equal(payload.runner.safety.directGatewayCallsAllowed, false); assert.equal(payload.runner.safety.directBoxSimuCallsAllowed, false); assert.equal(payload.runner.safety.directPatchPanelCallsAllowed, false); diff --git a/scripts/code-agent-chat-smoke.mjs b/scripts/code-agent-chat-smoke.mjs index c1c736df..b336a49a 100644 --- a/scripts/code-agent-chat-smoke.mjs +++ b/scripts/code-agent-chat-smoke.mjs @@ -419,7 +419,14 @@ async function runLocalContractSmoke() { assert.equal(m3SkillWrite.provider, "hwlab-skill-cli"); assert.equal(m3SkillWrite.toolCalls[0].name, "hwlab-agent-runtime.m3-io"); assert.equal(m3SkillWrite.toolCalls[0].route, "/v1/m3/io"); + assert.equal(m3SkillWrite.toolCalls[0].method, "POST"); + assert.equal(m3SkillWrite.toolCalls[0].operationId, "op_m3_do_write_smoke"); + assert.equal(m3SkillWrite.toolCalls[0].auditId, "aud_m3_do_write_smoke_succeeded"); + assert.equal(m3SkillWrite.toolCalls[0].evidenceId, "evd_m3_do_write_smoke_succeeded"); + assert.equal(m3SkillWrite.toolCalls[0].readback.value, true); assert.equal(m3SkillWrite.providerTrace.fallbackUsed, false); + assert.equal(m3SkillWrite.providerTrace.method, "POST"); + assert.equal(m3SkillWrite.providerTrace.readback.value, true); assert.equal(m3SkillWrite.runner.safety.directGatewayCallsAllowed, false); assert.equal(m3SkillCalls.length, 1); assert.equal(m3SkillCalls[0].url, "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667/v1/m3/io"); diff --git a/scripts/src/rpt004-mvp-e2e-harness.mjs b/scripts/src/rpt004-mvp-e2e-harness.mjs index 91f5e148..e43dcc6b 100644 --- a/scripts/src/rpt004-mvp-e2e-harness.mjs +++ b/scripts/src/rpt004-mvp-e2e-harness.mjs @@ -486,10 +486,12 @@ export function classifySkillCliM3Route(payload, { httpStatus = null, httpOk = f ) ?? null; const providerTrace = payload?.providerTrace && typeof payload.providerTrace === "object" ? payload.providerTrace : null; const route = skillTool?.route ?? providerTrace?.route ?? payload?.skills?.items?.find((item) => item?.route)?.route ?? null; + const method = skillTool?.method ?? providerTrace?.method ?? null; const operationId = skillTool?.operationId ?? providerTrace?.operationId ?? null; const traceId = skillTool?.traceId ?? providerTrace?.traceId ?? payload?.traceId ?? null; - const auditId = skillTool?.audit?.auditId ?? null; - const evidenceId = skillTool?.evidence?.evidenceId ?? null; + const auditId = skillTool?.auditId ?? skillTool?.audit?.auditId ?? providerTrace?.auditId ?? null; + const evidenceId = skillTool?.evidenceId ?? skillTool?.evidence?.evidenceId ?? providerTrace?.evidenceId ?? null; + const readback = skillTool?.readback ?? providerTrace?.readback ?? skillTool?.result?.targetReadback ?? null; const blockers = normalizeM3RouteBlockers(payload, skillTool); const safety = { directGatewayCalls: Boolean(skillTool?.directGatewayCalls ?? payload?.runnerTrace?.directGatewayCalls ?? false), @@ -510,6 +512,7 @@ export function classifySkillCliM3Route(payload, { httpStatus = null, httpOk = f skillTool && ["completed", "blocked"].includes(skillTool.status) && routeEvidenceReady && + method === "POST" && safety.directGatewayCalls === false && safety.directBoxCalls === false && safety.directPatchPanelCalls === false && @@ -523,6 +526,7 @@ export function classifySkillCliM3Route(payload, { httpStatus = null, httpOk = f if (payload?.provider !== "hwlab-skill-cli") missing.push("provider=hwlab-skill-cli"); if (!skillTool) missing.push(`${HWLAB_M3_IO_SKILL_NAME} toolCall`); if (!routeEvidenceReady) missing.push(`route=${HWLAB_M3_IO_API_ROUTE}`); + if (method !== "POST") missing.push("method=POST"); if (safety.directGatewayCalls || safety.directBoxCalls || safety.directPatchPanelCalls || safety.directTargetMentioned) { missing.push("no direct gateway/box/patch-panel target"); } @@ -544,6 +548,7 @@ export function classifySkillCliM3Route(payload, { httpStatus = null, httpOk = f backend: payload?.backend ?? null, route, expectedRoute: HWLAB_M3_IO_API_ROUTE, + method, toolCallStatus: skillTool?.status ?? null, accepted, controlReady, @@ -552,6 +557,7 @@ export function classifySkillCliM3Route(payload, { httpStatus = null, httpOk = f traceId, auditId, evidenceId, + readback, idsPresent, structuredBlocker, blockers, @@ -1277,6 +1283,22 @@ function buildCommanderSummary(report) { auditId: route.auditId ?? null, evidenceId: route.evidenceId ?? null }; + const routeEvidenceContract = { + route: route.route ?? null, + expectedRoute: route.expectedRoute ?? HWLAB_M3_IO_API_ROUTE, + method: route.method ?? null, + operationId: ids.operationId, + traceId: ids.traceId, + auditId: ids.auditId, + evidenceId: ids.evidenceId, + accepted: route.accepted ?? null, + status: route.toolCallStatus ?? null, + controlReady: route.controlReady ?? null, + readback: route.readback ?? null, + visible: route.route === HWLAB_M3_IO_API_ROUTE && + route.method === "POST" && + Boolean(ids.operationId && ids.traceId && ids.auditId && ids.evidenceId) + }; return { reportClass: "live-mutating", allowedLiveMutationUsed: report.liveMutationUsed === true, @@ -1307,10 +1329,15 @@ function buildCommanderSummary(report) { status: report.dimensions.skillCliM3Route?.status ?? "not_run", route: route.route ?? null, expectedRoute: route.expectedRoute ?? HWLAB_M3_IO_API_ROUTE, + method: route.method ?? null, structuredBlocker: route.structuredBlocker === true, idsPresent: route.idsPresent === true, + accepted: route.accepted ?? null, + controlReady: route.controlReady ?? null, + readback: route.readback ?? null, safety: route.safety ?? null }, + exactRouteEvidenceContract: routeEvidenceContract, evidenceIds: Object.fromEntries(Object.entries(ids).filter(([, value]) => Boolean(value))), blockers: report.blockers.map((blocker) => ({ scope: blocker.scope, @@ -1383,6 +1410,10 @@ export function renderMarkdown(report) { `- liveMutationUsed: ${report.liveMutationUsed === true}`, `- allowM3Write: ${report.runPolicy?.allowM3Write === true}`, `- skillCliRoute: ${report.commanderSummary?.m3RouteResult?.route ?? "not_run"}`, + `- skillCliMethod: ${report.commanderSummary?.m3RouteResult?.method ?? "not_run"}`, + `- operationId: ${report.commanderSummary?.evidenceIds?.operationId ?? "none"}`, + `- traceId: ${report.commanderSummary?.evidenceIds?.traceId ?? "none"}`, + `- auditId: ${report.commanderSummary?.evidenceIds?.auditId ?? "none"}`, `- evidenceId: ${report.commanderSummary?.evidenceIds?.evidenceId ?? "none"}`, "", "| Dimension | Status | Summary |", diff --git a/scripts/src/rpt004-mvp-e2e-harness.test.mjs b/scripts/src/rpt004-mvp-e2e-harness.test.mjs index b0b5826a..b03563a5 100644 --- a/scripts/src/rpt004-mvp-e2e-harness.test.mjs +++ b/scripts/src/rpt004-mvp-e2e-harness.test.mjs @@ -291,7 +291,11 @@ test("Skill CLI M3 route passes with operation/audit/evidence ids and no direct assert.equal(result.status, "pass"); assert.equal(result.observations.route, "/v1/m3/io"); + assert.equal(result.observations.method, "POST"); assert.equal(result.observations.idsPresent, true); + assert.equal(result.observations.accepted, true); + assert.equal(result.observations.controlReady, true); + assert.equal(result.observations.readback.value, true); assert.equal(result.observations.safety.directGatewayCalls, false); assert.equal(result.observations.safety.fallbackUsed, false); }); @@ -327,6 +331,14 @@ test("Skill CLI M3 route blocks missing route and direct gateway evidence", () = assert.equal(result.observations.safety.directGatewayCalls, true); }); +test("Skill CLI M3 route requires explicit POST method evidence", () => { + const result = classifySkillCliM3Route(skillCliPayload({ method: null }), { httpStatus: 200, httpOk: true }); + + assert.equal(result.status, "blocked"); + assert.match(result.summary, /method=POST/u); + assert.ok(result.observations.missing.includes("method=POST")); +}); + test("RPT-004 live report defaults to read-only M3 route checks and records no live mutation", async () => { const report = await buildRpt004Report({ ...parseArgs([ @@ -387,6 +399,20 @@ test("RPT-004 live report defaults to read-only M3 route checks and records no l assert.equal(report.commanderSummary.sessionResult.status, "pass"); assert.equal(report.commanderSummary.skillCliResult.status, "pass"); assert.equal(report.commanderSummary.m3RouteResult.route, "/v1/m3/io"); + assert.equal(report.commanderSummary.m3RouteResult.method, "POST"); + assert.equal(report.commanderSummary.m3RouteResult.accepted, true); + assert.equal(report.commanderSummary.m3RouteResult.controlReady, true); + assert.equal(report.commanderSummary.m3RouteResult.readback.value, true); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.visible, true); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.route, "/v1/m3/io"); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.method, "POST"); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.operationId, "op_rpt004_skill_m3"); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.traceId, "trc_rpt004_skill"); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.auditId, "aud_rpt004_skill_m3"); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.evidenceId, "evd_rpt004_skill_m3"); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.accepted, true); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.status, "completed"); + assert.equal(report.commanderSummary.exactRouteEvidenceContract.readback.value, true); assert.equal(report.commanderSummary.evidenceIds.evidenceId, "evd_rpt004_skill_m3"); assert.equal(report.dimensions.skillCliM3Route.status, "pass"); assert.equal(report.dimensions.m3TrustedLoop.observations.skippedWriteLoop, true); @@ -597,6 +623,7 @@ function openAiFallbackPayload({ reply = "text fallback" } = {}) { function skillCliPayload({ route = "/v1/m3/io", + method = "POST", accepted = true, status = "succeeded", operationId = "op_rpt004_skill_m3", @@ -632,6 +659,7 @@ function skillCliPayload({ runnerTrace: { traceId: "trc_rpt004_skill", runnerKind: "hwlab-m3-io-skill-cli", + method, route, fallbackUsed: false, directGatewayCalls, @@ -641,22 +669,34 @@ function skillCliPayload({ providerTrace: { runnerKind: "hwlab-m3-io-skill-cli", route, + method, traceId: "trc_rpt004_skill", operationId, + auditId, + evidenceId, accepted, controlReady: accepted, - fallbackUsed: false + fallbackUsed: false, + readback: { + status: "succeeded", + value: true, + resourceId: "res_boxsimu_2", + port: "DI1" + } }, toolCalls: [{ name: "hwlab-agent-runtime.m3-io", status: accepted ? "completed" : "blocked", type: "skill-cli", route, + method, accepted, controlReady: accepted, capabilityLevel: accepted ? "hwlab-api-control-ready" : "hwlab-api-control-blocked", operationId, traceId: "trc_rpt004_skill", + auditId, + evidenceId, audit: { auditId, status @@ -672,6 +712,12 @@ function skillCliPayload({ status: "degraded", blocker: blocker?.code ?? "runtime_durable_not_green" }, + readback: { + status: "succeeded", + value: true, + resourceId: "res_boxsimu_2", + port: "DI1" + }, blocker, blockers: blocker ? [blocker] : [{ code: "runtime_durable_not_green", diff --git a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs index 024b0e0d..1e7d8178 100644 --- a/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs +++ b/skills/hwlab-agent-runtime/scripts/m3-io-skill-client.test.mjs @@ -84,14 +84,18 @@ test("M3 Skill CLI posts only to HWLAB API /v1/m3/io for DO1 writes", async () = assert.equal(result.ok, true); assert.equal(result.route, HWLAB_M3_IO_API_ROUTE); + assert.equal(result.method, "POST"); assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.ready); assert.equal(result.controlReady, true); assert.equal(result.readiness.status, "ready"); assert.equal(result.accepted, true); assert.equal(result.status, "succeeded"); assert.equal(result.operationId, "op_m3_do_write_cli"); + assert.equal(result.auditId, "aud_m3_do_write_cli_succeeded"); + assert.equal(result.evidenceId, "evd_m3_do_write_cli_succeeded"); assert.equal(result.audit.auditId, "aud_m3_do_write_cli_succeeded"); assert.equal(result.evidence.evidenceId, "evd_m3_do_write_cli_succeeded"); + assert.equal(result.readback.value, false); assert.equal(result.durable.blocker, "runtime_durable_not_green"); assert.equal(result.trustBlocker.code, "runtime_durable_not_green"); assert.equal(result.blockers.some((blocker) => blocker.code === "runtime_durable_not_green"), true); @@ -128,6 +132,7 @@ test("M3 Skill CLI rejects direct gateway, box, and patch-panel targets before r assert.equal(result.ok, false); assert.equal(result.status, "blocked"); + assert.equal(result.method, "POST"); assert.equal(result.capabilityLevel, HWLAB_M3_IO_CAPABILITY_LEVELS.blocked); assert.equal(result.controlReady, false); assert.equal(result.readiness.status, "blocked"); @@ -169,6 +174,8 @@ test("M3 Skill CLI reports missing API base as safe structured config blocker", ); assert.equal(result.ok, false); assert.equal(result.status, "blocked"); + assert.equal(result.method, "POST"); + assert.equal(result.accepted, false); assert.equal(result.blocker.code, "skill_cli_api_base_missing"); assert.equal(result.blocker.layer, "skill-cli-config"); assert.equal(result.blocker.retryable, false); diff --git a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs index 5cc77ed4..2faed4e4 100644 --- a/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs +++ b/skills/hwlab-agent-runtime/scripts/src/m3-io-skill-client.mjs @@ -376,6 +376,7 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, + method: "POST", hwlabApi: publicCloudApiTarget(apiTarget), capabilityLevel, controlReady, @@ -386,6 +387,8 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId requestId, actorId, operationId: body.operationId ?? null, + auditId: body.auditId ?? body.auditState?.auditId ?? null, + evidenceId: body.evidenceId ?? body.evidenceState?.evidenceId ?? null, audit: { auditId: body.auditId ?? body.auditState?.auditId ?? null, status: body.auditState?.status ?? null, @@ -418,6 +421,7 @@ function normalizeSkillResponse({ response, command, payload, apiTarget, traceId value: body.result?.value ?? body.value ?? null, targetReadback: body.result?.targetReadback ?? null }, + readback: normalizeReadback(body, command), controlPath: body.controlPath ?? { cloudApi: true, gatewaySimu: false, @@ -472,6 +476,7 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC service: HWLAB_M3_IO_SKILL_NAME, contractVersion: HWLAB_AGENT_RUNTIME_SKILL_CLI_VERSION, route: HWLAB_M3_IO_API_ROUTE, + method: "POST", hwlabApi: publicCloudApiTarget(apiTarget), capabilityLevel: HWLAB_M3_IO_CAPABILITY_LEVELS.blocked, controlReady: false, @@ -480,6 +485,8 @@ function blockedPayload({ traceId, requestId, apiTarget, code, message, missingC traceId, requestId, operationId: null, + auditId: null, + evidenceId: null, audit: { auditId: null, status: "not_written", @@ -683,6 +690,21 @@ function evidenceSummary(body) { return body.evidenceState?.reason ?? "evidence not written"; } +function normalizeReadback(body, command) { + if (command.action === "do.write") { + return body.result?.targetReadback ?? null; + } + if (command.action === "di.read") { + return { + status: body.status ?? null, + value: body.result?.value ?? body.value ?? null, + resourceId: command.resourceId, + port: command.port + }; + } + return null; +} + function helpPayload() { return { ok: true, diff --git a/web/hwlab-cloud-web/code-agent-facts.mjs b/web/hwlab-cloud-web/code-agent-facts.mjs index a0e30dc6..79e1b679 100644 --- a/web/hwlab-cloud-web/code-agent-facts.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.mjs @@ -163,10 +163,13 @@ export function hwlabApiFactsFromMessage(message) { facts.push({ tool: firstText(toolCall.name, "skill-cli"), route, + method: firstText(toolCall.method, parsed?.method, "POST"), operationId: nullableText(toolCall.operationId ?? parsed?.operationId), traceId: nullableText(toolCall.traceId ?? parsed?.traceId ?? message?.traceId), - auditId: nullableText(toolCall.audit?.auditId ?? parsed?.audit?.auditId ?? parsed?.auditId), - evidenceId: nullableText(toolCall.evidence?.evidenceId ?? parsed?.evidence?.evidenceId ?? parsed?.evidenceId), + auditId: nullableText(toolCall.auditId ?? toolCall.audit?.auditId ?? parsed?.auditId ?? parsed?.audit?.auditId), + evidenceId: nullableText(toolCall.evidenceId ?? toolCall.evidence?.evidenceId ?? parsed?.evidenceId ?? parsed?.evidence?.evidenceId), + accepted: toolCall.accepted ?? parsed?.accepted ?? null, + readback: toolCall.readback ?? parsed?.readback ?? parsed?.result?.targetReadback ?? null, blocker: nullableText(blocker), status: firstText(toolCall.status, parsed?.status, "unknown") }); @@ -178,6 +181,7 @@ export function compactHwlabApiFact(fact) { if (!fact) return ""; return compactFields([ field("route", fact.route), + field("method", fact.method), field("operationId", fact.operationId), field("traceId", fact.traceId), field("auditId", fact.auditId), diff --git a/web/hwlab-cloud-web/code-agent-facts.test.mjs b/web/hwlab-cloud-web/code-agent-facts.test.mjs index 2068a832..403fa77f 100644 --- a/web/hwlab-cloud-web/code-agent-facts.test.mjs +++ b/web/hwlab-cloud-web/code-agent-facts.test.mjs @@ -205,18 +205,38 @@ test("HWLAB Skill CLI toolCall shows compact copyable /v1/m3/io facts", () => { type: "skill-cli", status: "completed", route: "/v1/m3/io", + method: "POST", operationId: "op_m3_do_write_cli", traceId: "trc_m3_skill_cli", + auditId: "aud_m3_do_write_cli_succeeded", + evidenceId: "evd_m3_do_write_cli_succeeded", audit: { auditId: "aud_m3_do_write_cli_succeeded" }, evidence: { evidenceId: "evd_m3_do_write_cli_succeeded" }, + accepted: true, + readback: { + status: "succeeded", + value: false, + resourceId: "res_boxsimu_2", + port: "DI1" + }, blocker: { code: "runtime_durable_not_green" }, stdout: JSON.stringify({ route: "/v1/m3/io", + method: "POST", status: "succeeded", + accepted: true, traceId: "trc_m3_skill_cli", operationId: "op_m3_do_write_cli", + auditId: "aud_m3_do_write_cli_succeeded", + evidenceId: "evd_m3_do_write_cli_succeeded", audit: { auditId: "aud_m3_do_write_cli_succeeded" }, evidence: { evidenceId: "evd_m3_do_write_cli_succeeded" }, + readback: { + status: "succeeded", + value: false, + resourceId: "res_boxsimu_2", + port: "DI1" + }, durable: { blocker: "runtime_durable_not_green" } }) } @@ -242,15 +262,23 @@ test("HWLAB Skill CLI toolCall shows compact copyable /v1/m3/io facts", () => { assert.deepEqual(facts.hwlabApiFacts[0], { tool: "hwlab-agent-runtime.m3-io", route: "/v1/m3/io", + method: "POST", operationId: "op_m3_do_write_cli", traceId: "trc_m3_skill_cli", auditId: "aud_m3_do_write_cli_succeeded", evidenceId: "evd_m3_do_write_cli_succeeded", + accepted: true, + readback: { + status: "succeeded", + value: false, + resourceId: "res_boxsimu_2", + port: "DI1" + }, blocker: "runtime_durable_not_green", status: "completed" }); assert.equal( compactHwlabApiFact(facts.hwlabApiFacts[0]), - "route=/v1/m3/io / operationId=op_m3_do_write_cli / traceId=trc_m3_skill_cli / auditId=aud_m3_do_write_cli_succeeded / evidenceId=evd_m3_do_write_cli_succeeded / blocker=runtime_durable_not_green" + "route=/v1/m3/io / method=POST / operationId=op_m3_do_write_cli / traceId=trc_m3_skill_cli / auditId=aud_m3_do_write_cli_succeeded / evidenceId=evd_m3_do_write_cli_succeeded / blocker=runtime_durable_not_green" ); });