Merge pull request #2783 from pikasTech/feat/embdagentbench-workbench-caserun
feat: 桥接Workbench与CaseRun
This commit is contained in:
@@ -3,7 +3,7 @@ import { createWorkbenchHttpApp } from "../../internal/workbench/http.ts";
|
||||
import { workbenchRuntime } from "../../internal/workbench/runtime.ts";
|
||||
import { startHwlabKafkaEventBridge } from "../../internal/cloud/kafka-event-bridge.ts";
|
||||
|
||||
const runtime = workbenchRuntime(process.env, { eventPublisher: null });
|
||||
const runtime = workbenchRuntime(process.env, { eventPublisher: null, caseRunTransportMode: "api" });
|
||||
const kafkaEventBridge = runtime.mode === "agentrun-native" ? startHwlabKafkaEventBridge({ env: process.env }) : null;
|
||||
const app = createWorkbenchHttpApp({ dispatch: runtime.dispatch, snapshot: runtime.application.snapshot?.bind(runtime.application), providerModelCatalog: runtime.application.providerModelCatalog?.bind(runtime.application), authorization: process.env.WORKBENCH_API_AUTHORIZATION, mode: runtime.mode, kafkaEventBridge, close: async () => { await kafkaEventBridge?.stop?.(); await runtime.close(); } });
|
||||
const host = process.env.WORKBENCH_API_HOST || "0.0.0.0";
|
||||
|
||||
@@ -11,6 +11,12 @@ export type CaseAuthority = {
|
||||
definitionSha256: string;
|
||||
};
|
||||
|
||||
export type CaseRunLaunchContext = {
|
||||
workbenchSessionRef: { sessionId: string; actorId: string };
|
||||
hwpodId: string;
|
||||
subjectRef?: string;
|
||||
};
|
||||
|
||||
export type CaseRunEvent = {
|
||||
id: number;
|
||||
runId: string;
|
||||
@@ -29,6 +35,7 @@ export type CaseRunRecord = {
|
||||
stage: string;
|
||||
terminal: boolean;
|
||||
caseAuthority: CaseAuthority;
|
||||
launchContext: CaseRunLaunchContext | null;
|
||||
caseSnapshotDir: string;
|
||||
caseRepo: string;
|
||||
runDir: string;
|
||||
@@ -45,6 +52,7 @@ export type StartCaseRunInput = {
|
||||
caseId: string;
|
||||
workflowId: string;
|
||||
caseAuthority: CaseAuthority;
|
||||
launchContext: CaseRunLaunchContext | null;
|
||||
caseSnapshotDir: string;
|
||||
caseRepo: string;
|
||||
runDir: string;
|
||||
@@ -71,6 +79,7 @@ export type CaseRunListItem = {
|
||||
evidenceStatus: string | null;
|
||||
artifactManifestSha256: string | null;
|
||||
caseAuthority: Pick<CaseAuthority, "repository" | "ref" | "commit" | "definitionSha256">;
|
||||
launchContext: CaseRunLaunchContext | null;
|
||||
};
|
||||
|
||||
export type ActivityResult = {
|
||||
|
||||
@@ -46,6 +46,30 @@ test("API restart preserves PostgreSQL-shaped registry read model", async () =>
|
||||
assert.equal(record.workflowRunId, "temporal-run-1");
|
||||
});
|
||||
|
||||
test("Workbench launch context is frozen and conflicting run replay is rejected", async () => {
|
||||
const fixture = await softwareSmokeFixture();
|
||||
const registry = new MemoryRegistry();
|
||||
const service = createHarnessRLService({ registry, temporal: new FakeTemporal(), cwd: fixture.root, caseRepo: fixture.root, stateRoot: fixture.stateRoot, caseAuthority: fixture.caseAuthority });
|
||||
const launchContext = {
|
||||
workbenchSessionRef: { sessionId: "ses_caserun_arm2d", actorId: "usr_arm2d" },
|
||||
hwpodId: "d601-vm-stm32f405-qemu",
|
||||
subjectRef: "f909cdaf6f0cd579b1f1b66d2d1eee5235a09fb9",
|
||||
};
|
||||
|
||||
const accepted = await service.submit({ caseId: fixture.caseId, runId: "workbench-context", launchContext, runtimeApiUrl: "local://harnessrl" });
|
||||
expect(accepted?.launchContext).toEqual(launchContext);
|
||||
expect((await service.listRuns()).runs[0].launchContext).toEqual(launchContext);
|
||||
expect((await service.aggregate("workbench-context")).launchContext).toEqual(launchContext);
|
||||
await expect(service.submit({
|
||||
caseId: fixture.caseId,
|
||||
runId: "workbench-context",
|
||||
launchContext: { ...launchContext, hwpodId: "another-hwpod" },
|
||||
runtimeApiUrl: "local://harnessrl",
|
||||
})).rejects.toMatchObject({ code: "caserun_run_identity_conflict" });
|
||||
await expect(service.submit({ caseId: fixture.caseId, runId: "workbench-context", runtimeApiUrl: "local://harnessrl" }))
|
||||
.rejects.toMatchObject({ code: "caserun_run_identity_conflict" });
|
||||
});
|
||||
|
||||
test("run history lists shared registry results newest first with an opaque cursor", async () => {
|
||||
const fixture = await softwareSmokeFixture();
|
||||
const registry = new MemoryRegistry();
|
||||
@@ -92,7 +116,7 @@ test("run history lists shared registry results newest first with an opaque curs
|
||||
test("worker restart reuses idempotent prepare/build/collect activity results", async () => {
|
||||
const fixture = await softwareSmokeFixture();
|
||||
const registry = new MemoryRegistry();
|
||||
await registry.createRun({ runId: "worker-restart", caseId: fixture.caseId, workflowId: "workflow-worker-restart", caseAuthority: fixture.authority, caseSnapshotDir: fixture.root, caseRepo: fixture.root, runDir: path.join(fixture.stateRoot, "runs", "worker-restart"), runtimeApiUrl: "http://127.0.0.1:6667" });
|
||||
await registry.createRun({ runId: "worker-restart", caseId: fixture.caseId, workflowId: "workflow-worker-restart", caseAuthority: fixture.authority, launchContext: null, caseSnapshotDir: fixture.root, caseRepo: fixture.root, runDir: path.join(fixture.stateRoot, "runs", "worker-restart"), runtimeApiUrl: "http://127.0.0.1:6667" });
|
||||
const firstWorker = createHarnessRLActivities({ registry, env: {} });
|
||||
await firstWorker.markRunning({ runId: "worker-restart" });
|
||||
const prepared = await firstWorker.prepare({ runId: "worker-restart", identity: "worker-restart:prepare:v1" });
|
||||
@@ -124,7 +148,7 @@ test("worker uses configured cloud-api authority and resubmits the same identity
|
||||
const registry = new MemoryRegistry();
|
||||
const runId = "authority-retry";
|
||||
const identity = `${runId}:agent:v1`;
|
||||
await registry.createRun({ runId, caseId: fixture.caseId, workflowId: `workflow-${runId}`, caseAuthority: fixture.authority, caseSnapshotDir: fixture.root, caseRepo: fixture.root, runDir: path.join(fixture.stateRoot, "runs", runId), runtimeApiUrl: "https://public.example.test" });
|
||||
await registry.createRun({ runId, caseId: fixture.caseId, workflowId: `workflow-${runId}`, caseAuthority: fixture.authority, launchContext: null, caseSnapshotDir: fixture.root, caseRepo: fixture.root, runDir: path.join(fixture.stateRoot, "runs", runId), runtimeApiUrl: "https://public.example.test" });
|
||||
await registry.saveActivityResult({ runId, activityName: "prepare", identity: `${runId}:prepare:v1`, output: { state: "completed", result: { mode: "hardware", prepared: { run: { runId } } } } });
|
||||
await registry.saveActivityResult({ runId, activityName: "agent", identity, output: { state: "started", authorityIdentity: identity } });
|
||||
let submitCount = 0;
|
||||
@@ -148,7 +172,7 @@ test("worker uses configured cloud-api authority and resubmits the same identity
|
||||
test("AgentRun and HWPOD crash gaps reuse stable authoritative operation identities", async () => {
|
||||
const fixture = await hardwareFixture();
|
||||
const registry = new MemoryRegistry();
|
||||
await registry.createRun({ runId: "authority-gap", caseId: fixture.caseId, workflowId: "workflow-authority-gap", caseAuthority: fixture.authority, caseSnapshotDir: fixture.root, caseRepo: fixture.root, runDir: path.join(fixture.stateRoot, "runs", "authority-gap"), runtimeApiUrl: "http://127.0.0.1:6667" });
|
||||
await registry.createRun({ runId: "authority-gap", caseId: fixture.caseId, workflowId: "workflow-authority-gap", caseAuthority: fixture.authority, launchContext: null, caseSnapshotDir: fixture.root, caseRepo: fixture.root, runDir: path.join(fixture.stateRoot, "runs", "authority-gap"), runtimeApiUrl: "http://127.0.0.1:6667" });
|
||||
let agentDispatchCount = 0;
|
||||
let hwpodDispatchCount = 0;
|
||||
let agentLookupCount = 0;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { caseRunAccepted } from "./transport.ts";
|
||||
import type { CaseRunLaunchContext } from "./contracts.ts";
|
||||
|
||||
export function createHarnessRLHttpApp(options: { service: ReturnType<typeof import("./service.ts")["createHarnessRLService"]> }) {
|
||||
return {
|
||||
@@ -14,7 +15,7 @@ export function createHarnessRLHttpApp(options: { service: ReturnType<typeof imp
|
||||
}
|
||||
if (url.pathname === "/v1/caserun/runs" && request.method === "POST") {
|
||||
const body = await bodyObject(request);
|
||||
const record = await options.service.submit({ caseId: String(body.caseId ?? ""), runId: body.runId ? String(body.runId) : undefined, runtimeApiUrl: runtimeApiUrl(request) });
|
||||
const record = await options.service.submit({ caseId: String(body.caseId ?? ""), runId: body.runId ? String(body.runId) : undefined, launchContext: body.launchContext as CaseRunLaunchContext | undefined, runtimeApiUrl: runtimeApiUrl(request) });
|
||||
return json(202, caseRunAccepted(record));
|
||||
}
|
||||
if (url.pathname === "/v1/caserun/runs" && request.method === "GET") {
|
||||
|
||||
@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS harnessrl_runs (
|
||||
status TEXT NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
case_authority JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
launch_context JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
case_snapshot_dir TEXT NOT NULL DEFAULT '',
|
||||
case_repo TEXT NOT NULL,
|
||||
run_dir TEXT NOT NULL,
|
||||
@@ -26,6 +27,8 @@ ALTER TABLE harnessrl_runs
|
||||
ADD COLUMN IF NOT EXISTS case_authority JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
ALTER TABLE harnessrl_runs
|
||||
ADD COLUMN IF NOT EXISTS case_snapshot_dir TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE harnessrl_runs
|
||||
ADD COLUMN IF NOT EXISTS launch_context JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS harnessrl_run_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
|
||||
@@ -47,11 +47,11 @@ export class PostgresHarnessRLRegistry implements HarnessRLRegistry {
|
||||
async createRun(input: StartCaseRunInput) {
|
||||
await this.ensureSchema();
|
||||
const result = await this.pool.query(
|
||||
`INSERT INTO harnessrl_runs (run_id,case_id,workflow_id,status,stage,case_authority,case_snapshot_dir,case_repo,run_dir,runtime_api_url)
|
||||
VALUES ($1,$2,$3,'queued','queued',$4,$5,$6,$7,$8)
|
||||
`INSERT INTO harnessrl_runs (run_id,case_id,workflow_id,status,stage,case_authority,launch_context,case_snapshot_dir,case_repo,run_dir,runtime_api_url)
|
||||
VALUES ($1,$2,$3,'queued','queued',$4,$5,$6,$7,$8,$9)
|
||||
ON CONFLICT (run_id) DO UPDATE SET run_id=EXCLUDED.run_id
|
||||
RETURNING *`,
|
||||
[input.runId, input.caseId, input.workflowId, input.caseAuthority, input.caseSnapshotDir, input.caseRepo, input.runDir, input.runtimeApiUrl]
|
||||
[input.runId, input.caseId, input.workflowId, input.caseAuthority, input.launchContext ?? {}, input.caseSnapshotDir, input.caseRepo, input.runDir, input.runtimeApiUrl]
|
||||
);
|
||||
return runRow(result.rows[0]);
|
||||
}
|
||||
@@ -147,12 +147,17 @@ function runRow(row: any): CaseRunRecord {
|
||||
return {
|
||||
runId: row.run_id, caseId: row.case_id, workflowId: row.workflow_id, workflowRunId: row.workflow_run_id,
|
||||
status, stage: row.stage, terminal: terminalStatus(status), caseAuthority: row.case_authority,
|
||||
launchContext: launchContextValue(row.launch_context),
|
||||
caseSnapshotDir: row.case_snapshot_dir, caseRepo: row.case_repo, runDir: row.run_dir,
|
||||
runtimeApiUrl: row.runtime_api_url, result: row.result, error: row.error, blocker: row.blocker,
|
||||
createdAt: new Date(row.created_at).toISOString(), updatedAt: new Date(row.updated_at).toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
function launchContextValue(value: any) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function eventRow(row: any): CaseRunEvent {
|
||||
return { id: Number(row.id), runId: row.run_id, status: row.status, stage: row.stage, payload: row.payload ?? {}, createdAt: new Date(row.created_at).toISOString() };
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Buffer } from "node:buffer";
|
||||
import { mkdir, readdir, stat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import type { CaseRunListItem, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts";
|
||||
import type { CaseRunLaunchContext, CaseRunListItem, CaseRunRecord, CaseRunStatus, HarnessRLRegistry, HarnessRLTemporalGateway } from "./contracts.ts";
|
||||
import type { CaseAuthorityResolver } from "./case-authority.ts";
|
||||
|
||||
const RUN_ID_PATTERN = /^[A-Za-z0-9_.:-]{3,180}$/u;
|
||||
@@ -41,9 +41,10 @@ export function createHarnessRLService(options: { registry: HarnessRLRegistry; t
|
||||
nextCursor: hasMore && last ? encodeCursor(last) : null
|
||||
};
|
||||
},
|
||||
async submit(input: { caseId: string; runId?: string; runtimeApiUrl: string }) {
|
||||
async submit(input: { caseId: string; runId?: string; launchContext?: CaseRunLaunchContext; runtimeApiUrl: string }) {
|
||||
const caseId = opaqueId(input.caseId, "invalid_case_id");
|
||||
const runId = input.runId ? opaqueId(input.runId, "invalid_run_id") : `${caseId}-${Date.now()}-${randomUUID().slice(0, 8)}`;
|
||||
const launchContext = normalizeLaunchContext(input.launchContext);
|
||||
const workflowId = `harnessrl-caserun-${runId}`;
|
||||
const runDir = path.join(options.stateRoot, "runs", runId);
|
||||
const caseSnapshotDir = path.join(runDir, "case-authority");
|
||||
@@ -51,14 +52,20 @@ export function createHarnessRLService(options: { registry: HarnessRLRegistry; t
|
||||
if (record && record.caseId !== caseId) {
|
||||
throw codedError("caserun_run_identity_conflict", "runId already belongs to another case", { runId, requestedCaseId: caseId, existingCaseId: record.caseId });
|
||||
}
|
||||
if (record && !sameLaunchContext(record.launchContext, launchContext)) {
|
||||
throw codedError("caserun_run_identity_conflict", "runId already belongs to another Workbench launch context", { runId, requested: launchContext, existing: record.launchContext });
|
||||
}
|
||||
if (!record) {
|
||||
const resolved = await options.caseAuthority.resolve(caseId);
|
||||
await mkdir(runDir, { recursive: true });
|
||||
await resolved.materialize(caseSnapshotDir);
|
||||
record = await registry.createRun({ runId, caseId, workflowId, caseAuthority: resolved.authority, caseSnapshotDir, caseRepo: options.caseRepo, runDir, runtimeApiUrl: input.runtimeApiUrl });
|
||||
record = await registry.createRun({ runId, caseId, workflowId, caseAuthority: resolved.authority, launchContext, caseSnapshotDir, caseRepo: options.caseRepo, runDir, runtimeApiUrl: input.runtimeApiUrl });
|
||||
if (record.caseId !== caseId || !sameLaunchContext(record.launchContext, launchContext)) {
|
||||
throw codedError("caserun_run_identity_conflict", "runId was concurrently admitted with another launch identity", { runId, requestedCaseId: caseId, existingCaseId: record.caseId });
|
||||
}
|
||||
}
|
||||
if (!record.workflowRunId) {
|
||||
await registry.appendEvent(runId, "queued", "queued", { caseId, caseAuthority: record.caseAuthority });
|
||||
await registry.appendEvent(runId, "queued", "queued", { caseId, caseAuthority: record.caseAuthority, launchContext: record.launchContext });
|
||||
const started = await temporal.start({ workflowId, runId });
|
||||
await registry.updateRun(runId, { workflowRunId: started.workflowRunId });
|
||||
if (started.reused) await registry.appendEvent(runId, "queued", "workflow-reused", { workflowId, workflowRunId: started.workflowRunId });
|
||||
@@ -77,7 +84,7 @@ export function createHarnessRLService(options: { registry: HarnessRLRegistry; t
|
||||
async aggregate(runId: string) {
|
||||
const record = await this.getRun(runId);
|
||||
return { ok: true, contractVersion: "hwlab-caserun-aggregate-v1", runId: record.runId, caseId: record.caseId,
|
||||
status: record.status, stage: record.stage, terminal: record.terminal, caseAuthority: record.caseAuthority, result: record.result,
|
||||
status: record.status, stage: record.stage, terminal: record.terminal, caseAuthority: record.caseAuthority, launchContext: record.launchContext, result: record.result,
|
||||
sha256: record.result?.artifactManifestSha256 ?? null };
|
||||
},
|
||||
async cancel(runId: string) {
|
||||
@@ -114,7 +121,8 @@ function runListItem(record: CaseRunRecord): CaseRunListItem {
|
||||
ref: record.caseAuthority.ref,
|
||||
commit: record.caseAuthority.commit,
|
||||
definitionSha256: record.caseAuthority.definitionSha256
|
||||
}
|
||||
},
|
||||
launchContext: record.launchContext
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +188,30 @@ function stringValue(value: unknown): string | null {
|
||||
return typeof value === "string" && value ? value : null;
|
||||
}
|
||||
|
||||
function normalizeLaunchContext(value: CaseRunLaunchContext | undefined): CaseRunLaunchContext | null {
|
||||
if (value === undefined) return null;
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) throw codedError("invalid_launch_context", "launchContext must be an object");
|
||||
const workbenchSessionRef = value.workbenchSessionRef;
|
||||
if (!workbenchSessionRef || typeof workbenchSessionRef !== "object" || Array.isArray(workbenchSessionRef)) {
|
||||
throw codedError("invalid_launch_context", "launchContext.workbenchSessionRef is required");
|
||||
}
|
||||
const sessionId = requiredLaunchText(workbenchSessionRef.sessionId, "launchContext.workbenchSessionRef.sessionId");
|
||||
const actorId = requiredLaunchText(workbenchSessionRef.actorId, "launchContext.workbenchSessionRef.actorId");
|
||||
const hwpodId = requiredLaunchText(value.hwpodId, "launchContext.hwpodId");
|
||||
const subjectRef = String(value.subjectRef ?? "").trim();
|
||||
return { workbenchSessionRef: { sessionId, actorId }, hwpodId, ...(subjectRef ? { subjectRef } : {}) };
|
||||
}
|
||||
|
||||
function requiredLaunchText(value: unknown, field: string) {
|
||||
const text = String(value ?? "").trim();
|
||||
if (!text || text.length > 500) throw codedError("invalid_launch_context", `${field} is required and must not exceed 500 characters`, { field });
|
||||
return text;
|
||||
}
|
||||
|
||||
function sameLaunchContext(left: CaseRunLaunchContext | null, right: CaseRunLaunchContext | null) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function finiteNumber(value: unknown): number | null {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) && number >= 0 ? number : null;
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
// SPEC: PJ2026-010306 嵌入式AgentBench.
|
||||
// SPEC_VERSION: draft-2026-07-24-p0-l0-l1-arm2d.
|
||||
|
||||
import type { CaseRunRecord } from "./contracts.ts";
|
||||
import type { CaseRunLaunchContext, CaseRunRecord } from "./contracts.ts";
|
||||
|
||||
export type CaseRunCommand =
|
||||
| { kind: "start"; caseId: string; runId?: string }
|
||||
| { kind: "start"; caseId: string; runId?: string; launchContext?: CaseRunLaunchContext }
|
||||
| { kind: "status"; runId: string }
|
||||
| { kind: "events"; runId: string }
|
||||
| { kind: "aggregate"; runId: string }
|
||||
| { kind: "cancel"; runId: string };
|
||||
|
||||
export type HarnessRLService = {
|
||||
submit(input: { caseId: string; runId?: string; runtimeApiUrl: string }): Promise<CaseRunRecord | null>;
|
||||
submit(input: { caseId: string; runId?: string; launchContext?: CaseRunLaunchContext; runtimeApiUrl: string }): Promise<CaseRunRecord | null>;
|
||||
getRun(runId: string): Promise<CaseRunRecord>;
|
||||
events(runId: string): Promise<Record<string, unknown>>;
|
||||
aggregate(runId: string): Promise<Record<string, unknown>>;
|
||||
@@ -47,7 +47,7 @@ export function createLocalCaseRunTransport(options: { service: HarnessRLService
|
||||
},
|
||||
async execute(command) {
|
||||
try {
|
||||
if (command.kind === "start") return caseRunAccepted(await options.service.submit({ caseId: command.caseId, runId: command.runId, runtimeApiUrl: "local://harnessrl" }));
|
||||
if (command.kind === "start") return caseRunAccepted(await options.service.submit({ caseId: command.caseId, runId: command.runId, launchContext: command.launchContext, runtimeApiUrl: "local://harnessrl" }));
|
||||
if (command.kind === "status") return options.service.getRun(command.runId);
|
||||
if (command.kind === "events") return options.service.events(command.runId);
|
||||
if (command.kind === "aggregate") return options.service.aggregate(command.runId);
|
||||
@@ -81,7 +81,7 @@ export function createApiCaseRunTransport(options: { baseUrl: string; fetchImpl?
|
||||
...(command.kind === "start" ? { "content-type": "application/json" } : {}),
|
||||
...(options.apiKey ? { authorization: `Bearer ${options.apiKey}` } : {})
|
||||
},
|
||||
body: command.kind === "start" ? JSON.stringify({ caseId: command.caseId, ...(command.runId ? { runId: command.runId } : {}) }) : undefined
|
||||
body: command.kind === "start" ? JSON.stringify({ caseId: command.caseId, ...(command.runId ? { runId: command.runId } : {}), ...(command.launchContext ? { launchContext: command.launchContext } : {}) }) : undefined
|
||||
});
|
||||
const body = await response.json().catch((error) => {
|
||||
if (controller.signal.aborted) throw error;
|
||||
@@ -114,6 +114,7 @@ export function caseRunAccepted(record: CaseRunRecord | null) {
|
||||
workflowId: record.workflowId,
|
||||
workflowRunId: record.workflowRunId,
|
||||
caseAuthority: record.caseAuthority,
|
||||
launchContext: record.launchContext,
|
||||
status: record.status,
|
||||
stage: record.stage,
|
||||
statusUrl: `/v1/caserun/runs/${encodeURIComponent(record.runId)}`,
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
import { afterEach, expect, test } from "bun:test";
|
||||
|
||||
import type { CaseRunCommand, CaseRunTransport } from "../harnessrl/transport.ts";
|
||||
import { createWorkbenchCaseRunLauncher, sessionIdForRun } from "./caserun-bridge.ts";
|
||||
import { createWorkbenchDispatcher } from "./dispatcher.ts";
|
||||
import { createNativeTestWorkbenchApplication } from "./native-application.ts";
|
||||
import { createNativeTestTemporalGateway } from "./native-temporal.ts";
|
||||
|
||||
const temporaryDirectories: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(temporaryDirectories.splice(0).map((directory) => rm(directory, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
test("Workbench CaseRun bridge creates one deterministic session and freezes launch identity", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "workbench-caserun-bridge-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const application = createNativeTestWorkbenchApplication({ stateFile: path.join(directory, "state.json") });
|
||||
const commands: CaseRunCommand[] = [];
|
||||
const launcher = createWorkbenchCaseRunLauncher({ application, transport: () => fakeTransport(commands) });
|
||||
const input = {
|
||||
actor: { id: "usr_arm2d", role: "user" },
|
||||
caseId: "d601-vm-stm32f405-qemu-arm2d-integration",
|
||||
hwpodId: "d601-vm-stm32f405-qemu",
|
||||
subjectRef: "f909cdaf6f0cd579b1f1b66d2d1eee5235a09fb9",
|
||||
runId: "run_arm2d_bridge",
|
||||
};
|
||||
|
||||
const first = await launcher.launch(input);
|
||||
const replay = await launcher.launch(input);
|
||||
const expectedSessionId = sessionIdForRun(input.runId);
|
||||
|
||||
expect(first).toMatchObject({
|
||||
accepted: true,
|
||||
sessionId: expectedSessionId,
|
||||
runId: input.runId,
|
||||
workflowId: `harnessrl-caserun-${input.runId}`,
|
||||
hwpodId: input.hwpodId,
|
||||
statusRef: `/v1/caserun/runs/${input.runId}`,
|
||||
eventsRef: `/v1/caserun/runs/${input.runId}/events`,
|
||||
aggregateRef: `/v1/caserun/runs/${input.runId}/aggregate`,
|
||||
harnessTransport: "local",
|
||||
});
|
||||
expect(replay.sessionId).toBe(expectedSessionId);
|
||||
expect(commands).toHaveLength(2);
|
||||
expect(commands[0]).toEqual({
|
||||
kind: "start",
|
||||
caseId: input.caseId,
|
||||
runId: input.runId,
|
||||
launchContext: {
|
||||
workbenchSessionRef: { sessionId: expectedSessionId, actorId: input.actor.id },
|
||||
hwpodId: input.hwpodId,
|
||||
subjectRef: input.subjectRef,
|
||||
},
|
||||
});
|
||||
const state = await application.snapshot!();
|
||||
expect(Object.keys(state.sessions)).toEqual([expectedSessionId]);
|
||||
expect(state.sessions[expectedSessionId].launchContext).toEqual({ kind: "caserun", caseId: input.caseId, runId: input.runId, hwpodId: input.hwpodId, subjectRef: input.subjectRef });
|
||||
});
|
||||
|
||||
test("Workbench dispatcher exposes CaseRun as an explicit control operation", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "workbench-caserun-dispatcher-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const application = createNativeTestWorkbenchApplication({ stateFile: path.join(directory, "state.json") });
|
||||
const caseRun = createWorkbenchCaseRunLauncher({ application, transport: () => fakeTransport([]) });
|
||||
const dispatch = createWorkbenchDispatcher({ application, temporal: createNativeTestTemporalGateway(application), caseRun, mode: "native-test" });
|
||||
|
||||
const result = await dispatch({
|
||||
operation: "caserun.start",
|
||||
actor: { id: "usr_arm2d" },
|
||||
params: { caseId: "arm2d-integration", hwpodId: "d601-vm-stm32f405-qemu", runId: "run_dispatcher_arm2d" },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
operation: "caserun.start",
|
||||
mode: "native-test",
|
||||
data: {
|
||||
sessionId: sessionIdForRun("run_dispatcher_arm2d"),
|
||||
runId: "run_dispatcher_arm2d",
|
||||
workflowId: "harnessrl-caserun-run_dispatcher_arm2d",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
function fakeTransport(commands: CaseRunCommand[]): CaseRunTransport {
|
||||
return {
|
||||
mode: "local",
|
||||
visibility() { return {}; },
|
||||
async execute(command) {
|
||||
commands.push(command);
|
||||
if (command.kind !== "start") throw new Error("start required");
|
||||
return {
|
||||
ok: true,
|
||||
runId: command.runId,
|
||||
workflowId: `harnessrl-caserun-${command.runId}`,
|
||||
workflowRunId: "temporal-run-1",
|
||||
status: "queued",
|
||||
stage: "queued",
|
||||
caseAuthority: { commit: "a".repeat(40) },
|
||||
statusUrl: `/v1/caserun/runs/${command.runId}`,
|
||||
eventsUrl: `/v1/caserun/runs/${command.runId}/events`,
|
||||
aggregateUrl: `/v1/caserun/runs/${command.runId}/aggregate`,
|
||||
cancelUrl: `/v1/caserun/runs/${command.runId}/cancel`,
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// SPEC: PJ2026-010306 嵌入式AgentBench.
|
||||
// SPEC_VERSION: draft-2026-07-24-p0-l0-l1-arm2d.
|
||||
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import type { CaseRunCommand, CaseRunTransport } from "../harnessrl/transport.ts";
|
||||
import type { WorkbenchApplication, WorkbenchCaseRunLauncher } from "./contracts.ts";
|
||||
|
||||
export function createWorkbenchCaseRunLauncher(options: {
|
||||
application: WorkbenchApplication;
|
||||
transport: () => CaseRunTransport | Promise<CaseRunTransport>;
|
||||
}): WorkbenchCaseRunLauncher {
|
||||
return {
|
||||
async launch(input) {
|
||||
const caseId = requiredText(input.caseId, "caseId");
|
||||
const hwpodId = requiredText(input.hwpodId, "hwpodId");
|
||||
const actorId = requiredText(input.actor.id, "actorId");
|
||||
const runId = optionalId(input.runId, "runId") ?? `run_${randomUUID()}`;
|
||||
const sessionId = sessionIdForRun(runId);
|
||||
const subjectRef = optionalText(input.subjectRef);
|
||||
const launchContext = {
|
||||
workbenchSessionRef: { sessionId, actorId },
|
||||
hwpodId,
|
||||
...(subjectRef ? { subjectRef } : {}),
|
||||
};
|
||||
const created = await options.application.createSession({
|
||||
actor: input.actor,
|
||||
params: {
|
||||
sessionId,
|
||||
projectId: "pikasTech/HWLAB",
|
||||
launchContext: { kind: "caserun", caseId, runId, hwpodId, ...(subjectRef ? { subjectRef } : {}) },
|
||||
},
|
||||
});
|
||||
const actualSessionId = sessionIdFrom(created);
|
||||
if (actualSessionId !== sessionId) {
|
||||
throw codedError("workbench_caserun_session_identity_mismatch", "Workbench session creation returned another session identity", { expected: sessionId, actual: actualSessionId });
|
||||
}
|
||||
const command: CaseRunCommand = { kind: "start", caseId, runId, launchContext };
|
||||
const transport = await options.transport();
|
||||
try {
|
||||
const accepted = await transport.execute(command);
|
||||
return {
|
||||
accepted: true,
|
||||
sessionId,
|
||||
runId: requiredText(accepted.runId, "accepted.runId"),
|
||||
workflowId: requiredText(accepted.workflowId, "accepted.workflowId"),
|
||||
workflowRunId: optionalText(accepted.workflowRunId),
|
||||
caseId,
|
||||
hwpodId,
|
||||
actorId,
|
||||
subjectRef: subjectRef || null,
|
||||
status: accepted.status,
|
||||
stage: accepted.stage,
|
||||
caseAuthority: accepted.caseAuthority,
|
||||
statusRef: requiredText(accepted.statusUrl, "accepted.statusUrl"),
|
||||
eventsRef: requiredText(accepted.eventsUrl, "accepted.eventsUrl"),
|
||||
aggregateRef: requiredText(accepted.aggregateUrl, "accepted.aggregateUrl"),
|
||||
cancelRef: requiredText(accepted.cancelUrl, "accepted.cancelUrl"),
|
||||
harnessTransport: transport.mode,
|
||||
};
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function sessionIdForRun(runId: string) {
|
||||
return `ses_caserun_${createHash("sha256").update(runId).digest("hex").slice(0, 32)}`;
|
||||
}
|
||||
|
||||
function sessionIdFrom(value: Record<string, unknown>) {
|
||||
const session = value.session && typeof value.session === "object" && !Array.isArray(value.session)
|
||||
? value.session as Record<string, unknown>
|
||||
: null;
|
||||
return requiredText(session?.sessionId, "session.sessionId");
|
||||
}
|
||||
|
||||
function optionalId(value: unknown, field: string) {
|
||||
const text = optionalText(value);
|
||||
if (!text) return null;
|
||||
if (!/^[A-Za-z0-9_.:-]{3,180}$/u.test(text)) throw codedError("invalid_input", `${field} is invalid`, { field });
|
||||
return text;
|
||||
}
|
||||
|
||||
function requiredText(value: unknown, field: string) {
|
||||
const text = optionalText(value);
|
||||
if (!text) throw codedError("invalid_input", `${field} is required`, { field });
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown) {
|
||||
return String(value ?? "").trim();
|
||||
}
|
||||
|
||||
function codedError(code: string, message: string, details?: unknown) {
|
||||
return Object.assign(new Error(message), { code, details });
|
||||
}
|
||||
@@ -8,6 +8,7 @@ export type WorkbenchMode = "native-test" | "agentrun-native" | "temporal";
|
||||
export type WorkbenchCommand =
|
||||
| { operation: "health" }
|
||||
| { operation: "session.create"; actor: WorkbenchActor; params: Record<string, unknown> }
|
||||
| { operation: "caserun.start"; actor: WorkbenchActor; params: Record<string, unknown> }
|
||||
| { operation: "turn.submit"; actor: WorkbenchActor; params: Record<string, unknown>; traceId?: string }
|
||||
| { operation: "turn.steer"; actor: WorkbenchActor; traceId: string; params: Record<string, unknown> }
|
||||
| { operation: "turn.cancel"; actor: WorkbenchActor; traceId: string; params?: Record<string, unknown> };
|
||||
@@ -20,6 +21,16 @@ export type WorkbenchCommandResult = {
|
||||
error?: { code: string; message: string; details?: unknown };
|
||||
};
|
||||
|
||||
export interface WorkbenchCaseRunLauncher {
|
||||
launch(input: {
|
||||
actor: WorkbenchActor;
|
||||
caseId: string;
|
||||
hwpodId: string;
|
||||
subjectRef?: string;
|
||||
runId?: string;
|
||||
}): Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export type WorkbenchTurnInput = {
|
||||
actor: WorkbenchActor;
|
||||
traceId: string;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import type { WorkbenchApplication, WorkbenchCommand, WorkbenchCommandResult, WorkbenchMode, WorkbenchTemporalGateway } from "./contracts.ts";
|
||||
import type { WorkbenchApplication, WorkbenchCaseRunLauncher, WorkbenchCommand, WorkbenchCommandResult, WorkbenchMode, WorkbenchTemporalGateway } from "./contracts.ts";
|
||||
|
||||
const SESSION_ID = /^ses_[A-Za-z0-9._:-]{3,180}$/u;
|
||||
const TRACE_ID = /^trc_[A-Za-z0-9._:-]{3,180}$/u;
|
||||
@@ -9,6 +9,7 @@ export function createWorkbenchDispatcher(options: {
|
||||
application: WorkbenchApplication;
|
||||
temporal: WorkbenchTemporalGateway;
|
||||
mode: WorkbenchMode;
|
||||
caseRun?: WorkbenchCaseRunLauncher;
|
||||
}) {
|
||||
return async function dispatch(command: WorkbenchCommand): Promise<WorkbenchCommandResult> {
|
||||
try {
|
||||
@@ -19,6 +20,16 @@ export function createWorkbenchDispatcher(options: {
|
||||
if (command.operation === "session.create") {
|
||||
return success(command.operation, options.mode, await options.application.createSession(command));
|
||||
}
|
||||
if (command.operation === "caserun.start") {
|
||||
if (!options.caseRun) throw codedError("workbench_caserun_unavailable", "Workbench CaseRun bridge is unavailable");
|
||||
return success(command.operation, options.mode, await options.caseRun.launch({
|
||||
actor: command.actor,
|
||||
caseId: requiredText(command.params.caseId, "caseId"),
|
||||
hwpodId: requiredText(command.params.hwpodId, "hwpodId"),
|
||||
subjectRef: optionalText(command.params.subjectRef),
|
||||
runId: optionalText(command.params.runId),
|
||||
}));
|
||||
}
|
||||
if (command.operation === "turn.submit") {
|
||||
const traceId = validId(command.traceId ?? `trc_${randomUUID()}`, TRACE_ID, "traceId");
|
||||
const sessionId = validId(command.params.sessionId, SESSION_ID, "sessionId");
|
||||
@@ -100,6 +111,11 @@ function requiredText(value: unknown, field: string) {
|
||||
return text;
|
||||
}
|
||||
|
||||
function optionalText(value: unknown) {
|
||||
const text = String(value ?? "").trim();
|
||||
return text || undefined;
|
||||
}
|
||||
|
||||
function codedError(code: string, message: string, details?: unknown) {
|
||||
return Object.assign(new Error(message), { code, details });
|
||||
}
|
||||
|
||||
@@ -58,6 +58,13 @@ export function createNativeAgentRunWorkbenchApplication(options: {
|
||||
return mutate((state) => {
|
||||
const sessionId = validOptionalId(params.sessionId, "ses_") ?? `ses_${randomUUID()}`;
|
||||
const conversationId = validOptionalId(params.conversationId, "cnv_") ?? `cnv_${randomUUID()}`;
|
||||
const existing = state.sessions[sessionId];
|
||||
if (existing) {
|
||||
if (existing.ownerUserId !== actor.id || !sameLaunchContext(existing.launchContext, params.launchContext)) {
|
||||
throw codedError("session_identity_conflict", "sessionId already belongs to another launch identity");
|
||||
}
|
||||
return { status: "reused", session: existing, valuesRedacted: true, secretMaterialStored: false };
|
||||
}
|
||||
const timestamp = now();
|
||||
const session = {
|
||||
sessionId,
|
||||
@@ -67,6 +74,7 @@ export function createNativeAgentRunWorkbenchApplication(options: {
|
||||
ownerRole: actor.role ?? "user",
|
||||
status: "idle",
|
||||
providerProfile: text(params.providerProfile) || text(env.HWLAB_CODE_AGENT_DEFAULT_PROVIDER_PROFILE) || "gpt.pika",
|
||||
launchContext: objectValue(params.launchContext),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
mode: "agentrun-native",
|
||||
@@ -370,6 +378,8 @@ async function load(stateFile: string): Promise<NativeState> {
|
||||
return { sessions: object(parsed.sessions), turns: object(parsed.turns) };
|
||||
}
|
||||
function object(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, Record<string, any>> : {}; }
|
||||
function objectValue(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }
|
||||
function sameLaunchContext(left: unknown, right: unknown) { return JSON.stringify(objectValue(left)) === JSON.stringify(objectValue(right)); }
|
||||
function text(value: unknown) { return String(value ?? "").trim(); }
|
||||
function validTimestamp(value: unknown) { const timestamp = text(value); return timestamp && Number.isFinite(Date.parse(timestamp)) ? new Date(Date.parse(timestamp)).toISOString() : null; }
|
||||
function requiredText(value: unknown, field: string) { const result = text(value); if (!result) throw codedError("invalid_input", `${field} is required`); return result; }
|
||||
|
||||
@@ -28,7 +28,14 @@ export function createNativeTestWorkbenchApplication(options: { stateFile: strin
|
||||
return update((state) => {
|
||||
const sessionId = validOptionalId(params.sessionId, "ses_") ?? `ses_${randomUUID()}`;
|
||||
const conversationId = validOptionalId(params.conversationId, "cnv_") ?? `cnv_${randomUUID()}`;
|
||||
const session = { sessionId, conversationId, projectId: text(params.projectId) || "prj_hwpod_workbench", ownerUserId: actor.id, status: "idle", providerProfile: text(params.providerProfile) || "native-test", createdAt: now(), updatedAt: now(), mode: "native-test", messages: [] as Record<string, unknown>[] };
|
||||
const existing = state.sessions[sessionId];
|
||||
if (existing) {
|
||||
if (existing.ownerUserId !== actor.id || !sameLaunchContext(existing.launchContext, params.launchContext)) {
|
||||
throw codedError("session_identity_conflict", "sessionId already belongs to another launch identity");
|
||||
}
|
||||
return { status: "reused", session: existing, valuesRedacted: true, secretMaterialStored: false };
|
||||
}
|
||||
const session = { sessionId, conversationId, projectId: text(params.projectId) || "prj_hwpod_workbench", ownerUserId: actor.id, status: "idle", providerProfile: text(params.providerProfile) || "native-test", launchContext: objectValue(params.launchContext), createdAt: now(), updatedAt: now(), mode: "native-test", messages: [] as Record<string, unknown>[] };
|
||||
state.sessions[sessionId] = session;
|
||||
return { status: "created", session, valuesRedacted: true, secretMaterialStored: false };
|
||||
});
|
||||
@@ -94,6 +101,8 @@ async function load(stateFile: string): Promise<NativeState> {
|
||||
return { sessions: object(parsed.sessions), turns: object(parsed.turns) };
|
||||
}
|
||||
function object(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, Record<string, unknown>> : {}; }
|
||||
function objectValue(value: unknown) { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : null; }
|
||||
function sameLaunchContext(left: unknown, right: unknown) { return JSON.stringify(objectValue(left)) === JSON.stringify(objectValue(right)); }
|
||||
function text(value: unknown) { return String(value ?? "").trim(); }
|
||||
function validOptionalId(value: unknown, prefix: string) { const id = text(value); if (!id) return null; if (!id.startsWith(prefix)) throw codedError("invalid_input", `${prefix.slice(0, -1)} id is invalid`); return id; }
|
||||
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import path from "node:path";
|
||||
|
||||
import { harnessRLRuntime } from "../harnessrl/runtime.ts";
|
||||
import { createApiCaseRunTransport, createLocalCaseRunTransport } from "../harnessrl/transport.ts";
|
||||
import { createWorkbenchCaseRunLauncher } from "./caserun-bridge.ts";
|
||||
import { createCloudWorkbenchApplication } from "./cloud-application.ts";
|
||||
import { createWorkbenchDispatcher } from "./dispatcher.ts";
|
||||
import { createNativeTestWorkbenchApplication } from "./native-application.ts";
|
||||
@@ -9,7 +12,7 @@ import { createWorkbenchTemporalGateway } from "./temporal.ts";
|
||||
import { createWorkbenchKafkaEventPublisher } from "./kafka-event-publisher.ts";
|
||||
import type { WorkbenchEventPublisher } from "./contracts.ts";
|
||||
|
||||
export function workbenchRuntime(env: Record<string, string | undefined> = process.env, options: { eventPublisher?: WorkbenchEventPublisher | null } = {}) {
|
||||
export function workbenchRuntime(env: Record<string, string | undefined> = process.env, options: { eventPublisher?: WorkbenchEventPublisher | null; caseRunTransportMode?: "local" | "api" } = {}) {
|
||||
const mode = String(env.WORKBENCH_MODE ?? "").trim();
|
||||
if (mode !== "native-test" && mode !== "agentrun-native" && mode !== "temporal") throw codedError("workbench_mode_required", "WORKBENCH_MODE must be native-test, agentrun-native, or temporal");
|
||||
const stateFile = path.resolve(String(env.WORKBENCH_NATIVE_STATE_FILE ?? ".state/workbench-native/state.json"));
|
||||
@@ -33,6 +36,20 @@ export function workbenchRuntime(env: Record<string, string | undefined> = proce
|
||||
retryMaximumAttempts: requiredPositiveInteger(env.WORKBENCH_ACTIVITY_RETRY_MAXIMUM_ATTEMPTS, "WORKBENCH_ACTIVITY_RETRY_MAXIMUM_ATTEMPTS")
|
||||
};
|
||||
const temporal = mode === "native-test" ? createNativeTestTemporalGateway(application) : createWorkbenchTemporalGateway({ address: temporalAddress, namespace: temporalNamespace, taskQueue, activity: activity! });
|
||||
const caseRunTransportMode = options.caseRunTransportMode ?? "local";
|
||||
const caseRun = createWorkbenchCaseRunLauncher({
|
||||
application,
|
||||
async transport() {
|
||||
if (caseRunTransportMode === "api") {
|
||||
return createApiCaseRunTransport({
|
||||
baseUrl: String(env.WORKBENCH_CASERUN_API_URL ?? ""),
|
||||
apiKey: String(env.WORKBENCH_CASERUN_API_KEY ?? "") || undefined,
|
||||
});
|
||||
}
|
||||
const runtime = harnessRLRuntime(env);
|
||||
return createLocalCaseRunTransport({ service: runtime.service, close: () => runtime.service.close() });
|
||||
},
|
||||
});
|
||||
return {
|
||||
mode,
|
||||
stateFile,
|
||||
@@ -42,7 +59,7 @@ export function workbenchRuntime(env: Record<string, string | undefined> = proce
|
||||
eventPublisher,
|
||||
application,
|
||||
temporal,
|
||||
dispatch: createWorkbenchDispatcher({ application, temporal, mode }),
|
||||
dispatch: createWorkbenchDispatcher({ application, temporal, mode, caseRun }),
|
||||
async close() { await temporal.close(); await application.close?.(); }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { expect, test } from "bun:test";
|
||||
|
||||
import { runWorkbenchCli } from "./workbench-cli.ts";
|
||||
|
||||
test("Workbench CaseRun CLI over-api preserves the shared command DTO", async () => {
|
||||
let received: Record<string, any> | null = null;
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
async fetch(request) {
|
||||
received = await request.json() as Record<string, any>;
|
||||
return Response.json({ ok: true, operation: "caserun.start", mode: "agentrun-native", data: {
|
||||
accepted: true,
|
||||
sessionId: "ses_caserun_cli",
|
||||
runId: "run_caserun_cli",
|
||||
workflowId: "harnessrl-caserun-run_caserun_cli",
|
||||
workflowRunId: "temporal-cli",
|
||||
statusRef: "/v1/caserun/runs/run_caserun_cli",
|
||||
eventsRef: "/v1/caserun/runs/run_caserun_cli/events",
|
||||
aggregateRef: "/v1/caserun/runs/run_caserun_cli/aggregate",
|
||||
} }, { status: 202 });
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await runWorkbenchCli([
|
||||
"caserun", "start",
|
||||
"--case-id", "arm2d-integration",
|
||||
"--hwpod-id", "d601-vm-stm32f405-qemu",
|
||||
"--actor-id", "usr_arm2d",
|
||||
"--subject-ref", "f909cdaf6f0cd579b1f1b66d2d1eee5235a09fb9",
|
||||
"--run-id", "run_caserun_cli",
|
||||
"--over-api",
|
||||
], { WORKBENCH_API_URL: `http://127.0.0.1:${server.port}` });
|
||||
|
||||
expect(received).toEqual({
|
||||
operation: "caserun.start",
|
||||
actor: { id: "usr_arm2d", role: "user" },
|
||||
params: {
|
||||
caseId: "arm2d-integration",
|
||||
hwpodId: "d601-vm-stm32f405-qemu",
|
||||
subjectRef: "f909cdaf6f0cd579b1f1b66d2d1eee5235a09fb9",
|
||||
runId: "run_caserun_cli",
|
||||
},
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
operation: "caserun.start",
|
||||
transport: "api",
|
||||
route: "POST /v1/workbench/commands",
|
||||
httpStatus: 202,
|
||||
identity: { sessionId: "ses_caserun_cli", runId: "run_caserun_cli", workflowId: "harnessrl-caserun-run_caserun_cli" },
|
||||
});
|
||||
} finally {
|
||||
server.stop(true);
|
||||
}
|
||||
});
|
||||
@@ -13,7 +13,7 @@ export async function runWorkbenchCli(argv: string[], env: Record<string, string
|
||||
if (parsed.positionals[0] === "events" && parsed.positionals[1] === "inspect") return inspectEvents(parsed, env);
|
||||
const command = commandFrom(parsed, env);
|
||||
if (parsed.overApi) return overApi(command, parsed, env);
|
||||
const runtime = workbenchRuntime(env);
|
||||
const runtime = workbenchRuntime(env, { caseRunTransportMode: "local" });
|
||||
try {
|
||||
const result = await runtime.dispatch(command);
|
||||
return { ...result, transport: "local", route: "application-dispatcher", identity: identity(result.data) };
|
||||
@@ -30,6 +30,7 @@ function help() {
|
||||
usage: [
|
||||
"hwlab-cli workbench health [--over-api]",
|
||||
"hwlab-cli workbench session create --actor-id ID [--provider-profile PROFILE] [--over-api]",
|
||||
"hwlab-cli workbench caserun start --case-id ID --hwpod-id ID --actor-id ID [--subject-ref REF] [--run-id ID] [--over-api]",
|
||||
"hwlab-cli workbench turn submit --actor-id ID --session-id ID --message TEXT [--trace-id ID] [--provider-profile PROFILE] [--model MODEL] [--reasoning-effort low|medium|high|xhigh] [--over-api]",
|
||||
"hwlab-cli workbench turn steer --actor-id ID --session-id ID --trace-id TARGET_TRACE_ID --steer-trace-id STEER_TRACE_ID --message TEXT [--over-api]",
|
||||
"hwlab-cli workbench events inspect --session-id ID [--trace-id ID] --over-api [--timeout-ms MS] [--wait-for user,backend,assistant,terminal,final] [--min-events N] [--timing-detail summary|full]",
|
||||
@@ -38,8 +39,8 @@ function help() {
|
||||
"hwlab-cli workbench service api|worker|web start|stop|restart|status|logs"
|
||||
],
|
||||
transportContract: "--over-api only changes transport; --overapi is unsupported",
|
||||
localConfig: ["WORKBENCH_MODE", "WORKBENCH_NATIVE_STATE_FILE"],
|
||||
nativeServiceConfig: ["WORKBENCH_PUBLIC_BASE_URL", "WORKBENCH_API_BIND_HOST", "WORKBENCH_API_PROBE_HOST", "WORKBENCH_API_PORT", "WORKBENCH_API_IDLE_TIMEOUT_SECONDS", "WORKBENCH_WORKER_BIND_HOST", "WORKBENCH_WORKER_PROBE_HOST", "WORKBENCH_WORKER_HEALTH_PORT", "WORKBENCH_WEB_BIND_HOST", "WORKBENCH_WEB_PROBE_HOST", "WORKBENCH_WEB_PORT", "WORKBENCH_NATIVE_API_URL", "WORKBENCH_WEB_RUNTIME_CONFIG"],
|
||||
localConfig: ["WORKBENCH_MODE", "WORKBENCH_NATIVE_STATE_FILE", "HARNESSRL_DATABASE_URL", "HARNESSRL_TEMPORAL_ADDRESS", "HWLAB_CASERUN_CASE_REPO"],
|
||||
nativeServiceConfig: ["WORKBENCH_PUBLIC_BASE_URL", "WORKBENCH_API_BIND_HOST", "WORKBENCH_API_PROBE_HOST", "WORKBENCH_API_PORT", "WORKBENCH_API_IDLE_TIMEOUT_SECONDS", "WORKBENCH_WORKER_BIND_HOST", "WORKBENCH_WORKER_PROBE_HOST", "WORKBENCH_WORKER_HEALTH_PORT", "WORKBENCH_WEB_BIND_HOST", "WORKBENCH_WEB_PROBE_HOST", "WORKBENCH_WEB_PORT", "WORKBENCH_NATIVE_API_URL", "WORKBENCH_CASERUN_API_URL", "WORKBENCH_WEB_RUNTIME_CONFIG"],
|
||||
apiConfig: ["WORKBENCH_API_URL", "HWLAB_API_KEY"]
|
||||
};
|
||||
}
|
||||
@@ -265,6 +266,7 @@ function commandFrom(parsed: Parsed, env: Record<string, string | undefined>): W
|
||||
if (group === "health") return { operation: "health" };
|
||||
const actor = { id: required(parsed, "actor-id"), role: parsed.values["actor-role"] ?? "user" };
|
||||
if (group === "session" && action === "create") return { operation: "session.create", actor, params: compact({ sessionId: parsed.values["session-id"], conversationId: parsed.values["conversation-id"], projectId: parsed.values["project-id"], providerProfile: parsed.values["provider-profile"] }) };
|
||||
if (group === "caserun" && action === "start") return { operation: "caserun.start", actor, params: compact({ caseId: required(parsed, "case-id"), hwpodId: required(parsed, "hwpod-id"), subjectRef: parsed.values["subject-ref"], runId: parsed.values["run-id"] }) };
|
||||
if (group === "turn" && action === "submit") return { operation: "turn.submit", actor, traceId: parsed.values["trace-id"], params: compact({ sessionId: required(parsed, "session-id"), message: required(parsed, "message"), projectId: parsed.values["project-id"], providerProfile: parsed.values["provider-profile"], model: parsed.values.model, reasoningEffort: parsed.values["reasoning-effort"], shortConnection: true }) };
|
||||
if (group === "turn" && action === "steer") {
|
||||
const targetTraceId = required(parsed, "trace-id");
|
||||
@@ -305,7 +307,7 @@ function parse(argv: string[]): Parsed {
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
function identity(data: any) { return { sessionId: data?.sessionId ?? data?.session?.sessionId ?? null, traceId: data?.traceId ?? null, targetTraceId: data?.targetTraceId ?? null, steerTraceId: data?.steerTraceId ?? null, workflowId: data?.workflowId ?? null, workflowRunId: data?.workflowRunId ?? null }; }
|
||||
function identity(data: any) { return { sessionId: data?.sessionId ?? data?.session?.sessionId ?? null, runId: data?.runId ?? null, traceId: data?.traceId ?? null, targetTraceId: data?.targetTraceId ?? null, steerTraceId: data?.steerTraceId ?? null, workflowId: data?.workflowId ?? null, workflowRunId: data?.workflowRunId ?? null }; }
|
||||
function record(value: unknown): Record<string, any> | null { return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : null; }
|
||||
function positiveInteger(value: unknown, fallback: number): number { const parsed = Number.parseInt(String(value ?? ""), 10); return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback; }
|
||||
const eventSemantics = ["user", "backend", "assistant", "terminal", "final"] as const;
|
||||
|
||||
Reference in New Issue
Block a user