Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions apps/server/src/embedded-runtime-config-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string, string> {
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<ReturnType<typeof startEmbeddedServer>> | 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");
}
});
});
6 changes: 5 additions & 1 deletion apps/server/src/embedded.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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);
}
Expand Down
78 changes: 78 additions & 0 deletions evals/flows/embedded-runtime-config-listener-lifecycle.flow.mjs
Original file line number Diff line number Diff line change
@@ -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);
},
});
},
},
],
};
Original file line number Diff line number Diff line change
@@ -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.