diff --git a/package.json b/package.json index 319ff18b..83485908 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,7 @@ "harnessrl:native:l1-smoke": "node scripts/harnessrl-native-l1-smoke.mjs", "workbench:api:dev": "bun --watch cmd/hwlab-workbench-api/main.ts", "workbench:worker:dev": "bun --watch cmd/hwlab-workbench-worker/main.ts", - "workbench:web:dev": "cd web/hwlab-cloud-web && WORKBENCH_NATIVE_API_URL=http://127.0.0.1:6677 WORKBENCH_VITE_USE_POLLING=1 bun run dev", + "workbench:web:dev": "cd web/hwlab-cloud-web && WORKBENCH_VITE_USE_POLLING=1 bun run dev", "workbench:native:smoke": "bun test internal/workbench/workbench.test.ts && bun run scripts/workbench-native-smoke.mjs", "workbench:p1:inventory": "node scripts/workbench-long-reliability-p1-inventory.mjs", "dev-runtime-base:build": "node scripts/dev-runtime-base-image.mjs", diff --git a/scripts/workbench-native-smoke.mjs b/scripts/workbench-native-smoke.mjs index 943c5dd2..8c5dd34b 100755 --- a/scripts/workbench-native-smoke.mjs +++ b/scripts/workbench-native-smoke.mjs @@ -65,7 +65,7 @@ try { 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"); if (cancel.status !== "cancel_requested") throw new Error("native cancel was not accepted"); - process.stdout.write(`${JSON.stringify({ ok: true, mode: "native-test", apiUrl: "http://127.0.0.1:6677", webUrl: "http://127.0.0.1:5173/workbench", 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: "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`); } 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; diff --git a/tools/src/workbench-cli.ts b/tools/src/workbench-cli.ts index 262b6fa1..98b3e2e4 100644 --- a/tools/src/workbench-cli.ts +++ b/tools/src/workbench-cli.ts @@ -31,6 +31,7 @@ function help() { ], transportContract: "--over-api only changes transport; --overapi is unsupported", localConfig: ["WORKBENCH_MODE", "WORKBENCH_NATIVE_STATE_FILE"], + nativeServiceConfig: ["WORKBENCH_API_BIND_HOST", "WORKBENCH_API_PROBE_HOST", "WORKBENCH_API_PUBLIC_HOST", "WORKBENCH_API_PORT", "WORKBENCH_WORKER_BIND_HOST", "WORKBENCH_WORKER_PROBE_HOST", "WORKBENCH_WORKER_HEALTH_PORT", "WORKBENCH_WEB_BIND_HOST", "WORKBENCH_WEB_PROBE_HOST", "WORKBENCH_WEB_PUBLIC_HOST", "WORKBENCH_WEB_PORT", "WORKBENCH_NATIVE_API_URL"], apiConfig: ["WORKBENCH_API_URL", "HWLAB_API_KEY"] }; } diff --git a/tools/src/workbench-native-service.ts b/tools/src/workbench-native-service.ts index 0e7c6c4e..b715471a 100644 --- a/tools/src/workbench-native-service.ts +++ b/tools/src/workbench-native-service.ts @@ -25,6 +25,7 @@ async function start(service: ServiceName, stateDir: string, stateFile: string, const current = await readState(stateFile); if (current?.pid && alive(current.pid)) throw codedError("service_already_running", `${service} is already running`, { pid: current.pid, correctionCommand: `hwlab-cli workbench service ${service} stop` }); await mkdir(stateDir, { recursive: true }); + const endpoints = serviceEndpoints(service, env); const command = serviceCommand(service, env); const logFd = openSync(logFile, "w"); const child = spawn(command[0], command.slice(1), { @@ -35,7 +36,7 @@ async function start(service: ServiceName, stateDir: string, stateFile: string, }); closeSync(logFd); child.unref(); - const state = { service, pid: child.pid, status: "starting", command, cwd, logFile, healthUrl: healthUrl(service, env), startedAt: new Date().toISOString() }; + const state = { service, pid: child.pid, status: "starting", command, cwd, logFile, endpoints, healthUrl: endpoints.probe?.url ?? null, startedAt: new Date().toISOString() }; await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8"); return { ok: true, operation: `workbench.service.${service}.start`, ...state, nextCommand: `hwlab-cli workbench service ${service} status` }; } @@ -51,9 +52,9 @@ async function stop(service: ServiceName, stateFile: string, logFile: string) { async function status(service: ServiceName, stateFile: string, logFile: string) { const state = await readState(stateFile); const processRunning = Boolean(state?.pid && alive(state.pid)); - const health = processRunning ? await probeHealth(state.healthUrl ?? healthUrl(service, {})) : { ok: false, status: null, error: "process-not-running" }; + const health = processRunning && state?.healthUrl ? await probeHealth(state.healthUrl) : { ok: false, status: null, error: processRunning ? "health-url-not-declared" : "process-not-running" }; const running = processRunning && health.ok; - return { ok: running, operation: `workbench.service.${service}.status`, service, status: running ? "running" : processRunning ? "degraded" : "stopped", pid: processRunning ? state.pid : null, health, stateFile, logFile, nextCommand: running ? `hwlab-cli workbench service ${service} logs` : `hwlab-cli workbench service ${service} logs` }; + return { ok: running, operation: `workbench.service.${service}.status`, service, status: running ? "running" : processRunning ? "degraded" : "stopped", pid: processRunning ? state.pid : null, endpoints: state?.endpoints ?? null, health, stateFile, logFile, nextCommand: `hwlab-cli workbench service ${service} logs` }; } async function logs(service: ServiceName, stateFile: string, logFile: string) { @@ -69,18 +70,35 @@ function serviceCommand(service: ServiceName, env: Record) { - if (service === "api") return { ...process.env, ...env, WORKBENCH_MODE: env.WORKBENCH_MODE ?? "native-test", WORKBENCH_API_HOST: env.WORKBENCH_API_HOST ?? "127.0.0.1", WORKBENCH_API_PORT: env.WORKBENCH_API_PORT ?? "6677" }; - if (service === "web") return { ...process.env, ...env, WORKBENCH_NATIVE_API_URL: env.WORKBENCH_NATIVE_API_URL ?? "http://127.0.0.1:6677" }; - return { ...process.env, ...env, WORKBENCH_MODE: "temporal" }; + if (service === "api") return { ...process.env, ...env, WORKBENCH_MODE: env.WORKBENCH_MODE ?? "native-test", WORKBENCH_API_HOST: requiredEnv(env, "WORKBENCH_API_BIND_HOST"), WORKBENCH_API_PORT: requiredEnv(env, "WORKBENCH_API_PORT") }; + if (service === "web") return { ...process.env, ...env, WORKBENCH_WEB_HOST: requiredEnv(env, "WORKBENCH_WEB_BIND_HOST"), WORKBENCH_WEB_PORT: requiredEnv(env, "WORKBENCH_WEB_PORT"), WORKBENCH_NATIVE_API_URL: requiredEnv(env, "WORKBENCH_NATIVE_API_URL") }; + return { ...process.env, ...env, WORKBENCH_MODE: "temporal", WORKBENCH_WORKER_HEALTH_HOST: requiredEnv(env, "WORKBENCH_WORKER_BIND_HOST"), WORKBENCH_WORKER_HEALTH_PORT: requiredEnv(env, "WORKBENCH_WORKER_HEALTH_PORT") }; } function validService(value: string): ServiceName { if (value === "api" || value === "worker" || value === "web") return value; throw codedError("invalid_service", "service must be api, worker, or web"); } async function readState(file: string) { return JSON.parse(await readFile(file, "utf8").catch(() => "null")); } function alive(pid: number) { try { process.kill(pid, 0); return true; } catch { return false; } } -function healthUrl(service: ServiceName, env: Record) { - if (service === "api") return `http://${env.WORKBENCH_API_HOST ?? "127.0.0.1"}:${env.WORKBENCH_API_PORT ?? "6677"}/health/ready`; - if (service === "worker") return `http://${env.WORKBENCH_WORKER_HEALTH_HOST ?? "127.0.0.1"}:${env.WORKBENCH_WORKER_HEALTH_PORT ?? "6678"}/health/ready`; - return `http://${env.WORKBENCH_WEB_HOST ?? "127.0.0.1"}:${env.WORKBENCH_WEB_PORT ?? "5173"}/workbench`; +function serviceEndpoints(service: ServiceName, env: Record) { + if (service === "worker") { + const bindHost = requiredEnv(env, "WORKBENCH_WORKER_BIND_HOST"); + const probeHost = requiredEnv(env, "WORKBENCH_WORKER_PROBE_HOST"); + const port = requiredEnv(env, "WORKBENCH_WORKER_HEALTH_PORT"); + return { bind: { host: bindHost, port: validPort(port) }, probe: endpoint(probeHost, port, "/health/ready"), public: null }; + } + const prefix = service === "api" ? "WORKBENCH_API" : "WORKBENCH_WEB"; + const bindHost = requiredEnv(env, `${prefix}_BIND_HOST`); + const probeHost = requiredEnv(env, `${prefix}_PROBE_HOST`); + const publicHost = requiredEnv(env, `${prefix}_PUBLIC_HOST`); + const port = requiredEnv(env, `${prefix}_PORT`); + const pathname = service === "api" ? "/health/ready" : "/workbench"; + return { + bind: { host: bindHost, port: validPort(port) }, + probe: endpoint(probeHost, port, pathname), + public: endpoint(publicHost, port, service === "api" ? "" : pathname) + }; } +function endpoint(host: string, port: string, pathname: string) { const numericPort = validPort(port); return { host, port: numericPort, url: `http://${host}:${numericPort}${pathname}` }; } +function validPort(value: string) { const port = Number(value); if (!Number.isInteger(port) || port < 1 || port > 65535) throw codedError("invalid_service_port", `invalid Workbench service port: ${value}`); return port; } +function requiredEnv(env: Record, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("native_exposure_config_required", `${key} is required from the owning YAML native development exposure`); return value; } async function probeHealth(url: string) { try { const response = await fetch(url, { signal: AbortSignal.timeout(1000) }); return { ok: response.ok, status: response.status, url }; } catch (error) { return { ok: false, status: null, url, error: error instanceof Error ? error.message : String(error) }; }