From 9dd223b71e1ad0293266ab43632ca6ea4e25bcc6 Mon Sep 17 00:00:00 2001 From: Ben Younes <2910651+ousamabenyounes@users.noreply.github.com> Date: Sat, 1 Aug 2026 12:17:32 +0000 Subject: [PATCH] fix(server): release embedded runtime config listener --- .../embedded-runtime-config-lifecycle.test.ts | 144 ++++++++++++++++++ apps/server/src/embedded.ts | 6 +- ...runtime-config-listener-lifecycle.flow.mjs | 78 ++++++++++ ...edded-runtime-config-listener-lifecycle.md | 5 + 4 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/embedded-runtime-config-lifecycle.test.ts create mode 100644 evals/flows/embedded-runtime-config-listener-lifecycle.flow.mjs create mode 100644 evals/voiceovers/embedded-runtime-config-listener-lifecycle.md diff --git a/apps/server/src/embedded-runtime-config-lifecycle.test.ts b/apps/server/src/embedded-runtime-config-lifecycle.test.ts new file mode 100644 index 0000000000..fb871bd7fe --- /dev/null +++ b/apps/server/src/embedded-runtime-config-lifecycle.test.ts @@ -0,0 +1,144 @@ +import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, test } from "bun:test"; + +import { startEmbeddedServer } from "./embedded.js"; +import { writeRuntimeOpencodeConfig } from "./runtime-opencode-config-store.js"; + +const HOST = "127.0.0.1"; +const SERVER_TOKEN = "server-token"; +const HOST_TOKEN = "host-token"; +const PROVIDER_ID = "lpr_anthropic"; +const PROVIDER = { id: "anthropic", name: "Anthropic", env: ["ANTHROPIC_API_KEY"] }; +const DEV_MODE_ENV = "OPENWORK_DEV_MODE"; +const RUNTIME_DB_ENV = "OPENWORK_RUNTIME_DB"; +const OPENCODE_BASE_URL_ENV = "OPENWORK_OPENCODE_BASE_URL"; +const FIRST_WORKSPACE_DIR = "first-workspace"; +const SECOND_WORKSPACE_DIR = "second-workspace"; +const RUNTIME_DB_FILE = "runtime.sqlite"; +const FIRST_CONFIG_FILE = "first-server.json"; +const SECOND_CONFIG_FILE = "second-server.json"; +const HTTP_OK = 200; +const PATCH_METHOD = "PATCH"; +const CONTENT_TYPE = "application/json"; +const RELOADED = "reloaded"; +const SKIPPED = "skipped"; +const STALE_MCP_URL = "https://stale.example.test/mcp"; + +function restoreProcessEnv(name: string, value: string | undefined): void { + if (value === undefined) { + delete process.env[name]; + return; + } + process.env[name] = value; +} + +async function writeFakeOpencodeBin(root: string): Promise { + const binPath = join(root, "fake-opencode.mjs"); + await writeFile(binPath, [ + "#!/usr/bin/env bun", + "const portIndex = process.argv.indexOf(\"--port\");", + "const requestedPort = Number(process.argv[portIndex + 1] ?? 0);", + "const server = Bun.serve({ port: requestedPort, fetch: () => Response.json({ ok: true }) });", + "console.log(`opencode server listening on http://127.0.0.1:${server.port}`);", + "process.on(\"SIGTERM\", () => { server.stop(true); process.exit(0); });", + ].join("\n")); + await chmod(binPath, 0o755); + return binPath; +} + +function hostHeaders(): Record { + return { + "content-type": CONTENT_TYPE, + "x-openwork-host-token": HOST_TOKEN, + }; +} + +describe("embedded runtime config lifecycle", () => { + test("stopped embedded servers cannot rewrite the active runtime config file", async () => { + const root = await mkdtemp(join(tmpdir(), "openwork-embedded-runtime-lifecycle-")); + const previousDevMode = process.env[DEV_MODE_ENV]; + const previousRuntimeDb = process.env[RUNTIME_DB_ENV]; + const previousOpencodeBaseUrl = process.env[OPENCODE_BASE_URL_ENV]; + let activeHandle: Awaited> | null = null; + + try { + const firstWorkspace = join(root, FIRST_WORKSPACE_DIR); + const secondWorkspace = join(root, SECOND_WORKSPACE_DIR); + await Promise.all([ + mkdir(firstWorkspace, { recursive: true }), + mkdir(secondWorkspace, { recursive: true }), + ]); + const opencodeBin = await writeFakeOpencodeBin(root); + process.env[DEV_MODE_ENV] = "1"; + process.env[RUNTIME_DB_ENV] = join(root, RUNTIME_DB_FILE); + delete process.env[OPENCODE_BASE_URL_ENV]; + + const stoppedHandle = await startEmbeddedServer({ + configPath: join(root, FIRST_CONFIG_FILE), + host: HOST, + port: 0, + token: SERVER_TOKEN, + hostToken: HOST_TOKEN, + workspaces: [firstWorkspace], + manageOpencode: true, + opencodeBin, + opencodeCwd: firstWorkspace, + }); + await stoppedHandle.stop(); + + activeHandle = await startEmbeddedServer({ + configPath: join(root, SECOND_CONFIG_FILE), + host: HOST, + port: 0, + token: SERVER_TOKEN, + hostToken: HOST_TOKEN, + workspaces: [secondWorkspace], + manageOpencode: true, + opencodeBin, + opencodeCwd: secondWorkspace, + }); + + const providerPatch = { provider: { [PROVIDER_ID]: PROVIDER } }; + const initialPatch = await fetch(`${activeHandle.url}/runtime-config/providers`, { + method: PATCH_METHOD, + headers: hostHeaders(), + body: JSON.stringify(providerPatch), + }); + expect(initialPatch.status).toBe(HTTP_OK); + expect(await initialPatch.json()).toMatchObject({ changed: true, reload: RELOADED }); + + const stoppedWorkspace = stoppedHandle.config.workspaces[0]; + if (!stoppedWorkspace) throw new Error("Expected the stopped server workspace"); + await writeRuntimeOpencodeConfig(stoppedHandle.config, stoppedWorkspace.id, (current) => ({ + ...current, + mcp: { stale: { type: "remote", url: STALE_MCP_URL } }, + })); + + const identicalPatch = await fetch(`${activeHandle.url}/runtime-config/providers`, { + method: PATCH_METHOD, + headers: hostHeaders(), + body: JSON.stringify(providerPatch), + }); + expect(identicalPatch.status).toBe(HTTP_OK); + expect(await identicalPatch.json()).toMatchObject({ changed: false, reload: SKIPPED }); + } finally { + const cleanupErrors: unknown[] = []; + try { + await activeHandle?.stop(); + } catch (error) { + cleanupErrors.push(error); + } + restoreProcessEnv(DEV_MODE_ENV, previousDevMode); + restoreProcessEnv(RUNTIME_DB_ENV, previousRuntimeDb); + restoreProcessEnv(OPENCODE_BASE_URL_ENV, previousOpencodeBaseUrl); + try { + await rm(root, { recursive: true, force: true }); + } catch (error) { + cleanupErrors.push(error); + } + if (cleanupErrors.length > 0) throw new AggregateError(cleanupErrors, "Failed to clean up embedded runtime config lifecycle test"); + } + }); +}); diff --git a/apps/server/src/embedded.ts b/apps/server/src/embedded.ts index 8b4c1a1b25..d19408eb8f 100644 --- a/apps/server/src/embedded.ts +++ b/apps/server/src/embedded.ts @@ -65,7 +65,6 @@ export async function startEmbeddedServer(options: EmbeddedServerOptions): Promi // instance rebuild, and keepOpenworkRuntimeConfigFileFresh synchronizes it // on every runtime-DB write — so disposes always pick up current state. const { path: runtimeConfigPath } = await writeOpenworkRuntimeConfigFile(config, workspace.id); - keepOpenworkRuntimeConfigFileFresh(config, workspace.id); const cwd = options.opencodeCwd || process.env.OPENWORK_MANAGED_OPENCODE_CWD?.trim() || workspace.path; @@ -117,6 +116,10 @@ export async function startEmbeddedServer(options: EmbeddedServerOptions): Promi } const server = await startServer(config); + const managedWorkspace = managedOpencode ? findManagedEngineWorkspace(config.workspaces) : null; + const stopRuntimeConfigFileRefresh = managedWorkspace + ? keepOpenworkRuntimeConfigFileFresh(config, managedWorkspace.id) + : null; // The runtime config file above only covers workspaces[0]. Push every // workspace's runtime-DB MCPs into the engine so they aren't invisible @@ -134,6 +137,7 @@ export async function startEmbeddedServer(options: EmbeddedServerOptions): Promi ? { pid: managedOpencode.pid ?? null, isAlive: managedOpencode.isAlive } : null, async stop() { + stopRuntimeConfigFileRefresh?.(); if (managedOpencodeIdentity) { clearTrustedOpencodeProcess(config, managedOpencodeIdentity); } diff --git a/evals/flows/embedded-runtime-config-listener-lifecycle.flow.mjs b/evals/flows/embedded-runtime-config-listener-lifecycle.flow.mjs new file mode 100644 index 0000000000..65dbb4bdeb --- /dev/null +++ b/evals/flows/embedded-runtime-config-listener-lifecycle.flow.mjs @@ -0,0 +1,78 @@ +/** + * Internal proof: execute the embedded-server lifecycle regression test in the + * pinned Bun environment and bind its HTTP observations to Fraimz claims. + */ +import { execFile } from "node:child_process"; +import { dirname, join } from "node:path"; +import { promisify } from "node:util"; +import { fileURLToPath } from "node:url"; +import { loadVoiceoverParagraphs } from "../runner/voiceover.mjs"; + +const FLOW_ID = "embedded-runtime-config-listener-lifecycle"; +const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", ".."); +const DOCKER_BIN = "docker"; +const BUN_IMAGE = "oven/bun:1.3.6"; +const CLEANUP_LABEL = "com.ora.cleanup.scope=fraimz"; +const CONTAINER_ROOT = "/workspace"; +const SERVER_WORKDIR = `${CONTAINER_ROOT}/apps/server`; +const TEST_PATH = "src/embedded-runtime-config-lifecycle.test.ts"; +const MAX_OUTPUT_BYTES = 1024 * 1024; +const PASS_MARKER = "1 pass"; +const FAILURE_MARKER = "0 fail"; +const PATCH_MARKER = "PATCH /runtime-config/providers 200"; +const execFileAsync = promisify(execFile); +const vo = await loadVoiceoverParagraphs(FLOW_ID); +let testOutput = ""; + +function witness(ctx, condition, assertion, actual = "") { + ctx.recordEvidence({ type: "assertion", status: condition ? "passed" : "failed", assertion, actual }); + ctx.assert(condition, actual ? `${assertion} (actual: ${actual})` : assertion); +} + +async function runLifecycleTest() { + const { stdout, stderr } = await execFileAsync(DOCKER_BIN, [ + "run", "--rm", "--label", CLEANUP_LABEL, + "-v", `${ROOT}:${CONTAINER_ROOT}`, + "-w", SERVER_WORKDIR, + BUN_IMAGE, + "bun", "test", TEST_PATH, + ], { maxBuffer: MAX_OUTPUT_BYTES }); + return `${stdout}\n${stderr}`.trim(); +} + +export default { + id: FLOW_ID, + title: "Stopped embedded servers release runtime config listeners", + kind: "internal", + requiresApp: false, + steps: [ + { + name: "Stopping an embedded server releases its listener", + run: async (ctx) => { + await ctx.prove("A real embedded server is stopped before another starts against the shared runtime database", { + voiceover: vo[0], + assert: async () => { + testOutput = await runLifecycleTest(); + witness(ctx, testOutput.includes(PASS_MARKER), "The lifecycle regression test passes", testOutput); + witness(ctx, testOutput.includes(FAILURE_MARKER), "The lifecycle regression test has no failures", testOutput); + ctx.output("pinned Bun lifecycle test", testOutput); + }, + }); + }, + }, + { + name: "An identical provider update skips reload", + run: async (ctx) => { + await ctx.prove("The active HTTP endpoint accepts both provider PATCH requests and the identical request skips reload", { + voiceover: vo[1], + assert: async () => { + const patchCount = testOutput.split(PATCH_MARKER).length - 1; + witness(ctx, patchCount === 2, "Both provider PATCH requests returned HTTP 200", String(patchCount)); + witness(ctx, testOutput.includes(PASS_MARKER), "The skipped-reload assertion passed", testOutput); + ctx.output("provider PATCH observations", testOutput); + }, + }); + }, + }, + ], +}; diff --git a/evals/voiceovers/embedded-runtime-config-listener-lifecycle.md b/evals/voiceovers/embedded-runtime-config-listener-lifecycle.md new file mode 100644 index 0000000000..5ce6558d59 --- /dev/null +++ b/evals/voiceovers/embedded-runtime-config-listener-lifecycle.md @@ -0,0 +1,5 @@ +# embedded-runtime-config-listener-lifecycle — stopped servers release runtime config listeners + +1. An embedded OpenWork server is stopped and another server starts against the same runtime database. Updating the stopped server's workspace can no longer rewrite the active server's generated OpenCode config. + +2. Repeating the active server's provider configuration is now a true no-op. OpenWork reports that nothing changed and skips the OpenCode reload.