fix: persist and select device-pod profiles

This commit is contained in:
Codex
2026-05-28 02:30:49 +08:00
parent af58fbb42c
commit 7accdd4568
4 changed files with 165 additions and 23 deletions
+9 -2
View File
@@ -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" } }
]
}
}
}
+26 -5
View File
@@ -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/<devicePodId>.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/<devicePodId>.json` when that
directory is set, otherwise from `.device-pod/<devicePodId>.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\\<devicePodId>.json --pod-id <devicePodId>`, 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 <gatewaySessionId>:/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 <gatewaySessionId>:/d/Work/HWLAB-workspace cmd -- node tools\\device-host-cli.mjs health
node /app/tools/tran.mjs <gatewaySessionId>:/d/Work/HWLAB-workspace cmd -- node tools\\device-host-cli.mjs --profile .device-pod\\<devicePodId>.json --pod-id <devicePodId> 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\<devicePodId>.json --pod-id <devicePodId> 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 <jobId>
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,
+114 -13
View File
@@ -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/<devicePodId>.json health',
'node tools/device-host-cli.mjs --pod-id <devicePodId> 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 <path> [--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();
+16 -3
View File
@@ -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';