Merge pull request #2709 from pikasTech/feat/hwpod-over-api-compare
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
feat: 支持 HWPOD L1 over-api 对比复测
This commit is contained in:
@@ -119,6 +119,82 @@ test("HWPOD L0 local mode fails closed without an explicit spec", async () => {
|
||||
assert.equal(result.payload.error.code, "hwpod_l0_spec_required");
|
||||
});
|
||||
|
||||
test("HWPOD execution modes fail closed when local and over-api are combined", async () => {
|
||||
const result = await runHwpodCli(["workspace", "ls", ".", "--local", "--over-api", "--spec", "unused.yaml"]);
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.equal(result.payload.error.code, "hwpod_execution_mode_conflict");
|
||||
});
|
||||
|
||||
test("HWPOD L0 and L1 over-api expose the same operation contract", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "hwpod-cli-compare-"));
|
||||
const specPath = path.join(root, "hwpod-spec.yaml");
|
||||
const operationId = "hwpod_compare_001";
|
||||
let submitted: any = null;
|
||||
const fetchImpl = async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = new URL(String(input));
|
||||
if (url.pathname === "/v1/hwpod/operations" && init?.method === "POST") {
|
||||
submitted = JSON.parse(String(init.body));
|
||||
return Response.json({
|
||||
ok: true,
|
||||
status: "accepted",
|
||||
operationId: submitted.operationId,
|
||||
workflowId: `hwpod-operation-${submitted.operationId}`,
|
||||
workflowRunId: "run-001",
|
||||
authority: "hwpod-temporal",
|
||||
valuesPrinted: false
|
||||
}, { status: 202 });
|
||||
}
|
||||
if (url.pathname === `/v1/hwpod/operations/${operationId}` && init?.method === "GET") {
|
||||
return Response.json({
|
||||
ok: true,
|
||||
operationId,
|
||||
workflowId: `hwpod-operation-${operationId}`,
|
||||
workflowRunId: "run-001",
|
||||
status: "completed",
|
||||
result: { ok: true, status: "completed" },
|
||||
valuesPrinted: false
|
||||
});
|
||||
}
|
||||
return Response.json({ ok: false, error: { code: "unexpected_route" } }, { status: 404 });
|
||||
};
|
||||
const env = { HWLAB_HWPOD_OPERATION_IDENTITY: operationId };
|
||||
|
||||
try {
|
||||
await writeFile(specPath, hwpodSpecText(root), "utf8");
|
||||
const local = await runHwpodCli(["workspace", "ls", ".", "--local", "--spec", specPath], { env });
|
||||
const overApi = await runHwpodCli(["workspace", "ls", ".", "--over-api", "--spec", specPath, "--api-base-url", "http://hwpod.test"], { env, fetchImpl: fetchImpl as typeof fetch });
|
||||
|
||||
assert.equal(local.exitCode, 0);
|
||||
assert.equal(overApi.exitCode, 0);
|
||||
assert.equal(overApi.payload.action, "hwpod-cli.over-api");
|
||||
assert.equal(overApi.payload.mode, "l1-native-api");
|
||||
assert.equal(overApi.payload.transport, "over-api");
|
||||
assert.equal(overApi.payload.httpStatus, 202);
|
||||
for (const field of ["operationId", "hwpodId", "nodeId", "intent", "contractVersion", "ops"]) {
|
||||
assert.deepEqual(overApi.payload[field], local.payload[field], field);
|
||||
}
|
||||
assert.equal(submitted.operationId, operationId);
|
||||
assert.equal(submitted.plan.planId, operationId);
|
||||
assert.equal(submitted.plan.intent, local.payload.intent);
|
||||
assert.deepEqual(submitted.plan.ops.map((operation: any) => operation.op), local.payload.ops);
|
||||
|
||||
const status = await runHwpodCli(["operation", "status", operationId, "--over-api", "--api-base-url", "http://hwpod.test"], { env, fetchImpl: fetchImpl as typeof fetch });
|
||||
assert.equal(status.exitCode, 0);
|
||||
assert.equal(status.payload.action, "hwpod-cli.operation.status");
|
||||
assert.equal(status.payload.mode, "l1-native-api");
|
||||
assert.equal(status.payload.operationId, operationId);
|
||||
assert.equal(status.payload.body.status, "completed");
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("HWPOD operation status requires explicit over-api mode", async () => {
|
||||
const result = await runHwpodCli(["operation", "status", "operation-001"]);
|
||||
assert.equal(result.exitCode, 1);
|
||||
assert.equal(result.payload.error.code, "hwpod_operation_status_over_api_required");
|
||||
});
|
||||
|
||||
function hwpodSpecText(workspacePath = "F:\\Work\\HWLAB-CASE-F103", buildCommand = "") {
|
||||
return `kind: Hwpod\nmetadata:\n name: test-hwpod\nspec:\n nodeBinding:\n nodeId: test-node\n workspace:\n path: ${JSON.stringify(workspacePath)}\n${buildCommand ? ` buildCommand: ${JSON.stringify(buildCommand)}\n` : ""} targetDevice:\n board: D601-F103-V2\n debugProbe:\n type: daplink\n ioProbe:\n uart:\n port: COM9\n`;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ const COMPILER_NAME = "hwpod-compiler-cli";
|
||||
const CTL_NAME = "hwpod-ctl";
|
||||
const CLI_NAME = "hwpod-cli";
|
||||
const DEFAULT_TIMEOUT_MS = 30000;
|
||||
const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "ignoreCase", "json", "local", "noAuth", "sessionOnly", "wait"]);
|
||||
const BOOLEAN_OPTIONS = new Set(["all", "allowMultiple", "dryRun", "finalNewline", "force", "full", "help", "h", "ignoreCase", "json", "local", "noAuth", "overApi", "sessionOnly", "wait"]);
|
||||
const DEFAULT_KEIL_COMMAND_TIMEOUT_MS = 30000;
|
||||
type EnvLike = Record<string, string | undefined>;
|
||||
type ParsedArgs = Record<string, unknown> & { _: string[] };
|
||||
@@ -67,7 +67,7 @@ export async function runHwpodCompilerCli(argv: string[], options: { env?: EnvLi
|
||||
const intent = requiredText(parsed.intent ?? parsed._[1], "intent");
|
||||
const args = parseJsonObject(parsed.argsJson ?? parsed.args ?? "{}", "args");
|
||||
const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl: options.fetchImpl });
|
||||
const plan = compileHwpodNodeOpsPlan({ document: resolved.document, specPath: resolved.specPath, specAuthority: resolved.authority, intent, args, now });
|
||||
const plan = compileHwpodNodeOpsPlan({ document: resolved.document, specPath: resolved.specPath, specAuthority: resolved.authority, intent, args, operationId: env.HWLAB_HWPOD_OPERATION_IDENTITY, now });
|
||||
return result(0, ok("hwpod-compiler.compile", { specPath: resolved.specPath, hwpodId: resolved.hwpodId, specAuthority: resolved.authority, intent: plan.intent, contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION, plan }), now);
|
||||
} catch (error) {
|
||||
return result(1, failure(COMPILER_NAME, error), now);
|
||||
@@ -106,6 +106,31 @@ export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetc
|
||||
if (["help", "--help", "-h"].includes(command)) return result(0, cliHelp(), now);
|
||||
if (parsed.help === true || parsed.h === true) return result(0, hwpodCliCommandHelp(command, parsed), now);
|
||||
if (command === "closeout") return result(0, closeout(parsed), now);
|
||||
if (parsed.local === true && parsed.overApi === true) {
|
||||
throw cliError("hwpod_execution_mode_conflict", "--local and --over-api are mutually exclusive", {
|
||||
modes: ["l0-native-function", "l1-native-api"]
|
||||
});
|
||||
}
|
||||
if (command === "operation") {
|
||||
if (parsed._[1] !== "status") throw cliError("unsupported_operation_command", `unsupported hwpod operation command: ${parsed._[1] || ""}`);
|
||||
if (parsed.overApi !== true) throw cliError("hwpod_operation_status_over_api_required", "HWPOD operation status requires --over-api", { next: "pass --over-api" });
|
||||
const operationId = requiredText(parsed.operationId ?? parsed._[2], "operationId");
|
||||
const response = await getHwpodOperationStatus({ parsed, env, fetchImpl: options.fetchImpl, operationId });
|
||||
const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0;
|
||||
const payload = ok("hwpod-cli.operation.status", {
|
||||
mode: "l1-native-api",
|
||||
transport: "over-api",
|
||||
serviceRuntime: true,
|
||||
operationId,
|
||||
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||
route: response.route,
|
||||
runtimeEndpoint: response.runtimeEndpoint,
|
||||
body: response.body,
|
||||
httpStatus: response.status
|
||||
}, response.body?.status ?? (exitCode === 0 ? "succeeded" : "failed"));
|
||||
if (exitCode !== 0) payload.ok = false;
|
||||
return result(exitCode, payload, now);
|
||||
}
|
||||
|
||||
const { intent, args } = commandToIntent(parsed, options.stdinText);
|
||||
if (parsed.local === true && !text(parsed.spec ?? parsed.specPath)) {
|
||||
@@ -131,12 +156,42 @@ export async function runHwpodCli(argv: string[], options: { env?: EnvLike; fetc
|
||||
specAuthority: compiled.specAuthority,
|
||||
intent: plan.intent,
|
||||
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||
operationId: plan.planId,
|
||||
nodeId: plan.nodeId,
|
||||
ops: plan.ops.map((operation: any) => operation.op),
|
||||
compilerInvocation: compiled.compilerInvocation,
|
||||
body
|
||||
}, body.status ?? (exitCode === 0 ? "completed" : "failed"));
|
||||
if (exitCode !== 0) payload.ok = false;
|
||||
return result(exitCode, payload, now);
|
||||
}
|
||||
if (parsed.overApi === true) {
|
||||
const response = await submitHwpodOperationOverApi({ parsed, env, fetchImpl: options.fetchImpl, plan });
|
||||
const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0;
|
||||
const payload = ok("hwpod-cli.over-api", {
|
||||
mode: "l1-native-api",
|
||||
transport: "over-api",
|
||||
serviceRuntime: true,
|
||||
specPath: compiled.specPath,
|
||||
hwpodId: compiled.hwpodId,
|
||||
nodeId: plan.nodeId,
|
||||
specAuthority: compiled.specAuthority,
|
||||
intent: plan.intent,
|
||||
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||
operationId: plan.planId,
|
||||
ops: plan.ops.map((operation: any) => operation.op),
|
||||
compilerInvocation: compiled.compilerInvocation,
|
||||
route: response.route,
|
||||
runtimeEndpoint: response.runtimeEndpoint,
|
||||
body: response.body,
|
||||
httpStatus: response.status,
|
||||
next: {
|
||||
status: `hwlab-cli hwpod operation status ${plan.planId} --over-api`
|
||||
}
|
||||
}, response.body?.status ?? (exitCode === 0 ? "accepted" : "failed"));
|
||||
if (exitCode !== 0) payload.ok = false;
|
||||
return result(exitCode, payload, now);
|
||||
}
|
||||
const response = await submitHwpodNodeOpsPlan({ parsed, env, fetchImpl: options.fetchImpl, plan });
|
||||
const exitCode = response.body?.ok === false || response.status >= 400 ? 1 : 0;
|
||||
const diagnostic = hwpodNodeOpsCliDiagnostic(response);
|
||||
@@ -166,7 +221,7 @@ export async function readHwpodSpec(specPath = DEFAULT_HWPOD_SPEC_PATH) {
|
||||
return normalizeHwpodSpec(parseSimpleYaml(text, specPath), specPath);
|
||||
}
|
||||
|
||||
export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, specAuthority = "code-agent-workspace", intent, args = {}, now = () => new Date().toISOString() }: any) {
|
||||
export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPEC_PATH, specAuthority = "code-agent-workspace", intent, args = {}, operationId = process.env.HWLAB_HWPOD_OPERATION_IDENTITY, now = () => new Date().toISOString() }: any) {
|
||||
const normalizedIntent = normalizeIntent(intent);
|
||||
const nodeId = document.spec.nodeBinding.nodeId;
|
||||
const hwpodId = document.metadata.name || document.metadata.uid;
|
||||
@@ -176,7 +231,7 @@ export function compileHwpodNodeOpsPlan({ document, specPath = DEFAULT_HWPOD_SPE
|
||||
}));
|
||||
return {
|
||||
contractVersion: HWPOD_NODE_OPS_CONTRACT_VERSION,
|
||||
planId: String(process.env.HWLAB_HWPOD_OPERATION_IDENTITY ?? "").trim() || `hwpod_plan_${randomUUID()}`,
|
||||
planId: String(operationId ?? "").trim() || `hwpod_plan_${randomUUID()}`,
|
||||
hwpodId,
|
||||
nodeId,
|
||||
intent: normalizedIntent,
|
||||
@@ -230,6 +285,8 @@ function cliHelp() {
|
||||
"bun tools/hwpod-cli.ts workspace ls . --hwpod-id d601-f103-v2 --workspace-path <run-worktree> --dry-run",
|
||||
"hwlab-cli hwpod workspace ls . --local --spec .hwlab/hwpod-spec.yaml",
|
||||
"hwlab-cli hwpod build --local --spec .hwlab/hwpod-spec.yaml",
|
||||
"hwlab-cli hwpod workspace ls . --hwpod-id d601-f103-v2 --over-api",
|
||||
"hwlab-cli hwpod operation status <operationId> --over-api",
|
||||
"bun tools/hwpod-cli.ts workspace cat projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
||||
"bun tools/hwpod-cli.ts workspace read projects/01_baseline/User/main.c --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
||||
"bun tools/hwpod-cli.ts workspace rg arm_2d_init projects/01_baseline/Middlewares/Arm-2D --context 3 --hwpod-id d601-f103-v2 --workspace-path <run-worktree>",
|
||||
@@ -266,7 +323,8 @@ function cliHelp() {
|
||||
"*** End Patch"
|
||||
].join("\n"),
|
||||
route: "POST /v1/hwpod-node-ops",
|
||||
localMode: "Pass --local --spec <path> to compile and execute the same plan in-process without API, worker, Temporal, Web, or Kubernetes."
|
||||
localMode: "Pass --local --spec <path> to compile and execute the same plan in-process without API, worker, Temporal, Web, or Kubernetes.",
|
||||
overApiMode: "Pass --over-api to submit the same plan to POST /v1/hwpod/operations, then query it with hwpod operation status <operationId> --over-api."
|
||||
});
|
||||
}
|
||||
|
||||
@@ -416,7 +474,7 @@ function closeout(parsed: ParsedArgs) {
|
||||
|
||||
async function compilePlanWithCompilerCli({ parsed, env, fetchImpl, intent, args }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; intent: string; args: Record<string, unknown> }) {
|
||||
const resolved = await resolveHwpodDocument({ parsed, env, fetchImpl });
|
||||
const plan = compileHwpodNodeOpsPlan({ document: resolved.document, specPath: resolved.specPath, specAuthority: resolved.authority, intent, args });
|
||||
const plan = compileHwpodNodeOpsPlan({ document: resolved.document, specPath: resolved.specPath, specAuthority: resolved.authority, intent, args, operationId: env.HWLAB_HWPOD_OPERATION_IDENTITY });
|
||||
return {
|
||||
plan,
|
||||
specPath: resolved.specPath,
|
||||
@@ -1077,6 +1135,31 @@ async function submitHwpodNodeOpsPlan({ parsed, env, fetchImpl, plan }: { parsed
|
||||
return { status: response.status, body: enrichHwpodNodeOpsBody(body, { otelTraceId, traceparent }), route, runtimeEndpoint: runtimeEndpointVisibility(endpoint), otelTraceId, traceparent };
|
||||
}
|
||||
|
||||
async function submitHwpodOperationOverApi({ parsed, env, fetchImpl, plan }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; plan: any }) {
|
||||
const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env });
|
||||
const route = { method: "POST", path: "/v1/hwpod/operations" };
|
||||
const url = `${endpoint.baseUrl}${route.path}`;
|
||||
const headers = authHeaders(parsed, env);
|
||||
const requestBody = JSON.stringify({ operationId: plan.planId, plan });
|
||||
const response = fetchImpl
|
||||
? await fetchImpl(url, { method: route.method, headers, body: requestBody })
|
||||
: await postJsonNative(url, { method: route.method, headers, body: requestBody, timeoutMs: numberValue(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS });
|
||||
const body = await response.json().catch(() => null);
|
||||
return { status: response.status, body, route, runtimeEndpoint: runtimeEndpointVisibility(endpoint) };
|
||||
}
|
||||
|
||||
async function getHwpodOperationStatus({ parsed, env, fetchImpl, operationId }: { parsed: ParsedArgs; env: EnvLike; fetchImpl?: FetchLike; operationId: string }) {
|
||||
const endpoint = resolveRuntimeEndpoint({ kind: "api", parsed, env });
|
||||
const route = { method: "GET", path: `/v1/hwpod/operations/${encodeURIComponent(operationId)}` };
|
||||
const url = `${endpoint.baseUrl}${route.path}`;
|
||||
const headers = authHeaders(parsed, env);
|
||||
const response = fetchImpl
|
||||
? await fetchImpl(url, { method: route.method, headers })
|
||||
: await requestJsonNative(url, { method: route.method, headers, timeoutMs: numberValue(parsed.timeoutMs) ?? DEFAULT_TIMEOUT_MS });
|
||||
const body = await response.json().catch(() => null);
|
||||
return { status: response.status, body, route, runtimeEndpoint: runtimeEndpointVisibility(endpoint) };
|
||||
}
|
||||
|
||||
function hwpodNodeOpsCliDiagnostic(response: any) {
|
||||
const body = response?.body;
|
||||
if (!body || typeof body !== "object" || body.ok !== false) return null;
|
||||
|
||||
Reference in New Issue
Block a user