fix: discover preinstalled hwpod specs
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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.
|
- 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`.
|
- 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.
|
- 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.
|
- Use `unidesk-ssh` only for UniDesk passthrough tasks that are not covered by HWPOD APIs.
|
||||||
|
|
||||||
Do not use fallback execution paths:
|
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.
|
- 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 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.
|
||||||
|
|||||||
@@ -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, /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-cli/u);
|
||||||
assert.match(prompt, /hwpod-ctl/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);
|
assert.match(prompt, /unidesk-ssh/u);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import assert from "node:assert/strict";
|
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 os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { test } from "bun:test";
|
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(response.status, 200);
|
||||||
assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1");
|
assert.equal(payload.hwpod.contractVersion, "hwpod-node-ops-v1");
|
||||||
assert.equal(payload.hwpod.apiRole, "node-ops-forwarder");
|
assert.equal(payload.hwpod.apiRole, "node-ops-forwarder");
|
||||||
|
assert.equal(payload.hwpod.specDiscoveryRoute, "/v1/hwpod/specs");
|
||||||
assert.ok(payload.hwpod.supportedOps.includes("workspace.ls"));
|
assert.ok(payload.hwpod.supportedOps.includes("workspace.ls"));
|
||||||
} finally {
|
} finally {
|
||||||
await close(server);
|
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 () => {
|
test("cloud-api forwards valid hwpod-node-ops plans to injected node handler", async () => {
|
||||||
const seen: any[] = [];
|
const seen: any[] = [];
|
||||||
const server = createCloudApiServer({
|
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) {
|
async function listen(server: any) {
|
||||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<string, unknown>) {
|
||||||
|
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined && item !== "" && item !== null));
|
||||||
|
}
|
||||||
+106
-28
@@ -1,6 +1,6 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
import { randomUUID } from "node:crypto";
|
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 path from "node:path";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -69,6 +69,11 @@ import {
|
|||||||
ensureCodexSkillsAggregationSync
|
ensureCodexSkillsAggregationSync
|
||||||
} from "./skills-store.ts";
|
} from "./skills-store.ts";
|
||||||
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.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";
|
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
|
||||||
|
|
||||||
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
||||||
@@ -153,12 +158,24 @@ export function ensureCodeAgentRuntimeBase(env = process.env) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
lstatSync(workspace);
|
lstatSync(workspace);
|
||||||
|
ensurePreinstalledHwpodSpec({ workspace });
|
||||||
ensureCodexSkillsAggregationSync(skillRuntime);
|
ensureCodexSkillsAggregationSync(skillRuntime);
|
||||||
} catch {
|
} catch {
|
||||||
// Runtime health reports the concrete missing/writable blocker.
|
// 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 = {}) {
|
export async function buildHealthPayload(options = {}) {
|
||||||
const serviceId = CLOUD_API_SERVICE_ID;
|
const serviceId = CLOUD_API_SERVICE_ID;
|
||||||
const env = options.env ?? process.env;
|
const env = options.env ?? process.env;
|
||||||
@@ -319,8 +336,9 @@ async function handleRestAdapter(request, response, url, options) {
|
|||||||
blockerCodes: readiness.blockerCodes,
|
blockerCodes: readiness.blockerCodes,
|
||||||
hwpod: {
|
hwpod: {
|
||||||
route: "/v1/hwpod-node-ops",
|
route: "/v1/hwpod-node-ops",
|
||||||
|
specDiscoveryRoute: "/v1/hwpod/specs",
|
||||||
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||||
specAuthority: "code-agent-workspace",
|
specAuthority: "workspace-or-registry",
|
||||||
compiler: "hwpod-compiler-cli",
|
compiler: "hwpod-compiler-cli",
|
||||||
apiRole: "node-ops-forwarder",
|
apiRole: "node-ops-forwarder",
|
||||||
nodeRole: "thin-hwpod-node-executor",
|
nodeRole: "thin-hwpod-node-executor",
|
||||||
@@ -425,6 +443,11 @@ async function handleRestAdapter(request, response, url, options) {
|
|||||||
return;
|
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") {
|
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() });
|
sendJson(response, 426, { ok: false, status: "upgrade_required", route: "/v1/hwpod-node/ws", websocket: options.hwpodNodeWsRegistry.describe() });
|
||||||
return;
|
return;
|
||||||
@@ -631,38 +654,93 @@ async function handleHwpodNodeOpsHttp(request, response, options) {
|
|||||||
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
serviceId: getHeader(request, "x-source-service-id") || CLOUD_API_SERVICE_ID,
|
||||||
environment: runtimeEnvironment(options.env ?? process.env)
|
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 {
|
try {
|
||||||
const handled = typeof options.hwpodNodeOpsHandler === "function"
|
const handled = await dispatchHwpodNodeOpsPlan(plan, requestMeta, options, { request });
|
||||||
? await options.hwpodNodeOpsHandler(plan, { request, requestMeta, env: options.env ?? process.env })
|
sendJson(response, handled.httpStatus, handled.payload);
|
||||||
: 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
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
sendJson(response, 200, hwpodNodeOpsBlockedPayload(plan, requestMeta, error?.message ?? "hwpod-node-ops handler failed"));
|
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) {
|
async function forwardHwpodNodeOpsPlan(targetUrl, plan, requestMeta, options) {
|
||||||
const response = await fetch(targetUrl, {
|
const response = await fetch(targetUrl, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -788,6 +788,7 @@ function dockerfile(baseImage, port, labels = []) {
|
|||||||
"COPY web ./web",
|
"COPY web ./web",
|
||||||
"COPY tools ./tools",
|
"COPY tools ./tools",
|
||||||
"COPY skills ./skills",
|
"COPY skills ./skills",
|
||||||
|
"COPY .hwlab ./.hwlab",
|
||||||
"COPY deploy ./deploy",
|
"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 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}`,
|
`RUN ${runtimeReadinessScript}`,
|
||||||
|
|||||||
@@ -168,9 +168,30 @@ test("component model uses built-in service paths", () => {
|
|||||||
"internal/db/",
|
"internal/db/",
|
||||||
"skills/hwlab-agent-runtime/"
|
"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"]);
|
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 () => {
|
test("v02 planner treats AgentRun runtime prompt changes as cloud-api inputs", async () => {
|
||||||
const repo = await createFixtureRepo();
|
const repo = await createFixtureRepo();
|
||||||
await mkdir(path.join(repo, "internal/agent/prompts"), { recursive: true });
|
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-cli"), { recursive: true });
|
||||||
await mkdir(path.join(repo, "skills/hwpod-ctl"), { 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, "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/boot"), { recursive: true });
|
||||||
await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true });
|
await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true });
|
||||||
await mkdir(path.join(repo, "deploy"), { 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, "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-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, "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/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, "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");
|
await writeFile(path.join(repo, "skills/hwlab-agent-runtime/SKILL.md"), "agent runtime skill\n");
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export const DEFAULT_RUNTIME_PACKAGE_FIELDS = Object.freeze([
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
export const DEFAULT_SHARED_RUNTIME_PATHS = Object.freeze([
|
export const DEFAULT_SHARED_RUNTIME_PATHS = Object.freeze([
|
||||||
|
".hwlab/",
|
||||||
"internal/protocol/",
|
"internal/protocol/",
|
||||||
"internal/build-metadata.mjs",
|
"internal/build-metadata.mjs",
|
||||||
"tools/hwpod-cli.ts",
|
"tools/hwpod-cli.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 });
|
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 });
|
const build = await runHwpodCompilerCli(["compile", "--spec", specPath, "--intent", "debug.build"], { now: () => NOW });
|
||||||
assert.equal(build.exitCode, 0);
|
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].op, "debug.build");
|
||||||
assert.equal(build.payload.plan.ops[0].args.command, "printf spec-build");
|
assert.equal(build.payload.plan.ops[0].args.command, "printf spec-build");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -107,3 +107,18 @@ test("hwpod-node applies workspace patches through the stable node op", async ()
|
|||||||
await rm(root, { recursive: true, force: true });
|
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);
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 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 pythonCommand = text(document.spec.workspace.pythonCommand) || text(document.spec.debugProbe.pythonCommand) || "py -3";
|
||||||
const jobStatusCommand = jobStatusCommandForTest(pythonCommand, keilCliPath, jobId);
|
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 = {
|
const plan = {
|
||||||
contractVersion: "hwpod-node-ops-v1",
|
contractVersion: "hwpod-node-ops-v1",
|
||||||
planId: `case_job_${randomUUID()}`,
|
planId: `case_job_${randomUUID()}`,
|
||||||
|
|||||||
@@ -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) {
|
export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, intent, args = {}, now = () => new Date().toISOString() }: any) {
|
||||||
const normalizedIntent = normalizeIntent(intent);
|
const normalizedIntent = normalizeIntent(intent);
|
||||||
const nodeId = document.spec.nodeBinding.nodeId;
|
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) => ({
|
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, "_")}`,
|
opId: operation.opId ?? `op_${String(index + 1).padStart(2, "0")}_${operation.op.replace(/[^a-z0-9]+/giu, "_")}`,
|
||||||
...operation
|
...operation
|
||||||
@@ -334,7 +334,7 @@ function opsForIntent(intent: string, args: any, document: any) {
|
|||||||
|
|
||||||
function commonOpArgs(document: any) {
|
function commonOpArgs(document: any) {
|
||||||
return {
|
return {
|
||||||
hwpodId: document.metadata.uid || document.metadata.name,
|
hwpodId: document.metadata.name || document.metadata.uid,
|
||||||
workspacePath: document.spec.workspace.path,
|
workspacePath: document.spec.workspace.path,
|
||||||
targetDevice: document.spec.targetDevice,
|
targetDevice: document.spec.targetDevice,
|
||||||
debugProbe: document.spec.debugProbe,
|
debugProbe: document.spec.debugProbe,
|
||||||
|
|||||||
@@ -448,18 +448,40 @@ async function spawnOutput(command: string[], { cwd, stdinText = "", timeoutMs }
|
|||||||
}
|
}
|
||||||
|
|
||||||
function workspaceRoot(args: any) {
|
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) {
|
function resolveWorkspacePath(args: any, relativePath: string) {
|
||||||
const root = workspaceRoot(args);
|
const root = workspaceRoot(args);
|
||||||
const target = path.resolve(root, relativePath);
|
const target = resolveWorkspaceChild(root, relativePath);
|
||||||
if (target !== root && !target.startsWith(`${root}${path.sep}`)) {
|
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 });
|
throw cliError("workspace_path_outside_root", "workspace path must stay inside workspacePath", { workspacePath: root, path: relativePath });
|
||||||
}
|
}
|
||||||
return target;
|
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) {
|
function planFromParsed(parsed: ParsedArgs, stdinText?: string) {
|
||||||
if (parsed.planJson) return JSON.parse(String(parsed.planJson));
|
if (parsed.planJson) return JSON.parse(String(parsed.planJson));
|
||||||
if (parsed.plan) return JSON.parse(String(parsed.plan));
|
if (parsed.plan) return JSON.parse(String(parsed.plan));
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { ReactElement } from "react";
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import type { HwpodNodeOpsResult, LiveSurface } from "../../types/domain";
|
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";
|
import { StateTag } from "../shared/StateTag";
|
||||||
|
|
||||||
interface HwpodNodeOpsSidebarProps {
|
interface HwpodNodeOpsSidebarProps {
|
||||||
@@ -13,10 +13,14 @@ interface HwpodNodeOpsSidebarProps {
|
|||||||
export function HwpodNodeOpsSidebar({ live, collapsed }: HwpodNodeOpsSidebarProps): ReactElement {
|
export function HwpodNodeOpsSidebar({ live, collapsed }: HwpodNodeOpsSidebarProps): ReactElement {
|
||||||
const [dialog, setDialog] = useState<{ title: string; body: string } | null>(null);
|
const [dialog, setDialog] = useState<{ title: string; body: string } | null>(null);
|
||||||
const payload = live?.hwpodNodeOps?.data ?? 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 firstResult = results[0] ?? null;
|
||||||
const status = payload?.status ?? (live?.hwpodNodeOps?.ok ? "completed" : live?.hwpodNodeOps?.error ? "blocked" : "unknown");
|
const status = selectedSpec?.availability?.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 nodeId = selectedSpec?.nodeId ?? payload?.nodeId ?? (payload?.requestMeta?.nodeId as string | undefined) ?? "未发现";
|
||||||
|
const hwpodName = selectedSpec?.name ?? selectedSpec?.hwpodId ?? payload?.hwpodId ?? "未发现";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside className={`right-sidebar${collapsed ? " is-hwpod-collapsed" : ""}`} id="hwpod-node-sidebar" aria-label="HWPOD node-ops 摘要看板" aria-hidden={collapsed}>
|
<aside className={`right-sidebar${collapsed ? " is-hwpod-collapsed" : ""}`} id="hwpod-node-sidebar" aria-label="HWPOD node-ops 摘要看板" aria-hidden={collapsed}>
|
||||||
@@ -31,21 +35,25 @@ export function HwpodNodeOpsSidebar({ live, collapsed }: HwpodNodeOpsSidebarProp
|
|||||||
<section className="hwpod-node-status" aria-label="HWPOD node-ops status">
|
<section className="hwpod-node-status" aria-label="HWPOD node-ops status">
|
||||||
<div className="hwpod-node-summary" id="hwpod-node-summary">
|
<div className="hwpod-node-summary" id="hwpod-node-summary">
|
||||||
<SummaryTile label="Contract" value={payload?.contractVersion ?? "hwpod-node-ops-v1"} detail="contract" title="Contract" onOpen={() => setDialog({ title: "Contract", body: jsonPreview(payload, 1600) })} />
|
<SummaryTile label="Contract" value={payload?.contractVersion ?? "hwpod-node-ops-v1"} detail="contract" title="Contract" onOpen={() => setDialog({ title: "Contract", body: jsonPreview(payload, 1600) })} />
|
||||||
<SummaryTile label="Node" value={nodeId} detail="node" title="Node" onOpen={() => setDialog({ title: "Node", body: jsonPreview({ nodeId, payload }, 1600) })} />
|
<SummaryTile label="Specs" value={String(specPayload?.availableCount ?? specPayload?.count ?? 0)} detail="specs" title="Specs" onOpen={() => setDialog({ title: "Specs", body: jsonPreview(specPayload, 2200) })} />
|
||||||
<SummaryTile label="Ops" value={String(payload?.acceptedOps ?? results.length)} detail="ops" title="Ops" onOpen={() => setDialog({ title: "Ops", body: jsonPreview(results, 1600) })} />
|
<SummaryTile label="Spec" value={hwpodName} detail="spec" title="Spec" onOpen={() => setDialog({ title: "Spec", body: jsonPreview(selectedSpec, 2200) })} />
|
||||||
</div>
|
</div>
|
||||||
<dl className="hwpod-node-meta">
|
<dl className="hwpod-node-meta">
|
||||||
<div><dt>hwpod</dt><dd>{payload?.hwpodId ?? "hwpod-web-workbench"}</dd></div>
|
<div><dt>hwpod</dt><dd>{hwpodName}</dd></div>
|
||||||
<div><dt>plan</dt><dd>{shortToken(payload?.planId, 18)}</dd></div>
|
<div><dt>node</dt><dd>{nodeId}</dd></div>
|
||||||
|
<div><dt>workspace</dt><dd>{selectedSpec?.workspacePath ?? "未发现"}</dd></div>
|
||||||
|
<div><dt>case</dt><dd>{String(selectedSpec?.source?.caseId ?? "未声明")}</dd></div>
|
||||||
<div><dt>route</dt><dd>{payload?.route ?? "/v1/hwpod-node-ops"}</dd></div>
|
<div><dt>route</dt><dd>{payload?.route ?? "/v1/hwpod-node-ops"}</dd></div>
|
||||||
<div><dt>updated</dt><dd>{formatTimestamp(payload?.observedAt ?? live?.loadedAt)}</dd></div>
|
<div><dt>updated</dt><dd>{formatTimestamp(payload?.observedAt ?? live?.loadedAt)}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
<section className="hwpod-node-workspace" aria-label="HWPOD node-ops results">
|
<section className="hwpod-node-workspace" aria-label="HWPOD node-ops results">
|
||||||
<div className="hwpod-node-ops" id="hwpod-node-ops-results">
|
<div className="hwpod-node-ops" id="hwpod-node-ops-results">
|
||||||
{results.length === 0 ? <SummaryTile label="node.health" value={live?.hwpodNodeOps?.error ?? "等待结果"} detail="node-health" title="node.health" onOpen={() => setDialog({ title: "node.health", body: jsonPreview(live?.hwpodNodeOps, 1600) })} /> : results.map((item, index) => <OpTile key={`${item.opId ?? index}`} item={item} index={index} onOpen={() => setDialog({ title: item.op ?? item.opId ?? `op ${index + 1}`, body: jsonPreview(item, 1600) })} />)}
|
<SummaryTile label="Node" value={nodeId} detail="node" title="Node" onOpen={() => setDialog({ title: "Node", body: jsonPreview({ nodeId, selectedSpec, payload }, 2200) })} />
|
||||||
|
<SummaryTile label="Ops" value={String(payload?.acceptedOps ?? results.length)} detail="ops" title="Ops" onOpen={() => setDialog({ title: "Ops", body: jsonPreview(results, 1600) })} />
|
||||||
|
{results.length === 0 ? <SummaryTile label="probe" value={live?.hwpodSpecs?.error ?? live?.hwpodNodeOps?.error ?? "等待结果"} detail="probe" title="probe" onOpen={() => setDialog({ title: "probe", body: jsonPreview({ hwpodSpecs: live?.hwpodSpecs, hwpodNodeOps: live?.hwpodNodeOps }, 2200) })} /> : results.map((item, index) => <OpTile key={`${item.opId ?? index}`} item={item} index={index} onOpen={() => setDialog({ title: item.op ?? item.opId ?? `op ${index + 1}`, body: jsonPreview(item, 1600) })} />)}
|
||||||
</div>
|
</div>
|
||||||
<NodeOpsStream lines={nodeOpsLines(payload?.results ?? [], firstResult, live?.hwpodNodeOps?.error)} />
|
<NodeOpsStream lines={nodeOpsLines(results, firstResult, live?.hwpodSpecs?.error ?? live?.hwpodNodeOps?.error, selectedSpec)} />
|
||||||
</section>
|
</section>
|
||||||
<dialog className="hwpod-detail-dialog" id="hwpod-detail-dialog" open={dialog !== null}>
|
<dialog className="hwpod-detail-dialog" id="hwpod-detail-dialog" open={dialog !== null}>
|
||||||
<form method="dialog">
|
<form method="dialog">
|
||||||
@@ -89,13 +97,15 @@ function NodeOpsStream({ lines }: { lines: string[] }): ReactElement {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function nodeOpsLines(results: HwpodNodeOpsResult[], firstResult: HwpodNodeOpsResult | null, error?: string | null): string[] {
|
function nodeOpsLines(results: HwpodNodeOpsResult[], firstResult: HwpodNodeOpsResult | null, error?: string | null, spec?: { name?: string; nodeId?: string; workspacePath?: string; availability?: { status?: string } } | null): string[] {
|
||||||
|
const specLine = spec ? [`spec ${spec.name ?? "unknown"} ${spec.nodeId ?? "unknown-node"} ${spec.availability?.status ?? "discovered"} ${spec.workspacePath ?? ""}`.trim()] : [];
|
||||||
if (results.length > 0) {
|
if (results.length > 0) {
|
||||||
return results.map((item) => {
|
return specLine.concat(results.map((item) => {
|
||||||
const status = item.ok === false ? item.blocker?.code ?? item.status ?? "blocked" : item.status ?? "completed";
|
const status = item.ok === false ? item.blocker?.code ?? item.status ?? "blocked" : item.status ?? "completed";
|
||||||
return `${item.opId ?? "op"} ${item.op ?? "unknown"} ${status}`;
|
return `${item.opId ?? "op"} ${item.op ?? "unknown"} ${status}`;
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
if (firstResult) return [`${firstResult.opId ?? "op"} ${firstResult.op ?? "unknown"} ${firstResult.status ?? "observed"}`];
|
if (firstResult) return [`${firstResult.opId ?? "op"} ${firstResult.op ?? "unknown"} ${firstResult.status ?? "observed"}`];
|
||||||
|
if (specLine.length > 0) return specLine;
|
||||||
return error ? [`blocked ${error}`] : [];
|
return error ? [`blocked ${error}`] : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
ApiResult,
|
ApiResult,
|
||||||
ConversationRecord,
|
ConversationRecord,
|
||||||
HwpodNodeOpsResponse,
|
HwpodNodeOpsResponse,
|
||||||
|
HwpodSpecsResponse,
|
||||||
LiveBuildsPayload,
|
LiveBuildsPayload,
|
||||||
LiveProbePayload,
|
LiveProbePayload,
|
||||||
ProviderProfile,
|
ProviderProfile,
|
||||||
@@ -218,16 +219,11 @@ export const api = {
|
|||||||
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
|
health: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/health", { timeoutMs: 12000, timeoutName: "health" }),
|
||||||
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
|
restIndex: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1", { timeoutMs: 12000, timeoutName: "REST index" }),
|
||||||
adapter: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1/rpc/system.health", { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "adapter" }),
|
adapter: (): Promise<ApiResult<LiveProbePayload>> => fetchJson("/v1/rpc/system.health", { method: "POST", body: JSON.stringify({}), timeoutMs: 12000, timeoutName: "adapter" }),
|
||||||
|
hwpodSpecs: (): Promise<ApiResult<HwpodSpecsResponse>> => fetchJson("/v1/hwpod/specs?probe=1", {
|
||||||
|
timeoutMs: 12000,
|
||||||
|
timeoutName: "HWPOD specs"
|
||||||
|
}),
|
||||||
hwpodNodeOpsHealth: (): Promise<ApiResult<HwpodNodeOpsResponse>> => fetchJson("/v1/hwpod-node-ops", {
|
hwpodNodeOpsHealth: (): Promise<ApiResult<HwpodNodeOpsResponse>> => fetchJson("/v1/hwpod-node-ops", {
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({
|
|
||||||
contractVersion: "hwpod-node-ops-v1",
|
|
||||||
planId: "hwpod_plan_web_probe",
|
|
||||||
hwpodId: "hwpod-web-workbench",
|
|
||||||
nodeId: "web-selected-node",
|
|
||||||
intent: "node.health",
|
|
||||||
ops: [{ opId: "op_01_node_health", op: "node.health", args: {} }]
|
|
||||||
}),
|
|
||||||
timeoutMs: 12000,
|
timeoutMs: 12000,
|
||||||
timeoutName: "HWPOD node-ops"
|
timeoutName: "HWPOD node-ops"
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -149,15 +149,16 @@ export function useWorkbenchStore(enabled: boolean, projectId = WORKBENCH_PROJEC
|
|||||||
}, [projectId]);
|
}, [projectId]);
|
||||||
|
|
||||||
const refreshLive = useCallback(async () => {
|
const refreshLive = useCallback(async () => {
|
||||||
const [healthLive, health, restIndex, adapter, hwpodNodeOps, liveBuilds] = await Promise.all([
|
const [healthLive, health, restIndex, adapter, hwpodSpecs, hwpodNodeOps, liveBuilds] = await Promise.all([
|
||||||
api.healthLive(),
|
api.healthLive(),
|
||||||
api.health(),
|
api.health(),
|
||||||
api.restIndex(),
|
api.restIndex(),
|
||||||
api.adapter(),
|
api.adapter(),
|
||||||
|
api.hwpodSpecs(),
|
||||||
api.hwpodNodeOpsHealth(),
|
api.hwpodNodeOpsHealth(),
|
||||||
api.liveBuilds()
|
api.liveBuilds()
|
||||||
]);
|
]);
|
||||||
const live: LiveSurface = { healthLive, health, restIndex, adapter, hwpodNodeOps, liveBuilds, loadedAt: new Date().toISOString() };
|
const live: LiveSurface = { healthLive, health, restIndex, adapter, hwpodSpecs, hwpodNodeOps, liveBuilds, loadedAt: new Date().toISOString() };
|
||||||
dispatch({ type: "live:set", live, availability: availabilityFromLive(live) });
|
dispatch({ type: "live:set", live, availability: availabilityFromLive(live) });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface LiveSurface {
|
|||||||
health: ApiResult<LiveProbePayload>;
|
health: ApiResult<LiveProbePayload>;
|
||||||
restIndex: ApiResult<LiveProbePayload>;
|
restIndex: ApiResult<LiveProbePayload>;
|
||||||
adapter: ApiResult<LiveProbePayload>;
|
adapter: ApiResult<LiveProbePayload>;
|
||||||
|
hwpodSpecs: ApiResult<HwpodSpecsResponse>;
|
||||||
hwpodNodeOps: ApiResult<HwpodNodeOpsResponse>;
|
hwpodNodeOps: ApiResult<HwpodNodeOpsResponse>;
|
||||||
liveBuilds: ApiResult<LiveBuildsPayload>;
|
liveBuilds: ApiResult<LiveBuildsPayload>;
|
||||||
loadedAt: string;
|
loadedAt: string;
|
||||||
@@ -424,6 +425,35 @@ export interface HwpodNodeOpsResponse {
|
|||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HwpodSpecsResponse {
|
||||||
|
ok?: boolean;
|
||||||
|
status?: string;
|
||||||
|
contractVersion?: string;
|
||||||
|
route?: string;
|
||||||
|
count?: number;
|
||||||
|
availableCount?: number | null;
|
||||||
|
specs?: HwpodSpecDiscoveryItem[];
|
||||||
|
observedAt?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HwpodSpecDiscoveryItem {
|
||||||
|
ok?: boolean;
|
||||||
|
status?: string;
|
||||||
|
name?: string;
|
||||||
|
hwpodId?: string;
|
||||||
|
uid?: string | null;
|
||||||
|
specPath?: string;
|
||||||
|
source?: Record<string, unknown>;
|
||||||
|
nodeId?: string;
|
||||||
|
nodeType?: string | null;
|
||||||
|
workspacePath?: string;
|
||||||
|
workspace?: Record<string, unknown>;
|
||||||
|
targetDevice?: Record<string, unknown>;
|
||||||
|
availability?: { ok?: boolean | null; status?: string; checkedAt?: string | null; results?: HwpodNodeOpsResult[]; blocker?: Record<string, unknown> | null; [key: string]: unknown };
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export type AccessUserRole = "admin" | "user";
|
export type AccessUserRole = "admin" | "user";
|
||||||
export type AccessUserStatus = "active" | "disabled";
|
export type AccessUserStatus = "active" | "disabled";
|
||||||
export type AccessToolId = "hwpod" | "unidesk_ssh" | "trans_cmd" | "github_pr";
|
export type AccessToolId = "hwpod" | "unidesk_ssh" | "trans_cmd" | "github_pr";
|
||||||
|
|||||||
Reference in New Issue
Block a user