fix: wait for Workbench event semantics

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
root
2026-07-18 09:54:06 +02:00
parent faef792fab
commit 28c6a21d27
3 changed files with 166 additions and 21 deletions
+28 -11
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { mkdtemp, rm } from "node:fs/promises";
import { createServer } from "node:net";
import os from "node:os";
import path from "node:path";
@@ -10,26 +11,31 @@ const root = path.resolve(import.meta.dirname, "..");
const stateDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-workbench-native-"));
const children = [];
const logs = [];
const apiPort = await availablePort();
const webPort = await availablePort();
const apiUrl = `http://127.0.0.1:${apiPort}`;
const webUrl = `http://127.0.0.1:${webPort}/workbench`;
try {
const nativeEnv = {
...process.env,
WORKBENCH_MODE: "native-test",
WORKBENCH_NATIVE_STATE_FILE: path.join(stateDir, "state.json")
WORKBENCH_NATIVE_STATE_FILE: path.join(stateDir, "state.json"),
WORKBENCH_API_IDLE_TIMEOUT_SECONDS: "60"
};
const api = start(["bun", "cmd/hwlab-workbench-api/main.ts"], {
...nativeEnv,
WORKBENCH_API_HOST: "127.0.0.1",
WORKBENCH_API_PORT: "6677"
WORKBENCH_API_PORT: String(apiPort)
}, "api");
children.push(api);
await waitFor("http://127.0.0.1:6677/health/ready");
await waitFor(`${apiUrl}/health/ready`);
const web = start(["bun", "run", "workbench:web:dev"], process.env, "web");
const web = start(["bun", "run", "workbench:web:dev"], { ...process.env, WORKBENCH_WEB_PORT: String(webPort) }, "web");
children.push(web);
await waitFor("http://127.0.0.1:5173/workbench", { acceptHtml: true });
await waitFor(webUrl, { acceptHtml: true });
const commandHealth = await requiredJson(await fetch("http://127.0.0.1:6677/v1/workbench/commands", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ operation: "health" }) }), "command.health");
const commandHealth = await requiredJson(await fetch(`${apiUrl}/v1/workbench/commands`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ operation: "health" }) }), "command.health");
if (commandHealth.mode !== "native-test") throw new Error("native command transport did not expose mode=native-test");
const localSession = await runWorkbenchCli(["session", "create", "--actor-id", "usr_native", "--provider-profile", "native-test"], nativeEnv);
@@ -41,7 +47,7 @@ try {
const localCancel = await runWorkbenchCli(["turn", "cancel", "--actor-id", "usr_native", "--trace-id", localTraceId], nativeEnv);
if (localCancel.data?.status !== "cancel_requested") throw new Error("CLI local cancel was not accepted");
const overApiArgs = ["--over-api", "--api-url", "http://127.0.0.1:6677"];
const overApiArgs = ["--over-api", "--api-url", apiUrl];
const apiHealth = await runWorkbenchCli(["health", ...overApiArgs], nativeEnv);
if (apiHealth.transport !== "api" || apiHealth.httpStatus !== 200) throw new Error("CLI --over-api health transport contract failed");
const apiSession = await runWorkbenchCli(["session", "create", "--actor-id", "usr_native", "--provider-profile", "native-test", ...overApiArgs], nativeEnv);
@@ -54,18 +60,18 @@ try {
if (apiCancel.data?.status !== "cancel_requested") throw new Error("CLI --over-api cancel was not accepted");
const actorHeaders = { "content-type": "application/json", cookie: "hwlab_session=native-test" };
const sessionResponse = await fetch("http://127.0.0.1:6677/v1/agent/sessions", { method: "POST", headers: actorHeaders, body: JSON.stringify({ providerProfile: "native-test" }) });
const sessionResponse = await fetch(`${apiUrl}/v1/agent/sessions`, { method: "POST", headers: actorHeaders, body: JSON.stringify({ providerProfile: "native-test" }) });
const session = await requiredJson(sessionResponse, "session.create");
const sessionId = session.session?.sessionId;
if (!sessionId) throw new Error("native session.create returned no sessionId");
const traceId = "trc_native_smoke";
const submit = await requiredJson(await fetch("http://127.0.0.1:6677/v1/agent/chat", { method: "POST", headers: actorHeaders, body: JSON.stringify({ sessionId, traceId, message: "native smoke" }) }), "turn.submit");
const submit = await requiredJson(await fetch(`${apiUrl}/v1/agent/chat`, { method: "POST", headers: actorHeaders, body: JSON.stringify({ sessionId, traceId, message: "native smoke" }) }), "turn.submit");
if (submit.orchestrationMode !== "native-test" || submit.resultSynthesized !== false) throw new Error("native submit mode/authority contract failed");
const cancel = await requiredJson(await fetch("http://127.0.0.1:6677/v1/agent/chat/cancel", { method: "POST", headers: actorHeaders, body: JSON.stringify({ sessionId, traceId }) }), "turn.cancel");
const cancel = await requiredJson(await fetch(`${apiUrl}/v1/agent/chat/cancel`, { method: "POST", headers: actorHeaders, body: JSON.stringify({ sessionId, traceId }) }), "turn.cancel");
if (cancel.status !== "cancel_requested") throw new Error("native cancel was not accepted");
process.stdout.write(`${JSON.stringify({ ok: true, mode: "native-test", probeEndpoints: { api: "http://127.0.0.1:6677", web: "http://127.0.0.1:5173/workbench" }, publicEndpoints: null, exposureAuthority: "owning-yaml-service-launcher", sessionId, traceId, cli: { local: "application-dispatcher", overApi: "POST /v1/workbench/commands" }, services: { api: "independent", web: "vite-hmr" }, terminalAuthority: "fixture-only", resultSynthesized: false })}\n`);
process.stdout.write(`${JSON.stringify({ ok: true, mode: "native-test", probeEndpoints: { api: apiUrl, web: webUrl }, publicEndpoints: null, exposureAuthority: "owning-yaml-service-launcher", sessionId, traceId, cli: { local: "application-dispatcher", overApi: "POST /v1/workbench/commands" }, services: { api: "independent", web: "vite-hmr" }, terminalAuthority: "fixture-only", resultSynthesized: false })}\n`);
} catch (error) {
process.stderr.write(`${JSON.stringify({ ok: false, error: { code: "workbench_native_smoke_failed", message: error instanceof Error ? error.message : String(error) }, logs: logs.slice(-20) })}\n`);
process.exitCode = 1;
@@ -92,3 +98,14 @@ async function waitFor(url, options = {}) {
throw new Error(`timed out waiting for ${url}`);
}
async function requiredJson(response, operation) { const body = await response.json().catch(() => null); if (!response.ok || !body) throw new Error(`${operation} failed with HTTP ${response.status}`); return body; }
async function availablePort() {
return await new Promise((resolve, reject) => {
const server = createServer();
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
const address = server.address();
const port = typeof address === "object" && address ? address.port : 0;
server.close((error) => error ? reject(error) : resolve(port));
});
});
}