From 7accdd45688833b0e437b9f083b7ac8a93855cf5 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 02:30:49 +0800 Subject: [PATCH] fix: persist and select device-pod profiles --- scripts/g14-gitops-render.mjs | 11 +- skills/device-pod-cli/SKILL.md | 31 ++++- .../device-pod-cli/assets/device-host-cli.mjs | 127 ++++++++++++++++-- tools/device-pod-cli.mjs | 19 ++- 4 files changed, 165 insertions(+), 23 deletions(-) diff --git a/scripts/g14-gitops-render.mjs b/scripts/g14-gitops-render.mjs index ccb8768e..b9bebc93 100644 --- a/scripts/g14-gitops-render.mjs +++ b/scripts/g14-gitops-render.mjs @@ -2216,6 +2216,7 @@ function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, ca { name: "HWLAB_IMAGE_TAG", value: bridgeImageTag }, { name: "DEVICE_ID", value: "71-freq" }, { name: "DEVICE_WORKSPACE_ROOT", value: "F:\\Work\\ConStart" }, + { name: "DEVICE_POD_PROFILE_DIR", value: "/workspace/hwlab/.device-pod" }, { name: "HWLAB_CLOUD_API_URL", value: `http://hwlab-cloud-api.${namespace}.svc.cluster.local:6667` }, { name: "HWLAB_GATEWAY_SESSION_ID", value: "gws_d601_win_71_freq" }, { name: "HWLAB_GATEWAY_RESOURCE_ID", value: "res_d601_windows_host" }, @@ -2225,9 +2226,15 @@ function deviceAgent71FreqManifest({ profile = "dev", source, registryPrefix, ca readinessProbe: { httpGet: { path: "/health", port: "http" }, initialDelaySeconds: 3, periodSeconds: 10 }, livenessProbe: { httpGet: { path: "/health", port: "http" }, initialDelaySeconds: 10, periodSeconds: 20 }, resources: { requests: { cpu: "10m", memory: "64Mi" }, limits: { cpu: "200m", memory: "256Mi" } }, - volumeMounts: [{ name: "script", mountPath: "/opt/device-agent", readOnly: true }] + volumeMounts: [ + { name: "script", mountPath: "/opt/device-agent", readOnly: true }, + { name: "hwlab-code-agent-workspace", mountPath: "/workspace" } + ] }], - volumes: [{ name: "script", configMap: { name: `${name}-script` } }] + volumes: [ + { name: "script", configMap: { name: `${name}-script` } }, + { name: "hwlab-code-agent-workspace", persistentVolumeClaim: { claimName: "hwlab-code-agent-workspace" } } + ] } } } diff --git a/skills/device-pod-cli/SKILL.md b/skills/device-pod-cli/SKILL.md index 771d07f0..bd2acbec 100644 --- a/skills/device-pod-cli/SKILL.md +++ b/skills/device-pod-cli/SKILL.md @@ -28,9 +28,19 @@ device pod, debug probe control, or I/O probe reads such as UART boot logs. `/app/skills/device-pod-cli/assets/device-host-cli.mjs`. It is bundled with this skill so a runner can send the exact tested host-side CLI to a new Windows hardware PC before the first device-pod operation. -- Profiles are read from `.device-pod/.json` in the current code - agent workspace, or from `DEVICE_POD_PROFILE` when a task explicitly points to - a profile file. +- Profiles are read from `DEVICE_POD_PROFILE_DIR/.json` when that + directory is set, otherwise from `.device-pod/.json` in the + current code agent workspace; `DEVICE_POD_PROFILE` may point to one explicit + profile file. G14 device-agent runners should keep shared profiles in the + persistent `/workspace/hwlab/.device-pod` directory rather than only in + ephemeral `/app/.device-pod`. +- Multiple profiles may coexist in the same runner workspace. Use the selector + prefix, such as `device-pod-71-freq:...` or `device-pod-71-00075-11:...`, as + the source of truth for the active pod. `device-pod-cli` automatically passes + that selected profile to Windows `device-host-cli` as `--profile + .device-pod\\.json --pod-id `, so host-side commands + must not fall back to the default `device-pod-71-freq` profile when another + pod is selected. - The CLI calls HWLAB cloud-api `hardware.invoke.shell`, which dispatches to an outbound `hwlab-gateway` session and then to the user PC side `device-host-cli`. Do not call gateway, serial ports, Keil, pyOCD, DAPLink, or @@ -75,7 +85,7 @@ default to hide slow transfers. If upload/download becomes awkward again, fix ```text node /app/tools/tran.mjs :/d/Work/HWLAB-workspace upload /app/skills/device-pod-cli/assets/device-host-cli.mjs tools/device-host-cli.mjs -node /app/tools/tran.mjs :/d/Work/HWLAB-workspace cmd -- node tools\\device-host-cli.mjs health +node /app/tools/tran.mjs :/d/Work/HWLAB-workspace cmd -- node tools\\device-host-cli.mjs --profile .device-pod\\.json --pod-id health ``` When only HWLAB gateway `hardware.invoke.shell` is available, send the asset @@ -83,7 +93,7 @@ bytes as bounded base64 chunks through cmd/PowerShell into `tools\device-host-cli.mjs`, then verify with: ```text -node tools\device-host-cli.mjs health +node tools\device-host-cli.mjs --profile .device-pod\.json --pod-id health ``` After that, `device-pod-cli` should use the profile's `hostCli`, normally @@ -126,12 +136,23 @@ node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:wo node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe chip-id node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --approved --reason "DEV smoke" node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download status +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe download start --capture-uart uart/1 --capture-duration-ms 8000 --approved --reason "DEV boot log capture" node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:debug-probe launch-flash --approved --reason "manual flash-vector launch" node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 ports node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --duration-ms 5000 +node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read --port COM4 --baud-rate 921600 --duration-ms 5000 node /app/skills/device-pod-cli/scripts/device-pod-cli.mjs device-pod-71-freq:io-probe:/uart/1 read-after-launch-flash --approved --reason "boot log capture" ``` +Use `--port COMx` and `--baud-rate N` only as a short-lived UART discovery +override. After the real port is proven, write the stable value back into the +device-pod profile so later agents do not depend on a hidden command override. + +For boot logs that only appear during reset/download, prefer +`debug-probe download start --capture-uart uart/1 --capture-duration-ms N` so +the host opens the UART before Keil starts flashing and stores the captured +bytes in the download job status under `result.uartCapture`. + ## Safety - Mutating operations such as workspace patching, download, reset, diff --git a/skills/device-pod-cli/assets/device-host-cli.mjs b/skills/device-pod-cli/assets/device-host-cli.mjs index 26cdb73a..5d9a71b2 100644 --- a/skills/device-pod-cli/assets/device-host-cli.mjs +++ b/skills/device-pod-cli/assets/device-host-cli.mjs @@ -7,10 +7,42 @@ import path from 'node:path'; import { fileURLToPath } from 'node:url'; const VERSION = '0.1.0-mvp'; -const DEFAULT_POD_ID = process.env.DEVICE_POD_ID || 'device-pod-71-freq'; +const DEFAULT_POD_ID = 'device-pod-71-freq'; +const GLOBAL_CLI = parseGlobalCli(process.argv.slice(2)); + +function parseGlobalCli(argv) { + const options = {}; + const args = []; + for (let i = 0; i < argv.length; i += 1) { + const item = argv[i]; + if (item === '--profile') { + options.profile = argv[i + 1]; + i += 1; + } else if (item.startsWith('--profile=')) { + options.profile = item.slice('--profile='.length); + } else if (item === '--pod-id') { + options.podId = argv[i + 1]; + i += 1; + } else if (item.startsWith('--pod-id=')) { + options.podId = item.slice('--pod-id='.length); + } else if (item === '--workspace') { + options.workspace = argv[i + 1]; + i += 1; + } else if (item.startsWith('--workspace=')) { + options.workspace = item.slice('--workspace='.length); + } else { + args.push(item); + } + } + return { options, args }; +} + +function selectedPodId() { + return GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || DEFAULT_POD_ID; +} function workspaceRoot() { - return path.resolve(process.env.DEVICE_POD_WORKSPACE || process.cwd()); + return path.resolve(GLOBAL_CLI.options.workspace || process.env.DEVICE_POD_WORKSPACE || process.cwd()); } function devicePodDir(root = workspaceRoot()) { @@ -49,15 +81,19 @@ function writeJson(file, value) { fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`, 'utf8'); } -function profilePath(root = workspaceRoot(), podId = DEFAULT_POD_ID) { +function profilePath(root = workspaceRoot(), podId = selectedPodId()) { return path.join(devicePodDir(root), `${podId}.json`); } function loadProfile() { const root = workspaceRoot(); - const file = process.env.DEVICE_POD_PROFILE || profilePath(root); + const requestedProfile = GLOBAL_CLI.options.profile || process.env.DEVICE_POD_PROFILE || ''; + const file = requestedProfile ? (path.isAbsolute(requestedProfile) ? requestedProfile : path.resolve(root, requestedProfile)) : profilePath(root); if (!fs.existsSync(file)) throw new Error(`device-pod profile not found: ${file}`); const profile = readJson(file); + const actualPodId = profile.devicePodId || profile.podId; + const requestedPodId = GLOBAL_CLI.options.podId || process.env.DEVICE_POD_ID || ''; + if (requestedPodId && actualPodId && actualPodId !== requestedPodId) throw new Error(`device-pod profile mismatch: requested ${requestedPodId}, profile ${actualPodId}`); profile.__profilePath = file; profile.__workspaceRoot = path.resolve(profile.workspaceRoot || root); return profile; @@ -138,6 +174,10 @@ function psQuote(value) { return `'${String(value).replace(/'/g, "''")}'`; } +function psArray(values) { + return `@(${values.map(psQuote).join(', ')})`; +} + function parseArgs(args) { const out = { _: [] }; for (let i = 0; i < args.length; i += 1) { @@ -537,10 +577,59 @@ async function runKeilDownload(profile, payload = {}) { const logFile = path.join(outDir, `download_${safeTarget(target)}_${Date.now()}.log`); const args = ['-f', project, '-j0', '-sg', '-o', logFile]; if (target) args.push('-t', target); - const result = await runCapture(tools.uv4.path, args, { cwd: path.dirname(project), timeoutMs: Number(payload.timeoutMs || 300000) }); + const timeoutMs = Number(payload.timeoutMs || 300000); + const result = payload['capture-uart'] || payload.captureUart + ? await runKeilDownloadWithUartCapture(profile, tools.uv4.path, args, path.dirname(project), payload) + : await runCapture(tools.uv4.path, args, { cwd: path.dirname(project), timeoutMs }); const logText = fs.existsSync(logFile) ? fs.readFileSync(logFile, 'utf8') : ''; const success = result.exitCode === 0 && !keilLogHasErrors(logText); - return { success, exitCode: result.exitCode, timedOut: result.timedOut, elapsedMs: result.elapsedMs, project, target, binding, logFile, logTail: shortTail(logText), stdout: shortTail(result.stdout), stderr: shortTail(result.stderr) }; + return { success, exitCode: result.exitCode, timedOut: result.timedOut, elapsedMs: result.elapsedMs, project, target, binding, logFile, logTail: shortTail(logText), stdout: shortTail(result.stdout), stderr: shortTail(result.stderr), uartCapture: result.uartCapture }; +} + +async function runKeilDownloadWithUartCapture(profile, uv4Path, uv4Args, cwd, payload = {}) { + const uart = resolveUart(profile, payload['capture-uart'] || payload.captureUart, payload); + const captureDurationMs = Number(payload['capture-duration-ms'] || payload.captureDurationMs || payload['duration-ms'] || payload.durationMs || 8000); + const timeoutMs = Number(payload.timeoutMs || 300000); + const logDir = path.join(stateRoot(profile.__workspaceRoot), 'serial'); + ensureDir(logDir); + const serialLogFile = path.join(logDir, `${uart.id.replace(/\//g, '_')}.jsonl`); + const script = [ + '$ErrorActionPreference = \'Stop\';', + `$uv4 = ${psQuote(uv4Path)};`, + `$uv4Args = ${psArray(uv4Args)};`, + `$cwd = ${psQuote(cwd)};`, + `$portName = ${psQuote(uart.port)};`, + `$baud = ${Number(uart.baudRate || 115200)};`, + `$captureDurationMs = ${captureDurationMs};`, + '$port = [System.IO.Ports.SerialPort]::new($portName, $baud, [System.IO.Ports.Parity]::None, 8, [System.IO.Ports.StopBits]::One);', + '$port.Encoding = [System.Text.UTF8Encoding]::new($false);', + '$port.ReadTimeout = 200;', + '$timer = [System.Diagnostics.Stopwatch]::StartNew();', + 'try {', + ' $port.Open();', + ' $port.DiscardInBuffer();', + ' Push-Location -LiteralPath $cwd;', + ' try { $uv4Output = & $uv4 @uv4Args 2>&1; $uv4ExitCode = $LASTEXITCODE } finally { Pop-Location }', + ' Start-Sleep -Milliseconds $captureDurationMs;', + ' $text = $port.ReadExisting();', + ' $bytes = [System.Text.Encoding]::UTF8.GetBytes($text);', + ' $timer.Stop();', + ' [pscustomobject]@{ exitCode = $uv4ExitCode; timedOut = $false; elapsedMs = [int]$timer.ElapsedMilliseconds; stdout = ($uv4Output | Out-String); stderr = ""; contentB64 = [Convert]::ToBase64String($bytes) } | ConvertTo-Json -Depth 5 -Compress | Write-Output;', + '} finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', + ].join(' '); + const psResult = await runPowerShell(script, Math.max(timeoutMs + captureDurationMs + 10000, 15000)); + if (psResult.exitCode !== 0) throw new Error(`keil download UART capture failed on ${uart.port}@${uart.baudRate}: ${shortTail(psResult.stderr || psResult.stdout)}`); + const payloadJson = JSON.parse(psResult.stdout.trim()); + const content = payloadJson.contentB64 ? Buffer.from(payloadJson.contentB64, 'base64').toString('utf8') : ''; + fs.appendFileSync(serialLogFile, `${JSON.stringify({ ts: nowIso(), direction: 'download-capture-uart', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, content })}\n`, 'utf8'); + return { + exitCode: Number(payloadJson.exitCode || 0), + timedOut: Boolean(payloadJson.timedOut), + elapsedMs: Number(payloadJson.elapsedMs || 0), + stdout: payloadJson.stdout || '', + stderr: payloadJson.stderr || '', + uartCapture: { uart, durationMs: captureDurationMs, content, bytes: Buffer.byteLength(content), logFile: serialLogFile }, + }; } async function pyocdArgs(profile, commandArgs) { @@ -658,16 +747,24 @@ async function serialPorts() { ok('io-probe.ports', { configured: profile.ioInterface?.uart || [], ports, stderr: result.stderr, exitCode: result.exitCode }); } -function resolveUart(profile, uartId) { +function resolveUart(profile, uartId, options = {}) { const normalized = String(uartId || 'uart/1').replace(/^\/+/, ''); const item = (profile.ioInterface?.uart || []).find((entry) => entry.id === normalized); if (!item) throw new Error(`uart profile not found: ${normalized}`); - return item; + const resolved = { ...item }; + if (options.port) resolved.port = String(options.port); + const baudRate = options['baud-rate'] || options.baudRate; + if (baudRate !== undefined) { + const parsed = Number(baudRate); + if (!Number.isFinite(parsed) || parsed <= 0) throw new Error(`invalid UART baud rate: ${baudRate}`); + resolved.baudRate = parsed; + } + return resolved; } async function serialRead(uartId, options) { const profile = loadProfile(); - const uart = resolveUart(profile, uartId); + const uart = resolveUart(profile, uartId, options); const durationMs = Number(options['duration-ms'] || options.durationMs || 1000); const script = [ '$ErrorActionPreference = \'Stop\';', @@ -685,13 +782,14 @@ async function serialRead(uartId, options) { const logDir = path.join(stateRoot(profile.__workspaceRoot), 'serial'); ensureDir(logDir); const logFile = path.join(logDir, `${uart.id.replace(/\//g, '_')}.jsonl`); - fs.appendFileSync(logFile, `${JSON.stringify({ ts: nowIso(), direction: 'read', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, content })}\n`, 'utf8'); + fs.appendFileSync(logFile, `${JSON.stringify({ ts: nowIso(), direction: 'read', uartId: uart.id, port: uart.port, baudRate: uart.baudRate, exitCode: result.exitCode, content, stderr: result.stderr })}\n`, 'utf8'); + if (result.exitCode !== 0) throw new Error(`uart read failed on ${uart.port}@${uart.baudRate}: ${shortTail(result.stderr || result.stdout)}`); ok('io-probe.uart.read', { uart, durationMs, exitCode: result.exitCode, content, bytes: Buffer.byteLength(content), logFile, stderr: result.stderr }); } async function serialReadAfterLaunchFlash(uartId, options) { const profile = loadProfile(); - const uart = resolveUart(profile, uartId); + const uart = resolveUart(profile, uartId, options); const tools = await toolInfo(profile); if (!tools.pyocd.found) throw new Error('pyOCD executable not found'); const debug = profile.debugInterface || {}; @@ -758,7 +856,7 @@ async function serialReadAfterLaunchFlash(uartId, options) { async function serialWrite(uartId, options) { const profile = loadProfile(); - const uart = resolveUart(profile, uartId); + const uart = resolveUart(profile, uartId, options); const message = options.hex ? Buffer.from(String(options._[0] || ''), 'hex').toString('binary') : String(options._[0] || ''); const encoded = Buffer.from(message, options.hex ? 'binary' : 'utf8').toString('base64'); const script = [ @@ -771,12 +869,15 @@ async function serialWrite(uartId, options) { 'try { $port.Open(); $port.BaseStream.Write($bytes, 0, $bytes.Length); $port.BaseStream.Flush(); [Console]::Out.Write($bytes.Length) } finally { if ($port.IsOpen) { $port.Close() }; $port.Dispose() }', ].join(' '); const result = await runPowerShell(script, 10000); + if (result.exitCode !== 0) throw new Error(`uart write failed on ${uart.port}@${uart.baudRate}: ${shortTail(result.stderr || result.stdout)}`); ok('io-probe.uart.write', { uart, exitCode: result.exitCode, bytesWritten: Number(result.stdout.trim() || 0), stderr: result.stderr }); } function usage() { ok('help', { usage: [ + 'node tools/device-host-cli.mjs --profile .device-pod/.json health', + 'node tools/device-host-cli.mjs --pod-id health', 'node tools/device-host-cli.mjs health', 'node tools/device-host-cli.mjs workspace ls [path]', 'node tools/device-host-cli.mjs workspace cat [--limit N]', @@ -790,7 +891,7 @@ function usage() { } async function main() { - const [group, command, ...rest] = process.argv.slice(2); + const [group, command, ...rest] = GLOBAL_CLI.args; if (!group || group === 'help' || group === '--help') return usage(); if (group === '__job') return await runJob(command); if (group === 'health') return await health(); diff --git a/tools/device-pod-cli.mjs b/tools/device-pod-cli.mjs index 0f076e10..83530109 100644 --- a/tools/device-pod-cli.mjs +++ b/tools/device-pod-cli.mjs @@ -6,9 +6,16 @@ import https from 'node:https'; import path from 'node:path'; const VERSION = '0.1.0-mvp'; -const PROFILE_DIR = process.env.DEVICE_POD_PROFILE_DIR || path.resolve(process.cwd(), '.device-pod'); const DEFAULT_TIMEOUT_MS = 30000; +function defaultProfileDir() { + if (process.env.DEVICE_POD_PROFILE_DIR) return process.env.DEVICE_POD_PROFILE_DIR; + const candidates = ['/workspace/hwlab/.device-pod', path.resolve(process.cwd(), '.device-pod'), '/app/.device-pod']; + return candidates.find((dir) => fs.existsSync(dir)) || path.resolve(process.cwd(), '.device-pod'); +} + +const PROFILE_DIR = defaultProfileDir(); + function nowIso() { return new Date().toISOString(); } function stripSlash(value) { return String(value || '').replace(/\/+$/u, ''); } function sha256(text) { return createHash('sha256').update(text).digest('hex'); } @@ -93,6 +100,10 @@ function cmdQuote(value) { return `"${text.replace(/"/g, '\\"')}"`; } +function hostProfilePath(profile) { + return profile.hostProfilePath || `.device-pod\\${profile.podId}.json`; +} + const CONTROL_OPTION_KEYS = new Set([ 'approved', 'reason', @@ -181,7 +192,8 @@ async function invoke(profile, command, parsed) { }); const dispatch = response.body?.result?.dispatch || response.body?.result || {}; const hostJson = parseMaybeJson(dispatch.stdout); - const status = response.body?.error || dispatch.exitCode !== 0 || dispatch.status === 'failed' ? 'failed' : 'succeeded'; + const hostFailed = hostJson && hostJson.ok === false; + const status = response.body?.error || dispatch.exitCode !== 0 || dispatch.status === 'failed' || hostFailed ? 'failed' : 'succeeded'; return { requestId, operationId, traceId, httpStatus: response.status, status, dispatch, hostJson, gatewayBody: response.body }; } @@ -236,7 +248,8 @@ async function main() { const profile = readProfile(selector.podId || parsed.podId); if (selector.kind === 'health') return print({ ok: true, action: 'health', status: 'succeeded', ...profileSummary(profile), route: routeSummary(profile) }); const built = await buildHostCommand(selector, parsed); - const command = `${profile.hostCli} ${built.commandArgs.map(cmdQuote).join(' ')}`; + const hostArgs = ['--profile', hostProfilePath(profile), '--pod-id', profile.podId, ...built.commandArgs]; + const command = `${profile.hostCli} ${hostArgs.map(cmdQuote).join(' ')}`; if (parsed.dryRun) return print({ ok: true, action: 'device-pod.plan', status: 'planned', selector, operation: built.operation, mutating: built.mutating, command, ...profileSummary(profile), route: routeSummary(profile) }); const result = await invoke(profile, command, parsed); const ok = result.status === 'succeeded';