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:
Codex
2026-06-03 21:29:14 +08:00
parent 867f8dd751
commit eb5bada0d1
6 changed files with 153 additions and 11 deletions
+7 -6
View File
@@ -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));