From 42b53e5795907c30174351f072d669d6603fe587 Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sat, 6 Jun 2026 01:49:05 +0800 Subject: [PATCH] fix: discover preinstalled hwpod specs --- .hwlab/hwpod-spec.meta.json | 12 + .hwlab/hwpod-spec.yaml | 29 +++ internal/agent/prompts/hwlab-v02-runtime.md | 3 + internal/agent/runtime-prompt.test.mjs | 2 + internal/cloud/hwpod-node-ops.test.ts | 81 +++++- internal/cloud/hwpod-spec-discovery.ts | 242 ++++++++++++++++++ internal/cloud/server.ts | 134 ++++++++-- scripts/artifact-publish.mjs | 1 + scripts/g14-ci-plan.test.mjs | 23 ++ scripts/src/g14-ci-plan-lib.mjs | 1 + tools/hwpod-harness.test.ts | 1 + tools/hwpod-node.test.ts | 15 ++ tools/src/hwlab-caserun-lib.ts | 2 +- tools/src/hwpod-harness-lib.ts | 4 +- tools/src/hwpod-node-lib.ts | 28 +- .../components/hwpod/HwpodNodeOpsSidebar.tsx | 36 ++- .../src/services/api/client.ts | 14 +- web/hwlab-cloud-web/src/state/workbench.ts | 5 +- web/hwlab-cloud-web/src/types/domain.ts | 30 +++ 19 files changed, 604 insertions(+), 59 deletions(-) create mode 100644 .hwlab/hwpod-spec.meta.json create mode 100644 .hwlab/hwpod-spec.yaml create mode 100644 internal/cloud/hwpod-spec-discovery.ts diff --git a/.hwlab/hwpod-spec.meta.json b/.hwlab/hwpod-spec.meta.json new file mode 100644 index 00000000..01f670ba --- /dev/null +++ b/.hwlab/hwpod-spec.meta.json @@ -0,0 +1,12 @@ +{ + "contractVersion": "hwpod-spec-registry-v1", + "source": { + "kind": "preinstalled-verified-spec", + "caseRepo": "pikasTech/hwpod-cases", + "caseId": "d601-f103-v2-compile", + "caseSpecPath": "cases/d601-f103-v2-compile/hwpod-spec.yaml", + "verificationIssue": "pikasTech/HWLAB#924", + "verificationPr": "pikasTech/hwpod-cases#1", + "verifiedAt": "2026-06-05" + } +} diff --git a/.hwlab/hwpod-spec.yaml b/.hwlab/hwpod-spec.yaml new file mode 100644 index 00000000..1ebfda69 --- /dev/null +++ b/.hwlab/hwpod-spec.yaml @@ -0,0 +1,29 @@ +apiVersion: hwlab.dev/v0alpha1 +kind: Hwpod +metadata: + uid: D601-F103-V2 + name: d601-f103-v2 +spec: + targetDevice: + board: D601-F103-V2 + mcu: STM32F103 + workspace: + path: "F:\\Work\\D601-HWLAB" + toolchain: keil-mdk + keilProject: projects/01_baseline/Projects/MDK-ARM/atk_f103.uvprojx + keilTarget: USART + keilCliPath: "C:\\Users\\liang\\.agents\\skills\\keil\\keil-cli.py" + debugProbe: + type: daplink + adapter: keil + probeUid: 3FD750C63E342E24 + probeName: MicroLink CMSIS-DAP + programBackend: keil + ioProbe: + uart: + id: uart/1 + port: COM9 + baudrate: 115200 + nodeBinding: + nodeId: node-d601-f103-v2 + nodeType: pc-host diff --git a/internal/agent/prompts/hwlab-v02-runtime.md b/internal/agent/prompts/hwlab-v02-runtime.md index 66ad85b9..e61781e9 100644 --- a/internal/agent/prompts/hwlab-v02-runtime.md +++ b/internal/agent/prompts/hwlab-v02-runtime.md @@ -7,6 +7,7 @@ Use the repo-local resource bundle as the source of runtime rules: - Required skills are mounted from `ResourceBundleRef.skillRefs` into `.agents/skills` for this run. - The expected HWLAB skills are `hwpod-cli`, `hwpod-ctl`, and `hwlab-agent-runtime`. - Use `hwpod` from PATH for HWPOD work. It is the standard `hwpod-cli` entry and must read `.hwlab/hwpod-spec.yaml`, call `hwpod-compiler-cli`, submit `/v1/hwpod-node-ops`, and wait for `hwpod-node` results. +- The runner workspace should include a preinstalled `.hwlab/hwpod-spec.yaml`. When the user asks which HWPOD or `hwpod-spec` is available, first query `${HWLAB_RUNTIME_API_URL}/v1/hwpod/specs?probe=1` or validate the preinstalled spec; report the discovered spec count and names before explaining concepts. - Use `unidesk-ssh` only for UniDesk passthrough tasks that are not covered by HWPOD APIs. Do not use fallback execution paths: @@ -29,3 +30,5 @@ MiniMax-M3 tool-call guidance: - If a command fails because the command arguments were malformed, report the malformed command and retry once with a shorter single-purpose command. When the user asks to compile or operate hardware such as `D601-F103-V2`, start from the HWPOD path: validate `.hwlab/hwpod-spec.yaml` with `hwpod-ctl`, run `hwpod inspect`, then use `hwpod build` / `hwpod download` / `hwpod reset` as needed. If HWPOD returns a named blocker, report and fix that blocker in the HWPOD path. + +When `.hwlab/hwpod-spec.yaml` is missing, do not answer that zero HWPODs are available until `/v1/hwpod/specs?probe=1` also returns an empty list. If discovery returns a spec, initialize or restore the workspace-local spec from that preinstalled/registry source and continue through the HWPOD path. diff --git a/internal/agent/runtime-prompt.test.mjs b/internal/agent/runtime-prompt.test.mjs index 6dffd671..23a44a35 100644 --- a/internal/agent/runtime-prompt.test.mjs +++ b/internal/agent/runtime-prompt.test.mjs @@ -15,5 +15,7 @@ test("HWLAB v0.2 runtime prompt constrains MiniMax-M3 tool calls", async () => { assert.match(prompt, /curl -fsS http:\/\/74\.48\.78\.17:19666\/v1\/skills -o \/tmp\/skills\.json/u); assert.match(prompt, /hwpod-cli/u); assert.match(prompt, /hwpod-ctl/u); + assert.match(prompt, /\/v1\/hwpod\/specs\?probe=1/u); + assert.match(prompt, /preinstalled `.hwlab\/hwpod-spec.yaml`/u); assert.match(prompt, /unidesk-ssh/u); }); diff --git a/internal/cloud/hwpod-node-ops.test.ts b/internal/cloud/hwpod-node-ops.test.ts index f5e9bf1b..bb65f6ef 100644 --- a/internal/cloud/hwpod-node-ops.test.ts +++ b/internal/cloud/hwpod-node-ops.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { test } from "bun:test"; @@ -17,12 +17,67 @@ test("cloud-api exposes hwpod-node-ops contract on /v1", async () => { assert.equal(response.status, 200); assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1"); assert.equal(payload.hwpod.apiRole, "node-ops-forwarder"); + assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs"); assert.ok(payload.hwpod.supportedOps.includes("workspace.ls")); } finally { await close(server); } }); +test("cloud-api discovers preinstalled workspace hwpod-specs without hardcoded device constants", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-discovery-")); + const specDir = path.join(root, ".hwlab"); + await mkdir(specDir, { recursive: true }); + await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8"); + await writeFile(path.join(specDir, "hwpod-spec.meta.json"), JSON.stringify({ source: { kind: "preinstalled-verified-spec", caseRepo: "pikasTech/hwpod-cases", caseId: "d601-f103-v2-compile" } }), "utf8"); + const server = createCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root } }); + await listen(server); + try { + const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs`); + const payload = await response.json(); + assert.equal(response.status, 200); + assert.equal(payload.contractVersion, "hwpod-spec-discovery-v1"); + assert.equal(payload.count, 1); + assert.equal(payload.specs[0].name, "d601-f103-v2"); + assert.equal(payload.specs[0].uid, "D601-F103-V2"); + assert.equal(payload.specs[0].nodeId, "node-d601-f103-v2"); + assert.equal(payload.specs[0].source.caseId, "d601-f103-v2-compile"); + } finally { + await close(server); + await rm(root, { recursive: true, force: true }); + } +}); + +test("cloud-api probes discovered hwpod-spec availability through node-ops", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-hwpod-spec-probe-")); + const specDir = path.join(root, ".hwlab"); + await mkdir(specDir, { recursive: true }); + await writeFile(path.join(specDir, "hwpod-spec.yaml"), sampleSpecYaml(), "utf8"); + const seen: any[] = []; + const server = createCloudApiServer({ + env: { PATH: process.env.PATH, HWLAB_CODE_AGENT_WORKSPACE: root }, + hwpodNodeOpsHandler: async (plan: any) => { + seen.push(plan); + return { ok: true, status: "completed", results: plan.ops.map((op: any) => ({ opId: op.opId, op: op.op, ok: true, status: "completed", output: { path: op.args.workspacePath } })) }; + } + }); + await listen(server); + try { + const response = await fetch(`${serverUrl(server)}/v1/hwpod/specs?probe=1`); + const payload = await response.json(); + assert.equal(response.status, 200); + assert.equal(payload.count, 1); + assert.equal(payload.availableCount, 1); + assert.equal(payload.specs[0].availability.status, "available"); + assert.equal(seen[0].hwpodId, "d601-f103-v2"); + assert.equal(seen[0].nodeId, "node-d601-f103-v2"); + assert.equal(seen[0].ops[0].op, "workspace.ls"); + } finally { + await close(server); + await rm(root, { recursive: true, force: true }); + } +}); + test("cloud-api forwards valid hwpod-node-ops plans to injected node handler", async () => { const seen: any[] = []; const server = createCloudApiServer({ @@ -171,6 +226,30 @@ function samplePlan() { }; } +function sampleSpecYaml() { + return `apiVersion: hwlab.dev/v0alpha1 +kind: Hwpod +metadata: + uid: D601-F103-V2 + name: d601-f103-v2 +spec: + targetDevice: + board: D601-F103-V2 + mcu: STM32F103 + workspace: + path: "F:\\\\Work\\\\D601-HWLAB" + toolchain: keil-mdk + debugProbe: + type: daplink + ioProbe: + uart: + port: COM9 + nodeBinding: + nodeId: node-d601-f103-v2 + nodeType: pc-host +`; +} + async function listen(server: any) { await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); } diff --git a/internal/cloud/hwpod-spec-discovery.ts b/internal/cloud/hwpod-spec-discovery.ts new file mode 100644 index 00000000..0f72f16f --- /dev/null +++ b/internal/cloud/hwpod-spec-discovery.ts @@ -0,0 +1,242 @@ +import { readdir, readFile, stat } from "node:fs/promises"; +import path from "node:path"; + +import { HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; +import { readHwpodSpec } from "../../tools/src/hwpod-harness-lib.ts"; + +const HWPOD_SPEC_DISCOVERY_CONTRACT_VERSION = "hwpod-spec-discovery-v1"; +const DEFAULT_HWPOD_SPEC_PATH = ".hwlab/hwpod-spec.yaml"; +const DEFAULT_HWPOD_SPEC_REGISTRY_DIR = ".hwlab/hwpod-specs"; + +export { HWPOD_SPEC_DISCOVERY_CONTRACT_VERSION }; + +export async function discoverHwpodSpecs(options: any = {}) { + const env = options.env ?? process.env; + const workspace = text(env.HWLAB_CODE_AGENT_CODEX_WORKSPACE) || text(env.HWLAB_CODE_AGENT_WORKSPACE) || process.cwd(); + const roots = uniquePaths([workspace, process.cwd(), options.repoRoot].filter(Boolean)); + const explicitPaths = splitEnvList(env.HWLAB_HWPOD_SPEC_PATHS).map((value) => resolveMaybeRelative(value, process.cwd())); + const explicitDirs = splitEnvList(env.HWLAB_HWPOD_SPEC_REGISTRY_DIRS).map((value) => resolveMaybeRelative(value, process.cwd())); + const candidatePaths = uniquePaths([ + ...explicitPaths, + ...roots.map((root) => path.resolve(root, DEFAULT_HWPOD_SPEC_PATH)), + ...await candidateRegistrySpecPaths(uniquePaths([ + ...explicitDirs, + ...roots.map((root) => path.resolve(root, DEFAULT_HWPOD_SPEC_REGISTRY_DIR)) + ])) + ]); + const discovered = []; + for (const specPath of candidatePaths) { + const item = await readSpecCandidate(specPath); + if (item) discovered.push(item); + } + return dedupeSpecs(discovered); +} + +export function hwpodSpecDiscoveryPayload(specs: any[], options: any = {}) { + const availableCount = specs.filter((item) => item.availability?.ok === true).length; + const probed = specs.some((item) => item.availability?.status !== "unprobed"); + return { + ok: true, + status: specs.length === 0 ? "empty" : "completed", + contractVersion: HWPOD_SPEC_DISCOVERY_CONTRACT_VERSION, + nodeOpsContractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, + route: "/v1/hwpod/specs", + specAuthority: "workspace-or-registry", + count: specs.length, + availableCount: probed ? availableCount : null, + specs, + observedAt: options.observedAt ?? new Date().toISOString() + }; +} + +export function hwpodSpecWorkspaceProbePlan(spec: any, options: any = {}) { + const document = spec.document ?? spec; + const metadata = objectValue(document.metadata); + const body = objectValue(document.spec); + const nodeBinding = objectValue(body.nodeBinding); + const workspace = objectValue(body.workspace); + const hwpodId = text(metadata.name) || text(metadata.uid); + const nodeId = text(nodeBinding.nodeId); + return { + contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, + planId: options.planId ?? `hwpod_spec_probe_${safeId(hwpodId || nodeId || "spec")}_${Date.now()}`, + hwpodId, + nodeId, + intent: "workspace.ls", + source: { + compiler: "hwpod-spec-discovery", + specPath: spec.specPath ?? options.specPath, + specAuthority: spec.authority ?? "workspace-or-registry" + }, + resourceHints: { + workspacePath: workspace.path, + targetDevice: body.targetDevice, + debugProbe: body.debugProbe, + ioProbe: body.ioProbe + }, + ops: [ + { + opId: "op_01_workspace_ls", + op: "workspace.ls", + args: { + hwpodId, + workspacePath: workspace.path, + path: ".", + targetDevice: body.targetDevice, + debugProbe: body.debugProbe, + ioProbe: body.ioProbe + } + } + ] + }; +} + +async function candidateRegistrySpecPaths(dirs: string[]) { + const paths: string[] = []; + for (const dir of dirs) { + try { + const entries = await readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!/\.ya?ml$/iu.test(entry.name)) continue; + paths.push(path.join(dir, entry.name)); + } + } catch (error) { + if (error?.code !== "ENOENT" && error?.code !== "ENOTDIR") throw error; + } + } + return paths; +} + +async function readSpecCandidate(specPath: string) { + try { + const info = await stat(specPath); + if (!info.isFile()) return null; + const document = await readHwpodSpec(specPath); + const metadata = await readSpecMetadata(specPath); + return summarizeSpec({ document, specPath, metadata }); + } catch (error) { + if (error?.code === "ENOENT" || error?.code === "ENOTDIR") return null; + return { + ok: false, + status: "invalid", + specPath, + error: { + code: error?.code ?? "invalid_hwpod_spec", + message: error?.message ?? String(error) + }, + availability: { ok: false, status: "invalid", checkedAt: new Date().toISOString() } + }; + } +} + +async function readSpecMetadata(specPath: string) { + for (const candidate of metadataCandidates(specPath)) { + try { + const parsed = JSON.parse(await readFile(candidate, "utf8")); + return isPlainObject(parsed) ? { ...parsed, metadataPath: candidate } : { metadataPath: candidate }; + } catch (error) { + if (error?.code !== "ENOENT") return { metadataPath: candidate, metadataError: error?.message ?? String(error) }; + } + } + return {}; +} + +function metadataCandidates(specPath: string) { + const parsed = path.parse(specPath); + const basename = parsed.name === "hwpod-spec" ? "hwpod-spec.meta.json" : `${parsed.name}.meta.json`; + return [path.join(parsed.dir, basename), `${specPath}.meta.json`]; +} + +function summarizeSpec({ document, specPath, metadata }: { document: any; specPath: string; metadata: any }) { + const docMetadata = objectValue(document.metadata); + const body = objectValue(document.spec); + const nodeBinding = objectValue(body.nodeBinding); + const workspace = objectValue(body.workspace); + const name = text(docMetadata.name) || text(docMetadata.uid); + const uid = text(docMetadata.uid) || null; + const source = clean({ + kind: text(metadata?.source?.kind) || "workspace-or-registry", + caseRepo: text(metadata?.source?.caseRepo), + caseId: text(metadata?.source?.caseId), + caseSpecPath: text(metadata?.source?.caseSpecPath), + verificationIssue: text(metadata?.source?.verificationIssue), + verificationPr: text(metadata?.source?.verificationPr), + verifiedAt: text(metadata?.source?.verifiedAt), + metadataPath: text(metadata?.metadataPath), + metadataError: text(metadata?.metadataError) + }); + return { + ok: true, + status: "discovered", + name, + hwpodId: name, + uid, + metadata: docMetadata, + specPath, + authority: source.kind, + source, + nodeBinding, + nodeId: text(nodeBinding.nodeId), + nodeType: text(nodeBinding.nodeType) || null, + workspace, + workspacePath: text(workspace.path), + targetDevice: body.targetDevice, + debugProbe: body.debugProbe, + ioProbe: body.ioProbe, + document, + availability: { ok: null, status: "unprobed", checkedAt: null } + }; +} + +function dedupeSpecs(specs: any[]) { + const byKey = new Map(); + for (const spec of specs) { + const key = spec.ok === false + ? `invalid:${spec.specPath}` + : [spec.name || spec.uid, spec.nodeId, spec.workspacePath].filter(Boolean).join("|") || spec.specPath; + const previous = byKey.get(key); + if (!previous || specPriority(spec) > specPriority(previous)) byKey.set(key, spec); + } + return Array.from(byKey.values()).sort((left, right) => String(left.name ?? left.specPath).localeCompare(String(right.name ?? right.specPath))); +} + +function specPriority(spec: any) { + const sourceKind = text(spec.source?.kind); + const specPath = text(spec.specPath); + if (specPath.endsWith(DEFAULT_HWPOD_SPEC_PATH)) return 30; + if (sourceKind.includes("preinstalled")) return 20; + return 10; +} + +function splitEnvList(value: unknown) { + return String(value ?? "").split(/[,\n]/u).map((item) => item.trim()).filter(Boolean); +} + +function resolveMaybeRelative(value: string, base: string) { + return path.isAbsolute(value) ? value : path.resolve(base, value); +} + +function uniquePaths(values: string[]) { + return Array.from(new Set(values.map((value) => path.resolve(value)))); +} + +function safeId(value: string) { + return String(value || "spec").replace(/[^A-Za-z0-9_.:-]+/gu, "_").slice(0, 80) || "spec"; +} + +function text(value: unknown) { + return typeof value === "string" && value.trim() ? value.trim() : ""; +} + +function objectValue(value: any) { + return isPlainObject(value) ? value : {}; +} + +function isPlainObject(value: any) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function clean(value: Record) { + return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null)); +} diff --git a/internal/cloud/server.ts b/internal/cloud/server.ts index e5ea6bac..07dc5017 100644 --- a/internal/cloud/server.ts +++ b/internal/cloud/server.ts @@ -1,6 +1,6 @@ import { createServer } from "node:http"; import { randomUUID } from "node:crypto"; -import { existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; +import { copyFileSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs"; import path from "node:path"; import { @@ -69,6 +69,11 @@ import { ensureCodexSkillsAggregationSync } from "./skills-store.ts"; import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts"; +import { + discoverHwpodSpecs, + hwpodSpecDiscoveryPayload, + hwpodSpecWorkspaceProbePlan +} from "./hwpod-spec-discovery.ts"; import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; @@ -153,12 +158,24 @@ export function ensureCodeAgentRuntimeBase(env = process.env) { } } lstatSync(workspace); + ensurePreinstalledHwpodSpec({ workspace }); ensureCodexSkillsAggregationSync(skillRuntime); } catch { // Runtime health reports the concrete missing/writable blocker. } } +function ensurePreinstalledHwpodSpec({ workspace }) { + const source = path.resolve(process.cwd(), ".hwlab/hwpod-spec.yaml"); + const target = path.join(workspace, ".hwlab/hwpod-spec.yaml"); + const sourceMeta = path.resolve(process.cwd(), ".hwlab/hwpod-spec.meta.json"); + const targetMeta = path.join(workspace, ".hwlab/hwpod-spec.meta.json"); + if (!existsSync(source)) return; + mkdirSync(path.dirname(target), { recursive: true }); + if (!existsSync(target)) copyFileSync(source, target); + if (existsSync(sourceMeta) && !existsSync(targetMeta)) copyFileSync(sourceMeta, targetMeta); +} + export async function buildHealthPayload(options = {}) { const serviceId = CLOUD_API_SERVICE_ID; const env = options.env ?? process.env; @@ -319,8 +336,9 @@ async function handleRestAdapter(request, response, url, options) { blockerCodes: readiness.blockerCodes, hwpod: { route: "/v1/hwpod-node-ops", + specDiscoveryRoute: "/v1/hwpod/specs", contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, - specAuthority: "code-agent-workspace", + specAuthority: "workspace-or-registry", compiler: "hwpod-compiler-cli", apiRole: "node-ops-forwarder", nodeRole: "thin-hwpod-node-executor", @@ -425,6 +443,11 @@ async function handleRestAdapter(request, response, url, options) { return; } + if (request.method === "GET" && url.pathname === "/v1/hwpod/specs") { + await handleHwpodSpecDiscoveryHttp(request, response, url, options); + return; + } + if (request.method === "GET" && url.pathname === "/v1/hwpod-node/ws") { sendJson(response, 426, { ok: false, status: "upgrade_required", route: "/v1/hwpod-node/ws", websocket: options.hwpodNodeWsRegistry.describe() }); return; @@ -631,38 +654,93 @@ async function handleHwpodNodeOpsHttp(request, response, options) { serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID, environment: runtimeEnvironment(options.env ?? process.env) }; - const hwpodNodeOpsUrl = normalizedHwpodNodeOpsUrl(options.env ?? process.env); - const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry; - if (typeof options.hwpodNodeOpsHandler !== "function" && !hwpodNodeWsRegistry?.hasNode?.(plan.nodeId) && !hwpodNodeOpsUrl) { - sendJson(response, 200, hwpodNodeOpsBlockedPayload(plan, requestMeta, "no outbound WebSocket hwpod-node is connected and HWLAB_HWPOD_NODE_OPS_URL is not configured; cloud-api is only validating the hwpod-node-ops contract")); - return; - } - try { - const handled = typeof options.hwpodNodeOpsHandler === "function" - ? await options.hwpodNodeOpsHandler(plan, { request, requestMeta, env: options.env ?? process.env }) - : hwpodNodeWsRegistry?.hasNode?.(plan.nodeId) - ? await hwpodNodeWsRegistry.dispatch(plan, requestMeta, { timeoutMs: parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000) }) - : await forwardHwpodNodeOpsPlan(hwpodNodeOpsUrl, plan, requestMeta, options); - const results = Array.isArray(handled?.results) ? handled.results : []; - const failed = results.some((item) => item?.ok === false); - sendJson(response, handled?.httpStatus ?? (failed ? 409 : 200), { - ok: handled?.ok ?? !failed, - status: handled?.status ?? (failed ? "failed" : "completed"), - contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, - planId: plan.planId, - hwpodId: plan.hwpodId, - nodeId: plan.nodeId, - acceptedOps: plan.ops.length, - results, - blocker: handled?.blocker ?? null, - requestMeta - }); + const handled = await dispatchHwpodNodeOpsPlan(plan, requestMeta, options, { request }); + sendJson(response, handled.httpStatus, handled.payload); } catch (error) { sendJson(response, 200, hwpodNodeOpsBlockedPayload(plan, requestMeta, error?.message ?? "hwpod-node-ops handler failed")); } } +async function handleHwpodSpecDiscoveryHttp(request, response, url, options) { + const probe = truthyFlag(url.searchParams.get("probe")); + const observedAt = new Date().toISOString(); + let specs = await discoverHwpodSpecs(options); + if (probe) { + specs = await Promise.all(specs.map((spec) => probeDiscoveredHwpodSpec(spec, { request, options, observedAt }))); + } + sendJson(response, 200, hwpodSpecDiscoveryPayload(specs, { observedAt })); +} + +async function probeDiscoveredHwpodSpec(spec, { request, options, observedAt }) { + if (spec.ok === false) return spec; + const plan = hwpodSpecWorkspaceProbePlan(spec); + const validation = validateHwpodNodeOpsPlan(plan); + if (!validation.ok) { + return { + ...spec, + availability: { + ok: false, + status: "invalid_probe_plan", + checkedAt: observedAt, + blocker: validation.error + } + }; + } + const requestMeta = { + requestId: getHeader(request, "x-request-id") || `req_hwpod_spec_${randomUUID()}`, + traceId: getHeader(request, "x-trace-id") || `trc_hwpod_spec_${randomUUID()}`, + serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID, + environment: runtimeEnvironment(options.env ?? process.env) + }; + const handled = await dispatchHwpodNodeOpsPlan(validation.plan, requestMeta, options, { request }); + const payload = handled.payload; + return { + ...spec, + availability: { + ok: payload.ok === true, + status: payload.ok === true ? "available" : "blocked", + checkedAt: observedAt, + probePlanId: payload.planId, + nodeOpsRoute: "/v1/hwpod-node-ops", + results: payload.results ?? [], + blocker: payload.blocker ?? null, + httpStatus: handled.httpStatus + } + }; +} + +async function dispatchHwpodNodeOpsPlan(plan, requestMeta, options, context = {}) { + const hwpodNodeOpsUrl = normalizedHwpodNodeOpsUrl(options.env ?? process.env); + const hwpodNodeWsRegistry = options.hwpodNodeWsRegistry; + if (typeof options.hwpodNodeOpsHandler !== "function" && !hwpodNodeWsRegistry?.hasNode?.(plan.nodeId) && !hwpodNodeOpsUrl) { + return { + httpStatus: 200, + payload: hwpodNodeOpsBlockedPayload(plan, requestMeta, "no outbound WebSocket hwpod-node is connected and HWLAB_HWPOD_NODE_OPS_URL is not configured; cloud-api is only validating the hwpod-node-ops contract") + }; + } + const handled = typeof options.hwpodNodeOpsHandler === "function" + ? await options.hwpodNodeOpsHandler(plan, { request: context.request, requestMeta, env: options.env ?? process.env }) + : hwpodNodeWsRegistry?.hasNode?.(plan.nodeId) + ? await hwpodNodeWsRegistry.dispatch(plan, requestMeta, { timeoutMs: parsePositiveInteger(options.env?.HWLAB_HWPOD_NODE_OPS_TIMEOUT_MS, 30000) }) + : await forwardHwpodNodeOpsPlan(hwpodNodeOpsUrl, plan, requestMeta, options); + const results = Array.isArray(handled?.results) ? handled.results : []; + const failed = results.some((item) => item?.ok === false); + const payload = { + ok: handled?.ok ?? !failed, + status: handled?.status ?? (failed ? "failed" : "completed"), + contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, + planId: plan.planId, + hwpodId: plan.hwpodId, + nodeId: plan.nodeId, + acceptedOps: plan.ops.length, + results, + blocker: handled?.blocker ?? null, + requestMeta + }; + return { httpStatus: handled?.httpStatus ?? (failed ? 409 : 200), payload }; +} + async function forwardHwpodNodeOpsPlan(targetUrl, plan, requestMeta, options) { const response = await fetch(targetUrl, { method: "POST", diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index 54d1d539..8113bae9 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -788,6 +788,7 @@ function dockerfile(baseImage, port, labels = []) { "COPY web ./web", "COPY tools ./tools", "COPY skills ./skills", + "COPY .hwlab ./.hwlab", "COPY deploy ./deploy", "RUN printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-cli.ts \"$@\"' > /usr/local/bin/hwpod && chmod 755 /usr/local/bin/hwpod && printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-ctl.ts \"$@\"' > /usr/local/bin/hwpod-ctl && chmod 755 /usr/local/bin/hwpod-ctl && printf '%s\\n' '#!/usr/bin/env sh' 'exec node /app/scripts/run-bun.mjs /app/tools/hwpod-compiler-cli.ts \"$@\"' > /usr/local/bin/hwpod-compiler && chmod 755 /usr/local/bin/hwpod-compiler", `RUN ${runtimeReadinessScript}`, diff --git a/scripts/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs index 3d9d7d1a..e1a6cc0a 100644 --- a/scripts/g14-ci-plan.test.mjs +++ b/scripts/g14-ci-plan.test.mjs @@ -168,9 +168,30 @@ test("component model uses built-in service paths", () => { "internal/db/", "skills/hwlab-agent-runtime/" ]); + assert.equal(models[0].sharedPaths.includes(".hwlab/"), true); assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]); }); +test("v02 planner treats preinstalled hwpod-spec changes as shared runtime inputs", async () => { + const repo = await createFixtureRepo(); + await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n name: changed-preinstalled\nspec:\n nodeBinding:\n nodeId: node-changed\n workspace:\n path: F:\\\\Work\\\\D601-HWLAB\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n", "utf8"); + await git(repo, ["add", ".hwlab/hwpod-spec.yaml"]); + await git(repo, ["commit", "-m", "change preinstalled hwpod spec"]); + + const plan = await createG14CiPlan({ + repoRoot: repo, + lane: "v02", + baseRef: "HEAD~1", + targetRef: "HEAD", + artifactCatalogPath: "deploy/artifact-catalog.v02.json", + services: ["hwlab-cloud-api", "hwlab-cloud-web"] + }); + assert.deepEqual(plan.affectedServices, ["hwlab-cloud-api", "hwlab-cloud-web"]); + assert.deepEqual(plan.buildServices, ["hwlab-cloud-api"]); + assert.deepEqual(plan.services.find((service) => service.serviceId === "hwlab-cloud-api").reason, ["shared-runtime-changed"]); + assert.equal(plan.services.find((service) => service.serviceId === "hwlab-cloud-web").codeChanged, true); +}); + test("v02 planner treats AgentRun runtime prompt changes as cloud-api inputs", async () => { const repo = await createFixtureRepo(); await mkdir(path.join(repo, "internal/agent/prompts"), { recursive: true }); @@ -475,6 +496,7 @@ async function createFixtureRepo(options = {}) { await mkdir(path.join(repo, "skills/hwpod-cli"), { recursive: true }); await mkdir(path.join(repo, "skills/hwpod-ctl"), { recursive: true }); await mkdir(path.join(repo, "skills/hwlab-agent-runtime"), { recursive: true }); + await mkdir(path.join(repo, ".hwlab"), { recursive: true }); await mkdir(path.join(repo, "deploy/runtime/boot"), { recursive: true }); await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true }); await mkdir(path.join(repo, "deploy"), { recursive: true }); @@ -501,6 +523,7 @@ async function createFixtureRepo(options = {}) { await writeFile(path.join(repo, "tools/src/runtime-endpoint-resolver.ts"), "export const endpoint = true;\n"); await writeFile(path.join(repo, "skills/hwpod-cli/SKILL.md"), "hwpod cli skill\n"); await writeFile(path.join(repo, "skills/hwpod-ctl/SKILL.md"), "hwpod ctl skill\n"); + await writeFile(path.join(repo, ".hwlab/hwpod-spec.yaml"), "kind: Hwpod\nmetadata:\n name: d601-f103-v2\nspec:\n nodeBinding:\n nodeId: node-d601-f103-v2\n workspace:\n path: F:\\\\Work\\\\D601-HWLAB\n targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n"); await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-cloud-web.sh"), "#!/bin/sh\nbun run --cwd web/hwlab-cloud-web build\nexec bun .hwlab-cloud-web-runtime.mjs\n"); await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n"); await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n"); diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index 00f8625c..ea1127bb 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -43,6 +43,7 @@ export const DEFAULT_RUNTIME_PACKAGE_FIELDS = Object.freeze([ ]); export const DEFAULT_SHARED_RUNTIME_PATHS = Object.freeze([ + ".hwlab/", "internal/protocol/", "internal/build-metadata.mjs", "tools/hwpod-cli.ts", diff --git a/tools/hwpod-harness.test.ts b/tools/hwpod-harness.test.ts index 9208fc7f..1fa8cc57 100644 --- a/tools/hwpod-harness.test.ts +++ b/tools/hwpod-harness.test.ts @@ -69,6 +69,7 @@ test("hwpod-compiler-cli compiles workspace-local spec into node ops", async () await runHwpodCtl(["spec", "set", "spec.workspace.buildCommand", "printf spec-build", "--spec", specPath], { now: () => NOW }); const build = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build"], { now: () => NOW }); assert.equal(build.exitCode, 0); + assert.equal(build.payload.plan.hwpodId, "local-hwpod"); assert.equal(build.payload.plan.ops[0].op, "debug.build"); assert.equal(build.payload.plan.ops[0].args.command, "printf spec-build"); } finally { diff --git a/tools/hwpod-node.test.ts b/tools/hwpod-node.test.ts index dc158e10..623aa8c9 100644 --- a/tools/hwpod-node.test.ts +++ b/tools/hwpod-node.test.ts @@ -107,3 +107,18 @@ test("hwpod-node applies workspace patches through the stable node op", async () await rm(root, { recursive: true, force: true }); } }); + +test("hwpod-node preserves Windows absolute workspace paths for PC-host nodes", async () => { + const result = await executeHwpodNodeOpsPlan({ + contractVersion: "hwpod-node-ops-v1", + planId: "hwpod_plan_windows_path", + hwpodId: "d601-f103-v2", + nodeId: "node-d601-f103-v2", + ops: [{ opId: "op_01", op: "workspace.ls", args: { workspacePath: "F:\\Work\\D601-HWLAB", path: "." } }] + }, { now: () => "2026-06-05T00:00:00.000Z" }); + + assert.equal(result.ok, false); + assert.equal(result.results[0].blocker.code, "ENOENT"); + assert.match(result.results[0].blocker.summary, /scandir 'F:\\Work\\D601-HWLAB'/u); + assert.doesNotMatch(result.results[0].blocker.summary, /\/root\//u); +}); diff --git a/tools/src/hwlab-caserun-lib.ts b/tools/src/hwlab-caserun-lib.ts index db42290a..d1be44e5 100644 --- a/tools/src/hwlab-caserun-lib.ts +++ b/tools/src/hwlab-caserun-lib.ts @@ -259,7 +259,7 @@ async function requestKeilJobStatus(context: CaseContext, run: PreparedCaseRun, const keilCliPath = text(document.spec.workspace.keilCliPath) || text(document.spec.debugProbe.keilCliPath) || "keil-cli.py"; const pythonCommand = text(document.spec.workspace.pythonCommand) || text(document.spec.debugProbe.pythonCommand) || "py -3"; const jobStatusCommand = jobStatusCommandForTest(pythonCommand, keilCliPath, jobId); - const hwpodId = text(document.metadata.uid) || text(document.metadata.name) || run.caseId; + const hwpodId = text(document.metadata.name) || text(document.metadata.uid) || run.caseId; const plan = { contractVersion: "hwpod-node-ops-v1", planId: `case_job_${randomUUID()}`, diff --git a/tools/src/hwpod-harness-lib.ts b/tools/src/hwpod-harness-lib.ts index f34da71d..b9f6a57a 100644 --- a/tools/src/hwpod-harness-lib.ts +++ b/tools/src/hwpod-harness-lib.ts @@ -108,7 +108,7 @@ export async function readHwpodSpec(specPath = DEFAULT_HWPOD_SPEC_PATH) { export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, intent, args = {}, now = () => new Date().toISOString() }: any) { const normalizedIntent = normalizeIntent(intent); const nodeId = document.spec.nodeBinding.nodeId; - const hwpodId = document.metadata.uid || document.metadata.name; + const hwpodId = document.metadata.name || document.metadata.uid; const ops = opsForIntent(normalizedIntent, args, document).map((operation: any, index: number) => ({ opId: operation.opId ?? `op_${String(index + 1).padStart(2, "0")}_${operation.op.replace(/[^a-z0-9]+/giu, "_")}`, ...operation @@ -334,7 +334,7 @@ function opsForIntent(intent: string, args: any, document: any) { function commonOpArgs(document: any) { return { - hwpodId: document.metadata.uid || document.metadata.name, + hwpodId: document.metadata.name || document.metadata.uid, workspacePath: document.spec.workspace.path, targetDevice: document.spec.targetDevice, debugProbe: document.spec.debugProbe, diff --git a/tools/src/hwpod-node-lib.ts b/tools/src/hwpod-node-lib.ts index 0fee63d5..b55a698d 100644 --- a/tools/src/hwpod-node-lib.ts +++ b/tools/src/hwpod-node-lib.ts @@ -448,18 +448,40 @@ async function spawnOutput(command: string[], { cwd, stdinText = "", timeoutMs } } function workspaceRoot(args: any) { - return path.resolve(text(args.workspacePath) || process.env.HWPOD_WORKSPACE_PATH || process.cwd()); + const workspacePath = text(args.workspacePath) || process.env.HWPOD_WORKSPACE_PATH || process.cwd(); + return isWindowsAbsolutePath(workspacePath) ? normalizeWindowsPath(workspacePath) : path.resolve(workspacePath); } function resolveWorkspacePath(args: any, relativePath: string) { const root = workspaceRoot(args); - const target = path.resolve(root, relativePath); - if (target !== root && !target.startsWith(`${root}${path.sep}`)) { + const target = resolveWorkspaceChild(root, relativePath); + const separator = windowsPathKind(root) ? "\\" : path.sep; + if (target !== root && !target.startsWith(`${root}${separator}`)) { throw cliError("workspace_path_outside_root", "workspace path must stay inside workspacePath", { workspacePath: root, path: relativePath }); } return target; } +function resolveWorkspaceChild(root: string, relativePath: string) { + const normalizedRelative = text(relativePath) || "."; + if (!windowsPathKind(root)) return path.resolve(root, normalizedRelative); + if (normalizedRelative === ".") return root; + if (isWindowsAbsolutePath(normalizedRelative) || normalizedRelative.startsWith("/")) return normalizeWindowsPath(normalizedRelative); + return `${root.replace(/[\\/]+$/u, "")}\\${normalizedRelative.replace(/[\\/]+/gu, "\\").replace(/^[\\/]+/u, "")}`; +} + +function windowsPathKind(value: string) { + return /^[A-Za-z]:[\\/]/u.test(value) || value.startsWith("\\\\"); +} + +function isWindowsAbsolutePath(value: string) { + return windowsPathKind(String(value ?? "").trim()); +} + +function normalizeWindowsPath(value: string) { + return String(value).replace(/\//gu, "\\").replace(/[\\]+$/u, ""); +} + function planFromParsed(parsed: ParsedArgs, stdinText?: string) { if (parsed.planJson) return JSON.parse(String(parsed.planJson)); if (parsed.plan) return JSON.parse(String(parsed.plan)); diff --git a/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsSidebar.tsx b/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsSidebar.tsx index 10a31c09..4c3d541f 100644 --- a/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsSidebar.tsx +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsSidebar.tsx @@ -2,7 +2,7 @@ import type { ReactElement } from "react"; import { useEffect, useRef, useState } from "react"; import type { HwpodNodeOpsResult, LiveSurface } from "../../types/domain"; -import { formatTimestamp, jsonPreview, shortToken, toneClass } from "../../utils"; +import { formatTimestamp, jsonPreview, toneClass } from "../../utils"; import { StateTag } from "../shared/StateTag"; interface HwpodNodeOpsSidebarProps { @@ -13,10 +13,14 @@ interface HwpodNodeOpsSidebarProps { export function HwpodNodeOpsSidebar({ live, collapsed }: HwpodNodeOpsSidebarProps): ReactElement { const [dialog, setDialog] = useState<{ title: string; body: string } | null>(null); const payload = live?.hwpodNodeOps?.data ?? null; - const results = payload?.results ?? []; + const specPayload = live?.hwpodSpecs?.data ?? null; + const selectedSpec = specPayload?.specs?.find((item) => item.availability?.ok === true) ?? specPayload?.specs?.[0] ?? null; + const specResults = selectedSpec?.availability?.results ?? []; + const results = specResults.length > 0 ? specResults : payload?.results ?? []; const firstResult = results[0] ?? null; - const status = payload?.status ?? (live?.hwpodNodeOps?.ok ? "completed" : live?.hwpodNodeOps?.error ? "blocked" : "unknown"); - const nodeId = payload?.nodeId ?? (payload?.requestMeta?.nodeId as string | undefined) ?? "web-selected-node"; + const status = selectedSpec?.availability?.status ?? payload?.status ?? (live?.hwpodNodeOps?.ok ? "completed" : live?.hwpodNodeOps?.error ? "blocked" : "unknown"); + const nodeId = selectedSpec?.nodeId ?? payload?.nodeId ?? (payload?.requestMeta?.nodeId as string | undefined) ?? "未发现"; + const hwpodName = selectedSpec?.name ?? selectedSpec?.hwpodId ?? payload?.hwpodId ?? "未发现"; return (