Merge pull request #2620 from pikasTech/feat/2616-caserun-native-web
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
feat: 实现 CaseRun 前端 native 热重载
This commit is contained in:
@@ -6,6 +6,14 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"deps": "node ../../scripts/worktree-deps.mjs --web",
|
"deps": "node ../../scripts/worktree-deps.mjs --web",
|
||||||
"dev": "bun run deps --quiet && vite",
|
"dev": "bun run deps --quiet && vite",
|
||||||
|
"dev:native-caserun": "CHOKIDAR_USEPOLLING=1 HWLAB_CASERUN_NATIVE_TEST=1 bun run dev",
|
||||||
|
"caserun:native": "bun scripts/caserun-native-supervisor.ts",
|
||||||
|
"caserun:native:start": "bun run scripts/caserun-native-service.ts start",
|
||||||
|
"caserun:native:stop": "bun run scripts/caserun-native-service.ts stop",
|
||||||
|
"caserun:native:status": "bun run scripts/caserun-native-service.ts status",
|
||||||
|
"caserun:native:logs": "bun run scripts/caserun-native-service.ts logs",
|
||||||
|
"caserun:native:smoke": "bun run scripts/caserun-native-smoke.ts",
|
||||||
|
"caserun:native:reload-smoke": "bun run scripts/caserun-native-reload-smoke.ts",
|
||||||
"check": "bun run deps --quiet && bun run scripts/tsc-check.ts && bun test scripts",
|
"check": "bun run deps --quiet && bun run scripts/tsc-check.ts && bun test scripts",
|
||||||
"check:tsc": "bun run deps --quiet && bun run scripts/tsc-check.ts",
|
"check:tsc": "bun run deps --quiet && bun run scripts/tsc-check.ts",
|
||||||
"check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict",
|
"check:tsc-strict": "bun run deps --quiet && bun run scripts/tsc-check.ts --strict",
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
|
||||||
|
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, resetNativeFixtures, startNativeRun } from "./caserun-native-fixtures";
|
||||||
|
|
||||||
|
test("native CaseRun fixtures expose six deterministic API-owned states", () => {
|
||||||
|
resetNativeFixtures();
|
||||||
|
const cases = listNativeCases();
|
||||||
|
assert.equal(cases.mode, "native-test");
|
||||||
|
assert.deepEqual(cases.cases.map((item) => item.caseId), ["native-queued", "native-running", "native-completed", "native-failed", "native-blocked", "native-canceled"]);
|
||||||
|
|
||||||
|
const started = startNativeRun("native-completed");
|
||||||
|
assert.equal(started?.status, "queued");
|
||||||
|
assert.equal(started?.terminal, false);
|
||||||
|
const running = readNativeRun(started!.runId);
|
||||||
|
assert.equal(running?.status, "running");
|
||||||
|
assert.equal(running?.terminal, false);
|
||||||
|
const completed = readNativeRun(started!.runId);
|
||||||
|
assert.equal(completed?.status, "completed");
|
||||||
|
assert.equal(completed?.terminal, true);
|
||||||
|
assert.match(completed?.references.aggregate.sha256 ?? "", /^[a-f0-9]{64}$/);
|
||||||
|
|
||||||
|
const aggregate = readNativeAggregate(started!.runId);
|
||||||
|
assert.equal(aggregate?.mode, "native-test");
|
||||||
|
assert.equal(aggregate?.runId, started!.runId);
|
||||||
|
assert.equal(aggregate?.status, "completed");
|
||||||
|
assert.equal(aggregate?.sha256, completed?.references.aggregate.sha256);
|
||||||
|
|
||||||
|
const events = readNativeEvents(started!.runId);
|
||||||
|
assert.deepEqual(events?.events.map((event) => event.status), ["queued", "running", "completed"]);
|
||||||
|
assert.equal(events?.mode, "native-test");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("native blockers and cancellation come from the API fixture", () => {
|
||||||
|
resetNativeFixtures();
|
||||||
|
const blocked = startNativeRun("native-blocked")!;
|
||||||
|
const blockedTerminal = readNativeRun(blocked.runId)!;
|
||||||
|
assert.equal(blockedTerminal.terminal, true);
|
||||||
|
assert.equal(blockedTerminal.blocker?.code, "native-capability-blocked");
|
||||||
|
|
||||||
|
const canceled = startNativeRun("native-canceled")!;
|
||||||
|
readNativeRun(canceled.runId);
|
||||||
|
const canceledTerminal = readNativeRun(canceled.runId)!;
|
||||||
|
assert.equal(canceledTerminal.status, "canceled");
|
||||||
|
assert.equal(canceledTerminal.terminal, true);
|
||||||
|
});
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { createHash } from "node:crypto";
|
||||||
|
|
||||||
|
type NativeStatus = "queued" | "running" | "completed" | "failed" | "blocked" | "canceled";
|
||||||
|
|
||||||
|
interface NativeStep {
|
||||||
|
status: NativeStatus;
|
||||||
|
stage: string;
|
||||||
|
terminal: boolean;
|
||||||
|
blocker?: { code: string; summary: string; layer: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NativeRunState {
|
||||||
|
runId: string;
|
||||||
|
caseId: string;
|
||||||
|
cursor: number;
|
||||||
|
steps: NativeStep[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MODE = "native-test";
|
||||||
|
const CONTRACT_VERSION = "caserun.v1";
|
||||||
|
const BASE_TIME = Date.parse("2026-07-17T00:00:00.000Z");
|
||||||
|
|
||||||
|
const scenarios: Record<string, { title: string; steps: NativeStep[] }> = {
|
||||||
|
"native-queued": { title: "Native queued", steps: [{ status: "queued", stage: "queued", terminal: false }] },
|
||||||
|
"native-running": { title: "Native running", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }] },
|
||||||
|
"native-completed": { title: "Native completed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "completed", stage: "completed", terminal: true }] },
|
||||||
|
"native-failed": { title: "Native failed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "building", terminal: false }, { status: "failed", stage: "failed", terminal: true, blocker: { code: "native-build-failed", summary: "Deterministic native build failure", layer: "build" } }] },
|
||||||
|
"native-blocked": { title: "Native blocked", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "Deterministic native capability blocker", layer: "hwpod" } }] },
|
||||||
|
"native-canceled": { title: "Native canceled", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "canceled", stage: "canceled", terminal: true }] }
|
||||||
|
};
|
||||||
|
|
||||||
|
let runSequence = 0;
|
||||||
|
const runs = new Map<string, NativeRunState>();
|
||||||
|
|
||||||
|
function timestamp(index: number): string {
|
||||||
|
return new Date(BASE_TIME + index * 1000).toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateResponse(run: NativeRunState) {
|
||||||
|
const step = run.steps[run.cursor] ?? run.steps.at(-1)!;
|
||||||
|
const summary = {
|
||||||
|
status: step.status,
|
||||||
|
jobId: `job-${run.runId}`,
|
||||||
|
artifactCount: step.status === "completed" ? 2 : 0
|
||||||
|
};
|
||||||
|
const evidence = {
|
||||||
|
status: step.status,
|
||||||
|
blocker: step.blocker ?? null,
|
||||||
|
artifacts: step.status === "completed" ? [{ name: "firmware.bin" }, { name: "caserun.log" }] : []
|
||||||
|
};
|
||||||
|
const archivedRun = {
|
||||||
|
runId: run.runId,
|
||||||
|
caseId: run.caseId,
|
||||||
|
status: step.status,
|
||||||
|
stage: step.stage,
|
||||||
|
terminal: step.terminal,
|
||||||
|
mode: MODE
|
||||||
|
};
|
||||||
|
const source = { runId: run.runId, caseId: run.caseId, status: step.status, summary, evidence, run: archivedRun };
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mode: MODE,
|
||||||
|
contractVersion: "caserun.aggregate.v1",
|
||||||
|
runId: run.runId,
|
||||||
|
caseId: run.caseId,
|
||||||
|
status: step.status,
|
||||||
|
stage: step.stage,
|
||||||
|
terminal: step.terminal,
|
||||||
|
sourceAuthority: { mode: MODE, sequence: "request-driven" },
|
||||||
|
sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex"),
|
||||||
|
summary,
|
||||||
|
evidence,
|
||||||
|
run: archivedRun
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runResponse(run: NativeRunState) {
|
||||||
|
const step = run.steps[run.cursor] ?? run.steps.at(-1)!;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
mode: MODE,
|
||||||
|
contractVersion: CONTRACT_VERSION,
|
||||||
|
runId: run.runId,
|
||||||
|
caseId: run.caseId,
|
||||||
|
status: step.status,
|
||||||
|
stage: step.stage,
|
||||||
|
terminal: step.terminal,
|
||||||
|
blocker: step.blocker ?? null,
|
||||||
|
createdAt: timestamp(0),
|
||||||
|
updatedAt: timestamp(run.cursor),
|
||||||
|
facts: {
|
||||||
|
jobId: `job-${run.runId}`,
|
||||||
|
artifactCount: step.status === "completed" ? 2 : 0,
|
||||||
|
evidenceStatus: step.status
|
||||||
|
},
|
||||||
|
references: {
|
||||||
|
status: { href: `/v1/caserun/runs/${run.runId}` },
|
||||||
|
events: { href: `/v1/caserun/runs/${run.runId}/events`, count: run.cursor + 1 },
|
||||||
|
aggregate: { href: `/v1/caserun/runs/${run.runId}/aggregate`, sha256: aggregateResponse(run).sha256 },
|
||||||
|
manifest: step.status === "completed" ? { href: `/v1/caserun/runs/${run.runId}/manifest`, count: 2 } : null,
|
||||||
|
replay: null,
|
||||||
|
trace: { traceId: `trace-${run.runId}` },
|
||||||
|
hwpod: { hwpodId: "native-hwpod", nodeId: "native-node" }
|
||||||
|
},
|
||||||
|
sourceAuthority: { mode: MODE, sequence: "request-driven" },
|
||||||
|
error: null,
|
||||||
|
statusUrl: `/v1/caserun/runs/${run.runId}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listNativeCases() {
|
||||||
|
const cases = Object.entries(scenarios).map(([caseId, scenario]) => ({
|
||||||
|
caseId,
|
||||||
|
title: scenario.title,
|
||||||
|
mode: MODE,
|
||||||
|
available: true,
|
||||||
|
hwpodSpec: "native-test",
|
||||||
|
runtime: { sequence: scenario.steps.map((step) => step.status) }
|
||||||
|
}));
|
||||||
|
return { ok: true, mode: MODE, contractVersion: CONTRACT_VERSION, count: cases.length, cases, caseAuthority: { mode: MODE } };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startNativeRun(caseId: string) {
|
||||||
|
const scenario = scenarios[caseId];
|
||||||
|
if (!scenario) return null;
|
||||||
|
runSequence += 1;
|
||||||
|
const runId = `${caseId}-${runSequence}`;
|
||||||
|
const run: NativeRunState = { runId, caseId, cursor: 0, steps: scenario.steps };
|
||||||
|
runs.set(runId, run);
|
||||||
|
return runResponse(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNativeRun(runId: string, advance = true) {
|
||||||
|
const run = runs.get(runId);
|
||||||
|
if (!run) return null;
|
||||||
|
if (advance && run.cursor < run.steps.length - 1) run.cursor += 1;
|
||||||
|
return runResponse(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNativeEvents(runId: string) {
|
||||||
|
const run = runs.get(runId);
|
||||||
|
if (!run) return null;
|
||||||
|
const current = runResponse(run);
|
||||||
|
const events = run.steps.slice(0, run.cursor + 1).map((step, index) => ({
|
||||||
|
at: timestamp(index),
|
||||||
|
status: step.status,
|
||||||
|
stage: step.stage,
|
||||||
|
payload: { mode: MODE, terminal: step.terminal, blocker: step.blocker ?? null }
|
||||||
|
}));
|
||||||
|
return { ok: true, mode: MODE, contractVersion: CONTRACT_VERSION, runId, caseId: run.caseId, status: current.status, stage: current.stage, terminal: current.terminal, sourceAuthority: current.sourceAuthority, events };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readNativeAggregate(runId: string) {
|
||||||
|
const run = runs.get(runId);
|
||||||
|
return run ? aggregateResponse(run) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resetNativeFixtures(): void {
|
||||||
|
runSequence = 0;
|
||||||
|
runs.clear();
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { readFileSync, renameSync, rmSync, statSync, utimesSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const watchedPath = resolve(root, "scripts/caserun-native-fixtures.ts");
|
||||||
|
const displacedPath = `${watchedPath}.reload-smoke`;
|
||||||
|
const originalStat = statSync(watchedPath);
|
||||||
|
const port = Number(process.env.HWLAB_CASERUN_RELOAD_SMOKE_PORT || 14316);
|
||||||
|
const baseUrl = `http://127.0.0.1:${port}`;
|
||||||
|
const reloadEvents: Array<{ pid?: number; reason?: string }> = [];
|
||||||
|
let output = "";
|
||||||
|
let displaced = false;
|
||||||
|
|
||||||
|
function requireCondition(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(description: string, condition: () => boolean | Promise<boolean>, timeoutMs = 5000): Promise<void> {
|
||||||
|
const deadline = Date.now() + timeoutMs;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (await condition()) return;
|
||||||
|
await Bun.sleep(50);
|
||||||
|
}
|
||||||
|
throw new Error(`timeout waiting for ${description}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readOutput(stream: ReadableStream<Uint8Array>): Promise<void> {
|
||||||
|
const reader = stream.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let pending = "";
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
output += chunk;
|
||||||
|
pending += chunk;
|
||||||
|
const lines = pending.split("\n");
|
||||||
|
pending = lines.pop() || "";
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.includes('"event":"caserun-native-reload"')) continue;
|
||||||
|
const event = JSON.parse(line) as { pid?: number; reason?: string };
|
||||||
|
if (!reloadEvents.some((existing) => existing.pid === event.pid)) reloadEvents.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const finalChunk = decoder.decode();
|
||||||
|
output += finalChunk;
|
||||||
|
pending += finalChunk;
|
||||||
|
if (pending.includes('"event":"caserun-native-reload"')) {
|
||||||
|
const event = JSON.parse(pending) as { pid?: number; reason?: string };
|
||||||
|
if (!reloadEvents.some((existing) => existing.pid === event.pid)) reloadEvents.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function healthAvailable(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${baseUrl}/health`);
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function childPids(supervisorPid: number): number[] {
|
||||||
|
try {
|
||||||
|
return readFileSync(`/proc/${supervisorPid}/task/${supervisorPid}/children`, "utf8")
|
||||||
|
.trim()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(Number);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmSync(displacedPath, { force: true });
|
||||||
|
const supervisor = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], {
|
||||||
|
cwd: root,
|
||||||
|
env: { ...process.env, HWLAB_CASERUN_NATIVE_PORT: String(port) },
|
||||||
|
stdin: "ignore",
|
||||||
|
stdout: "pipe",
|
||||||
|
stderr: "pipe"
|
||||||
|
});
|
||||||
|
const stdoutTask = readOutput(supervisor.stdout);
|
||||||
|
const stderrTask = readOutput(supervisor.stderr);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await waitFor("initial native health", healthAvailable);
|
||||||
|
|
||||||
|
renameSync(watchedPath, displacedPath);
|
||||||
|
displaced = true;
|
||||||
|
await Bun.sleep(300);
|
||||||
|
requireCondition(supervisor.exitCode === null, `supervisor exited during atomic save window: ${supervisor.exitCode}`);
|
||||||
|
renameSync(displacedPath, watchedPath);
|
||||||
|
displaced = false;
|
||||||
|
const restoredAt = new Date(Date.now() + 1000);
|
||||||
|
utimesSync(watchedPath, restoredAt, restoredAt);
|
||||||
|
await waitFor("reload after atomic save", () => reloadEvents.length >= 2);
|
||||||
|
|
||||||
|
for (let index = 0; index < 5; index += 1) {
|
||||||
|
const changedAt = new Date(Date.now() + (index + 1) * 1000);
|
||||||
|
utimesSync(watchedPath, changedAt, changedAt);
|
||||||
|
await Bun.sleep(120);
|
||||||
|
}
|
||||||
|
|
||||||
|
await waitFor("final native health", healthAvailable);
|
||||||
|
await Bun.sleep(300);
|
||||||
|
const liveChildren = childPids(supervisor.pid);
|
||||||
|
requireCondition(liveChildren.length === 1, `expected one native server child, found ${liveChildren.join(",") || "none"}`);
|
||||||
|
requireCondition(reloadEvents.length >= 3, `expected repeated reloads, found ${reloadEvents.length}`);
|
||||||
|
requireCondition(!output.includes("EADDRINUSE"), "native reload logged EADDRINUSE");
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
mode: "native-test",
|
||||||
|
supervisorPid: supervisor.pid,
|
||||||
|
serverPid: liveChildren[0],
|
||||||
|
reloadCount: reloadEvents.length,
|
||||||
|
reloadPids: reloadEvents.map((event) => event.pid),
|
||||||
|
atomicSaveMissingFileTolerated: true,
|
||||||
|
eaddrinuse: false
|
||||||
|
}));
|
||||||
|
} finally {
|
||||||
|
if (displaced) renameSync(displacedPath, watchedPath);
|
||||||
|
rmSync(displacedPath, { force: true });
|
||||||
|
utimesSync(watchedPath, originalStat.atime, originalStat.mtime);
|
||||||
|
if (supervisor.exitCode === null) supervisor.kill("SIGTERM");
|
||||||
|
await supervisor.exited;
|
||||||
|
await Promise.all([stdoutTask, stderrTask]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures";
|
||||||
|
|
||||||
|
const port = Number(process.env.HWLAB_CASERUN_NATIVE_PORT || 4316);
|
||||||
|
const hostname = process.env.HWLAB_CASERUN_NATIVE_HOST || "127.0.0.1";
|
||||||
|
|
||||||
|
function json(data: unknown, status = 200): Response {
|
||||||
|
return Response.json(data, { status, headers: { "cache-control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFound(kind: string, id?: string): Response {
|
||||||
|
return json({ ok: false, mode: "native-test", error: { code: `${kind}-not-found`, message: id ? `${kind} ${id} not found` : `${kind} not found` } }, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
function authSession(): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
authenticated: true,
|
||||||
|
mode: "native-test",
|
||||||
|
sessionKind: "native-test",
|
||||||
|
user: { id: "native-admin", username: "native-admin", displayName: "Native Admin", role: "admin", status: "active" },
|
||||||
|
capabilities: { admin: "available" },
|
||||||
|
access: { nav: { profileId: "native-test", allowedIds: ["*"], valuesRedacted: true } },
|
||||||
|
valuesRedacted: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = Bun.serve({
|
||||||
|
hostname,
|
||||||
|
port,
|
||||||
|
development: true,
|
||||||
|
async fetch(request) {
|
||||||
|
const url = new URL(request.url);
|
||||||
|
if (request.method === "GET" && url.pathname === "/health") return json({ ok: true, mode: "native-test" });
|
||||||
|
if (request.method === "POST" && url.pathname === "/auth/login") {
|
||||||
|
return Response.json(authSession(), { headers: { "cache-control": "no-store", "set-cookie": "hwlab_session=native-test; Path=/; HttpOnly; SameSite=Lax" } });
|
||||||
|
}
|
||||||
|
if (request.method === "GET" && url.pathname === "/auth/session") return json(authSession());
|
||||||
|
if (request.method === "GET" && url.pathname === "/v1/caserun/cases") return json(listNativeCases());
|
||||||
|
if (request.method === "POST" && url.pathname === "/v1/caserun/runs") {
|
||||||
|
const body = await request.json().catch(() => null) as { caseId?: string } | null;
|
||||||
|
if (!body?.caseId) return json({ ok: false, mode: "native-test", error: { code: "case-id-required", message: "caseId is required" } }, 400);
|
||||||
|
const run = startNativeRun(body.caseId);
|
||||||
|
return run ? json(run, 202) : notFound("case", body.caseId);
|
||||||
|
}
|
||||||
|
const eventsMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/events$/);
|
||||||
|
if (request.method === "GET" && eventsMatch) {
|
||||||
|
const runId = decodeURIComponent(eventsMatch[1]!);
|
||||||
|
const events = readNativeEvents(runId);
|
||||||
|
return events ? json(events) : notFound("run", runId);
|
||||||
|
}
|
||||||
|
const aggregateMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)\/aggregate$/);
|
||||||
|
if (request.method === "GET" && aggregateMatch) {
|
||||||
|
const runId = decodeURIComponent(aggregateMatch[1]!);
|
||||||
|
const aggregate = readNativeAggregate(runId);
|
||||||
|
return aggregate ? json(aggregate) : notFound("run", runId);
|
||||||
|
}
|
||||||
|
const runMatch = url.pathname.match(/^\/v1\/caserun\/runs\/([^/]+)$/);
|
||||||
|
if (request.method === "GET" && runMatch) {
|
||||||
|
const runId = decodeURIComponent(runMatch[1]!);
|
||||||
|
const run = readNativeRun(runId);
|
||||||
|
return run ? json(run) : notFound("run", runId);
|
||||||
|
}
|
||||||
|
return notFound("route", url.pathname);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(JSON.stringify({ event: "caserun-native-listening", mode: "native-test", hostname: server.hostname, port: server.port }));
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import { mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const action = process.argv[2];
|
||||||
|
const root = process.cwd();
|
||||||
|
const stateDir = resolve(root, ".state/caserun-native");
|
||||||
|
const pidPath = resolve(stateDir, "server.pid");
|
||||||
|
const logPath = resolve(stateDir, "server.log");
|
||||||
|
|
||||||
|
function readPid(): number | null {
|
||||||
|
try {
|
||||||
|
const pid = Number(readFileSync(pidPath, "utf8").trim());
|
||||||
|
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning(pid: number | null): boolean {
|
||||||
|
if (!pid) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForExit(pid: number): Promise<boolean> {
|
||||||
|
const deadline = Date.now() + 5000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
if (!isRunning(pid)) return true;
|
||||||
|
await Bun.sleep(25);
|
||||||
|
}
|
||||||
|
return !isRunning(pid);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(stateDir, { recursive: true });
|
||||||
|
const pid = readPid();
|
||||||
|
|
||||||
|
if (action === "start") {
|
||||||
|
if (isRunning(pid)) {
|
||||||
|
console.log(JSON.stringify({ ok: true, action, status: "already-running", pid, logPath }));
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
rmSync(pidPath, { force: true });
|
||||||
|
const logFd = openSync(logPath, "a");
|
||||||
|
const child = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], { cwd: root, stdin: "ignore", stdout: logFd, stderr: logFd });
|
||||||
|
child.unref();
|
||||||
|
await Bun.write(pidPath, `${child.pid}\n`);
|
||||||
|
console.log(JSON.stringify({ ok: true, action, status: "started", pid: child.pid, logPath }));
|
||||||
|
} else if (action === "stop") {
|
||||||
|
if (!isRunning(pid)) {
|
||||||
|
rmSync(pidPath, { force: true });
|
||||||
|
console.log(JSON.stringify({ ok: true, action, status: "stopped" }));
|
||||||
|
} else {
|
||||||
|
process.kill(pid!, "SIGTERM");
|
||||||
|
if (!await waitForExit(pid!)) {
|
||||||
|
console.error(JSON.stringify({ ok: false, action, status: "stop-timeout", pid }));
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
rmSync(pidPath, { force: true });
|
||||||
|
console.log(JSON.stringify({ ok: true, action, status: "stopped", pid }));
|
||||||
|
}
|
||||||
|
} else if (action === "status") {
|
||||||
|
const running = isRunning(pid);
|
||||||
|
console.log(JSON.stringify({ ok: running, action, status: running ? "running" : "stopped", pid: running ? pid : null, logPath }));
|
||||||
|
process.exit(running ? 0 : 1);
|
||||||
|
} else if (action === "logs") {
|
||||||
|
const lines = readFileSync(logPath, "utf8").trimEnd().split("\n").slice(-80);
|
||||||
|
console.log(lines.join("\n"));
|
||||||
|
} else {
|
||||||
|
console.error("usage: bun run scripts/caserun-native-service.ts <start|stop|status|logs>");
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
const baseUrl = process.env.HWLAB_CASERUN_NATIVE_URL || "http://127.0.0.1:4316";
|
||||||
|
|
||||||
|
export {};
|
||||||
|
|
||||||
|
function requireCondition(condition: unknown, message: string): asserts condition {
|
||||||
|
if (!condition) throw new Error(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readJson(path: string, init?: RequestInit): Promise<{ response: Response; body: Record<string, unknown> }> {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, init);
|
||||||
|
const body = await response.json();
|
||||||
|
requireCondition(body && typeof body === "object" && !Array.isArray(body), `invalid JSON object from ${path}`);
|
||||||
|
return { response, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
const started = await readJson("/v1/caserun/runs", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ caseId: "native-completed" })
|
||||||
|
});
|
||||||
|
requireCondition(started.response.status === 202, `start status=${started.response.status}`);
|
||||||
|
requireCondition(typeof started.body.runId === "string", "start runId missing");
|
||||||
|
|
||||||
|
await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}`);
|
||||||
|
const completed = await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}`);
|
||||||
|
requireCondition(completed.body.status === "completed", `run status=${completed.body.status}`);
|
||||||
|
|
||||||
|
const aggregate = await readJson(`/v1/caserun/runs/${encodeURIComponent(started.body.runId)}/aggregate`);
|
||||||
|
requireCondition(aggregate.response.status === 200, `aggregate httpStatus=${aggregate.response.status}`);
|
||||||
|
requireCondition(aggregate.body.mode === "native-test", `aggregate mode=${aggregate.body.mode}`);
|
||||||
|
requireCondition(aggregate.body.status === "completed", `aggregate status=${aggregate.body.status}`);
|
||||||
|
requireCondition(aggregate.body.runId === started.body.runId, `aggregate runId=${aggregate.body.runId}`);
|
||||||
|
requireCondition(typeof aggregate.body.sha256 === "string" && /^[a-f0-9]{64}$/.test(aggregate.body.sha256), `aggregate sha256=${aggregate.body.sha256}`);
|
||||||
|
const references = completed.body.references;
|
||||||
|
requireCondition(references && typeof references === "object" && !Array.isArray(references), "run aggregate reference missing");
|
||||||
|
const aggregateReference = (references as Record<string, unknown>).aggregate;
|
||||||
|
requireCondition(aggregateReference && typeof aggregateReference === "object" && !Array.isArray(aggregateReference), "run aggregate reference invalid");
|
||||||
|
requireCondition(aggregate.body.sha256 === (aggregateReference as Record<string, unknown>).sha256, "aggregate SHA does not match run reference");
|
||||||
|
|
||||||
|
console.log(JSON.stringify({
|
||||||
|
ok: true,
|
||||||
|
mode: aggregate.body.mode,
|
||||||
|
path: `/v1/caserun/runs/${started.body.runId}/aggregate`,
|
||||||
|
status: aggregate.body.status,
|
||||||
|
runId: aggregate.body.runId,
|
||||||
|
sha256: aggregate.body.sha256
|
||||||
|
}));
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { statSync } from "node:fs";
|
||||||
|
import { resolve } from "node:path";
|
||||||
|
|
||||||
|
const root = process.cwd();
|
||||||
|
const watchedFiles = [
|
||||||
|
resolve(root, "scripts/caserun-native-server.ts"),
|
||||||
|
resolve(root, "scripts/caserun-native-fixtures.ts")
|
||||||
|
];
|
||||||
|
|
||||||
|
let child: ReturnType<typeof Bun.spawn> | null = null;
|
||||||
|
let signature = sourceSignature();
|
||||||
|
let stopping = false;
|
||||||
|
let restarting = false;
|
||||||
|
|
||||||
|
function sourceSignature(): string | null {
|
||||||
|
try {
|
||||||
|
return watchedFiles.map((path) => {
|
||||||
|
const source = statSync(path);
|
||||||
|
return `${path}:${source.mtimeMs}:${source.size}`;
|
||||||
|
}).join("|");
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof Error && "code" in error && error.code === "ENOENT") return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startChild(reason: string): void {
|
||||||
|
const spawned = Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
|
||||||
|
child = spawned;
|
||||||
|
console.log(JSON.stringify({ event: "caserun-native-reload", mode: "native-test", reason, pid: spawned.pid }));
|
||||||
|
void spawned.exited.then((exitCode) => {
|
||||||
|
if (!stopping && child === spawned) console.log(JSON.stringify({ event: "caserun-native-exit", exitCode }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopChild(): Promise<void> {
|
||||||
|
const runningChild = child;
|
||||||
|
if (!runningChild) return;
|
||||||
|
if (runningChild.exitCode === null) runningChild.kill("SIGTERM");
|
||||||
|
await runningChild.exited;
|
||||||
|
if (child === runningChild) child = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
startChild("initial");
|
||||||
|
|
||||||
|
async function reloadIfChanged(): Promise<void> {
|
||||||
|
if (stopping || restarting) return;
|
||||||
|
const nextSignature = sourceSignature();
|
||||||
|
if (nextSignature === null || nextSignature === signature) return;
|
||||||
|
signature = nextSignature;
|
||||||
|
restarting = true;
|
||||||
|
try {
|
||||||
|
await stopChild();
|
||||||
|
if (!stopping) startChild("source-change");
|
||||||
|
} finally {
|
||||||
|
restarting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const timer = setInterval(() => void reloadIfChanged(), 100);
|
||||||
|
|
||||||
|
async function shutdown(): Promise<void> {
|
||||||
|
if (stopping) return;
|
||||||
|
stopping = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
await stopChild();
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.on("SIGINT", () => void shutdown());
|
||||||
|
process.on("SIGTERM", () => void shutdown());
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
export default async function caserunNativeSmoke({ page, goto, wait, screenshot, recordStep }) {
|
||||||
|
await goto("/workbench");
|
||||||
|
const panel = page.locator("#caserun-panel");
|
||||||
|
await panel.waitFor({ state: "visible", timeout: 20000 });
|
||||||
|
await page.locator("#caserun-case-select").selectOption("native-completed");
|
||||||
|
await page.locator("#caserun-start").click();
|
||||||
|
await page.locator("#caserun-refresh").click();
|
||||||
|
await wait(300);
|
||||||
|
|
||||||
|
const state = await page.locator("#caserun-status").evaluate((element) => element.textContent || "");
|
||||||
|
const runId = (await page.locator("#caserun-run-id").textContent())?.trim() || "";
|
||||||
|
const events = await page.locator("#caserun-event-text").textContent();
|
||||||
|
const aggregateStatus = (await page.locator("#caserun-aggregate-status").textContent())?.trim() || "";
|
||||||
|
const aggregateRunId = (await page.locator("#caserun-aggregate-run-id").textContent())?.trim() || "";
|
||||||
|
const aggregateSha = (await page.locator("#caserun-aggregate-sha").textContent())?.trim() || "";
|
||||||
|
const conditions = {
|
||||||
|
mode: state.includes("native-test"),
|
||||||
|
terminal: state.includes("true"),
|
||||||
|
aggregateStatus: aggregateStatus === "completed",
|
||||||
|
aggregateRunId: aggregateRunId === runId && runId.startsWith("native-completed-"),
|
||||||
|
aggregateSha: /^[a-f0-9]{64}$/.test(aggregateSha),
|
||||||
|
queuedEvent: events?.includes("queued") === true,
|
||||||
|
runningEvent: events?.includes("running") === true,
|
||||||
|
completedEvent: events?.includes("completed") === true
|
||||||
|
};
|
||||||
|
const image = await screenshot("caserun-native-completed.png");
|
||||||
|
recordStep("caserun-native-completed", { ok: Object.values(conditions).every(Boolean), conditions });
|
||||||
|
return {
|
||||||
|
ok: Object.values(conditions).every(Boolean),
|
||||||
|
mode: "native-test",
|
||||||
|
conditions,
|
||||||
|
screenshot: image,
|
||||||
|
finalUrl: page.url(),
|
||||||
|
valuesRedacted: true
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { fetchJson } from "./client";
|
import { fetchJson } from "./client";
|
||||||
import type { ApiResult, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types";
|
import type { ApiResult, CaseRunAggregateResponse, CaseRunCasesResponse, CaseRunEventsResponse, CaseRunRunResponse } from "@/types";
|
||||||
|
|
||||||
export const caserunAPI = {
|
export const caserunAPI = {
|
||||||
cases: (): Promise<ApiResult<CaseRunCasesResponse>> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }),
|
cases: (): Promise<ApiResult<CaseRunCasesResponse>> => fetchJson("/v1/caserun/cases", { timeoutMs: 12000, timeoutName: "CaseRun cases" }),
|
||||||
@@ -10,5 +10,6 @@ export const caserunAPI = {
|
|||||||
timeoutName: "CaseRun start"
|
timeoutName: "CaseRun start"
|
||||||
}),
|
}),
|
||||||
run: (runId: string): Promise<ApiResult<CaseRunRunResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}`, { timeoutMs: 12000, timeoutName: "CaseRun run" }),
|
run: (runId: string): Promise<ApiResult<CaseRunRunResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}`, { timeoutMs: 12000, timeoutName: "CaseRun run" }),
|
||||||
events: (runId: string): Promise<ApiResult<CaseRunEventsResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" })
|
events: (runId: string): Promise<ApiResult<CaseRunEventsResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/events`, { timeoutMs: 12000, timeoutName: "CaseRun events" }),
|
||||||
|
aggregate: (runId: string): Promise<ApiResult<CaseRunAggregateResponse>> => fetchJson(`/v1/caserun/runs/${encodeURIComponent(runId)}/aggregate`, { timeoutMs: 12000, timeoutName: "CaseRun aggregate" })
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,14 +18,15 @@ const statusTone = computed(() => {
|
|||||||
const eventLines = computed(() => caserun.events.map((event) => `[${formatTimestamp(event.at)}] ${event.stage} ${event.status}${event.payload ? ` ${JSON.stringify(event.payload)}` : ""}`));
|
const eventLines = computed(() => caserun.events.map((event) => `[${formatTimestamp(event.at)}] ${event.stage} ${event.status}${event.payload ? ` ${JSON.stringify(event.payload)}` : ""}`));
|
||||||
const artifactCount = computed(() => Number(caserun.currentRun?.facts?.artifactCount ?? 0));
|
const artifactCount = computed(() => Number(caserun.currentRun?.facts?.artifactCount ?? 0));
|
||||||
const jobId = computed(() => String(caserun.currentRun?.facts?.jobId ?? ""));
|
const jobId = computed(() => String(caserun.currentRun?.facts?.jobId ?? ""));
|
||||||
const aggregateHash = computed(() => caserun.currentRun?.references?.aggregate?.sha256 ?? "");
|
const aggregateHash = computed(() => caserun.aggregate?.sha256 ?? "");
|
||||||
const blockerText = computed(() => caserun.currentRun?.blocker ? `${caserun.currentRun.blocker.code}: ${caserun.currentRun.blocker.summary}` : "");
|
const blockerText = computed(() => caserun.currentRun?.blocker ? `${caserun.currentRun.blocker.code}: ${caserun.currentRun.blocker.summary}` : "");
|
||||||
|
const fixtureMode = computed(() => String(caserun.aggregate?.mode ?? caserun.currentRun?.mode ?? caserun.cases[0]?.mode ?? "live"));
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await caserun.refreshCases();
|
await caserun.refreshCases();
|
||||||
selectedCaseId.value = caserun.selectedCaseId;
|
selectedCaseId.value = caserun.selectedCaseId;
|
||||||
pollTimer = setInterval(() => {
|
pollTimer = setInterval(() => {
|
||||||
if (["queued", "running"].includes(runStatus.value)) void caserun.refreshRun();
|
if (caserun.shouldPoll) void caserun.refreshRun();
|
||||||
}, 5000);
|
}, 5000);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,8 +63,10 @@ async function startRun(): Promise<void> {
|
|||||||
<button id="caserun-refresh" type="button" :disabled="!caserun.currentRun?.runId" @click="caserun.refreshRun()">刷新</button>
|
<button id="caserun-refresh" type="button" :disabled="!caserun.currentRun?.runId" @click="caserun.refreshRun()">刷新</button>
|
||||||
</div>
|
</div>
|
||||||
<dl class="caserun-meta" id="caserun-status">
|
<dl class="caserun-meta" id="caserun-status">
|
||||||
<div><dt>run</dt><dd>{{ caserun.currentRun?.runId || '-' }}</dd></div>
|
<div><dt>run</dt><dd id="caserun-run-id">{{ caserun.currentRun?.runId || '-' }}</dd></div>
|
||||||
<div><dt>stage</dt><dd>{{ caserun.currentRun?.stage || '-' }}</dd></div>
|
<div><dt>stage</dt><dd>{{ caserun.currentRun?.stage || '-' }}</dd></div>
|
||||||
|
<div><dt>mode</dt><dd>{{ fixtureMode }}</dd></div>
|
||||||
|
<div><dt>terminal</dt><dd>{{ caserun.currentRun?.terminal ?? '-' }}</dd></div>
|
||||||
<div><dt>job</dt><dd>{{ jobId || '-' }}</dd></div>
|
<div><dt>job</dt><dd>{{ jobId || '-' }}</dd></div>
|
||||||
<div><dt>artifacts</dt><dd>{{ artifactCount }}</dd></div>
|
<div><dt>artifacts</dt><dd>{{ artifactCount }}</dd></div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -75,8 +78,9 @@ async function startRun(): Promise<void> {
|
|||||||
<pre id="caserun-event-text">{{ eventLines.join('\n') || blockerText || caserun.error || '等待 CaseRun。' }}</pre>
|
<pre id="caserun-event-text">{{ eventLines.join('\n') || blockerText || caserun.error || '等待 CaseRun。' }}</pre>
|
||||||
</section>
|
</section>
|
||||||
<section class="caserun-aggregate" id="caserun-aggregate" aria-label="CaseRun aggregate">
|
<section class="caserun-aggregate" id="caserun-aggregate" aria-label="CaseRun aggregate">
|
||||||
<strong>{{ aggregateHash ? aggregateHash.slice(0, 16) : 'aggregate pending' }}</strong>
|
<strong id="caserun-aggregate-sha">{{ aggregateHash || 'aggregate pending' }}</strong>
|
||||||
<span>{{ caserun.currentRun?.status || 'idle' }}</span>
|
<span id="caserun-aggregate-status">{{ caserun.aggregate?.status || 'pending' }}</span>
|
||||||
|
<small id="caserun-aggregate-run-id">{{ caserun.aggregate?.runId || '-' }}</small>
|
||||||
</section>
|
</section>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { createPinia, setActivePinia } from "pinia";
|
||||||
|
import { beforeEach, expect, test, vi } from "vitest";
|
||||||
|
|
||||||
|
const { casesRequest, startRequest, runRequest, eventsRequest, aggregateRequest } = vi.hoisted(() => ({
|
||||||
|
casesRequest: vi.fn(),
|
||||||
|
startRequest: vi.fn(),
|
||||||
|
runRequest: vi.fn(),
|
||||||
|
eventsRequest: vi.fn(),
|
||||||
|
aggregateRequest: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/api", () => ({
|
||||||
|
caserunAPI: { cases: casesRequest, startRun: startRequest, run: runRequest, events: eventsRequest, aggregate: aggregateRequest }
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { useCaseRunStore } from "./caserun";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia());
|
||||||
|
casesRequest.mockReset();
|
||||||
|
startRequest.mockReset();
|
||||||
|
runRequest.mockReset();
|
||||||
|
eventsRequest.mockReset();
|
||||||
|
aggregateRequest.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("polling follows API terminal instead of status names", async () => {
|
||||||
|
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "native-completed", status: "custom-progress", stage: "agent-running", terminal: false } });
|
||||||
|
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", events: [] } });
|
||||||
|
aggregateRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", status: "custom-progress", sha256: "progress-sha" } });
|
||||||
|
const store = useCaseRunStore();
|
||||||
|
|
||||||
|
await store.refreshRun("run-1");
|
||||||
|
expect(store.shouldPoll).toBe(true);
|
||||||
|
|
||||||
|
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-1", caseId: "native-completed", status: "running", stage: "completed", terminal: true } });
|
||||||
|
await store.refreshRun("run-1");
|
||||||
|
expect(store.shouldPoll).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("start keeps blocker and independent aggregate from API read models", async () => {
|
||||||
|
startRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "queued", stage: "queued", terminal: false } });
|
||||||
|
runRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", caseId: "native-blocked", status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "blocked" }, references: { aggregate: { sha256: "embedded-sha" } } } });
|
||||||
|
eventsRequest.mockResolvedValue({ ok: true, data: { runId: "run-2", events: [{ status: "blocked", stage: "preparing" }] } });
|
||||||
|
aggregateRequest.mockResolvedValue({ ok: true, data: { mode: "native-test", runId: "run-2", status: "blocked", sha256: "api-aggregate-sha" } });
|
||||||
|
const store = useCaseRunStore();
|
||||||
|
|
||||||
|
await store.startRun("native-blocked");
|
||||||
|
|
||||||
|
expect(store.currentRun?.blocker?.code).toBe("native-capability-blocked");
|
||||||
|
expect(store.aggregate?.runId).toBe("run-2");
|
||||||
|
expect(store.aggregate?.status).toBe("blocked");
|
||||||
|
expect(store.aggregate?.sha256).toBe("api-aggregate-sha");
|
||||||
|
expect(store.shouldPoll).toBe(false);
|
||||||
|
});
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import { computed, ref } from "vue";
|
import { computed, ref } from "vue";
|
||||||
import { defineStore } from "pinia";
|
import { defineStore } from "pinia";
|
||||||
import { caserunAPI } from "@/api";
|
import { caserunAPI } from "@/api";
|
||||||
import type { CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types";
|
import type { CaseRunAggregateResponse, CaseRunCaseSummary, CaseRunEvent, CaseRunRunResponse } from "@/types";
|
||||||
|
|
||||||
export const useCaseRunStore = defineStore("caserun", () => {
|
export const useCaseRunStore = defineStore("caserun", () => {
|
||||||
const cases = ref<CaseRunCaseSummary[]>([]);
|
const cases = ref<CaseRunCaseSummary[]>([]);
|
||||||
const currentRun = ref<CaseRunRunResponse | null>(null);
|
const currentRun = ref<CaseRunRunResponse | null>(null);
|
||||||
const events = ref<CaseRunEvent[]>([]);
|
const events = ref<CaseRunEvent[]>([]);
|
||||||
|
const aggregate = ref<CaseRunAggregateResponse | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const starting = ref(false);
|
const starting = ref(false);
|
||||||
const error = ref<string | null>(null);
|
const error = ref<string | null>(null);
|
||||||
const selectedCaseId = computed(() => currentRun.value?.caseId ?? cases.value[0]?.caseId ?? "");
|
const selectedCaseId = computed(() => currentRun.value?.caseId ?? cases.value[0]?.caseId ?? "");
|
||||||
|
const shouldPoll = computed(() => Boolean(currentRun.value?.runId && currentRun.value.terminal === false));
|
||||||
|
|
||||||
async function refreshCases(): Promise<void> {
|
async function refreshCases(): Promise<void> {
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
@@ -25,6 +27,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
|
|||||||
starting.value = true;
|
starting.value = true;
|
||||||
error.value = null;
|
error.value = null;
|
||||||
events.value = [];
|
events.value = [];
|
||||||
|
aggregate.value = null;
|
||||||
const result = await caserunAPI.startRun(caseId);
|
const result = await caserunAPI.startRun(caseId);
|
||||||
starting.value = false;
|
starting.value = false;
|
||||||
if (!result.ok || !result.data) {
|
if (!result.ok || !result.data) {
|
||||||
@@ -44,7 +47,7 @@ export const useCaseRunStore = defineStore("caserun", () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
currentRun.value = result.data;
|
currentRun.value = result.data;
|
||||||
await refreshEvents(runId);
|
await Promise.all([refreshEvents(runId), refreshAggregate(runId)]);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshEvents(runId = currentRun.value?.runId ?? ""): Promise<void> {
|
async function refreshEvents(runId = currentRun.value?.runId ?? ""): Promise<void> {
|
||||||
@@ -53,5 +56,11 @@ export const useCaseRunStore = defineStore("caserun", () => {
|
|||||||
if (result.ok && result.data?.runId === runId) events.value = result.data.events ?? [];
|
if (result.ok && result.data?.runId === runId) events.value = result.data.events ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return { cases, currentRun, events, loading, starting, error, selectedCaseId, refreshCases, startRun, refreshRun, refreshEvents };
|
async function refreshAggregate(runId = currentRun.value?.runId ?? ""): Promise<void> {
|
||||||
|
if (!runId) return;
|
||||||
|
const result = await caserunAPI.aggregate(runId);
|
||||||
|
if (result.ok && result.data?.runId === runId) aggregate.value = result.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { cases, currentRun, events, aggregate, loading, starting, error, selectedCaseId, shouldPoll, refreshCases, startRun, refreshRun, refreshEvents, refreshAggregate };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -516,12 +516,13 @@ export interface HwlabNodeUpdateMetadata {
|
|||||||
observedAt: string;
|
observedAt: string;
|
||||||
}
|
}
|
||||||
export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record<string, unknown> | null; expected?: Record<string, unknown> | null; runtime?: Record<string, unknown> | null; [key: string]: unknown }
|
export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record<string, unknown> | null; expected?: Record<string, unknown> | null; runtime?: Record<string, unknown> | null; [key: string]: unknown }
|
||||||
export interface CaseRunCasesResponse { ok?: boolean; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record<string, unknown>; [key: string]: unknown }
|
export interface CaseRunCasesResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record<string, unknown>; [key: string]: unknown }
|
||||||
export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
|
export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
|
||||||
export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record<string, unknown> | null }
|
export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record<string, unknown> | null }
|
||||||
export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record<string, unknown> | null; [key: string]: unknown }
|
export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record<string, unknown> | null; [key: string]: unknown }
|
||||||
export interface CaseRunRunResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record<string, unknown> | null; hwpod?: Record<string, unknown> | null }; sourceAuthority?: Record<string, unknown>; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown }
|
export interface CaseRunRunResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record<string, unknown> | null; hwpod?: Record<string, unknown> | null }; sourceAuthority?: Record<string, unknown>; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown }
|
||||||
export interface CaseRunEventsResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; events?: CaseRunEvent[]; [key: string]: unknown }
|
export interface CaseRunEventsResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; events?: CaseRunEvent[]; [key: string]: unknown }
|
||||||
|
export interface CaseRunAggregateResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record<string, unknown>; sha256?: string; summary?: Record<string, unknown> | null; evidence?: Record<string, unknown> | null; run?: Record<string, unknown> | null; [key: string]: unknown }
|
||||||
export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
|
export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
|
||||||
export interface ProviderProfileCatalogItem {
|
export interface ProviderProfileCatalogItem {
|
||||||
profile: string;
|
profile: string;
|
||||||
|
|||||||
@@ -1,25 +1,51 @@
|
|||||||
|
import { Agent } from "node:http";
|
||||||
import { fileURLToPath, URL } from "node:url";
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
|
||||||
import vue from "@vitejs/plugin-vue";
|
import vue from "@vitejs/plugin-vue";
|
||||||
import { defineConfig } from "vite";
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig(({ mode }) => {
|
||||||
plugins: [vue()],
|
const env = loadEnv(mode, process.cwd(), "");
|
||||||
resolve: {
|
const nativeCaseRunEnabled = (process.env.HWLAB_CASERUN_NATIVE_TEST ?? env.HWLAB_CASERUN_NATIVE_TEST) === "1";
|
||||||
alias: {
|
const nativeCaseRunTarget = process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "http://127.0.0.1:4316";
|
||||||
"@": fileURLToPath(new URL("./src", import.meta.url))
|
const nativeCaseRunAgent = new Agent({ keepAlive: true });
|
||||||
}
|
|
||||||
},
|
return {
|
||||||
build: {
|
plugins: [vue()],
|
||||||
outDir: "dist",
|
resolve: {
|
||||||
emptyOutDir: true,
|
alias: {
|
||||||
sourcemap: false,
|
"@": fileURLToPath(new URL("./src", import.meta.url))
|
||||||
rollupOptions: {
|
}
|
||||||
output: {
|
},
|
||||||
entryFileNames: "app.js",
|
server: nativeCaseRunEnabled ? {
|
||||||
chunkFileNames: "assets/[name].js",
|
watch: {
|
||||||
assetFileNames: "assets/[name][extname]"
|
usePolling: true,
|
||||||
|
interval: 300
|
||||||
|
},
|
||||||
|
proxy: {
|
||||||
|
"/auth": {
|
||||||
|
target: nativeCaseRunTarget,
|
||||||
|
changeOrigin: false,
|
||||||
|
agent: nativeCaseRunAgent
|
||||||
|
},
|
||||||
|
"/v1/caserun": {
|
||||||
|
target: nativeCaseRunTarget,
|
||||||
|
changeOrigin: false,
|
||||||
|
agent: nativeCaseRunAgent
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} : undefined,
|
||||||
|
build: {
|
||||||
|
outDir: "dist",
|
||||||
|
emptyOutDir: true,
|
||||||
|
sourcemap: false,
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
entryFileNames: "app.js",
|
||||||
|
chunkFileNames: "assets/[name].js",
|
||||||
|
assetFileNames: "assets/[name][extname]"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user