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
11 changes: 10 additions & 1 deletion apps/app/src/app/lib/openwork-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,16 @@ export type OpenworkRuntimeConfigStatus = {
sources?: {
projectOpencode: { path: string; exists: boolean; keys: string[]; config: Record<string, unknown> };
globalOpencode: { path: string; exists: boolean; keys: string[]; config: Record<string, unknown> };
runtimeDatabase: { keys: string[]; config: Record<string, unknown> };
runtimeDatabase: {
keys: string[];
config: Record<string, unknown>;
database?: {
path: string;
schemaVersion: number;
migrations: { version: number; name: string; appliedAt: number }[];
tables: Record<string, number>;
};
};
injected: { keys: string[]; config: Record<string, unknown> };
};
legacyOpenwork: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ function RuntimeConfigSourceBlock(props: {
exists?: boolean;
keys: string[];
config: Record<string, unknown>;
details?: ReactNode;
}) {
return (
<div className="space-y-2 rounded-xl border border-gray-6 bg-gray-1/70 p-3">
Expand All @@ -195,6 +196,7 @@ function RuntimeConfigSourceBlock(props: {
{props.path ? <div className="mt-1 break-all font-mono text-[11px] text-gray-8">{props.path}</div> : null}
{props.exists !== undefined ? <div className="text-[11px] text-gray-9">{props.exists ? "Found" : "Not found"}</div> : null}
<div className="text-[11px] text-gray-9">Keys: {formatKeys(props.keys)}</div>
{props.details}
</div>
<RuntimeConfigSummary config={props.config} />
<details className="rounded-lg bg-gray-3 p-2">
Expand Down Expand Up @@ -292,6 +294,22 @@ export function AdvancedRuntimeMigrationSection(props: AdvancedRuntimeMigrationS
description="OpenWork-managed runtime values stored outside workspace files."
keys={props.configStatus.sources.runtimeDatabase.keys}
config={props.configStatus.sources.runtimeDatabase.config}
details={props.configStatus.sources.runtimeDatabase.database ? (
<div className="mt-2 space-y-1 text-[11px] text-gray-9">
<div className="break-all font-mono">{props.configStatus.sources.runtimeDatabase.database.path}</div>
<div>Schema version: {props.configStatus.sources.runtimeDatabase.database.schemaVersion}</div>
<div>
Tables: {Object.entries(props.configStatus.sources.runtimeDatabase.database.tables)
.map(([name, count]) => `${name}: ${count}`)
.join(", ")}
</div>
<div>
Migrations: {props.configStatus.sources.runtimeDatabase.database.migrations
.map((migration) => `${migration.version} ${migration.name}`)
.join(", ") || "none"}
</div>
</div>
) : null}
/>
<RuntimeConfigSourceBlock
title="OpenWork injected config"
Expand Down
95 changes: 14 additions & 81 deletions apps/server/src/openwork-workspace-config-store.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,5 @@
import { homedir } from "node:os";
import { dirname, join, resolve } from "node:path";
import { eq } from "drizzle-orm";
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
import { openRuntimeDb, updateRuntimeJsonRow } from "./runtime-db.js";
import type { ServerConfig } from "./types.js";
import { ensureDir } from "./utils.js";

const openworkWorkspaceConfigs = sqliteTable("openwork_workspace_configs", {
workspaceId: text("workspace_id").primaryKey(),
configJson: text("config_json").notNull(),
updatedAt: integer("updated_at").notNull(),
});

type OpenworkWorkspaceConfigDb = {
get: (workspaceId: string) => { configJson: string } | undefined;
upsert: (value: { workspaceId: string; configJson: string; updatedAt: number }) => void;
};

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
Expand All @@ -24,71 +9,18 @@ function normalizeOpenworkWorkspaceConfig(value: unknown): Record<string, unknow
return isRecord(value) ? value : {};
}

function runtimeDbPath(config: ServerConfig): string {
const override = process.env.OPENWORK_RUNTIME_DB?.trim();
if (override) return resolve(override);
const configPath = config.configPath?.trim();
const configDir = configPath ? dirname(configPath) : join(homedir(), ".config", "openwork");
return join(configDir, "runtime.sqlite");
}

async function openDb(path: string): Promise<OpenworkWorkspaceConfigDb> {
await ensureDir(dirname(path));
if (typeof process.versions.bun === "string") {
const { Database } = await import("bun:sqlite");
const { drizzle } = await import("drizzle-orm/bun-sqlite");
const sqlite = new Database(path, { create: true });
sqlite.run("CREATE TABLE IF NOT EXISTS openwork_workspace_configs (workspace_id TEXT PRIMARY KEY NOT NULL, config_json TEXT NOT NULL, updated_at INTEGER NOT NULL)");
const db = drizzle(sqlite);
return {
get: (workspaceId) => db
.select()
.from(openworkWorkspaceConfigs)
.where(eq(openworkWorkspaceConfigs.workspaceId, workspaceId))
.get(),
upsert: ({ workspaceId, configJson, updatedAt }) => {
db
.insert(openworkWorkspaceConfigs)
.values({ workspaceId, configJson, updatedAt })
.onConflictDoUpdate({
target: openworkWorkspaceConfigs.workspaceId,
set: { configJson, updatedAt },
})
.run();
},
};
function parseOpenworkWorkspaceConfigJson(value: string | undefined): Record<string, unknown> {
if (!value) return {};
try {
return normalizeOpenworkWorkspaceConfig(JSON.parse(value));
} catch {
return {};
}
const { DatabaseSync } = await import("node:sqlite");
const sqlite = new DatabaseSync(path);
sqlite.exec("CREATE TABLE IF NOT EXISTS openwork_workspace_configs (workspace_id TEXT PRIMARY KEY NOT NULL, config_json TEXT NOT NULL, updated_at INTEGER NOT NULL)");
const get = sqlite.prepare("SELECT config_json AS configJson FROM openwork_workspace_configs WHERE workspace_id = ?");
const upsert = sqlite.prepare("INSERT INTO openwork_workspace_configs (workspace_id, config_json, updated_at) VALUES (?, ?, ?) ON CONFLICT(workspace_id) DO UPDATE SET config_json = excluded.config_json, updated_at = excluded.updated_at");
return {
get: (workspaceId) => {
const row = get.get(workspaceId);
if (!isRecord(row) || typeof row.configJson !== "string") return undefined;
return { configJson: row.configJson };
},
upsert: ({ workspaceId, configJson, updatedAt }) => {
upsert.run(workspaceId, configJson, updatedAt);
},
};
}

const dbByPath = new Map<string, Promise<OpenworkWorkspaceConfigDb>>();

async function workspaceConfigDb(config: ServerConfig): Promise<OpenworkWorkspaceConfigDb> {
const path = runtimeDbPath(config);
const existing = dbByPath.get(path);
if (existing) return existing;
const db = openDb(path);
dbByPath.set(path, db);
return db;
}

export async function readOpenworkWorkspaceConfig(config: ServerConfig, workspaceId: string): Promise<Record<string, unknown>> {
const db = await workspaceConfigDb(config);
const row = db.get(workspaceId);
const db = await openRuntimeDb(config);
const row = db.getJsonRow("openwork_workspace_configs", workspaceId);
if (!row) return {};
try {
return normalizeOpenworkWorkspaceConfig(JSON.parse(row.configJson));
Expand All @@ -102,10 +34,11 @@ export async function writeOpenworkWorkspaceConfig(
workspaceId: string,
updater: (current: Record<string, unknown>) => Record<string, unknown>,
): Promise<Record<string, unknown>> {
const db = await workspaceConfigDb(config);
const next = normalizeOpenworkWorkspaceConfig(updater(await readOpenworkWorkspaceConfig(config, workspaceId)));
db.upsert({ workspaceId, configJson: JSON.stringify(next), updatedAt: Date.now() });
return next;
const configJson = await updateRuntimeJsonRow(config, "openwork_workspace_configs", workspaceId, (currentJson) => {
const current = parseOpenworkWorkspaceConfigJson(currentJson);
return JSON.stringify(normalizeOpenworkWorkspaceConfig(updater(current)));
});
return parseOpenworkWorkspaceConfigJson(configJson);
}

export function mergeOpenworkWorkspaceConfigs(
Expand Down
35 changes: 35 additions & 0 deletions apps/server/src/runtime-db-schema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";

export const schemaMigrations = sqliteTable("schema_migrations", {
version: integer("version").primaryKey(),
name: text("name").notNull(),
appliedAt: integer("applied_at").notNull(),
});

export const migrationState = sqliteTable("migration_state", {
source: text("source").primaryKey(),
status: text("status").notNull(),
path: text("path").notNull().default(""),
hash: text("hash").notNull().default(""),
rowCount: integer("row_count").notNull().default(0),
importedAt: integer("imported_at").notNull(),
});

export const runtimeOpencodeConfigs = sqliteTable("runtime_opencode_configs", {
workspaceId: text("workspace_id").primaryKey(),
configJson: text("config_json").notNull(),
updatedAt: integer("updated_at").notNull(),
});

export const openworkWorkspaceConfigs = sqliteTable("openwork_workspace_configs", {
workspaceId: text("workspace_id").primaryKey(),
configJson: text("config_json").notNull(),
updatedAt: integer("updated_at").notNull(),
});

export const runtimeDbSchema = {
schemaMigrations,
migrationState,
runtimeOpencodeConfigs,
openworkWorkspaceConfigs,
};
132 changes: 132 additions & 0 deletions apps/server/src/runtime-db.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, expect, test } from "bun:test";
import { Database } from "bun:sqlite";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { addMcp } from "./mcp.js";
import { readOpenworkWorkspaceConfig, writeOpenworkWorkspaceConfig } from "./openwork-workspace-config-store.js";
import { addPlugin } from "./plugins.js";
import { getRuntimeDbDiagnostics } from "./runtime-db.js";
import { readRuntimeOpencodeConfig, writeRuntimeOpencodeConfig } from "./runtime-opencode-config-store.js";
import type { ServerConfig } from "./types.js";

const WORKSPACE_ID = "ws_runtime_db_test";

function serverConfig(root: string): ServerConfig {
return {
host: "127.0.0.1",
port: 0,
token: "token",
hostToken: "host-token",
configPath: join(root, "server.json"),
approval: { mode: "auto", timeoutMs: 0 },
corsOrigins: [],
workspaces: [{ id: WORKSPACE_ID, name: "Test", path: root, preset: "starter", workspaceType: "local" }],
authorizedRoots: [root],
readOnly: false,
startedAt: Date.now(),
tokenSource: "generated",
hostTokenSource: "generated",
logFormat: "pretty",
logRequests: false,
} satisfies ServerConfig;
}

async function withRuntimeDb(fn: (input: { root: string; dbPath: string; config: ServerConfig }) => Promise<void>) {
const root = await mkdtemp(join(tmpdir(), "openwork-runtime-db-"));
const previousDb = process.env.OPENWORK_RUNTIME_DB;
const dbPath = join(root, "runtime.sqlite");
process.env.OPENWORK_RUNTIME_DB = dbPath;
try {
await fn({ root, dbPath, config: serverConfig(root) });
} finally {
if (previousDb === undefined) delete process.env.OPENWORK_RUNTIME_DB;
else process.env.OPENWORK_RUNTIME_DB = previousDb;
await rm(root, { recursive: true, force: true });
}
}

function openSqlite(path: string): Database {
return new Database(path, { create: true });
}

describe("runtime DB", () => {
test("creates shared schema and records the runtime schema migration", async () => {
await withRuntimeDb(async ({ config }) => {
await addPlugin(config, WORKSPACE_ID, "runtime-plugin");
await writeOpenworkWorkspaceConfig(config, WORKSPACE_ID, () => ({ cloudImports: { plugins: {} } }));

const diagnostics = await getRuntimeDbDiagnostics(config);

expect(diagnostics.schemaVersion).toBe(1);
expect(diagnostics.migrations).toMatchObject([{ version: 1, name: "initial_runtime_store" }]);
expect(diagnostics.tables.runtime_opencode_configs).toBe(1);
expect(diagnostics.tables.openwork_workspace_configs).toBe(1);
expect(diagnostics.tables.migration_state).toBe(0);
});
});

test("opens an existing runtime DB with the old tables without dropping data", async () => {
await withRuntimeDb(async ({ dbPath, config }) => {
const sqlite = openSqlite(dbPath);
sqlite.run("CREATE TABLE runtime_opencode_configs (workspace_id TEXT PRIMARY KEY NOT NULL, config_json TEXT NOT NULL, updated_at INTEGER NOT NULL)");
sqlite.run("CREATE TABLE openwork_workspace_configs (workspace_id TEXT PRIMARY KEY NOT NULL, config_json TEXT NOT NULL, updated_at INTEGER NOT NULL)");
sqlite.run(
"INSERT INTO runtime_opencode_configs (workspace_id, config_json, updated_at) VALUES (?, ?, ?)",
[WORKSPACE_ID, JSON.stringify({ plugin: ["legacy-runtime-plugin"] }), Date.now()],
);
sqlite.close();

expect((await readRuntimeOpencodeConfig(config, WORKSPACE_ID)).plugin).toEqual(["legacy-runtime-plugin"]);
const diagnostics = await getRuntimeDbDiagnostics(config);

expect(diagnostics.schemaVersion).toBe(1);
expect(diagnostics.tables.runtime_opencode_configs).toBe(1);
expect(diagnostics.tables.openwork_workspace_configs).toBe(0);
});
});

test("serializes runtime config updates so unrelated keys are preserved", async () => {
await withRuntimeDb(async ({ config }) => {
await Promise.all([
addPlugin(config, WORKSPACE_ID, "runtime-plugin"),
addMcp(config, WORKSPACE_ID, "runtime-mcp", { type: "remote", url: "https://runtime.example/mcp" }),
writeRuntimeOpencodeConfig(config, WORKSPACE_ID, (current) => ({
...current,
provider: { runtimeProvider: { npm: "@openwork/runtime-provider" } },
})),
]);

const runtime = await readRuntimeOpencodeConfig(config, WORKSPACE_ID);

expect(runtime.plugin).toEqual(["runtime-plugin"]);
expect(runtime.mcp?.["runtime-mcp"]?.url).toBe("https://runtime.example/mcp");
expect(runtime.provider?.runtimeProvider).toEqual({ npm: "@openwork/runtime-provider" });
});
});

test("malformed stored JSON still reads as empty config", async () => {
await withRuntimeDb(async ({ dbPath, config }) => {
const sqlite = openSqlite(dbPath);
sqlite.run("CREATE TABLE runtime_opencode_configs (workspace_id TEXT PRIMARY KEY NOT NULL, config_json TEXT NOT NULL, updated_at INTEGER NOT NULL)");
sqlite.run("INSERT INTO runtime_opencode_configs (workspace_id, config_json, updated_at) VALUES (?, ?, ?)", [WORKSPACE_ID, "{ invalid", Date.now()]);
sqlite.close();

expect(await readRuntimeOpencodeConfig(config, WORKSPACE_ID)).toEqual({});
await addPlugin(config, WORKSPACE_ID, "runtime-plugin");
expect((await readRuntimeOpencodeConfig(config, WORKSPACE_ID)).plugin).toEqual(["runtime-plugin"]);
});
});

test("workspace config updates share the same DB and preserve existing JSON keys", async () => {
await withRuntimeDb(async ({ config }) => {
await writeOpenworkWorkspaceConfig(config, WORKSPACE_ID, () => ({ cloudImports: { plugins: { plugin_1: { name: "one" } } } }));
await writeOpenworkWorkspaceConfig(config, WORKSPACE_ID, (current) => ({ ...current, desktopCloudSync: { fetchedAt: 123 } }));

expect(await readOpenworkWorkspaceConfig(config, WORKSPACE_ID)).toEqual({
cloudImports: { plugins: { plugin_1: { name: "one" } } },
desktopCloudSync: { fetchedAt: 123 },
});
});
});
});
Loading
Loading