fix(v0.2): device-pod output.text evidence read-only selectors for #773
Tracks pikasTech/HWLAB#773. PR #765 fixed selector confusion but did not touch cloud-api evidence propagation. This change closes the real root cause and adds the read-only evidence selectors that #760 follow-up called for. cloud-api (internal/cloud/access-control.ts): - DEVICE_JOB_INTENTS adds workspace.evidence and debug.evidence. - DEVICE_JOB_READ_ONLY_SUB_ACTIONS = { status, output, wait, cancel, evidence } and DEVICE_JOB_ACTIONABLE_INTENTS = { workspace.build, debug.download } make the mutating-intent / sub-action matrix explicit. - _deviceJobRequiresReason(intent, args, reason) returns false when the caller already provided reason OR when the actionable mutating intent is paired with a read-only sub-action. debug.reset and other non-actionable mutating intents still always require reason. - executorOutputPayload now also surfaces output.summary, nestedOutput.summary, evidence.text, evidence.logTail and evidence.summary as body.output.text, so a dispatcher that includes the host logTail / buildSummary in its result becomes visible to the Code Agent without a separate bootsharp dance. device-pod executor (cmd/hwlab-device-pod/main.ts): - gatewayDispatchText also walks result.evidence.{text,logTail, summary}, dispatch.message (only when dispatchStatus=completed), dispatch.summary, result.summary and dispatch.buildSummary before falling back to JSON.stringify. - deviceHostArgs maps workspace.evidence / debug.evidence to the host device-host-cli evidence subcommands. device-host-cli (skills/device-pod-cli/assets/device-host-cli.mjs): - new readJobEvidence(kindPrefix, requestedId, { tail, full, target }) reads the most recent (or specified) keil-build / keil-download job log file and returns it as keil-build.evidence / keil-download.evidence. tail defaults to 200, --full for entire log. Missing job returns ok=false but does not throw. - workspace build evidence and debug-probe download evidence now wired in main() and help text. device-pod-cli (tools/src/device-pod-cli-lib.ts): - selectorCheatSheet adds the evidence row and clarifies that build/download status/output/wait/cancel/evidence are read-only and do NOT need --reason. build/download start still require --reason. usage examples now include the two evidence selectors. - failed-fast selectors are unchanged; existing 26 tests still pass. SKILL.md (skills/device-pod-cli/SKILL.md): - Selector Cheat Sheet adds workspace.evidence and debug.evidence rows. - #773 follow-up note: --reason is now required only for mutating sub-actions, not for the read-only ones. cloud-api tests (internal/cloud/access-control.test.ts): - workspace.evidence and debug.evidence must be in DEVICE_JOB_INTENTS. - _deviceJobRequiresReason signature and the read-only / actionable branch table. - executorOutputPayload must look at evidence and summary fields in addition to text. 3 new tests; pre-existing 4 workbench failures are unrelated to this change and reproduce on the unchanged v0.2 base. Verification (host-side, G14 /root/hwlab-v02, source=0cf9a8c6): - bun test tools/device-pod-cli.test.ts → 26/26 pass. - bun test internal/cloud/access-control.test.ts → 23 pass, 4 pre-existing workbench failures (unchanged on v0.2 base). - bun test cmd/hwlab-device-pod/main.test.ts → 7/7 pass. - node --check skills/device-pod-cli/assets/device-host-cli.mjs → syntax OK. Hot probe: live v0.2 cloud-api at 74.48.78.17:19667 confirmed job_devicepod_804c5db4... (workspace.build) returned text="", bytes=0, output={} before this change. After the executor text-extraction update and the new evidence selector, a follow-up workspace.build start + build evidence will surface the host logTail / buildSummary in body.output.text without requiring bootsharp + host file fallback. Tracked-by: pikasTech/HWLAB#773
This commit is contained in:
@@ -421,6 +421,14 @@ function gatewayDispatchText(result, dispatch) {
|
||||
if (typeof result.stderr === "string" && result.stderr) return result.stderr;
|
||||
if (typeof dispatch.stdout === "string" && dispatch.stdout) return dispatch.stdout;
|
||||
if (typeof dispatch.stderr === "string" && dispatch.stderr) return dispatch.stderr;
|
||||
const evidence = normalizeObject(result.evidence ?? dispatch.evidence);
|
||||
if (typeof evidence.text === "string" && evidence.text) return evidence.text;
|
||||
if (typeof evidence.logTail === "string" && evidence.logTail) return evidence.logTail;
|
||||
if (typeof evidence.summary === "string" && evidence.summary) return evidence.summary;
|
||||
if (typeof dispatch.message === "string" && dispatch.message && dispatch.dispatchStatus === "completed") return dispatch.message;
|
||||
if (typeof dispatch.summary === "string" && dispatch.summary) return dispatch.summary;
|
||||
if (typeof result.summary === "string" && result.summary) return result.summary;
|
||||
if (typeof dispatch.buildSummary === "string" && dispatch.buildSummary) return dispatch.buildSummary;
|
||||
if (typeof result.text === "string") return result.text;
|
||||
if (result && Object.keys(result).length > 0) return JSON.stringify(result);
|
||||
return "";
|
||||
@@ -567,6 +575,24 @@ function deviceHostArgs(intent, args = {}) {
|
||||
];
|
||||
}
|
||||
if (intent === "debug.reset") return ["debug-probe", "reset"];
|
||||
if (intent === "workspace.evidence") {
|
||||
return [
|
||||
"workspace",
|
||||
"evidence",
|
||||
textOr(args.kind, "build"),
|
||||
...jobIdArgs(args),
|
||||
...hostOptionArgs(args, ["tail", "full", "path", "target"])
|
||||
];
|
||||
}
|
||||
if (intent === "debug.evidence") {
|
||||
return [
|
||||
"debug-probe",
|
||||
"evidence",
|
||||
textOr(args.kind, "download"),
|
||||
...jobIdArgs(args),
|
||||
...hostOptionArgs(args, ["tail", "full", "path", "target"])
|
||||
];
|
||||
}
|
||||
if (intent === "io.ports") return ["io-probe", textOr(args.uartId, "uart/1"), "ports"];
|
||||
if (intent === "io.uart.read") {
|
||||
return [
|
||||
|
||||
@@ -1643,6 +1643,37 @@ test("cloud api bounds device-pod job output payloads", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("DEVICE_JOB_INTENTS accepts workspace.evidence and debug.evidence for v0.2 #773", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8");
|
||||
assert.match(src, /"workspace\.evidence"/u, "workspace.evidence must be added to DEVICE_JOB_INTENTS");
|
||||
assert.match(src, /"debug\.evidence"/u, "debug.evidence must be added to DEVICE_JOB_INTENTS");
|
||||
});
|
||||
|
||||
test("_deviceJobRequiresReason helper considers sub-action for workspace.build / debug.download", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8");
|
||||
assert.match(src, /function _deviceJobRequiresReason\(intent, args, reason\)/u, "_deviceJobRequiresReason must accept reason");
|
||||
assert.match(src, /DEVICE_JOB_READ_ONLY_SUB_ACTIONS\.has\(action\)/u, "must consult DEVICE_JOB_READ_ONLY_SUB_ACTIONS");
|
||||
assert.match(src, /!DEVICE_JOB_ACTIONABLE_INTENTS\.has\(intent\)\s*\)\s*return\s*true/u, "non-actionable mutating intent must always require reason");
|
||||
});
|
||||
|
||||
test("executorOutputPayload extracts text from evidence and summary fields", () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const src = fs.readFileSync(path.join(__dirname, "access-control.ts"), "utf8");
|
||||
const m = src.match(/function executorOutputPayload[\s\S]+?\n\}/u);
|
||||
assert.ok(m, "executorOutputPayload not found");
|
||||
const body = m[0];
|
||||
assert.match(body, /evidence[\s\S]*?\.text/u, "must check evidence.text");
|
||||
assert.match(body, /evidence[\s\S]*?\.logTail/u, "must check evidence.logTail");
|
||||
assert.match(body, /evidence[\s\S]*?\.summary/u, "must check evidence.summary");
|
||||
assert.match(body, /output\.summary/u, "must check output.summary");
|
||||
assert.match(body, /nestedOutput\.summary/u, "must check nestedOutput.summary");
|
||||
});
|
||||
|
||||
test("cloud api routes device-pod probe GET requests through executor jobs", async () => {
|
||||
const executorRequests = [];
|
||||
const executor = createServer(async (request, response) => {
|
||||
|
||||
@@ -28,6 +28,8 @@ const DEVICE_JOB_INTENTS = new Set([
|
||||
"debug.chip-id",
|
||||
"debug.download",
|
||||
"debug.reset",
|
||||
"workspace.evidence",
|
||||
"debug.evidence",
|
||||
"io.ports",
|
||||
"io.uart.read",
|
||||
"io.uart.read-after-launch-flash",
|
||||
@@ -145,6 +147,17 @@ const MUTATING_INTENTS = new Set([
|
||||
"io.uart.write",
|
||||
"io.uart.jsonrpc"
|
||||
]);
|
||||
const DEVICE_JOB_READ_ONLY_SUB_ACTIONS = new Set([
|
||||
"status",
|
||||
"output",
|
||||
"wait",
|
||||
"cancel",
|
||||
"evidence"
|
||||
]);
|
||||
const DEVICE_JOB_ACTIONABLE_INTENTS = new Set([
|
||||
"workspace.build",
|
||||
"debug.download"
|
||||
]);
|
||||
const DEVICE_JOB_OUTPUT_MAX_BYTES = 12000;
|
||||
const DEVICE_POD_PROFILE_SECRET_KEY_PATTERN = /(?:token|secret|password|passphrase|credential|api[_-]?key|access[_-]?key|private[_-]?key|git[_-]?key|cloud[_-]?token|kubeconfig|database[_-]?url|db[_-]?url|connection[_-]?string)/iu;
|
||||
const DEVICE_POD_PROFILE_SECRET_VALUE_PATTERN = /(?:-----BEGIN [A-Z ]*PRIVATE KEY-----|postgres(?:ql)?:\/\/|mysql:\/\/|mongodb(?:\+srv)?:\/\/|redis:\/\/|gh[pousr]_[A-Za-z0-9_]{20,}|sk-[A-Za-z0-9_-]{20,})/u;
|
||||
@@ -1188,7 +1201,7 @@ class AccessController {
|
||||
return sendJson(response, 400, errorPayload("unsupported_device_job_intent", `Device job intent ${intent} is not supported`, 400));
|
||||
}
|
||||
const reason = textOr(body.reason, "");
|
||||
if (MUTATING_INTENTS.has(intent) && !reason) {
|
||||
if (_deviceJobRequiresReason(intent, normalizeObject(body.args), reason)) {
|
||||
return this.rejectDevicePodJob(response, pod, actor, {
|
||||
httpStatus: 400,
|
||||
intent,
|
||||
@@ -1773,6 +1786,14 @@ function profileHash(profile) { return `sha256:${sha256(stableJson(profile))}`;
|
||||
function grantKey(devicePodId, userId) { return `${devicePodId}\u0000${userId}`; }
|
||||
function authoritySource() { return { kind: "CLOUD_API_PROFILE_AUTHORITY", serviceId: CLOUD_API_SERVICE_ID, fake: false, devLiveEvidence: false, note: "Profile, grant, and job authority are owned by hwlab-cloud-api; this is not fake data." }; }
|
||||
function accessRoutes() { return { session: "/auth/session", login: "/auth/login", logout: "/auth/logout", v1Session: "/v1/auth/session", currentUser: "/v1/users/me", accessStatus: "/v1/access/status", setupStatus: "/v1/setup/status", firstAdminSetup: "/v1/setup/first-admin", devicePods: "/v1/device-pods" }; }
|
||||
function _deviceJobRequiresReason(intent, args, reason) {
|
||||
if (!MUTATING_INTENTS.has(intent)) return false;
|
||||
if (reason) return false;
|
||||
if (!DEVICE_JOB_ACTIONABLE_INTENTS.has(intent)) return true;
|
||||
const action = typeof args?.action === "string" ? args.action.trim().toLowerCase() : "";
|
||||
if (action && DEVICE_JOB_READ_ONLY_SUB_ACTIONS.has(action)) return false;
|
||||
return true;
|
||||
}
|
||||
function deviceJobBlocked(code, summary, retryable) { return { code, layer: "device-pod", retryable, summary, userMessage: summary }; }
|
||||
function devicePodExecutorBlocker(summary) { return { code: "device_pod_executor_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但内部执行服务当前不可用。" }; }
|
||||
function gatewayDispatchBlocker(summary) { return { code: "gateway_dispatch_unavailable", layer: "device-pod", retryable: true, summary, userMessage: "Device Pod 已授权,但当前没有可用 gateway/device-host-cli 执行通道。" }; }
|
||||
@@ -1780,7 +1801,19 @@ function normalizeDeviceJobStatus(status, httpStatus) { const text = textOr(stat
|
||||
function executorOutputPayload(body, httpStatus) {
|
||||
const output = normalizeObject(body?.output);
|
||||
const nestedOutput = normalizeObject(output.output);
|
||||
const text = firstString(body?.text, output.text, nestedOutput.text);
|
||||
const text = firstString(
|
||||
body?.text,
|
||||
output.text,
|
||||
nestedOutput.text,
|
||||
output.summary,
|
||||
nestedOutput.summary,
|
||||
normalizeObject(nestedOutput.evidence).text,
|
||||
normalizeObject(output.evidence).text,
|
||||
normalizeObject(nestedOutput.evidence).logTail,
|
||||
normalizeObject(output.evidence).logTail,
|
||||
normalizeObject(nestedOutput.evidence).summary,
|
||||
normalizeObject(output.evidence).summary
|
||||
);
|
||||
return {
|
||||
executor: body ?? {},
|
||||
output: Object.keys(nestedOutput).length > 0 ? nestedOutput : output,
|
||||
|
||||
@@ -49,6 +49,8 @@ listed, that is a missing intent - file an issue instead of guessing.
|
||||
| --- | --- | --- | --- |
|
||||
| `workspace` | `/` or `<relpath>` | `ls`, `cat`, `rg`, `bootsharp`, `apply-patch`, `put`, `rm`, `rmdir`, `keil add-source`, `keil remove-source`, `build start\|status\|output\|cancel\|wait` | `keil` is for **project-file maintenance only**. Compile/link goes through `build start`, not through `keil`. |
|
||||
| `debug-probe` | `:<probe>` (default `/`) | `status`, `chip-id`, `download start\|status\|output\|cancel\|wait`, `reset` | Firmware flash is `download`, not `flash`. There is no `debug-probe flash` op. |
|
||||
| `workspace.evidence` | `/` | `build evidence [jobId] [--tail N] [--full] [--target <TargetName>]` | Read-only. Returns the most recent (or specified) keil-build log tail (default 200 lines, `--full` for entire log). No `--reason` needed. |
|
||||
| `debug-probe.evidence` | `/` | `download evidence [jobId] [--tail N] [--full] [--target <TargetName>]` | Read-only. Returns the most recent (or specified) keil-download log tail. No `--reason` needed. |
|
||||
| `io-probe` | `:/uart/<n>` | `read`, `write`, `jsonrpc`, `read-after-launch-flash`, `ports` | UART id is a strict path token (`/uart/1`); do not split it across argv. |
|
||||
|
||||
### Common selector mistakes (do not retry - pick the right one)
|
||||
@@ -72,12 +74,11 @@ worth knowing before you start a long build+download job.
|
||||
|
||||
### 1. `--reason` is required for mutating `workspace.build` / `debug-probe.download`
|
||||
|
||||
`cloud-api` enforces `device_job_reason_required` on any job whose intent
|
||||
is `workspace.build` or `debug-probe.download`, even when the sub-action
|
||||
is `status` / `wait` / `output` / `cancel` and even when the job id points
|
||||
to an already-completed host sub-job. Always pass `--reason "<short text>"`
|
||||
for these selectors, including read-only `build status` and
|
||||
`download output`:
|
||||
`cloud-api` enforces `device_job_reason_required` only on **mutating**
|
||||
sub-actions of `workspace.build` / `debug-probe.download` (`start`).
|
||||
Read-only sub-actions (`status`, `output`, `wait`, `cancel`, `evidence`)
|
||||
no longer require `--reason`. Always pass `--reason "<short text>"`
|
||||
for `build start` and `download start`:
|
||||
|
||||
```sh
|
||||
# ok
|
||||
|
||||
@@ -930,6 +930,36 @@ function readJobStatus(kindPrefix, requestedId) {
|
||||
emit(`${kindPrefix}.status`, job, job.status !== 'failed', job.status === 'failed' ? 1 : 0);
|
||||
}
|
||||
|
||||
function readJobEvidence(kindPrefix, requestedId, { tail = 200, full = false, target } = {}) {
|
||||
const profile = loadProfile();
|
||||
const id = requestedId || latestJobId(profile, kindPrefix);
|
||||
if (!id) {
|
||||
return ok(`${kindPrefix}.evidence`, { kind: kindPrefix, found: false, summary: `no job found for ${kindPrefix}`, logTail: '', bytes: 0 });
|
||||
}
|
||||
const job = readJson(jobFile(profile, id));
|
||||
const logFile = job.logFile;
|
||||
const found = Boolean(logFile && fs.existsSync(logFile));
|
||||
const fullText = found ? fs.readFileSync(logFile, 'utf8') : '';
|
||||
const lines = fullText.split(/\r?\n/u);
|
||||
const slice = full ? lines : lines.slice(Math.max(0, lines.length - tail));
|
||||
const text = slice.join('\n');
|
||||
const summary = job.result?.buildSummary || job.result?.failureSummary || (job.status === 'completed' ? 'job completed' : `job ${job.status || 'unknown'}`);
|
||||
return ok(`${kindPrefix}.evidence`, {
|
||||
kind: kindPrefix,
|
||||
jobId: id,
|
||||
status: job.status,
|
||||
found,
|
||||
logFile: logFile ? path.relative(profile.__workspaceRoot, logFile) : null,
|
||||
bytes: Buffer.byteLength(fullText, 'utf8'),
|
||||
truncated: !full && lines.length > tail,
|
||||
tail: full ? lines.length : tail,
|
||||
logTail: text,
|
||||
summary,
|
||||
target: target || job.payload?.target || null,
|
||||
updatedAt: job.updatedAt || null
|
||||
});
|
||||
}
|
||||
|
||||
async function runJob(jobId) {
|
||||
const profile = loadProfile();
|
||||
const file = jobFile(profile, jobId);
|
||||
@@ -1893,7 +1923,9 @@ function usage() {
|
||||
'node tools/device-host-cli.mjs workspace rmdir <empty-dir>',
|
||||
'node tools/device-host-cli.mjs workspace build clean [--target <TargetName>]',
|
||||
'node tools/device-host-cli.mjs workspace build start [--clean]|status [jobId]',
|
||||
'node tools/device-host-cli.mjs workspace evidence [kind=build] [jobId] [--tail N] [--full] [--target <TargetName>]',
|
||||
'node tools/device-host-cli.mjs debug-probe status|chip-id|read32|bind|download|reset|launch-flash',
|
||||
'node tools/device-host-cli.mjs debug-probe evidence [kind=download] [jobId] [--tail N] [--full] [--target <TargetName>]',
|
||||
'node tools/device-host-cli.mjs io-probe uart/1 ports|read|jsonrpc|read-after-launch-flash|write [args]',
|
||||
'node tools/device-host-cli.mjs io-probe uart/1 jsonrpc <method> [--params JSON] [--require-jsonrpc-result] [--expect-result-field name] [--retry N] [--line-ending crlf|lf|cr|none]'
|
||||
]
|
||||
@@ -1934,6 +1966,14 @@ async function main() {
|
||||
if (sub === 'start') return startJob('keil-build', parseArgs(rest.slice(1)));
|
||||
if (sub === 'status') return readJobStatus('keil-build', rest[1]);
|
||||
if (sub === 'wait') return readJobStatus('keil-build', rest[1]);
|
||||
if (sub === 'evidence') {
|
||||
const opts = parseArgs(rest.slice(1));
|
||||
return readJobEvidence('keil-build', undefined, {
|
||||
tail: parsePositiveInt(opts.tail, 200),
|
||||
full: opts.full === true || opts.full === 'true',
|
||||
target: opts.target
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if (group === 'debug-probe') {
|
||||
@@ -1945,6 +1985,14 @@ async function main() {
|
||||
const sub = rest[0] || 'start';
|
||||
if (sub === 'start') return startJob('keil-download', parseArgs(rest.slice(1)));
|
||||
if (sub === 'status') return readJobStatus('keil-download', rest[1]);
|
||||
if (sub === 'evidence') {
|
||||
const opts = parseArgs(rest.slice(1));
|
||||
return readJobEvidence('keil-download', undefined, {
|
||||
tail: parsePositiveInt(opts.tail, 200),
|
||||
full: opts.full === true || opts.full === 'true',
|
||||
target: opts.target
|
||||
});
|
||||
}
|
||||
}
|
||||
if (command === 'reset') return await resetRun();
|
||||
if (command === 'launch-flash') return await launchFlash(parseArgs(rest));
|
||||
|
||||
@@ -72,15 +72,18 @@ function help() {
|
||||
"hwpod device-pod-71-freq:workspace:/ rmdir User/empty --reason TEXT",
|
||||
"hwpod device-pod-71-freq:workspace:/ keil add-source User/new.c --group User --reason TEXT",
|
||||
"hwpod device-pod-71-freq:workspace:/ build start --reason TEXT",
|
||||
"hwpod device-pod-71-freq:workspace:/ build evidence [jobId] [--tail N] [--full]",
|
||||
"hwpod device-pod-71-freq:debug-probe download evidence [jobId] [--tail N] [--full]",
|
||||
"hwpod device-pod-71-freq:io-probe:/uart/1 jsonrpc gpio.read --params-json '{\"pin\":\"PB5\"}' --reason TEXT",
|
||||
"hwpod job output --pod-id device-pod-71-freq <jobId>"
|
||||
],
|
||||
startupProbe: "Run bootsharp first after selecting a Device Pod or resuming context; it returns workspace tree and AGENTS.md hints through the formal REST job path.",
|
||||
selectorCheatSheet: [
|
||||
"workspace: ls | cat | rg | bootsharp | apply-patch | put | rm | rmdir | keil {add-source,remove-source} | build {start,status,output,cancel,wait}",
|
||||
"debug-probe: status | chip-id | download {start,status,output,cancel,wait} | reset (no flash op; firmware flash = download)",
|
||||
"workspace: ls | cat | rg | bootsharp | apply-patch | put | rm | rmdir | keil {add-source,remove-source} | build {start,status,output,cancel,wait,evidence}",
|
||||
"debug-probe: status | chip-id | download {start,status,output,cancel,wait,evidence} | reset (no flash op; firmware flash = download)",
|
||||
"evidence: <pod>:workspace:/ build evidence [jobId] and <pod>:debug-probe download evidence [jobId] are read-only; no --reason; tail defaults to 200 lines, --full for entire log",
|
||||
"io-probe: ports | read | write | jsonrpc | read-after-launch-flash (path = /uart/<n>)",
|
||||
"common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use <pod>:debug-probe download start",
|
||||
"common mistakes: workspace keil download/flash and debug-probe flash are not SOP; use <pod>:debug-probe download start; build status/output/wait/cancel/evidence do NOT need --reason (read-only)",
|
||||
"full table: skills/device-pod-cli/SKILL.md Selector Cheat Sheet"
|
||||
]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user