feat(server-v2): add standalone runtime and SDK foundation (#1468)

* feat(server-v2): add standalone runtime and SDK foundation

* docs(server-v2): drop planning task checklists

* build(server-v2): generate OpenAPI and SDK during dev

* build(server-v2): generate API artifacts before builds

* build(server-v2): drop duplicate root SDK generation

* build(app): remove SDK generation hooks

---------

Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
Source Open
2026-04-17 09:54:26 -07:00
committed by GitHub
parent 2ad9dfbf0a
commit 12900a0b9e
111 changed files with 26679 additions and 1 deletions

View File

@@ -0,0 +1,122 @@
name: Windows Signed Artifacts
on:
workflow_dispatch:
inputs:
ref:
description: Git ref to build
required: false
type: string
permissions:
contents: read
jobs:
build-and-sign-windows:
name: Build and sign Windows artifacts
runs-on: windows-latest
env:
TAURI_TARGET: x86_64-pc-windows-msvc
BUN_TARGET: bun-windows-x64
WINDOWS_SIGNING_CERT_PASSWORD: ${{ secrets.WINDOWS_CERT_PASSWORD }}
WINDOWS_TIMESTAMP_URL: ${{ secrets.WINDOWS_TIMESTAMP_URL || 'http://timestamp.digicert.com' }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
ref: ${{ github.event.inputs.ref || github.ref }}
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.27.0
- name: Setup Bun
uses: oven-sh/setup-bun@v1
with:
bun-version: 1.3.10
- name: Setup Rust
uses: dtolnay/rust-toolchain@stable
with:
targets: x86_64-pc-windows-msvc
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Prepare sidecars
run: pnpm -C apps/desktop prepare:sidecar
- name: Import Windows signing certificate
shell: pwsh
env:
WINDOWS_CERT_PFX_BASE64: ${{ secrets.WINDOWS_CERT_PFX_BASE64 }}
run: |
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_CERT_PFX_BASE64)) {
throw "WINDOWS_CERT_PFX_BASE64 is required for Windows signing."
}
if ([string]::IsNullOrWhiteSpace($env:WINDOWS_SIGNING_CERT_PASSWORD)) {
throw "WINDOWS_CERT_PASSWORD is required for Windows signing."
}
$bytes = [Convert]::FromBase64String($env:WINDOWS_CERT_PFX_BASE64)
$certPath = Join-Path $env:RUNNER_TEMP "windows-codesign.pfx"
[IO.File]::WriteAllBytes($certPath, $bytes)
"WINDOWS_CERT_PATH=$certPath" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append
- name: Sign bundled Windows sidecars
shell: pwsh
run: |
$targets = @(
"apps/desktop/src-tauri/sidecars/opencode-$env:TAURI_TARGET.exe",
"apps/desktop/src-tauri/sidecars/opencode-router-$env:TAURI_TARGET.exe",
"apps/desktop/src-tauri/sidecars/openwork-server-v2-$env:TAURI_TARGET.exe"
)
foreach ($target in $targets) {
if (!(Test-Path $target)) {
throw "Expected Windows sidecar missing: $target"
}
signtool sign /fd SHA256 /td SHA256 /tr $env:WINDOWS_TIMESTAMP_URL /f $env:WINDOWS_CERT_PATH /p $env:WINDOWS_SIGNING_CERT_PASSWORD $target
}
- name: Build embedded Server V2 runtime
run: pnpm --filter openwork-server-v2 build:bin:embedded:windows --bundle-dir ../desktop/src-tauri/sidecars
working-directory: apps/server-v2
- name: Sign Server V2 executable
shell: pwsh
run: |
$serverPath = "apps/server-v2/dist/bin/openwork-server-v2-$env:BUN_TARGET.exe"
if (!(Test-Path $serverPath)) {
throw "Expected Server V2 executable missing: $serverPath"
}
signtool sign /fd SHA256 /td SHA256 /tr $env:WINDOWS_TIMESTAMP_URL /f $env:WINDOWS_CERT_PATH /p $env:WINDOWS_SIGNING_CERT_PASSWORD $serverPath
signtool verify /pa /v $serverPath
- name: Build desktop Windows bundle
run: pnpm --filter @openwork/desktop exec tauri build --target x86_64-pc-windows-msvc
- name: Sign desktop Windows artifacts
shell: pwsh
run: |
$artifacts = Get-ChildItem -Path "apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle" -Recurse -Include *.exe,*.msi
if ($artifacts.Count -eq 0) {
throw "No Windows desktop artifacts were produced to sign."
}
foreach ($artifact in $artifacts) {
signtool sign /fd SHA256 /td SHA256 /tr $env:WINDOWS_TIMESTAMP_URL /f $env:WINDOWS_CERT_PATH /p $env:WINDOWS_SIGNING_CERT_PASSWORD $artifact.FullName
signtool verify /pa /v $artifact.FullName
}
- name: Upload signed artifacts
uses: actions/upload-artifact@v4
with:
name: windows-signed-artifacts
path: |
apps/server-v2/dist/bin/openwork-server-v2-*.exe
apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.exe
apps/desktop/src-tauri/target/x86_64-pc-windows-msvc/release/bundle/**/*.msi

2
.gitignore vendored
View File

@@ -30,6 +30,8 @@ apps/desktop/src-tauri/sidecars/
# Bun build artifacts
*.bun-build
apps/server/cli
apps/server-v2/openapi/openapi.json
packages/openwork-server-sdk/generated/
# pnpm store (created by Docker volume mounts)
.pnpm-store/

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const binDir = dirname(fileURLToPath(import.meta.url));
const packageDir = resolve(binDir, "..");
const child = spawn("bun", ["src/cli.ts", ...process.argv.slice(2)], {
cwd: packageDir,
stdio: "inherit",
env: process.env,
});
child.once("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});

View File

@@ -0,0 +1,43 @@
{
"name": "openwork-server-v2",
"version": "0.11.206",
"private": true,
"type": "module",
"bin": {
"openwork-server-v2": "bin/openwork-server-v2.mjs"
},
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 bun --watch src/cli.ts",
"start": "bun src/cli.ts",
"openapi:generate": "bun ./scripts/generate-openapi.ts",
"openapi:watch": "node ./scripts/watch-openapi.mjs",
"test": "bun test",
"typecheck": "tsc -p tsconfig.json --noEmit",
"build:bin": "bun ./script/build.ts --outdir dist/bin --filename openwork-server-v2",
"build:bin:windows": "bun ./script/build.ts --outdir dist/bin --filename openwork-server-v2 --target bun-windows-x64",
"build:bin:embedded": "bun ./script/build.ts --outdir dist/bin --filename openwork-server-v2 --embed-runtime",
"build:bin:embedded:windows": "bun ./script/build.ts --outdir dist/bin --filename openwork-server-v2 --embed-runtime --target bun-windows-x64",
"build:bin:all": "bun ./script/build.ts --outdir dist/bin --filename openwork-server-v2 --target bun-darwin-arm64 --target bun-darwin-x64-baseline --target bun-linux-x64-baseline --target bun-linux-arm64 --target bun-windows-x64",
"build:bin:embedded:all": "bun ./script/build.ts --outdir dist/bin --filename openwork-server-v2 --embed-runtime --target bun-darwin-arm64 --target bun-darwin-x64-baseline --target bun-linux-x64-baseline --target bun-linux-arm64 --target bun-windows-x64",
"prepublishOnly": "pnpm openapi:generate && pnpm build:bin"
},
"files": [
"bin",
"openapi",
"src"
],
"dependencies": {
"@opencode-ai/sdk": "1.2.27",
"hono": "4.12.12",
"hono-openapi": "1.3.0",
"jsonc-parser": "^3.3.1",
"yaml": "^2.8.1",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^22.10.2",
"bun-types": "^1.3.6",
"typescript": "^5.6.3"
},
"packageManager": "pnpm@10.27.0"
}

View File

@@ -0,0 +1,267 @@
import { mkdtempSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import os from "node:os";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
const bunRuntime = (globalThis as typeof globalThis & {
Bun?: {
argv?: string[];
};
}).Bun;
if (!bunRuntime?.argv) {
console.error("This script must be run with Bun.");
process.exit(1);
}
type BuildOptions = {
bundleDir: string | null;
embedRuntime: boolean;
filename: string;
outdir: string;
targets: string[];
};
type RuntimeAssetPaths = {
manifestPath: string;
opencodePath: string;
routerPath: string;
};
const TARGET_TRIPLES: Record<string, string> = {
"bun-darwin-arm64": "aarch64-apple-darwin",
"bun-darwin-x64": "x86_64-apple-darwin",
"bun-darwin-x64-baseline": "x86_64-apple-darwin",
"bun-linux-arm64": "aarch64-unknown-linux-gnu",
"bun-linux-x64": "x86_64-unknown-linux-gnu",
"bun-linux-x64-baseline": "x86_64-unknown-linux-gnu",
"bun-windows-arm64": "aarch64-pc-windows-msvc",
"bun-windows-x64": "x86_64-pc-windows-msvc",
"bun-windows-x64-baseline": "x86_64-pc-windows-msvc",
};
function readPackageVersion() {
const packageJsonPath = resolve("package.json");
const contents = readFileSync(packageJsonPath, "utf8");
const parsed = JSON.parse(contents) as { version?: unknown };
const version = typeof parsed.version === "string" ? parsed.version.trim() : "";
if (!version) {
throw new Error(`Missing package version in ${packageJsonPath}`);
}
return version;
}
function fileExists(filePath: string) {
try {
return statSync(filePath).isFile();
} catch {
return false;
}
}
function readArgs(argv: string[]): BuildOptions {
const options: BuildOptions = {
bundleDir: process.env.OPENWORK_SERVER_V2_BUNDLE_DIR?.trim() ? resolve(process.env.OPENWORK_SERVER_V2_BUNDLE_DIR.trim()) : null,
embedRuntime: false,
filename: "openwork-server-v2",
outdir: resolve("dist", "bin"),
targets: [],
};
for (let index = 0; index < argv.length; index += 1) {
const value = argv[index];
if (!value) continue;
if (value === "--embed-runtime") {
options.embedRuntime = true;
continue;
}
if (value === "--bundle-dir") {
const next = argv[index + 1];
if (next) {
options.bundleDir = resolve(next);
index += 1;
}
continue;
}
if (value.startsWith("--bundle-dir=")) {
const next = value.slice("--bundle-dir=".length).trim();
if (next) options.bundleDir = resolve(next);
continue;
}
if (value === "--target") {
const next = argv[index + 1];
if (next) {
options.targets.push(next);
index += 1;
}
continue;
}
if (value.startsWith("--target=")) {
const next = value.slice("--target=".length).trim();
if (next) options.targets.push(next);
continue;
}
if (value === "--outdir") {
const next = argv[index + 1];
if (next) {
options.outdir = resolve(next);
index += 1;
}
continue;
}
if (value.startsWith("--outdir=")) {
const next = value.slice("--outdir=".length).trim();
if (next) options.outdir = resolve(next);
continue;
}
if (value === "--filename") {
const next = argv[index + 1];
if (next) {
options.filename = next;
index += 1;
}
continue;
}
if (value.startsWith("--filename=")) {
const next = value.slice("--filename=".length).trim();
if (next) options.filename = next;
}
}
return options;
}
function outputName(filename: string, target?: string) {
const needsExe = target ? target.includes("windows") : process.platform === "win32";
const suffix = target ? `-${target}` : "";
const ext = needsExe ? ".exe" : "";
return `${filename}${suffix}${ext}`;
}
function runtimeAssetCandidates(bundleDir: string, target?: string): RuntimeAssetPaths {
const triple = target ? TARGET_TRIPLES[target] ?? null : null;
const canonicalManifest = join(bundleDir, "manifest.json");
const targetManifest = triple ? join(bundleDir, `manifest.json-${triple}`) : null;
const manifestPath = [targetManifest, canonicalManifest].find((candidate) => candidate && fileExists(candidate)) ?? null;
const opencodeCandidates = [
triple ? join(bundleDir, `opencode-${triple}${triple.includes("windows") ? ".exe" : ""}`) : null,
join(bundleDir, process.platform === "win32" || target?.includes("windows") ? "opencode.exe" : "opencode"),
];
const routerCandidates = [
triple ? join(bundleDir, `opencode-router-${triple}${triple.includes("windows") ? ".exe" : ""}`) : null,
join(bundleDir, process.platform === "win32" || target?.includes("windows") ? "opencode-router.exe" : "opencode-router"),
];
const opencodePath = opencodeCandidates.find((candidate) => candidate && fileExists(candidate)) ?? null;
const routerPath = routerCandidates.find((candidate) => candidate && fileExists(candidate)) ?? null;
if (!manifestPath || !opencodePath || !routerPath) {
throw new Error(
`Missing runtime assets for embedded build in ${bundleDir} (target=${target ?? "current"}, manifest=${manifestPath ?? "missing"}, opencode=${opencodePath ?? "missing"}, router=${routerPath ?? "missing"}).`,
);
}
return {
manifestPath,
opencodePath,
routerPath,
};
}
function createEmbeddedEntrypoint(assets: RuntimeAssetPaths) {
const buildDir = mkdtempSync(join(os.tmpdir(), "openwork-server-v2-build-"));
const embeddedModulePath = join(buildDir, "embedded-runtime.ts");
const entrypointPath = join(buildDir, "entry.ts");
writeFileSync(
embeddedModulePath,
[
`import manifestPath from ${JSON.stringify(assets.manifestPath)} with { type: "file" };`,
`import opencodePath from ${JSON.stringify(assets.opencodePath)} with { type: "file" };`,
`import routerPath from ${JSON.stringify(assets.routerPath)} with { type: "file" };`,
"",
"export const embeddedRuntimeBundle = {",
" manifestPath,",
" opencodePath,",
" routerPath,",
"};",
"",
].join("\n"),
"utf8",
);
writeFileSync(
entrypointPath,
[
`import { registerEmbeddedRuntimeBundle } from ${JSON.stringify(resolve("src", "runtime", "embedded.ts"))};`,
`import { embeddedRuntimeBundle } from ${JSON.stringify(embeddedModulePath)};`,
"",
"registerEmbeddedRuntimeBundle(embeddedRuntimeBundle);",
"void (async () => {",
` await import(${JSON.stringify(resolve("src", "cli.ts"))});`,
"})();",
"",
].join("\n"),
"utf8",
);
return {
cleanup() {
rmSync(buildDir, { force: true, recursive: true });
},
entrypointPath,
};
}
function buildOnce(options: BuildOptions, target?: string) {
mkdirSync(options.outdir, { recursive: true });
const outfile = join(options.outdir, outputName(options.filename, target));
const version = readPackageVersion();
const embedded = options.embedRuntime
? createEmbeddedEntrypoint(runtimeAssetCandidates(
options.bundleDir ?? resolve("..", "desktop", "src-tauri", "sidecars"),
target,
))
: null;
const entrypoint = embedded?.entrypointPath ?? resolve("src", "cli.ts");
const args = [
"build",
entrypoint,
"--compile",
"--minify",
"--bytecode",
"--sourcemap",
"--outfile",
outfile,
"--define",
`__OPENWORK_SERVER_V2_VERSION__=${JSON.stringify(version)}`,
];
if (target) {
args.push("--target", target);
}
const result = spawnSync("bun", args, { stdio: "inherit" });
embedded?.cleanup();
if (result.status !== 0) {
process.exit(result.status ?? 1);
}
}
const options = readArgs(bunRuntime.argv.slice(2));
const targets = options.targets.length ? options.targets : [undefined];
for (const target of targets) {
buildOnce(options, target);
}

View File

@@ -0,0 +1,58 @@
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { createAppDependencies } from "../src/context/app-dependencies.js";
import { createApp } from "../src/app-factory.js";
import { resolveServerV2Version } from "../src/version.js";
const scriptDir = dirname(fileURLToPath(import.meta.url));
const packageDir = resolve(scriptDir, "..");
const outputPath = resolve(packageDir, "openapi/openapi.json");
async function writeIfChanged(filePath: string, contents: string) {
try {
const current = await readFile(filePath, "utf8");
if (current === contents) {
return false;
}
} catch {
// ignore missing file
}
await mkdir(dirname(filePath), { recursive: true });
await writeFile(filePath, contents, "utf8");
return true;
}
async function main() {
const workingDirectory = await mkdtemp(join(os.tmpdir(), "openwork-server-v2-openapi-"));
const dependencies = createAppDependencies({
environment: "test",
inMemory: true,
version: resolveServerV2Version(),
workingDirectory,
});
try {
const app = createApp({ dependencies });
const response = await app.request("http://openwork.local/openapi.json");
if (!response.ok) {
throw new Error(`Failed to generate OpenAPI document: ${response.status} ${response.statusText}`);
}
const document = await response.json();
const contents = `${JSON.stringify(document, null, 2)}\n`;
const changed = await writeIfChanged(outputPath, contents);
process.stdout.write(`[openwork-server-v2] ${changed ? "wrote" : "verified"} ${outputPath}\n`);
} finally {
await dependencies.close();
await rm(workingDirectory, { force: true, recursive: true });
}
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,72 @@
import { spawn } from "node:child_process";
import { watch } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const packageDir = path.resolve(scriptDir, "..");
const watchedDir = path.join(packageDir, "src");
let activeChild = null;
let queued = false;
let timer = null;
function runGenerate() {
if (activeChild) {
queued = true;
return;
}
activeChild = spawn("bun", ["./scripts/generate-openapi.ts"], {
cwd: packageDir,
env: process.env,
stdio: "inherit",
});
activeChild.once("exit", (code) => {
activeChild = null;
if (code && code !== 0) {
process.stderr.write(`[openwork-server-v2] OpenAPI generation failed with exit code ${code}.\n`);
}
if (queued) {
queued = false;
scheduleGenerate();
}
});
}
function scheduleGenerate() {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = null;
runGenerate();
}, 120);
}
runGenerate();
const watcher = watch(watchedDir, { recursive: true }, (_eventType, filename) => {
if (!filename || String(filename).includes(".DS_Store")) {
return;
}
scheduleGenerate();
});
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
watcher.close();
if (activeChild && activeChild.exitCode === null) {
activeChild.kill("SIGTERM");
}
process.exit(0);
});
}

View File

@@ -0,0 +1,289 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import { createBoundedOutputCollector, formatRuntimeOutput, type RuntimeOutputSnapshot } from "../../runtime/output-buffer.js";
type LocalOpencodeClient = ReturnType<typeof createOpencodeClient>;
export type CreateLocalOpencodeOptions = {
binary?: string;
client?: {
directory?: string;
fetch?: typeof fetch;
headers?: Record<string, string>;
responseStyle?: "data";
throwOnError?: boolean;
};
config?: Record<string, unknown>;
cwd?: string;
env?: Record<string, string | undefined>;
hostname?: string;
port?: number;
signal?: AbortSignal;
timeout?: number;
};
export type LocalProcessExit = {
at: string;
code: number | null;
signal: string | null;
};
export type LocalOpencodeHandle = {
client: LocalOpencodeClient;
server: {
close(): void;
getOutput(): RuntimeOutputSnapshot;
proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
url: string;
waitForExit(): Promise<LocalProcessExit>;
};
};
export class LocalOpencodeStartupError extends Error {
constructor(
message: string,
readonly code: "aborted" | "early_exit" | "missing_binary" | "spawn_failed" | "timeout",
readonly binary: string,
readonly output: RuntimeOutputSnapshot,
) {
super(message);
this.name = "LocalOpencodeStartupError";
}
}
function normalizeBinary(binary: string | undefined) {
const value = binary?.trim() ?? "";
if (!value) {
throw new LocalOpencodeStartupError(
"Failed to start OpenCode: no explicit binary path was provided.",
"missing_binary",
value,
{ combined: [], stderr: [], stdout: [], totalLines: 0, truncated: false },
);
}
return value;
}
function parseReadinessUrl(line: string) {
const match = line.match(/https?:\/\/\S+/);
return match?.[0] ?? null;
}
function buildSpawnErrorMessage(binary: string, error: unknown) {
const text = error instanceof Error ? error.message : String(error);
if (text.includes("ENOENT") || text.includes("executable file not found") || text.includes("No such file")) {
return `Failed to start OpenCode: executable not found at ${binary}`;
}
return `Failed to start OpenCode from ${binary}: ${text}`;
}
export async function createLocalOpencode(options: CreateLocalOpencodeOptions = {}): Promise<LocalOpencodeHandle> {
const binary = normalizeBinary(options.binary);
const hostname = options.hostname ?? "127.0.0.1";
const port = options.port ?? 4096;
const timeoutMs = options.timeout ?? 5_000;
let resolveReady: ((url: string) => void) | null = null;
const output = createBoundedOutputCollector({
maxBytes: 16_384,
maxLines: 200,
onLine(line) {
const readinessUrl = parseReadinessUrl(line.text);
if (readinessUrl && /listening/i.test(line.text)) {
resolveReady?.(readinessUrl);
}
},
});
const args = [
binary,
"serve",
`--hostname=${hostname}`,
`--port=${port}`,
];
if (typeof options.config?.logLevel === "string" && options.config.logLevel.trim()) {
args.push(`--log-level=${options.config.logLevel.trim()}`);
}
let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
try {
proc = Bun.spawn(args, {
cwd: options.cwd,
env: {
...process.env,
...options.env,
OPENCODE_CONFIG_CONTENT: JSON.stringify(options.config ?? {}),
},
stderr: "pipe",
stdin: "ignore",
stdout: "pipe",
});
} catch (error) {
throw new LocalOpencodeStartupError(buildSpawnErrorMessage(binary, error), "spawn_failed", binary, output.snapshot());
}
let settled = false;
let timeoutHandle: ReturnType<typeof setTimeout> | null = null;
let abortListener: (() => void) | null = null;
const waitForExit = async (): Promise<LocalProcessExit> => {
const code = await proc.exited;
return {
at: new Date().toISOString(),
code,
signal: "signalCode" in proc && typeof proc.signalCode === "string" ? proc.signalCode : null,
};
};
const pump = async (streamName: "stdout" | "stderr", stream: ReadableStream<Uint8Array> | null) => {
if (!stream) {
return;
}
const reader = stream.getReader();
const decoder = new TextDecoder();
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
output.finish(streamName);
return;
}
const text = decoder.decode(value, { stream: true });
output.pushChunk(streamName, text);
}
} finally {
output.finish(streamName);
reader.releaseLock();
}
};
const startup = await new Promise<{ client: LocalOpencodeClient; url: string }>((resolve, reject) => {
const rejectOnce = (error: LocalOpencodeStartupError) => {
if (settled) {
return;
}
settled = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (abortListener && options.signal) {
options.signal.removeEventListener("abort", abortListener);
}
reject(error);
};
const resolveOnce = (url: string) => {
if (settled) {
return;
}
settled = true;
if (timeoutHandle) {
clearTimeout(timeoutHandle);
}
if (abortListener && options.signal) {
options.signal.removeEventListener("abort", abortListener);
}
resolve({
client: createOpencodeClient({
baseUrl: url,
directory: options.client?.directory,
fetch: options.client?.fetch,
headers: options.client?.headers,
responseStyle: options.client?.responseStyle ?? "data",
throwOnError: options.client?.throwOnError ?? true,
}),
url,
});
};
resolveReady = resolveOnce;
void pump("stdout", proc.stdout);
void pump("stderr", proc.stderr);
void waitForExit().then((exit) => {
if (settled) {
return;
}
const snapshot = output.snapshot();
rejectOnce(
new LocalOpencodeStartupError(
`OpenCode exited before becoming ready (${exit.code === null ? "no exit code" : `exit code ${exit.code}`}).\nCollected output:\n${formatRuntimeOutput(snapshot)}`,
"early_exit",
binary,
snapshot,
),
);
});
timeoutHandle = setTimeout(() => {
if (settled) {
return;
}
proc.kill();
const snapshot = output.snapshot();
rejectOnce(
new LocalOpencodeStartupError(
`OpenCode did not become ready within ${timeoutMs}ms.\nCollected output:\n${formatRuntimeOutput(snapshot)}`,
"timeout",
binary,
snapshot,
),
);
}, timeoutMs);
if (options.signal) {
if (options.signal.aborted) {
proc.kill();
rejectOnce(
new LocalOpencodeStartupError(
`OpenCode startup aborted for ${binary}.`,
"aborted",
binary,
output.snapshot(),
),
);
return;
}
abortListener = () => {
proc.kill();
rejectOnce(
new LocalOpencodeStartupError(
`OpenCode startup aborted for ${binary}.`,
"aborted",
binary,
output.snapshot(),
),
);
};
options.signal.addEventListener("abort", abortListener, { once: true });
}
}).catch((error) => {
if (!settled) {
proc.kill();
}
if (error instanceof LocalOpencodeStartupError) {
throw error;
}
throw new LocalOpencodeStartupError(buildSpawnErrorMessage(binary, error), "spawn_failed", binary, output.snapshot());
});
return {
client: startup.client,
server: {
close() {
proc.kill();
},
getOutput() {
return output.snapshot();
},
proc,
url: startup.url,
waitForExit,
},
};
}

View File

@@ -0,0 +1,21 @@
import os from "node:os";
export type ProcessInfoAdapter = {
environment: string;
hostname: string;
pid: number;
platform: NodeJS.Platform;
runtime: "bun";
runtimeVersion: string | null;
};
export function createProcessInfoAdapter(environment: string = process.env.NODE_ENV ?? "development"): ProcessInfoAdapter {
return {
environment,
hostname: os.hostname(),
pid: process.pid,
platform: process.platform,
runtime: "bun",
runtimeVersion: globalThis.Bun?.version ?? null,
};
}

View File

@@ -0,0 +1,158 @@
import { HTTPException } from "hono/http-exception";
import type { ServerRecord, WorkspaceRecord } from "../database/types.js";
import { RouteError } from "../http.js";
function encodeBasicAuth(username: string, password: string) {
return Buffer.from(`${username}:${password}`, "utf8").toString("base64");
}
function pickString(record: Record<string, unknown> | null | undefined, keys: string[]) {
for (const key of keys) {
const value = record?.[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
export function buildRemoteOpenworkHeaders(server: ServerRecord) {
const auth = server.auth && typeof server.auth === "object" ? server.auth as Record<string, unknown> : null;
const headers: Record<string, string> = {
Accept: "application/json",
};
const bearer = pickString(auth, ["openworkClientToken", "openworkToken", "authToken", "token", "bearerToken"]);
const hostToken = pickString(auth, ["openworkHostToken", "hostToken"]);
const username = pickString(auth, ["username", "user"]);
const password = pickString(auth, ["password", "pass"]);
if (bearer) {
headers.Authorization = `Bearer ${bearer}`;
} else if (username && password) {
headers.Authorization = `Basic ${encodeBasicAuth(username, password)}`;
}
if (hostToken) {
headers["X-OpenWork-Host-Token"] = hostToken;
}
return headers;
}
function normalizeBaseUrl(value: string) {
return value.replace(/\/+$/, "");
}
function unwrapEnvelope<T>(payload: unknown): T {
if (payload && typeof payload === "object" && "ok" in (payload as Record<string, unknown>)) {
const record = payload as Record<string, unknown>;
if (record.ok === true && "data" in record) {
return record.data as T;
}
if (record.ok === false) {
const error = record.error && typeof record.error === "object" ? record.error as Record<string, unknown> : {};
const code = typeof error.code === "string" ? error.code : "bad_gateway";
const message = typeof error.message === "string" ? error.message : "Remote OpenWork request failed.";
throw new RouteError(502, code as any, message);
}
}
return payload as T;
}
export function resolveRemoteWorkspaceTarget(server: ServerRecord, workspace: WorkspaceRecord) {
const serverBaseUrl = server.baseUrl?.trim();
if (!serverBaseUrl) {
throw new RouteError(502, "bad_gateway", `Remote server ${server.id} is missing a base URL.`);
}
const remoteWorkspaceId = workspace.remoteWorkspaceId?.trim();
if (!remoteWorkspaceId) {
throw new RouteError(502, "bad_gateway", `Remote workspace ${workspace.id} is missing a remote workspace identifier.`);
}
return {
remoteWorkspaceId,
serverBaseUrl: normalizeBaseUrl(serverBaseUrl),
};
}
export async function requestRemoteOpenwork<T>(input: {
body?: unknown;
method?: string;
path: string;
server: ServerRecord;
timeoutMs?: number;
}): Promise<T> {
const baseUrl = input.server.baseUrl?.trim();
if (!baseUrl) {
throw new RouteError(502, "bad_gateway", `Remote server ${input.server.id} is missing a base URL.`);
}
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${input.path}`, {
body: input.body === undefined ? undefined : JSON.stringify(input.body),
headers: {
...buildRemoteOpenworkHeaders(input.server),
...(input.body === undefined ? {} : { "Content-Type": "application/json" }),
},
method: input.method ?? (input.body === undefined ? "GET" : "POST"),
signal: AbortSignal.timeout(input.timeoutMs ?? 10_000),
});
const text = await response.text();
const payload = text.trim() ? JSON.parse(text) : null;
if (response.status === 404) {
throw new HTTPException(404, { message: typeof (payload as any)?.message === "string" ? (payload as any).message : "Remote resource not found." });
}
if (response.status === 401) {
throw new RouteError(502, "bad_gateway", "Remote OpenWork server rejected the stored credentials.");
}
if (response.status === 403) {
throw new RouteError(502, "bad_gateway", "Remote OpenWork server rejected the stored permissions.");
}
if (!response.ok) {
const message = typeof (payload as any)?.error?.message === "string"
? (payload as any).error.message
: typeof (payload as any)?.message === "string"
? (payload as any).message
: `Remote OpenWork request failed with status ${response.status}.`;
throw new RouteError(502, "bad_gateway", message);
}
return unwrapEnvelope<T>(payload);
}
export async function requestRemoteOpenworkRaw(input: {
body?: BodyInit | null;
contentType?: string | null;
method?: string;
path: string;
server: ServerRecord;
timeoutMs?: number;
}) {
const baseUrl = input.server.baseUrl?.trim();
if (!baseUrl) {
throw new RouteError(502, "bad_gateway", `Remote server ${input.server.id} is missing a base URL.`);
}
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${input.path}`, {
body: input.body ?? undefined,
headers: {
...buildRemoteOpenworkHeaders(input.server),
...(input.contentType ? { "Content-Type": input.contentType } : {}),
},
method: input.method ?? (input.body ? "POST" : "GET"),
signal: AbortSignal.timeout(input.timeoutMs ?? 15_000),
});
if (response.status === 404) {
throw new HTTPException(404, { message: "Remote resource not found." });
}
if (response.status === 401 || response.status === 403) {
throw new RouteError(502, "bad_gateway", "Remote OpenWork server rejected the stored credentials.");
}
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new RouteError(502, "bad_gateway", text.trim() || `Remote OpenWork request failed with status ${response.status}.`);
}
return response;
}

View File

@@ -0,0 +1,23 @@
import { RouteError } from "../../http.js";
import type { RuntimeService } from "../../services/runtime-service.js";
import type { WorkspaceRecord } from "../../database/types.js";
import { createOpenCodeSessionBackend } from "./opencode-backend.js";
export function createLocalOpencodeSessionAdapter(input: {
runtime: RuntimeService;
workspace: WorkspaceRecord;
}) {
const runtimeHealth = input.runtime.getOpencodeHealth();
if (!runtimeHealth.baseUrl || !runtimeHealth.running) {
throw new RouteError(
503,
"service_unavailable",
"Local OpenCode runtime is not available for session operations.",
);
}
return createOpenCodeSessionBackend({
baseUrl: runtimeHealth.baseUrl,
directory: input.workspace.dataDir,
});
}

View File

@@ -0,0 +1,293 @@
import { createOpencodeClient } from "@opencode-ai/sdk/v2/client";
import type {
SessionMessageRecord,
SessionRecord,
SessionSnapshotRecord,
SessionStatusRecord,
SessionTodoRecord,
WorkspaceEventRecord,
} from "../../schemas/sessions.js";
import {
parseSessionData,
parseSessionListData,
parseSessionMessageData,
parseSessionMessagesData,
parseSessionStatusesData,
parseSessionTodosData,
parseWorkspaceEventData,
} from "../../schemas/sessions.js";
export class OpenCodeBackendError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = "OpenCodeBackendError";
}
}
type OpenCodeBackendOptions = {
baseUrl: string;
directory?: string | null;
headers?: Record<string, string>;
};
type RequestOptions = {
body?: unknown;
method?: string;
query?: Record<string, string | number | boolean | undefined>;
signal?: AbortSignal;
};
function buildDirectoryHeader(directory?: string | null) {
const trimmed = directory?.trim() ?? "";
if (!trimmed) {
return null;
}
return /[^\x00-\x7F]/.test(trimmed) ? encodeURIComponent(trimmed) : trimmed;
}
function buildUrl(baseUrl: string, path: string, query?: RequestOptions["query"]) {
const url = new URL(path, `${baseUrl.replace(/\/+$/, "")}/`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value === undefined) {
continue;
}
url.searchParams.set(key, String(value));
}
return url;
}
async function parseJsonResponse(response: Response) {
const text = await response.text();
if (!text.trim()) {
return null;
}
try {
return JSON.parse(text) as unknown;
} catch {
return text;
}
}
function toBackendError(response: Response, payload: unknown) {
const record = payload && typeof payload === "object" ? payload as Record<string, unknown> : null;
const code = typeof record?.code === "string" ? record.code : "opencode_request_failed";
const message = typeof record?.message === "string" ? record.message : response.statusText || "OpenCode request failed.";
const details = record?.details;
return new OpenCodeBackendError(response.status, code, message, details);
}
export type OpenCodeSessionBackend = ReturnType<typeof createOpenCodeSessionBackend>;
export function createOpenCodeSessionBackend(options: OpenCodeBackendOptions) {
const normalizedBaseUrl = options.baseUrl.replace(/\/+$/, "");
const baseHeaders = { ...(options.headers ?? {}) };
const directoryHeader = buildDirectoryHeader(options.directory);
if (directoryHeader) {
baseHeaders["x-opencode-directory"] = directoryHeader;
}
const eventClient = createOpencodeClient({
baseUrl: normalizedBaseUrl,
directory: options.directory ?? undefined,
headers: Object.keys(baseHeaders).length ? baseHeaders : undefined,
responseStyle: "data",
throwOnError: true,
});
async function requestJson(path: string, request: RequestOptions = {}) {
const url = buildUrl(normalizedBaseUrl, path, request.query);
const response = await fetch(url, {
method: request.method ?? "GET",
headers: {
...(request.body !== undefined ? { "Content-Type": "application/json" } : {}),
...baseHeaders,
},
body: request.body !== undefined ? JSON.stringify(request.body) : undefined,
signal: request.signal,
});
const payload = await parseJsonResponse(response);
if (!response.ok) {
throw toBackendError(response, payload);
}
return payload;
}
async function requestVoid(path: string, request: RequestOptions = {}) {
const url = buildUrl(normalizedBaseUrl, path, request.query);
const response = await fetch(url, {
method: request.method ?? "POST",
headers: {
...(request.body !== undefined ? { "Content-Type": "application/json" } : {}),
...baseHeaders,
},
body: request.body !== undefined ? JSON.stringify(request.body) : undefined,
signal: request.signal,
});
if (!response.ok) {
throw toBackendError(response, await parseJsonResponse(response));
}
}
return {
async abortSession(sessionId: string) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/abort`, { method: "POST" });
},
async command(sessionId: string, body: Record<string, unknown>) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/command`, { body, method: "POST" });
},
async createSession(body: Record<string, unknown>) {
return parseSessionData(await requestJson("/session", { body, method: "POST" }));
},
async deleteMessage(sessionId: string, messageId: string) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`, {
method: "DELETE",
});
},
async deleteMessagePart(sessionId: string, messageId: string, partId: string) {
await requestVoid(
`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}/part/${encodeURIComponent(partId)}`,
{ method: "DELETE" },
);
},
async deleteSession(sessionId: string) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}`, { method: "DELETE" });
},
async forkSession(sessionId: string, body: Record<string, unknown>) {
return parseSessionData(await requestJson(`/session/${encodeURIComponent(sessionId)}/fork`, { body, method: "POST" }));
},
async getMessage(sessionId: string, messageId: string) {
return parseSessionMessageData(
await requestJson(`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}`),
);
},
async getSession(sessionId: string) {
return parseSessionData(await requestJson(`/session/${encodeURIComponent(sessionId)}`));
},
async getSessionSnapshot(sessionId: string, input?: { limit?: number }) {
const [session, messages, todos, statuses] = await Promise.all([
this.getSession(sessionId),
this.listMessages(sessionId, input),
this.listTodos(sessionId),
this.listStatuses(),
]);
return {
messages,
session,
status: statuses[sessionId] ?? { type: "idle" },
todos,
} satisfies SessionSnapshotRecord;
},
async initSession(sessionId: string, body: Record<string, unknown>) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/init`, { body, method: "POST" });
},
async listMessages(sessionId: string, input?: { limit?: number }) {
return parseSessionMessagesData(
await requestJson(`/session/${encodeURIComponent(sessionId)}/message`, {
query: { limit: input?.limit },
}),
);
},
async listSessions(input?: { limit?: number; roots?: boolean; search?: string; start?: number }) {
return parseSessionListData(await requestJson("/session", { query: input }));
},
async listStatuses() {
return parseSessionStatusesData(await requestJson("/session/status"));
},
async listTodos(sessionId: string) {
return parseSessionTodosData(await requestJson(`/session/${encodeURIComponent(sessionId)}/todo`));
},
async promptAsync(sessionId: string, body: Record<string, unknown>) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/prompt_async`, { body, method: "POST" });
},
async revert(sessionId: string, body: { messageID: string }) {
return parseSessionData(await requestJson(`/session/${encodeURIComponent(sessionId)}/revert`, { body, method: "POST" }));
},
async sendMessage(sessionId: string, body: Record<string, unknown>) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/message`, { body, method: "POST" });
},
async shareSession(sessionId: string) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/share`, { method: "POST" });
},
async shell(sessionId: string, body: Record<string, unknown>) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/shell`, { body, method: "POST" });
},
async streamEvents(signal?: AbortSignal): Promise<AsyncIterable<WorkspaceEventRecord>> {
const subscription = await eventClient.event.subscribe(undefined, { signal });
const source = subscription.stream as AsyncIterable<unknown>;
const iterator = async function* () {
for await (const event of source) {
if (!event || typeof event !== "object") {
continue;
}
const record = event as Record<string, unknown>;
if (typeof record.type === "string") {
yield parseWorkspaceEventData({
properties: record.properties,
type: record.type,
});
continue;
}
const payload = record.payload;
if (payload && typeof payload === "object" && typeof (payload as Record<string, unknown>).type === "string") {
yield parseWorkspaceEventData(payload);
}
}
};
return iterator();
},
async summarizeSession(sessionId: string, body: Record<string, unknown>) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/summarize`, { body, method: "POST" });
},
async unshareSession(sessionId: string) {
await requestVoid(`/session/${encodeURIComponent(sessionId)}/share`, { method: "DELETE" });
},
async unrevert(sessionId: string) {
return parseSessionData(await requestJson(`/session/${encodeURIComponent(sessionId)}/unrevert`, { method: "POST" }));
},
async updateMessagePart(sessionId: string, messageId: string, partId: string, body: Record<string, unknown>) {
await requestVoid(
`/session/${encodeURIComponent(sessionId)}/message/${encodeURIComponent(messageId)}/part/${encodeURIComponent(partId)}`,
{ body, method: "PATCH" },
);
},
async updateSession(sessionId: string, body: Record<string, unknown>) {
return parseSessionData(await requestJson(`/session/${encodeURIComponent(sessionId)}`, { body, method: "PATCH" }));
},
};
}

View File

@@ -0,0 +1,33 @@
import { RouteError } from "../../http.js";
import type { ServerRecord, WorkspaceRecord } from "../../database/types.js";
import { createOpenCodeSessionBackend } from "./opencode-backend.js";
import { buildRemoteOpenworkHeaders } from "../remote-openwork.js";
export function createRemoteOpenworkSessionAdapter(input: {
server: ServerRecord;
workspace: WorkspaceRecord;
}) {
if (!input.server.baseUrl) {
throw new RouteError(502, "bad_gateway", "Remote workspace server is missing a base URL.");
}
const remoteType = input.workspace.notes?.remoteType === "opencode" ? "opencode" : "openwork";
const remoteWorkspaceId = input.workspace.remoteWorkspaceId?.trim() ?? "";
if (remoteType === "openwork") {
if (!remoteWorkspaceId) {
throw new RouteError(502, "bad_gateway", "Remote OpenWork workspace is missing its remote workspace identifier.");
}
return createOpenCodeSessionBackend({
baseUrl: `${input.server.baseUrl.replace(/\/+$/, "")}/w/${encodeURIComponent(remoteWorkspaceId)}/opencode`,
headers: buildRemoteOpenworkHeaders(input.server),
});
}
return createOpenCodeSessionBackend({
baseUrl: input.server.baseUrl,
directory: typeof input.workspace.notes?.directory === "string" ? input.workspace.notes.directory : undefined,
headers: buildRemoteOpenworkHeaders(input.server),
});
}

View File

@@ -0,0 +1,44 @@
import { Hono } from "hono";
import type { AppDependencies } from "./context/app-dependencies.js";
import { createAppDependencies } from "./context/app-dependencies.js";
import type { AppBindings } from "./context/request-context.js";
import { requestContextMiddleware } from "./context/request-context.js";
import { buildErrorResponse } from "./http.js";
import { errorHandlingMiddleware } from "./middleware/error-handler.js";
import { requestLoggerMiddleware } from "./middleware/request-logger.js";
import { requestIdMiddleware } from "./middleware/request-id.js";
import { responseFinalizerMiddleware } from "./middleware/response-finalizer.js";
import { registerRoutes } from "./routes/index.js";
export type CreateAppOptions = {
dependencies?: AppDependencies;
};
export function createApp(options: CreateAppOptions = {}) {
const dependencies = options.dependencies ?? createAppDependencies();
const app = new Hono<AppBindings>();
app.use("*", requestIdMiddleware);
app.use("*", requestContextMiddleware(dependencies));
app.use("*", responseFinalizerMiddleware);
app.use("*", requestLoggerMiddleware);
app.use("*", errorHandlingMiddleware);
registerRoutes(app, dependencies);
app.notFound((c) => {
const requestId = c.get("requestId");
return c.json(
buildErrorResponse({
requestId,
code: "not_found",
message: `Route not found: ${new URL(c.req.url).pathname}`,
}),
404,
);
});
return app;
}
export type AppType = ReturnType<typeof createApp>;

View File

@@ -0,0 +1,338 @@
import { afterEach, expect, test } from "bun:test";
import { createApp } from "./app.js";
import { createAppDependencies } from "./context/app-dependencies.js";
afterEach(() => {
delete process.env.OPENWORK_TOKEN;
delete process.env.OPENWORK_HOST_TOKEN;
});
function createTestApp(options?: { requireAuth?: boolean; seedRegistry?: boolean }) {
if (options?.requireAuth) {
process.env.OPENWORK_TOKEN = "client-token";
process.env.OPENWORK_HOST_TOKEN = "host-token";
}
const dependencies = createAppDependencies({
environment: "test",
inMemory: true,
legacy: {
desktopDataDir: `/tmp/openwork-server-v2-test-desktop-${Math.random().toString(16).slice(2)}`,
orchestratorDataDir: `/tmp/openwork-server-v2-test-orchestrator-${Math.random().toString(16).slice(2)}`,
},
runtime: {
bootstrapPolicy: "disabled",
},
startedAt: new Date("2026-04-14T00:00:00.000Z"),
version: "0.0.0-test",
});
if (options?.seedRegistry) {
dependencies.persistence.registry.importLocalWorkspace({
dataDir: "/tmp/openwork-phase5-local",
displayName: "Alpha Local",
status: "ready",
});
dependencies.persistence.registry.importRemoteWorkspace({
baseUrl: "https://remote.example.com/w/alpha",
directory: "/srv/remote-alpha",
displayName: "Remote Alpha",
legacyNotes: {
source: "test",
},
remoteType: "openwork",
remoteWorkspaceId: "alpha",
serverAuth: { openworkToken: "secret" },
serverBaseUrl: "https://remote.example.com",
serverHostingKind: "self_hosted",
serverLabel: "remote.example.com",
workspaceStatus: "ready",
});
}
return {
app: createApp({ dependencies }),
dependencies,
};
}
test("root info uses the shared success envelope and route conventions", async () => {
const { app } = createTestApp();
const response = await app.request("http://openwork.local/");
const body = await response.json();
expect(response.status).toBe(200);
expect(response.headers.get("x-request-id")).toBe(body.meta.requestId);
expect(body).toMatchObject({
ok: true,
data: {
service: "openwork-server-v2",
routes: {
system: "/system",
workspaces: "/workspaces",
workspaceResource: "/workspaces/:workspaceId",
},
contract: {
source: "hono-openapi",
sdkPackage: "@openwork/server-sdk",
},
},
});
});
test("system health returns a consistent envelope", async () => {
const { app } = createTestApp();
const response = await app.request("http://openwork.local/system/health");
const body = await response.json();
expect(response.status).toBe(200);
expect(body.ok).toBe(true);
expect(body.data.status).toBe("ok");
expect(body.data.database.kind).toBe("sqlite");
expect(["ready", "warning"]).toContain(body.data.database.status);
});
test("system metadata includes phase 10 registry, runtime, and cutover state", async () => {
const { app } = createTestApp();
const response = await app.request("http://openwork.local/system/meta");
const body = await response.json();
expect(response.status).toBe(200);
expect(body.data.foundation.phase).toBe(10);
expect(body.data.foundation.startup.registry.localServerId).toBe("srv_local");
expect(body.data.foundation.startup.registry.hiddenWorkspaceIds).toHaveLength(2);
expect(body.data.runtimeSupervisor.bootstrapPolicy).toBe("disabled");
});
test("openapi route is generated from the live Hono app", async () => {
const { app } = createTestApp();
const response = await app.request("http://openwork.local/openapi.json");
const document = await response.json();
expect(response.status).toBe(200);
expect(document.openapi).toBe("3.1.0");
expect(document.info.title).toBe("OpenWork Server V2");
expect(document.paths["/system/health"].get.operationId).toBe("getSystemHealth");
expect(document.paths["/system/meta"].get.operationId).toBe("getSystemMeta");
expect(document.paths["/system/capabilities"].get.operationId).toBe("getSystemCapabilities");
expect(document.paths["/system/status"].get.operationId).toBe("getSystemStatus");
expect(document.paths["/system/opencode/health"].get.operationId).toBe("getSystemOpencodeHealth");
expect(document.paths["/system/runtime/versions"].get.operationId).toBe("getSystemRuntimeVersions");
expect(document.paths["/system/runtime/upgrade"].post.operationId).toBe("postSystemRuntimeUpgrade");
expect(document.paths["/system/servers/connect"].post.operationId).toBe("postSystemServersConnect");
expect(document.paths["/workspaces"].get.operationId).toBe("getWorkspaces");
expect(document.paths["/workspaces/local"].post.operationId).toBe("postWorkspacesLocal");
expect(document.paths["/workspaces/{workspaceId}/config"].get.operationId).toBe("getWorkspacesByWorkspaceIdConfig");
expect(document.paths["/system/cloud-signin"].get.operationId).toBe("getSystemCloudSignin");
expect(document.paths["/system/managed/mcps"].get.operationId).toBe("getSystemManagedMcps");
expect(document.paths["/system/router/identities/telegram"].get.operationId).toBe("getSystemRouterIdentitiesTelegram");
expect(document.paths["/workspaces/{workspaceId}/export"].get.operationId).toBe("getWorkspacesByWorkspaceIdExport");
expect(document.paths["/workspaces/{workspaceId}/reload-events"].get.operationId).toBe("getWorkspacesByWorkspaceIdReloadEvents");
expect(document.paths["/workspaces/{workspaceId}/sessions"].get.operationId).toBe("getWorkspacesByWorkspaceIdSessions");
expect(document.paths["/workspaces/{workspaceId}/events"].get.operationId).toBe("getWorkspacesByWorkspaceIdEvents");
});
test("runtime routes expose the initial server-owned status surfaces", async () => {
const { app } = createTestApp();
const [opencodeResponse, routerResponse, runtimeResponse] = await Promise.all([
app.request("http://openwork.local/system/opencode/health"),
app.request("http://openwork.local/system/router/health"),
app.request("http://openwork.local/system/runtime/summary"),
]);
const opencodeBody = await opencodeResponse.json();
const routerBody = await routerResponse.json();
const runtimeBody = await runtimeResponse.json();
expect(opencodeResponse.status).toBe(200);
expect(opencodeBody.data.status).toBe("disabled");
expect(routerBody.data.status).toBe("disabled");
expect(runtimeBody.data.bootstrapPolicy).toBe("disabled");
});
test("not found routes use the shared error envelope", async () => {
const { app } = createTestApp();
const response = await app.request("http://openwork.local/nope");
const body = await response.json();
expect(response.status).toBe(404);
expect(response.headers.get("x-request-id")).toBe(body.error.requestId);
expect(body).toMatchObject({
ok: false,
error: {
code: "not_found",
},
});
});
test("system status reports registry summary and capabilities", async () => {
const { app } = createTestApp({ seedRegistry: true });
const response = await app.request("http://openwork.local/system/status");
const body = await response.json();
expect(response.status).toBe(200);
expect(body.data.registry).toMatchObject({
hiddenWorkspaceCount: 2,
remoteServerCount: 1,
totalServers: 2,
visibleWorkspaceCount: 2,
});
expect(body.data.capabilities.transport.v2).toBe(true);
expect(body.data.capabilities.registry.remoteServerConnections).toBe(true);
expect(body.data.auth.required).toBe(false);
});
test("workspace list excludes hidden workspaces by default", async () => {
const { app } = createTestApp({ seedRegistry: true });
const response = await app.request("http://openwork.local/workspaces");
const body = await response.json();
expect(response.status).toBe(200);
expect(body.data.items).toHaveLength(2);
expect(body.data.items.map((item: any) => item.displayName).sort()).toEqual(["Alpha Local", "Remote Alpha"]);
expect(body.data.items.find((item: any) => item.displayName === "Remote Alpha")?.backend.kind).toBe("remote_openwork");
});
test("workspace detail hides internal workspaces from non-host readers", async () => {
const { app, dependencies } = createTestApp({ requireAuth: true, seedRegistry: true });
const hiddenWorkspaceId = dependencies.persistence.registry.ensureHiddenWorkspace("control").id;
const clientResponse = await app.request(`http://openwork.local/workspaces/${hiddenWorkspaceId}`, {
headers: {
Authorization: "Bearer client-token",
},
});
const hostResponse = await app.request(`http://openwork.local/workspaces/${hiddenWorkspaceId}`, {
headers: {
"X-OpenWork-Host-Token": "host-token",
},
});
expect(clientResponse.status).toBe(404);
expect(hostResponse.status).toBe(200);
});
test("auth-protected registry reads require client or host scope", async () => {
const { app } = createTestApp({ requireAuth: true, seedRegistry: true });
const anonymous = await app.request("http://openwork.local/workspaces");
const client = await app.request("http://openwork.local/workspaces", {
headers: {
Authorization: "Bearer client-token",
},
});
const clientHidden = await app.request("http://openwork.local/workspaces?includeHidden=true", {
headers: {
Authorization: "Bearer client-token",
},
});
const hostInventory = await app.request("http://openwork.local/system/servers", {
headers: {
"X-OpenWork-Host-Token": "host-token",
},
});
expect(anonymous.status).toBe(401);
expect(client.status).toBe(200);
expect(clientHidden.status).toBe(403);
expect(hostInventory.status).toBe(200);
});
test("host-scoped remote server connect syncs remote workspaces into the local registry", async () => {
const remote = Bun.serve({
fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/workspaces") {
return Response.json({
ok: true,
data: {
items: [
{
backend: {
kind: "local_opencode",
local: { configDir: "/srv/config", dataDir: "/srv/project-alpha", opencodeProjectId: null },
remote: null,
serverId: "srv_local",
},
createdAt: new Date().toISOString(),
displayName: "Remote Project Alpha",
hidden: false,
id: "remote-alpha",
kind: "local",
notes: null,
preset: "starter",
runtime: { backendKind: "local_opencode", health: null, lastError: null, lastSessionRefreshAt: null, lastSyncAt: null, updatedAt: null },
server: { auth: { configured: false, scheme: "none" }, baseUrl: null, capabilities: {}, hostingKind: "self_hosted", id: "srv_local", isEnabled: true, isLocal: true, kind: "local", label: "Remote", lastSeenAt: null, source: "seeded", updatedAt: new Date().toISOString() },
slug: "remote-project-alpha",
status: "ready",
updatedAt: new Date().toISOString(),
},
],
},
meta: { requestId: "owreq_remote_1", timestamp: new Date().toISOString() },
});
}
return new Response("not found", { status: 404 });
},
hostname: "127.0.0.1",
port: 0,
});
try {
const { app } = createTestApp({ requireAuth: true });
const response = await app.request("http://openwork.local/system/servers/connect", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-OpenWork-Host-Token": "host-token",
},
body: JSON.stringify({
baseUrl: `http://127.0.0.1:${remote.port}`,
token: "remote-token",
workspaceId: "remote-alpha",
}),
});
const body = await response.json();
expect(response.status).toBe(200);
expect(body.data.server.kind).toBe("remote");
expect(body.data.selectedWorkspaceId).toMatch(/^ws_/);
expect(body.data.workspaces[0].backend.kind).toBe("remote_openwork");
expect(body.data.workspaces[0].backend.remote.remoteWorkspaceId).toBe("remote-alpha");
} finally {
remote.stop(true);
}
});
test("remote server connect returns a gateway error when the remote server rejects credentials", async () => {
const remote = Bun.serve({
fetch() {
return Response.json({ ok: false, error: { code: "unauthorized", message: "bad token", requestId: "owreq_remote_bad_auth" } }, { status: 401 });
},
hostname: "127.0.0.1",
port: 0,
});
try {
const { app } = createTestApp({ requireAuth: true });
const response = await app.request("http://openwork.local/system/servers/connect", {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-OpenWork-Host-Token": "host-token",
},
body: JSON.stringify({
baseUrl: `http://127.0.0.1:${remote.port}`,
token: "wrong-token",
}),
});
const body = await response.json();
expect(response.status).toBe(502);
expect(body.ok).toBe(false);
expect(body.error.code).toBe("bad_gateway");
} finally {
remote.stop(true);
}
});

View File

@@ -0,0 +1,4 @@
export { createApp, type AppType, type CreateAppOptions } from "./app-factory.js";
import { createApp } from "./app-factory.js";
export const app = createApp();

View File

@@ -0,0 +1,83 @@
import { createApp } from "../app.js";
import { createAppDependencies, type AppDependencies } from "../context/app-dependencies.js";
import { resolveServerV2Version } from "../version.js";
export type StartServerOptions = {
dependencies?: AppDependencies;
host?: string;
port?: number;
silent?: boolean;
};
export type StartedServer = {
app: ReturnType<typeof createApp>;
dependencies: AppDependencies;
host: string;
port: number;
server: Bun.Server<unknown>;
stop(): Promise<void>;
url: string;
};
function resolvePort(value: number | undefined) {
if (value === undefined) {
return 3100;
}
if (!Number.isInteger(value) || value < 0 || value > 65535) {
throw new Error(`Invalid port: ${value}`);
}
return value;
}
export function startServer(options: StartServerOptions = {}): StartedServer {
const host = options.host ?? process.env.OPENWORK_SERVER_V2_HOST ?? "127.0.0.1";
const port = resolvePort(options.port ?? Number.parseInt(process.env.OPENWORK_SERVER_V2_PORT ?? "3100", 10));
const version = resolveServerV2Version();
const dependencies = options.dependencies ?? createAppDependencies({
localServer: {
baseUrl: port === 0 ? null : `http://${host}:${port}`,
hostingKind: process.env.OPENWORK_SERVER_V2_HOSTING_KIND === "desktop" ? "desktop" : "self_hosted",
label: "Local OpenWork Server",
},
version,
});
const app = createApp({ dependencies });
const server = Bun.serve({
fetch: app.fetch,
hostname: host,
port,
});
const url = server.url.toString();
const resolvedPort = new URL(url).port;
dependencies.services.registry.attachLocalServerBaseUrl(url);
if (dependencies.services.runtime.getBootstrapPolicy() === "eager") {
void dependencies.services.runtime.bootstrap().catch(() => undefined);
}
if (!options.silent) {
console.info(
JSON.stringify({
bootstrap: dependencies.database.getStartupDiagnostics(),
host,
port: Number(resolvedPort || port),
scope: "openwork-server-v2.start",
url,
}),
);
}
return {
app,
dependencies,
host,
port: Number(resolvedPort || port),
server,
async stop() {
server.stop(true);
await dependencies.close();
},
url,
};
}

72
apps/server-v2/src/cli.ts Normal file
View File

@@ -0,0 +1,72 @@
import process from "node:process";
import { startServer } from "./bootstrap/server.js";
function printHelp() {
process.stdout.write([
"openwork-server-v2",
"",
"Options:",
" --host <host> Hostname to bind. Defaults to 127.0.0.1.",
" --port <port> Port to bind. Defaults to 3100.",
" --help Show this help text.",
"",
].join("\n"));
}
function parseArgs(argv: Array<string>) {
let host: string | undefined;
let port: number | undefined;
for (let index = 0; index < argv.length; index += 1) {
const argument = argv[index];
if (argument === "--help") {
printHelp();
process.exit(0);
}
if (argument === "--host") {
host = argv[index + 1];
index += 1;
continue;
}
if (argument === "--port") {
const rawPort = argv[index + 1];
if (!rawPort) {
throw new Error("Missing value for --port.");
}
port = Number.parseInt(rawPort, 10);
index += 1;
continue;
}
throw new Error(`Unknown argument: ${argument}`);
}
return { host, port };
}
async function main() {
const { host, port } = parseArgs(process.argv.slice(2));
const runtime = startServer({ host, port });
const shutdown = async (signal: NodeJS.Signals) => {
console.info(JSON.stringify({ scope: "openwork-server-v2.stop", signal }));
await runtime.stop();
process.exit(0);
};
for (const signal of ["SIGINT", "SIGTERM"] as const) {
process.on(signal, () => {
void shutdown(signal);
});
}
await new Promise(() => undefined);
}
main().catch((error) => {
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
process.exit(1);
});

View File

@@ -0,0 +1,214 @@
import { createAuthService, type AuthService } from "../services/auth-service.js";
import { createCapabilitiesService, type CapabilitiesService } from "../services/capabilities-service.js";
import { createConfigMaterializationService, type ConfigMaterializationService } from "../services/config-materialization-service.js";
import { createManagedResourceService, type ManagedResourceService } from "../services/managed-resource-service.js";
import { createProcessInfoAdapter, type ProcessInfoAdapter } from "../adapters/process-info.js";
import { createServerPersistence, type ServerPersistence } from "../database/persistence.js";
import { createSqliteDatabaseStatusProvider, type DatabaseStatusProvider } from "../database/status-provider.js";
import type { RuntimeAssetService } from "../runtime/assets.js";
import type { RegistryService } from "../services/registry-service.js";
import { createRouterProductService, type RouterProductService } from "../services/router-product-service.js";
import { createServerRegistryService, type ServerRegistryService } from "../services/server-registry-service.js";
import { createRuntimeService, type RuntimeService } from "../services/runtime-service.js";
import { createWorkspaceFileService, type WorkspaceFileService } from "../services/workspace-file-service.js";
import { createWorkspaceSessionService, type WorkspaceSessionService } from "../services/workspace-session-service.js";
import { createSystemService, type SystemService } from "../services/system-service.js";
import { createWorkspaceRegistryService, type WorkspaceRegistryService } from "../services/workspace-registry-service.js";
import { createRemoteServerService, type RemoteServerService } from "../services/remote-server-service.js";
import { createSchedulerService, type SchedulerService } from "../services/scheduler-service.js";
import { resolveServerV2Version } from "../version.js";
export type AppDependencies = {
database: DatabaseStatusProvider;
environment: string;
persistence: ServerPersistence;
processInfo: ProcessInfoAdapter;
services: {
auth: AuthService;
capabilities: CapabilitiesService;
config: ConfigMaterializationService;
files: WorkspaceFileService;
managed: ManagedResourceService;
registry: RegistryService;
remoteServers: RemoteServerService;
router: RouterProductService;
runtime: RuntimeService;
scheduler: SchedulerService;
sessions: WorkspaceSessionService;
serverRegistry: ServerRegistryService;
system: SystemService;
workspaceRegistry: WorkspaceRegistryService;
};
startedAt: Date;
version: string;
close(): Promise<void>;
};
type CreateAppDependenciesOverrides = Partial<Omit<AppDependencies, "services" | "close" | "database" | "persistence">> & {
inMemory?: boolean;
legacy?: {
cloudSigninJson?: string;
cloudSigninPath?: string;
desktopDataDir?: string;
orchestratorDataDir?: string;
};
localServer?: {
baseUrl?: string | null;
hostingKind?: "cloud" | "desktop" | "self_hosted";
label?: string;
};
persistence?: ServerPersistence;
runtime?: {
assetService?: RuntimeAssetService;
bootstrapPolicy?: "disabled" | "eager" | "manual";
restartPolicy?: {
backoffMs?: number;
maxAttempts?: number;
windowMs?: number;
};
};
workingDirectory?: string;
};
function isTruthy(value: string | undefined) {
if (!value) {
return false;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
function resolveLocalHostingKind(explicit?: "cloud" | "desktop" | "self_hosted") {
if (explicit) {
return explicit;
}
const fromEnv = process.env.OPENWORK_SERVER_V2_HOSTING_KIND?.trim();
if (fromEnv === "desktop" || fromEnv === "self_hosted" || fromEnv === "cloud") {
return fromEnv;
}
if (isTruthy(process.env.OPENWORK_DESKTOP_HOSTED) || Boolean(process.env.TAURI_ENV_PLATFORM)) {
return "desktop";
}
return "self_hosted";
}
export function createAppDependencies(overrides: CreateAppDependenciesOverrides = {}): AppDependencies {
const environment = overrides.environment ?? process.env.NODE_ENV ?? "development";
const startedAt = overrides.startedAt ?? new Date();
const version = overrides.version ?? resolveServerV2Version();
const processInfo = overrides.processInfo ?? createProcessInfoAdapter(environment);
const persistence = overrides.persistence ?? createServerPersistence({
environment,
inMemory: overrides.inMemory,
legacy: overrides.legacy,
localServer: {
baseUrl: overrides.localServer?.baseUrl ?? null,
hostingKind: resolveLocalHostingKind(overrides.localServer?.hostingKind),
label: overrides.localServer?.label ?? "Local OpenWork Server",
},
version,
workingDirectory: overrides.workingDirectory,
});
const database = createSqliteDatabaseStatusProvider({ diagnostics: persistence.diagnostics });
const auth = createAuthService();
const serverRegistry = createServerRegistryService({
localServerId: persistence.registry.localServerId,
repositories: persistence.repositories,
});
const workspaceRegistry = createWorkspaceRegistryService({
repositories: persistence.repositories,
servers: serverRegistry,
});
const runtime = createRuntimeService({
assetService: overrides.runtime?.assetService,
bootstrapPolicy: overrides.runtime?.bootstrapPolicy,
environment,
repositories: persistence.repositories,
restartPolicy: overrides.runtime?.restartPolicy,
serverId: persistence.registry.localServerId,
serverVersion: version,
workingDirectory: persistence.workingDirectory,
});
const capabilities = createCapabilitiesService({
auth,
runtime,
});
const config = createConfigMaterializationService({
repositories: persistence.repositories,
serverId: persistence.registry.localServerId,
workingDirectory: persistence.workingDirectory,
});
const sessions = createWorkspaceSessionService({
repositories: persistence.repositories,
runtime,
});
const files = createWorkspaceFileService({
config,
registry: persistence.registry,
repositories: persistence.repositories,
runtime,
serverId: persistence.registry.localServerId,
});
const managed = createManagedResourceService({
config,
files,
repositories: persistence.repositories,
serverId: persistence.registry.localServerId,
workingDirectory: persistence.workingDirectory,
});
const router = createRouterProductService({
repositories: persistence.repositories,
runtime,
serverId: persistence.registry.localServerId,
});
const remoteServers = createRemoteServerService({
repositories: persistence.repositories,
});
const scheduler = createSchedulerService({
workspaceRegistry,
});
return {
database,
environment,
persistence,
processInfo,
services: {
auth,
capabilities,
config,
files,
managed,
registry: persistence.registry,
remoteServers,
router,
runtime,
scheduler,
sessions,
serverRegistry,
system: createSystemService({
auth,
capabilities,
database,
environment,
processInfo,
serverRegistry,
runtime,
startedAt,
version,
workspaceRegistry,
}),
workspaceRegistry,
},
startedAt,
version,
async close() {
await files.dispose();
await runtime.dispose();
persistence.close();
},
};
}

View File

@@ -0,0 +1,39 @@
import type { Context, MiddlewareHandler } from "hono";
import type { AppDependencies } from "./app-dependencies.js";
import type { RequestActor } from "../services/auth-service.js";
export type RequestContext = {
actor: RequestActor;
dependencies: AppDependencies;
receivedAt: Date;
requestId: string;
services: AppDependencies["services"];
};
export type AppBindings = {
Variables: {
requestContext: RequestContext;
requestId: string;
};
};
export function createRequestContext(dependencies: AppDependencies, requestId: string, headers: Headers): RequestContext {
return {
actor: dependencies.services.auth.resolveActor(headers),
dependencies,
receivedAt: new Date(),
requestId,
services: dependencies.services,
};
}
export function requestContextMiddleware(dependencies: AppDependencies): MiddlewareHandler<AppBindings> {
return async (c, next) => {
c.set("requestContext", createRequestContext(dependencies, c.get("requestId"), c.req.raw.headers));
await next();
};
}
export function getRequestContext(c: Pick<Context<AppBindings>, "get">): RequestContext {
return c.get("requestContext");
}

View File

@@ -0,0 +1,64 @@
import { expect, test } from "bun:test";
import path from "node:path";
import { fileURLToPath } from "node:url";
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const repoDir = path.resolve(packageDir, "../..");
async function runCommand(command: Array<string>, cwd: string) {
const child = Bun.spawn(command, {
cwd,
env: process.env,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([
new Response(child.stdout).text(),
new Response(child.stderr).text(),
child.exited,
]);
return { exitCode, stderr, stdout };
}
test("openapi generation writes the committed server-v2 contract", async () => {
const result = await runCommand(["bun", "./scripts/generate-openapi.ts"], packageDir);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain("apps/server-v2/openapi/openapi.json");
const openApiContents = await Bun.file(path.join(packageDir, "openapi/openapi.json")).text();
expect(openApiContents).toContain('"/system/health"');
expect(openApiContents).toContain('"getSystemHealth"');
expect(openApiContents).toContain('"/system/status"');
expect(openApiContents).toContain('"/system/cloud-signin"');
expect(openApiContents).toContain('"/system/managed/mcps"');
expect(openApiContents).toContain('"/system/router/identities/telegram"');
expect(openApiContents).toContain('"/workspaces"');
expect(openApiContents).toContain('"/workspaces/{workspaceId}/export"');
expect(openApiContents).toContain('"/workspaces/{workspaceId}/sessions"');
expect(openApiContents).toContain('"/workspaces/{workspaceId}/events"');
expect(openApiContents).toContain('"/system/opencode/health"');
expect(openApiContents).toContain('"/system/runtime/versions"');
});
test("sdk generation succeeds from the server-v2 openapi document", async () => {
const result = await runCommand(["pnpm", "--filter", "@openwork/server-sdk", "generate"], repoDir);
expect(result.exitCode).toBe(0);
const sdkIndex = await Bun.file(path.join(repoDir, "packages/openwork-server-sdk/generated/index.ts")).text();
expect(sdkIndex).toContain("getSystemHealth");
expect(sdkIndex).toContain("getSystemStatus");
expect(sdkIndex).toContain("getSystemCloudSignin");
expect(sdkIndex).toContain("getSystemManagedMcps");
expect(sdkIndex).toContain("getSystemRouterIdentitiesTelegram");
expect(sdkIndex).toContain("getWorkspaces");
expect(sdkIndex).toContain("getWorkspacesByWorkspaceIdExport");
expect(sdkIndex).toContain("getWorkspacesByWorkspaceIdSessions");
expect(sdkIndex).toContain("getWorkspacesByWorkspaceIdEvents");
expect(sdkIndex).toContain("GetSystemHealthResponse");
expect(sdkIndex).toContain("getSystemOpencodeHealth");
expect(sdkIndex).toContain("getSystemRuntimeVersions");
});

View File

@@ -0,0 +1,64 @@
import { createHash } from "node:crypto";
import path from "node:path";
function stableHash(value: string) {
return createHash("sha256").update(value).digest("hex");
}
export function createStableId(prefix: string, key: string) {
return `${prefix}_${stableHash(key).slice(0, 12)}`;
}
export function createServerId(kind: "local" | "remote", key: string) {
if (kind === "local") {
return "srv_local";
}
return createStableId("srv", `remote::${key}`);
}
export function createLocalWorkspaceId(dataDir: string) {
return createStableId("ws", dataDir);
}
export function createRemoteWorkspaceId(input: {
baseUrl: string;
directory?: string | null;
remoteWorkspaceId?: string | null;
remoteType: "openwork" | "opencode";
}) {
if (input.remoteType === "openwork") {
const key = ["openwork", input.baseUrl, input.remoteWorkspaceId?.trim() ?? ""]
.filter(Boolean)
.join("::");
return createStableId("ws", key);
}
const key = ["remote", input.baseUrl, input.directory?.trim() ?? ""]
.filter(Boolean)
.join("::");
return createStableId("ws", key);
}
export function createInternalWorkspaceId(kind: "control" | "help") {
return createStableId("ws", `internal::${kind}`);
}
export function slugifyWorkspaceValue(value: string, fallback: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "");
return normalized || fallback;
}
export function deriveWorkspaceSlugSource(input: {
dataDir?: string | null;
displayName: string;
fallback: string;
}) {
const baseName = input.dataDir ? path.basename(input.dataDir) : "";
return slugifyWorkspaceValue(input.displayName || baseName, input.fallback);
}

View File

@@ -0,0 +1,15 @@
export function parseJsonValue<T>(value: string | null | undefined, fallback: T): T {
if (!value) {
return fallback;
}
try {
return JSON.parse(value) as T;
} catch {
return fallback;
}
}
export function stringifyJsonValue(value: unknown): string {
return JSON.stringify(value ?? null);
}

View File

@@ -0,0 +1,70 @@
export const phase2RegistryRuntimeMigration = {
name: "registry-runtime",
sql: `
CREATE TABLE IF NOT EXISTS servers (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('local', 'remote')),
hosting_kind TEXT NOT NULL CHECK (hosting_kind IN ('desktop', 'self_hosted', 'cloud')),
label TEXT NOT NULL,
base_url TEXT,
auth_json TEXT,
capabilities_json TEXT NOT NULL DEFAULT '{}',
is_local INTEGER NOT NULL DEFAULT 0,
is_enabled INTEGER NOT NULL DEFAULT 1,
source TEXT NOT NULL DEFAULT 'seeded',
notes_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_seen_at TEXT
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_servers_single_local ON servers (is_local) WHERE is_local = 1;
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
kind TEXT NOT NULL CHECK (kind IN ('local', 'remote', 'control', 'help')),
display_name TEXT NOT NULL,
slug TEXT NOT NULL,
is_hidden INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL CHECK (status IN ('ready', 'imported', 'attention')),
opencode_project_id TEXT,
remote_workspace_id TEXT,
data_dir TEXT,
config_dir TEXT,
notes_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_slug ON workspaces (slug);
CREATE UNIQUE INDEX IF NOT EXISTS idx_workspaces_local_data_dir ON workspaces (data_dir) WHERE data_dir IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_workspaces_server ON workspaces (server_id);
CREATE TABLE IF NOT EXISTS server_runtime_state (
server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
runtime_version TEXT,
opencode_status TEXT NOT NULL DEFAULT 'unknown',
opencode_version TEXT,
opencode_base_url TEXT,
router_status TEXT NOT NULL DEFAULT 'disabled',
router_version TEXT,
restart_policy_json TEXT,
last_started_at TEXT,
last_exit_json TEXT,
health_json TEXT,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_runtime_state (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
backend_kind TEXT NOT NULL CHECK (backend_kind IN ('local_opencode', 'remote_openwork')),
last_sync_at TEXT,
last_session_refresh_at TEXT,
last_error_json TEXT,
health_json TEXT,
updated_at TEXT NOT NULL
);
`,
version: "0001",
} as const;

View File

@@ -0,0 +1,145 @@
export const phase2ManagedStateMigration = {
name: "managed-state",
sql: `
CREATE TABLE IF NOT EXISTS mcps (
id TEXT PRIMARY KEY,
item_kind TEXT NOT NULL,
display_name TEXT NOT NULL,
item_key TEXT,
config_json TEXT NOT NULL DEFAULT '{}',
auth_json TEXT,
metadata_json TEXT,
source TEXT NOT NULL CHECK (source IN ('openwork_managed', 'imported', 'discovered', 'cloud_synced')),
cloud_item_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS skills (
id TEXT PRIMARY KEY,
item_kind TEXT NOT NULL DEFAULT 'skill',
display_name TEXT NOT NULL,
item_key TEXT,
config_json TEXT NOT NULL DEFAULT '{}',
auth_json TEXT,
metadata_json TEXT,
source TEXT NOT NULL CHECK (source IN ('openwork_managed', 'imported', 'discovered', 'cloud_synced')),
cloud_item_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS plugins (
id TEXT PRIMARY KEY,
item_kind TEXT NOT NULL DEFAULT 'plugin',
display_name TEXT NOT NULL,
item_key TEXT,
config_json TEXT NOT NULL DEFAULT '{}',
auth_json TEXT,
metadata_json TEXT,
source TEXT NOT NULL CHECK (source IN ('openwork_managed', 'imported', 'discovered', 'cloud_synced')),
cloud_item_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS provider_configs (
id TEXT PRIMARY KEY,
item_kind TEXT NOT NULL DEFAULT 'provider',
display_name TEXT NOT NULL,
item_key TEXT,
config_json TEXT NOT NULL DEFAULT '{}',
auth_json TEXT,
metadata_json TEXT,
source TEXT NOT NULL CHECK (source IN ('openwork_managed', 'imported', 'discovered', 'cloud_synced')),
cloud_item_id TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_mcps (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES mcps(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, item_id)
);
CREATE TABLE IF NOT EXISTS workspace_skills (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, item_id)
);
CREATE TABLE IF NOT EXISTS workspace_plugins (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES plugins(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, item_id)
);
CREATE TABLE IF NOT EXISTS workspace_provider_configs (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
item_id TEXT NOT NULL REFERENCES provider_configs(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, item_id)
);
CREATE TABLE IF NOT EXISTS cloud_signin (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL UNIQUE REFERENCES servers(id) ON DELETE CASCADE,
cloud_base_url TEXT NOT NULL,
user_id TEXT,
org_id TEXT,
auth_json TEXT,
metadata_json TEXT,
last_validated_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_shares (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
access_key TEXT,
status TEXT NOT NULL CHECK (status IN ('active', 'revoked', 'disabled')),
last_used_at TEXT,
audit_json TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE TABLE IF NOT EXISTS router_identities (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
kind TEXT NOT NULL,
display_name TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
auth_json TEXT,
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS router_bindings (
id TEXT PRIMARY KEY,
server_id TEXT NOT NULL REFERENCES servers(id) ON DELETE CASCADE,
router_identity_id TEXT NOT NULL REFERENCES router_identities(id) ON DELETE CASCADE,
binding_key TEXT NOT NULL,
config_json TEXT NOT NULL DEFAULT '{}',
is_enabled INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_workspace_shares_workspace ON workspace_shares (workspace_id);
CREATE INDEX IF NOT EXISTS idx_router_identities_server ON router_identities (server_id);
CREATE INDEX IF NOT EXISTS idx_router_bindings_server ON router_bindings (server_id);
`,
version: "0002",
} as const;

View File

@@ -0,0 +1,18 @@
export const phase7FilesConfigMigration = {
name: "files-config",
sql: `
CREATE TABLE IF NOT EXISTS server_config_state (
server_id TEXT PRIMARY KEY REFERENCES servers(id) ON DELETE CASCADE,
opencode_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_config_state (
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON DELETE CASCADE,
openwork_json TEXT NOT NULL DEFAULT '{}',
opencode_json TEXT NOT NULL DEFAULT '{}',
updated_at TEXT NOT NULL
);
`,
version: "0003",
} as const;

View File

@@ -0,0 +1,92 @@
import { createHash } from "node:crypto";
import type { Database } from "bun:sqlite";
import type { MigrationResult } from "../types.js";
import { phase2RegistryRuntimeMigration } from "./0001-registry-runtime.js";
import { phase2ManagedStateMigration } from "./0002-managed-state.js";
import { phase7FilesConfigMigration } from "./0003-files-config.js";
const migrations = [phase2RegistryRuntimeMigration, phase2ManagedStateMigration, phase7FilesConfigMigration].map((migration) => ({
...migration,
checksum: createHash("sha256").update(migration.sql).digest("hex"),
}));
export function runMigrations(database: Database): MigrationResult {
database.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
);
`);
const existingRows = database
.query("SELECT version, checksum FROM schema_migrations ORDER BY version")
.all() as Array<{ checksum: string; version: string }>;
const existing = new Map(existingRows.map((row) => [row.version, row.checksum]));
const applied: string[] = [];
const applyMigration = database.transaction((migration: (typeof migrations)[number]) => {
database.exec(migration.sql);
database
.query(
`
INSERT INTO schema_migrations (version, name, checksum, applied_at)
VALUES (?1, ?2, ?3, ?4)
`,
)
.run(migration.version, migration.name, migration.checksum, new Date().toISOString());
});
for (const migration of migrations) {
const currentChecksum = existing.get(migration.version);
if (currentChecksum) {
if (currentChecksum !== migration.checksum) {
throw new Error(
`Migration checksum mismatch for ${migration.version}. Expected ${migration.checksum} but found ${currentChecksum}.`,
);
}
continue;
}
applyMigration(migration);
applied.push(migration.version);
}
return {
applied,
currentVersion: migrations[migrations.length - 1]?.version ?? "0000",
totalApplied: existing.size + applied.length,
};
}
export function runSpecificMigrations(database: Database, versions: string[]) {
database.exec(`
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
name TEXT NOT NULL,
checksum TEXT NOT NULL,
applied_at TEXT NOT NULL
);
`);
const applyMigration = database.transaction((migration: (typeof migrations)[number]) => {
database.exec(migration.sql);
database
.query(
`
INSERT INTO schema_migrations (version, name, checksum, applied_at)
VALUES (?1, ?2, ?3, ?4)
`,
)
.run(migration.version, migration.name, migration.checksum, new Date().toISOString());
});
for (const version of versions) {
const migration = migrations.find((candidate) => candidate.version === version);
if (!migration) {
throw new Error(`Unknown migration version: ${version}`);
}
applyMigration(migration);
}
}

View File

@@ -0,0 +1,271 @@
import { afterEach, expect, test } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Database } from "bun:sqlite";
import { createServerPersistence } from "./persistence.js";
import { runSpecificMigrations } from "./migrations/index.js";
import { ensureServerWorkingDirectoryLayout, resolveServerWorkingDirectory } from "./working-directory.js";
const cleanupPaths: string[] = [];
afterEach(() => {
while (cleanupPaths.length > 0) {
const target = cleanupPaths.pop();
if (!target) {
continue;
}
fs.rmSync(target, { force: true, recursive: true });
}
});
function makeTempDir(name: string) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
cleanupPaths.push(directory);
return directory;
}
function createPersistence(overrides: Partial<Parameters<typeof createServerPersistence>[0]> = {}) {
return createServerPersistence({
environment: "development",
localServer: {
baseUrl: null,
hostingKind: "self_hosted",
label: "Local OpenWork Server",
},
version: "0.0.0-test",
...overrides,
});
}
test("fresh bootstrap seeds the local server and hidden workspaces", () => {
const workingDirectory = makeTempDir("openwork-server-v2-phase2-fresh");
const persistence = createPersistence({ workingDirectory });
expect(persistence.diagnostics.mode).toBe("fresh");
expect(persistence.repositories.servers.getById(persistence.registry.localServerId)).not.toBeNull();
const hiddenWorkspaces = persistence.repositories.workspaces.list({ includeHidden: true }).filter((workspace) => workspace.isHidden);
expect(hiddenWorkspaces.map((workspace) => workspace.kind).sort()).toEqual(["control", "help"]);
for (const workspace of hiddenWorkspaces) {
expect(workspace.configDir).toBeTruthy();
expect(fs.existsSync(workspace.configDir!)).toBe(true);
}
persistence.close();
});
test("migration runner upgrades an existing database from the first migration", () => {
const rootDir = makeTempDir("openwork-server-v2-phase2-upgrade");
const workingDirectory = resolveServerWorkingDirectory({ environment: "development", explicitRootDir: rootDir });
ensureServerWorkingDirectoryLayout(workingDirectory);
const database = new Database(workingDirectory.databasePath, { create: true });
runSpecificMigrations(database, ["0001"]);
database.close(false);
const persistence = createPersistence({ workingDirectory: rootDir });
expect(persistence.diagnostics.mode).toBe("existing");
expect(persistence.diagnostics.migrations.applied).toEqual(["0002", "0003"]);
expect(persistence.repositories.providerConfigs.list()).toEqual([]);
persistence.close();
});
test("legacy workspace import only runs once across repeated boots", () => {
const rootDir = makeTempDir("openwork-server-v2-phase2-idempotent");
const desktopDataDir = makeTempDir("openwork-server-v2-phase2-desktop");
const orchestratorDataDir = makeTempDir("openwork-server-v2-phase2-orchestrator");
const localWorkspaceDir = makeTempDir("openwork-server-v2-phase2-local-workspace");
const orchestratorOnlyWorkspaceDir = makeTempDir("openwork-server-v2-phase2-orch-only-workspace");
fs.writeFileSync(
path.join(desktopDataDir, "openwork-workspaces.json"),
JSON.stringify(
{
selectedWorkspaceId: "ws_legacy_selected",
watchedWorkspaceId: "ws_legacy_selected",
workspaces: [
{
id: "ws_legacy_selected",
name: "Local Test",
path: localWorkspaceDir,
preset: "starter",
workspaceType: "local",
},
{
id: "ws_remote_legacy",
name: "Remote Test",
workspaceType: "remote",
remoteType: "openwork",
baseUrl: "https://remote.example.com/w/remote-one",
openworkHostUrl: "https://remote.example.com",
openworkWorkspaceId: "remote-one",
openworkToken: "client-token",
},
],
},
null,
2,
),
);
fs.writeFileSync(
path.join(orchestratorDataDir, "openwork-orchestrator-state.json"),
JSON.stringify(
{
activeId: "orch-1",
cliVersion: "0.11.206",
daemon: {
baseUrl: "http://127.0.0.1:4321",
pid: 123,
port: 4321,
startedAt: Date.now(),
},
opencode: {
baseUrl: "http://127.0.0.1:4322",
pid: 456,
port: 4322,
startedAt: Date.now(),
},
workspaces: [
{
id: "orch-1",
name: "Local Test",
path: localWorkspaceDir,
workspaceType: "local",
},
{
id: "orch-2",
name: "Orchestrator Only",
path: orchestratorOnlyWorkspaceDir,
workspaceType: "local",
},
],
},
null,
2,
),
);
const first = createPersistence({
legacy: {
cloudSigninJson: JSON.stringify({
authToken: "den-token",
baseUrl: "https://app.openworklabs.com",
userId: "user-1",
}),
desktopDataDir,
orchestratorDataDir,
},
workingDirectory: rootDir,
});
const firstServers = first.repositories.servers.list();
const firstVisibleWorkspaces = first.repositories.workspaces.list();
expect(firstServers).toHaveLength(2);
expect(firstVisibleWorkspaces).toHaveLength(3);
expect(first.repositories.cloudSignin.getPrimary()?.cloudBaseUrl).toBe("https://app.openworklabs.com");
first.close();
const second = createPersistence({
legacy: {
desktopDataDir,
orchestratorDataDir,
},
workingDirectory: rootDir,
});
expect(second.diagnostics.mode).toBe("existing");
expect(second.repositories.servers.list()).toHaveLength(2);
expect(second.repositories.workspaces.list()).toHaveLength(3);
expect(second.repositories.workspaces.list({ includeHidden: true })).toHaveLength(5);
expect(second.diagnostics.importReports.desktopWorkspaceState.status).toBe("skipped");
expect(second.diagnostics.importReports.orchestratorState.status).toBe("skipped");
expect(second.diagnostics.legacyWorkspaceImport.completedAt).toBeTruthy();
expect(second.diagnostics.legacyWorkspaceImport.skipped).toBe(true);
second.close();
});
test("deleted legacy-imported workspace stays deleted after restart", () => {
const rootDir = makeTempDir("openwork-server-v2-phase2-delete-persist");
const desktopDataDir = makeTempDir("openwork-server-v2-phase2-delete-desktop");
const localWorkspaceDir = makeTempDir("openwork-server-v2-phase2-delete-workspace");
fs.writeFileSync(
path.join(desktopDataDir, "openwork-workspaces.json"),
JSON.stringify(
{
selectedWorkspaceId: "ws_legacy_selected",
workspaces: [
{
id: "ws_legacy_selected",
name: "Local Test",
path: localWorkspaceDir,
preset: "starter",
workspaceType: "local",
},
],
},
null,
2,
),
);
const first = createPersistence({
legacy: {
desktopDataDir,
},
workingDirectory: rootDir,
});
const normalizedWorkspaceDir = fs.realpathSync.native(localWorkspaceDir);
const importedWorkspace = first.repositories.workspaces
.list()
.find((workspace) => workspace.dataDir === normalizedWorkspaceDir);
expect(importedWorkspace).not.toBeUndefined();
first.close();
const second = createPersistence({
legacy: {
desktopDataDir,
},
workingDirectory: rootDir,
});
expect(second.diagnostics.importReports.desktopWorkspaceState.status).toBe("skipped");
expect(second.repositories.workspaces.deleteById(importedWorkspace!.id)).toBe(true);
expect(second.repositories.workspaces.list().some((workspace) => workspace.id === importedWorkspace!.id)).toBe(false);
second.close();
const third = createPersistence({
legacy: {
desktopDataDir,
},
workingDirectory: rootDir,
});
expect(third.diagnostics.importReports.desktopWorkspaceState.status).toBe("skipped");
expect(third.repositories.workspaces.list().some((workspace) => workspace.id === importedWorkspace!.id)).toBe(false);
third.close();
});
test("corrupt legacy workspace state is surfaced without blocking bootstrap", () => {
const rootDir = makeTempDir("openwork-server-v2-phase2-corrupt");
const desktopDataDir = makeTempDir("openwork-server-v2-phase2-corrupt-desktop");
const orchestratorDataDir = makeTempDir("openwork-server-v2-phase2-corrupt-orchestrator");
fs.writeFileSync(path.join(desktopDataDir, "openwork-workspaces.json"), "{not-json");
const persistence = createPersistence({
legacy: {
desktopDataDir,
orchestratorDataDir,
},
workingDirectory: rootDir,
});
expect(persistence.diagnostics.importReports.desktopWorkspaceState.status).toBe("error");
expect(persistence.repositories.servers.list()).toHaveLength(1);
expect(persistence.repositories.workspaces.list({ includeHidden: true })).toHaveLength(2);
persistence.close();
});

View File

@@ -0,0 +1,728 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { Database } from "bun:sqlite";
import { z } from "zod";
import { createRepositories, type ServerRepositories } from "./repositories.js";
import type { StartupDiagnostics, HostingKind, ImportSourceReport, JsonObject } from "./types.js";
import { runMigrations } from "./migrations/index.js";
import { ensureServerWorkingDirectoryLayout, resolveServerWorkingDirectory, type ServerWorkingDirectory } from "./working-directory.js";
import { createRegistryService, type RegistryService } from "../services/registry-service.js";
const legacyWorkspaceSchema = z.object({
baseUrl: z.string().optional().nullable(),
directory: z.string().optional().nullable(),
displayName: z.string().optional().nullable(),
id: z.string(),
name: z.string().optional().default(""),
openworkClientToken: z.string().optional().nullable(),
openworkHostToken: z.string().optional().nullable(),
openworkHostUrl: z.string().optional().nullable(),
openworkToken: z.string().optional().nullable(),
openworkWorkspaceId: z.string().optional().nullable(),
openworkWorkspaceName: z.string().optional().nullable(),
path: z.string().default(""),
preset: z.string().optional().nullable(),
remoteType: z.enum(["openwork", "opencode"]).optional().nullable(),
sandboxBackend: z.string().optional().nullable(),
sandboxContainerName: z.string().optional().nullable(),
sandboxRunId: z.string().optional().nullable(),
workspaceType: z.enum(["local", "remote"]),
});
const legacyWorkspaceStateSchema = z.object({
activeId: z.string().optional().nullable(),
selectedWorkspaceId: z.string().optional().nullable(),
version: z.number().optional(),
watchedWorkspaceId: z.string().optional().nullable(),
workspaces: z.array(legacyWorkspaceSchema),
});
const orchestratorStateSchema = z.object({
activeId: z.string().optional().nullable(),
binaries: z.object({
opencode: z.object({
actualVersion: z.string().optional().nullable(),
expectedVersion: z.string().optional().nullable(),
path: z.string().optional().nullable(),
source: z.string().optional().nullable(),
}).optional().nullable(),
}).optional().nullable(),
cliVersion: z.string().optional().nullable(),
daemon: z.object({
baseUrl: z.string(),
pid: z.number(),
port: z.number(),
startedAt: z.number(),
}).optional().nullable(),
opencode: z.object({
baseUrl: z.string(),
pid: z.number(),
port: z.number(),
startedAt: z.number(),
}).optional().nullable(),
workspaces: z.array(z.object({
baseUrl: z.string().optional().nullable(),
createdAt: z.number().optional().nullable(),
directory: z.string().optional().nullable(),
id: z.string(),
lastUsedAt: z.number().optional().nullable(),
name: z.string().optional().default(""),
path: z.string(),
workspaceType: z.string(),
})).default([]),
});
const orchestratorAuthSchema = z.object({
opencodePassword: z.string().optional().nullable(),
opencodeUsername: z.string().optional().nullable(),
projectDir: z.string().optional().nullable(),
updatedAt: z.number().optional().nullable(),
});
const cloudSigninSchema = z.object({
activeOrgId: z.string().optional().nullable(),
activeOrgName: z.string().optional().nullable(),
activeOrgSlug: z.string().optional().nullable(),
authToken: z.string().optional().nullable(),
baseUrl: z.string().optional().nullable(),
cloudBaseUrl: z.string().optional().nullable(),
lastValidatedAt: z.string().optional().nullable(),
orgId: z.string().optional().nullable(),
userId: z.string().optional().nullable(),
});
type CreateServerPersistenceOptions = {
environment: string;
inMemory?: boolean;
legacy?: {
cloudSigninJson?: string;
cloudSigninPath?: string;
desktopDataDir?: string;
orchestratorDataDir?: string;
};
localServer: {
baseUrl?: string | null;
hostingKind: HostingKind;
label: string;
};
version: string;
workingDirectory?: string;
};
export type ServerPersistence = {
close(): void;
database: Database;
diagnostics: StartupDiagnostics;
registry: RegistryService;
repositories: ServerRepositories;
workingDirectory: ServerWorkingDirectory;
};
function isTruthy(value: string | undefined) {
if (!value) {
return false;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
function normalizeWorkspacePath(value: string) {
const trimmed = value.trim();
if (!trimmed) {
return "";
}
const expanded = trimmed === "~"
? os.homedir()
: trimmed.startsWith("~/") || trimmed.startsWith("~\\")
? path.join(os.homedir(), trimmed.slice(2))
: trimmed;
try {
return fs.realpathSync.native(expanded);
} catch {
return path.resolve(expanded);
}
}
function normalizeUrl(value: string | null | undefined) {
const trimmed = value?.trim() ?? "";
if (!trimmed) {
return null;
}
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
try {
const url = new URL(withProtocol);
return url.toString().replace(/\/+$/, "");
} catch {
return null;
}
}
function stripWorkspaceMount(value: string | null | undefined) {
const normalized = normalizeUrl(value);
if (!normalized) {
return null;
}
const url = new URL(normalized);
const segments = url.pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1] ?? "";
const prev = segments[segments.length - 2] ?? "";
if (prev === "w" && last) {
url.pathname = `/${segments.slice(0, -2).join("/")}`;
}
return url.toString().replace(/\/+$/, "");
}
function parseWorkspaceIdFromUrl(value: string | null | undefined) {
const normalized = normalizeUrl(value);
if (!normalized) {
return null;
}
try {
const url = new URL(normalized);
const segments = url.pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1] ?? "";
const prev = segments[segments.length - 2] ?? "";
return prev === "w" && last ? decodeURIComponent(last) : null;
} catch {
return null;
}
}
function detectRemoteHostingKind(value: string) {
const normalized = normalizeUrl(value);
if (!normalized) {
return "self_hosted" as const;
}
const hostname = new URL(normalized).hostname.toLowerCase();
if (
hostname === "app.openworklabs.com" ||
hostname === "app.openwork.software" ||
hostname.endsWith(".openworklabs.com") ||
hostname.endsWith(".openwork.software")
) {
return "cloud" as const;
}
return "self_hosted" as const;
}
function legacyDesktopDataDirCandidates(explicitDir?: string) {
if (explicitDir?.trim()) {
return [path.resolve(explicitDir)];
}
const candidates: string[] = [];
const home = os.homedir();
const names = ["com.differentai.openwork.dev", "com.differentai.openwork", "OpenWork Dev", "OpenWork"];
if (process.platform === "darwin") {
for (const name of names) {
candidates.push(path.join(home, "Library", "Application Support", name));
}
} else if (process.platform === "win32") {
const appData = process.env.APPDATA?.trim() || path.join(home, "AppData", "Roaming");
for (const name of names) {
candidates.push(path.join(appData, name));
}
} else {
const xdgDataHome = process.env.XDG_DATA_HOME?.trim() || path.join(home, ".local", "share");
for (const name of names) {
candidates.push(path.join(xdgDataHome, name));
}
}
return Array.from(new Set(candidates));
}
function legacyOrchestratorDirCandidates(explicitDir?: string) {
if (explicitDir?.trim()) {
return [path.resolve(explicitDir)];
}
const candidates: string[] = [];
const fromEnv = process.env.OPENWORK_DATA_DIR?.trim();
if (fromEnv) {
candidates.push(path.resolve(fromEnv));
}
const home = os.homedir();
for (const name of ["openwork-orchestrator-dev-react", "openwork-orchestrator-dev", "openwork-orchestrator"]) {
candidates.push(path.join(home, ".openwork", name));
}
return Array.from(new Set(candidates));
}
function readTextIfExists(filePath: string | null) {
if (!filePath) {
return null;
}
try {
return fs.readFileSync(filePath, "utf8");
} catch {
return null;
}
}
function resolveExistingFile(candidates: string[], fileName: string) {
for (const directory of candidates) {
const filePath = path.join(directory, fileName);
if (fs.existsSync(filePath)) {
return filePath;
}
}
return null;
}
function fromUnixTimestamp(value: number | null | undefined) {
if (!value) {
return null;
}
const milliseconds = value > 10_000_000_000 ? value : value * 1000;
return new Date(milliseconds).toISOString();
}
function createEmptyReport(status: ImportSourceReport["status"], sourcePath: string | null, details: JsonObject = {}): ImportSourceReport {
return {
details,
sourcePath,
status,
warnings: [],
};
}
function mergeReportWarnings(report: ImportSourceReport, warnings: string[]) {
report.warnings.push(...warnings);
return report;
}
function asJsonObject(value: unknown): JsonObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonObject;
}
function readLegacyWorkspaceImportCompletedAt(value: JsonObject | null | undefined) {
const startup = asJsonObject(value?.startup);
const legacyWorkspaceImport = asJsonObject(startup?.legacyWorkspaceImport);
const completedAt = legacyWorkspaceImport?.completedAt;
return typeof completedAt === "string" && completedAt.trim() ? completedAt.trim() : null;
}
function summarizeMode(inMemory: boolean, databasePath: string) {
if (inMemory) {
return "fresh" as const;
}
return fs.existsSync(databasePath) ? "existing" as const : "fresh" as const;
}
export function createServerPersistence(options: CreateServerPersistenceOptions): ServerPersistence {
const inMemory = options.inMemory ?? (isTruthy(process.env.OPENWORK_SERVER_V2_IN_MEMORY) || options.environment === "test");
const workingDirectory = resolveServerWorkingDirectory({
environment: options.environment,
explicitRootDir: options.workingDirectory,
});
if (!inMemory) {
ensureServerWorkingDirectoryLayout(workingDirectory);
}
const mode = summarizeMode(inMemory, workingDirectory.databasePath);
const database = new Database(inMemory ? ":memory:" : workingDirectory.databasePath, { create: true });
database.exec("PRAGMA foreign_keys = ON");
database.exec("PRAGMA journal_mode = WAL");
database.exec("PRAGMA synchronous = NORMAL");
const migrations = runMigrations(database);
const repositories = createRepositories(database);
const registry = createRegistryService({
localServerCapabilities: {
configRoutes: true,
capabilitiesRoutes: true,
contractLoop: true,
fileRoutes: true,
managedConfigTables: true,
phase: 9,
remoteServerConnections: true,
remoteWorkspaceDiscovery: true,
reloadOwnership: true,
rootMounted: true,
runtimeRoutes: true,
runtimeSupervision: true,
runtimeStateTables: true,
version: options.version,
workspaceReadRoutes: true,
workspaceRegistry: true,
},
repositories,
workingDirectory,
});
const localServer = registry.ensureLocalServer({
baseUrl: options.localServer.baseUrl ?? null,
hostingKind: options.localServer.hostingKind,
label: options.localServer.label,
notes: {
workingDirectory: workingDirectory.rootDir,
},
});
const controlWorkspace = registry.ensureHiddenWorkspace("control");
const helpWorkspace = registry.ensureHiddenWorkspace("help");
const existingRuntimeState = repositories.serverRuntimeState.getByServerId(registry.localServerId);
const priorLegacyWorkspaceImportCompletedAt = readLegacyWorkspaceImportCompletedAt(existingRuntimeState?.health);
const shouldImportLegacyWorkspaceState = !priorLegacyWorkspaceImportCompletedAt;
const desktopWorkspaceFile = resolveExistingFile(
legacyDesktopDataDirCandidates(options.legacy?.desktopDataDir),
"openwork-workspaces.json",
);
const desktopWorkspaceReport = !shouldImportLegacyWorkspaceState
? createEmptyReport("skipped", desktopWorkspaceFile, {
completedAt: priorLegacyWorkspaceImportCompletedAt,
reason: "Legacy workspace import already completed on an earlier Server V2 startup.",
})
: desktopWorkspaceFile
? createEmptyReport("imported", desktopWorkspaceFile)
: createEmptyReport("unavailable", null, { reason: "No legacy desktop workspace registry file was found." });
if (shouldImportLegacyWorkspaceState && desktopWorkspaceFile) {
try {
const parsed = legacyWorkspaceStateSchema.parse(JSON.parse(readTextIfExists(desktopWorkspaceFile) ?? "{}"));
let localImported = 0;
let remoteImported = 0;
const importedWorkspaceIds: string[] = [];
for (const workspace of parsed.workspaces) {
if (workspace.workspaceType === "local") {
const dataDir = normalizeWorkspacePath(workspace.path);
if (!dataDir) {
desktopWorkspaceReport.warnings.push(`Skipped local workspace ${workspace.id} because its path was empty.`);
continue;
}
const record = registry.importLocalWorkspace({
dataDir,
displayName: (workspace.displayName?.trim() || workspace.name || path.basename(dataDir)).trim(),
legacyNotes: {
legacyDesktop: {
displayName: workspace.displayName ?? null,
legacyId: workspace.id,
name: workspace.name,
preset: workspace.preset ?? null,
source: "openwork-workspaces.json",
},
},
status: "imported",
});
localImported += 1;
importedWorkspaceIds.push(record.id);
continue;
}
const remoteType = workspace.remoteType === "openwork" ? "openwork" : "opencode";
const openworkServerBaseUrl = stripWorkspaceMount(workspace.openworkHostUrl ?? workspace.baseUrl ?? "");
const remoteServerBaseUrl = openworkServerBaseUrl ?? normalizeUrl(workspace.baseUrl) ?? "";
if (!remoteServerBaseUrl) {
desktopWorkspaceReport.warnings.push(`Skipped remote workspace ${workspace.id} because no valid base URL was found.`);
continue;
}
const auth: JsonObject = {};
if (workspace.openworkToken?.trim()) auth.openworkToken = workspace.openworkToken.trim();
if (workspace.openworkClientToken?.trim()) auth.openworkClientToken = workspace.openworkClientToken.trim();
if (workspace.openworkHostToken?.trim()) auth.openworkHostToken = workspace.openworkHostToken.trim();
const record = registry.importRemoteWorkspace({
baseUrl: normalizeUrl(workspace.baseUrl) ?? remoteServerBaseUrl,
directory: workspace.directory?.trim() || null,
displayName:
workspace.openworkWorkspaceName?.trim() ||
workspace.displayName?.trim() ||
workspace.name ||
remoteServerBaseUrl,
legacyNotes: {
legacyDesktop: {
baseUrl: workspace.baseUrl ?? null,
directory: workspace.directory ?? null,
displayName: workspace.displayName ?? null,
legacyId: workspace.id,
openworkHostUrl: workspace.openworkHostUrl ?? null,
sandboxBackend: workspace.sandboxBackend ?? null,
sandboxContainerName: workspace.sandboxContainerName ?? null,
sandboxRunId: workspace.sandboxRunId ?? null,
},
},
remoteType,
remoteWorkspaceId:
workspace.openworkWorkspaceId?.trim() ||
parseWorkspaceIdFromUrl(workspace.openworkHostUrl ?? null) ||
parseWorkspaceIdFromUrl(workspace.baseUrl ?? null),
serverAuth: Object.keys(auth).length > 0 ? auth : null,
serverBaseUrl: remoteServerBaseUrl,
serverHostingKind: detectRemoteHostingKind(remoteServerBaseUrl),
serverLabel: new URL(remoteServerBaseUrl).host,
workspaceStatus: "imported",
});
remoteImported += 1;
importedWorkspaceIds.push(record.id);
}
desktopWorkspaceReport.details = {
importedWorkspaceIds,
localImported,
remoteImported,
selectedWorkspaceId: parsed.selectedWorkspaceId?.trim() || parsed.activeId?.trim() || null,
watchedWorkspaceId: parsed.watchedWorkspaceId?.trim() || null,
};
} catch (error) {
desktopWorkspaceReport.status = "error";
desktopWorkspaceReport.details = {
error: error instanceof Error ? error.message : String(error),
};
}
}
const orchestratorStateFile = resolveExistingFile(
legacyOrchestratorDirCandidates(options.legacy?.orchestratorDataDir),
"openwork-orchestrator-state.json",
);
const orchestratorStateReport = !shouldImportLegacyWorkspaceState
? createEmptyReport("skipped", orchestratorStateFile, {
completedAt: priorLegacyWorkspaceImportCompletedAt,
reason: "Legacy workspace import already completed on an earlier Server V2 startup.",
})
: orchestratorStateFile
? createEmptyReport("imported", orchestratorStateFile)
: createEmptyReport("unavailable", null, { reason: "No legacy orchestrator state snapshot was found." });
if (shouldImportLegacyWorkspaceState && orchestratorStateFile) {
try {
const parsed = orchestratorStateSchema.parse(JSON.parse(readTextIfExists(orchestratorStateFile) ?? "{}"));
let importedWorkspaceCount = 0;
const importedWorkspaceIds: string[] = [];
for (const workspace of parsed.workspaces) {
if (workspace.workspaceType !== "local") {
continue;
}
const normalizedPath = normalizeWorkspacePath(workspace.path);
if (!normalizedPath) {
continue;
}
const record = registry.importLocalWorkspace({
dataDir: normalizedPath,
displayName: workspace.name?.trim() || path.basename(normalizedPath),
legacyNotes: {
legacyOrchestrator: {
baseUrl: workspace.baseUrl ?? null,
createdAt: fromUnixTimestamp(workspace.createdAt ?? null),
directory: workspace.directory ?? null,
lastUsedAt: fromUnixTimestamp(workspace.lastUsedAt ?? null),
legacyId: workspace.id,
},
},
status: "imported",
});
importedWorkspaceCount += 1;
importedWorkspaceIds.push(record.id);
}
const existingRuntimeState = repositories.serverRuntimeState.getByServerId(registry.localServerId);
repositories.serverRuntimeState.upsert({
health: {
...(existingRuntimeState?.health ?? {}),
orchestrator: {
activeLegacyWorkspaceId: parsed.activeId?.trim() || null,
daemonBaseUrl: parsed.daemon?.baseUrl ?? null,
workspaceCount: parsed.workspaces.length,
},
},
lastExit: existingRuntimeState?.lastExit ?? null,
lastStartedAt: fromUnixTimestamp(parsed.daemon?.startedAt ?? parsed.opencode?.startedAt ?? null),
opencodeBaseUrl: parsed.opencode?.baseUrl ?? existingRuntimeState?.opencodeBaseUrl ?? null,
opencodeStatus: parsed.opencode ? "detected" : existingRuntimeState?.opencodeStatus ?? "unknown",
opencodeVersion:
parsed.binaries?.opencode?.actualVersion ?? parsed.cliVersion ?? existingRuntimeState?.opencodeVersion ?? null,
restartPolicy: existingRuntimeState?.restartPolicy ?? null,
routerStatus: existingRuntimeState?.routerStatus ?? "disabled",
routerVersion: existingRuntimeState?.routerVersion ?? null,
runtimeVersion: parsed.cliVersion ?? existingRuntimeState?.runtimeVersion ?? options.version,
serverId: registry.localServerId,
});
orchestratorStateReport.details = {
activeLegacyWorkspaceId: parsed.activeId?.trim() || null,
importedWorkspaceCount,
importedWorkspaceIds,
opencodeBaseUrl: parsed.opencode?.baseUrl ?? null,
};
} catch (error) {
orchestratorStateReport.status = "error";
orchestratorStateReport.details = {
error: error instanceof Error ? error.message : String(error),
};
}
}
const orchestratorAuthFile = resolveExistingFile(
legacyOrchestratorDirCandidates(options.legacy?.orchestratorDataDir),
"openwork-orchestrator-auth.json",
);
const orchestratorAuthReport = orchestratorAuthFile
? createEmptyReport("skipped", orchestratorAuthFile)
: createEmptyReport("unavailable", null, { reason: "No legacy orchestrator auth snapshot was found." });
if (orchestratorAuthFile) {
try {
const parsed = orchestratorAuthSchema.parse(JSON.parse(readTextIfExists(orchestratorAuthFile) ?? "{}"));
const normalizedProjectDir = parsed.projectDir ? normalizeWorkspacePath(parsed.projectDir) : null;
const matchedWorkspace = normalizedProjectDir
? repositories.workspaces
.list({ includeHidden: true })
.find((workspace) => workspace.dataDir === normalizedProjectDir)
: null;
orchestratorAuthReport.details = {
credentialsDetected: Boolean(parsed.opencodeUsername?.trim() || parsed.opencodePassword?.trim()),
matchedWorkspaceId: matchedWorkspace?.id ?? null,
projectDir: normalizedProjectDir,
updatedAt: fromUnixTimestamp(parsed.updatedAt ?? null),
};
orchestratorAuthReport.warnings.push(
"Legacy orchestrator OpenCode credentials were detected but were not imported because they are transitional host secrets, not durable Phase 2 registry state.",
);
} catch (error) {
orchestratorAuthReport.status = "error";
orchestratorAuthReport.details = {
error: error instanceof Error ? error.message : String(error),
};
}
}
const cloudSigninFile =
options.legacy?.cloudSigninPath?.trim() ||
resolveExistingFile(legacyDesktopDataDirCandidates(options.legacy?.desktopDataDir), "openwork-cloud-signin.json");
const cloudSigninReport = options.legacy?.cloudSigninJson?.trim() || cloudSigninFile
? createEmptyReport("imported", cloudSigninFile ?? "env:OPENWORK_SERVER_V2_CLOUD_SIGNIN_JSON")
: createEmptyReport(
"unavailable",
null,
{
reason:
"No server-readable cloud signin snapshot was found. The current desktop app still persists cloud auth in browser localStorage, so later phases need an explicit handoff path.",
},
);
const cloudSigninRaw = options.legacy?.cloudSigninJson?.trim() || readTextIfExists(cloudSigninFile ?? null);
if (cloudSigninRaw) {
try {
const parsed = cloudSigninSchema.parse(JSON.parse(cloudSigninRaw));
const cloudBaseUrl = normalizeUrl(parsed.cloudBaseUrl ?? parsed.baseUrl ?? "");
if (!cloudBaseUrl) {
throw new Error("Cloud signin snapshot did not include a valid base URL.");
}
repositories.cloudSignin.upsert({
auth: parsed.authToken?.trim() ? { authToken: parsed.authToken.trim() } : null,
cloudBaseUrl,
id: "cloud_primary",
lastValidatedAt: parsed.lastValidatedAt?.trim() || null,
metadata: {
activeOrgName: parsed.activeOrgName?.trim() || null,
activeOrgSlug: parsed.activeOrgSlug?.trim() || null,
},
orgId: parsed.orgId?.trim() || parsed.activeOrgId?.trim() || null,
serverId: registry.localServerId,
userId: parsed.userId?.trim() || null,
});
cloudSigninReport.details = {
cloudBaseUrl,
imported: true,
orgId: parsed.orgId?.trim() || parsed.activeOrgId?.trim() || null,
userId: parsed.userId?.trim() || null,
};
} catch (error) {
cloudSigninReport.status = "error";
cloudSigninReport.details = {
error: error instanceof Error ? error.message : String(error),
};
}
}
const legacyWorkspaceImportCompletedAt = priorLegacyWorkspaceImportCompletedAt
?? (desktopWorkspaceReport.status !== "error" && orchestratorStateReport.status !== "error"
? new Date().toISOString()
: null);
const diagnostics: StartupDiagnostics = {
completedAt: new Date().toISOString(),
importReports: {
cloudSignin: cloudSigninReport,
desktopWorkspaceState: desktopWorkspaceReport,
orchestratorAuth: orchestratorAuthReport,
orchestratorState: orchestratorStateReport,
},
legacyWorkspaceImport: {
completedAt: legacyWorkspaceImportCompletedAt,
skipped: !shouldImportLegacyWorkspaceState,
},
mode,
migrations,
registry: {
hiddenWorkspaceIds: [controlWorkspace.id, helpWorkspace.id],
localServerCreated: localServer.created,
localServerId: localServer.server.id,
totalServers: repositories.servers.count(),
totalVisibleWorkspaces: repositories.workspaces.countVisible(),
},
warnings: [
...desktopWorkspaceReport.warnings,
...orchestratorStateReport.warnings,
...orchestratorAuthReport.warnings,
...cloudSigninReport.warnings,
],
workingDirectory: {
databasePath: inMemory ? ":memory:" : workingDirectory.databasePath,
rootDir: workingDirectory.rootDir,
workspacesDir: workingDirectory.workspacesDir,
},
};
repositories.serverRuntimeState.upsert({
health: {
startup: diagnostics,
},
lastExit: existingRuntimeState?.lastExit ?? null,
lastStartedAt: existingRuntimeState?.lastStartedAt ?? null,
opencodeBaseUrl: existingRuntimeState?.opencodeBaseUrl ?? null,
opencodeStatus: existingRuntimeState?.opencodeStatus ?? "unknown",
opencodeVersion: existingRuntimeState?.opencodeVersion ?? options.version,
restartPolicy: existingRuntimeState?.restartPolicy ?? null,
routerStatus: existingRuntimeState?.routerStatus ?? "disabled",
routerVersion: existingRuntimeState?.routerVersion ?? null,
runtimeVersion: existingRuntimeState?.runtimeVersion ?? options.version,
serverId: registry.localServerId,
});
return {
close() {
database.close(false);
},
database,
diagnostics,
registry,
repositories,
workingDirectory,
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,54 @@
import type { StartupDiagnostics } from "./types.js";
export type DatabaseStatus = {
bootstrapMode: "fresh" | "existing";
configured: true;
importWarnings: number;
kind: "sqlite";
migrations: {
appliedThisRun: string[];
currentVersion: string;
totalApplied: number;
};
path: string;
phaseOwner: 2;
status: "ready" | "warning";
summary: string;
workingDirectory: string;
};
export type DatabaseStatusProvider = {
getStartupDiagnostics(): StartupDiagnostics;
getStatus(): DatabaseStatus;
};
export function createSqliteDatabaseStatusProvider(input: { diagnostics: StartupDiagnostics }): DatabaseStatusProvider {
return {
getStartupDiagnostics() {
return input.diagnostics;
},
getStatus() {
const warningCount = input.diagnostics.warnings.length;
const appliedThisRun = input.diagnostics.migrations.applied;
const totalVisibleWorkspaces = input.diagnostics.registry.totalVisibleWorkspaces;
const totalServers = input.diagnostics.registry.totalServers;
return {
bootstrapMode: input.diagnostics.mode,
configured: true,
importWarnings: warningCount,
kind: "sqlite",
migrations: {
appliedThisRun,
currentVersion: input.diagnostics.migrations.currentVersion,
totalApplied: input.diagnostics.migrations.totalApplied,
},
path: input.diagnostics.workingDirectory.databasePath,
phaseOwner: 2,
status: warningCount > 0 ? "warning" : "ready",
summary: `SQLite ready with ${totalServers} server record(s), ${totalVisibleWorkspaces} visible workspace(s), and ${warningCount} import warning(s).`,
workingDirectory: input.diagnostics.workingDirectory.rootDir,
};
},
};
}

View File

@@ -0,0 +1,199 @@
export type ServerKind = "local" | "remote";
export type HostingKind = "desktop" | "self_hosted" | "cloud";
export type WorkspaceKind = "local" | "remote" | "control" | "help";
export type WorkspaceStatus = "ready" | "imported" | "attention";
export type BackendKind = "local_opencode" | "remote_openwork";
export type ImportStatus = "error" | "imported" | "skipped" | "unavailable";
export type JsonObject = Record<string, unknown>;
export type ServerRecord = {
auth: JsonObject | null;
baseUrl: string | null;
capabilities: JsonObject;
createdAt: string;
hostingKind: HostingKind;
id: string;
isEnabled: boolean;
isLocal: boolean;
kind: ServerKind;
label: string;
lastSeenAt: string | null;
notes: JsonObject | null;
source: string;
updatedAt: string;
};
export type WorkspaceRecord = {
configDir: string | null;
createdAt: string;
dataDir: string | null;
displayName: string;
id: string;
isHidden: boolean;
kind: WorkspaceKind;
notes: JsonObject | null;
opencodeProjectId: string | null;
remoteWorkspaceId: string | null;
serverId: string;
slug: string;
status: WorkspaceStatus;
updatedAt: string;
};
export type ServerRuntimeStateRecord = {
health: JsonObject | null;
lastExit: JsonObject | null;
lastStartedAt: string | null;
opencodeBaseUrl: string | null;
opencodeStatus: string;
opencodeVersion: string | null;
restartPolicy: JsonObject | null;
routerStatus: string;
routerVersion: string | null;
runtimeVersion: string | null;
serverId: string;
updatedAt: string;
};
export type WorkspaceRuntimeStateRecord = {
backendKind: BackendKind;
health: JsonObject | null;
lastError: JsonObject | null;
lastSessionRefreshAt: string | null;
lastSyncAt: string | null;
updatedAt: string;
workspaceId: string;
};
export type ServerConfigStateRecord = {
opencode: JsonObject;
serverId: string;
updatedAt: string;
};
export type WorkspaceConfigStateRecord = {
openwork: JsonObject;
opencode: JsonObject;
updatedAt: string;
workspaceId: string;
};
export type ManagedSource = "cloud_synced" | "discovered" | "imported" | "openwork_managed";
export type ManagedConfigRecord = {
auth: JsonObject | null;
cloudItemId: string | null;
config: JsonObject;
createdAt: string;
displayName: string;
id: string;
key: string | null;
metadata: JsonObject | null;
source: ManagedSource;
updatedAt: string;
};
export type WorkspaceAssignmentRecord = {
createdAt: string;
itemId: string;
updatedAt: string;
workspaceId: string;
};
export type CloudSigninRecord = {
auth: JsonObject | null;
cloudBaseUrl: string;
createdAt: string;
id: string;
lastValidatedAt: string | null;
metadata: JsonObject | null;
orgId: string | null;
serverId: string;
updatedAt: string;
userId: string | null;
};
export type WorkspaceShareRecord = {
accessKey: string | null;
audit: JsonObject | null;
createdAt: string;
id: string;
lastUsedAt: string | null;
revokedAt: string | null;
status: "active" | "disabled" | "revoked";
updatedAt: string;
workspaceId: string;
};
export type RouterIdentityRecord = {
auth: JsonObject | null;
config: JsonObject;
createdAt: string;
displayName: string;
id: string;
isEnabled: boolean;
kind: string;
serverId: string;
updatedAt: string;
};
export type RouterBindingRecord = {
config: JsonObject;
createdAt: string;
bindingKey: string;
id: string;
isEnabled: boolean;
routerIdentityId: string;
serverId: string;
updatedAt: string;
};
export type MigrationRecord = {
appliedAt: string;
checksum: string;
name: string;
version: string;
};
export type MigrationResult = {
applied: string[];
currentVersion: string;
totalApplied: number;
};
export type ImportSourceReport = {
details: JsonObject;
sourcePath: string | null;
status: ImportStatus;
warnings: string[];
};
export type StartupDiagnostics = {
completedAt: string;
importReports: {
cloudSignin: ImportSourceReport;
desktopWorkspaceState: ImportSourceReport;
orchestratorAuth: ImportSourceReport;
orchestratorState: ImportSourceReport;
};
legacyWorkspaceImport: {
completedAt: string | null;
skipped: boolean;
};
mode: "fresh" | "existing";
migrations: MigrationResult;
registry: {
hiddenWorkspaceIds: string[];
localServerCreated: boolean;
localServerId: string;
totalServers: number;
totalVisibleWorkspaces: number;
};
warnings: string[];
workingDirectory: {
databasePath: string;
rootDir: string;
workspacesDir: string;
};
};

View File

@@ -0,0 +1,117 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
export type ServerWorkingDirectory = {
databaseDir: string;
databasePath: string;
importsDir: string;
managedDir: string;
managedMcpDir: string;
managedPluginDir: string;
managedProviderDir: string;
managedSkillDir: string;
rootDir: string;
runtimeDir: string;
workspacesDir: string;
};
type ResolveServerWorkingDirectoryOptions = {
environment: string;
explicitRootDir?: string;
};
function isTruthy(value: string | undefined) {
if (!value) {
return false;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
function resolvePlatformDataRoot() {
const home = os.homedir();
const devMode = isTruthy(process.env.OPENWORK_DEV_MODE);
const folderName = devMode ? "com.differentai.openwork.dev" : "com.differentai.openwork";
if (process.platform === "darwin") {
return path.join(home, "Library", "Application Support", folderName);
}
if (process.platform === "win32") {
const appData = process.env.APPDATA?.trim() || path.join(home, "AppData", "Roaming");
return path.join(appData, folderName);
}
const xdgDataHome = process.env.XDG_DATA_HOME?.trim() || path.join(home, ".local", "share");
return path.join(xdgDataHome, folderName);
}
function resolveRootDir(options: ResolveServerWorkingDirectoryOptions) {
if (options.explicitRootDir?.trim()) {
return path.resolve(options.explicitRootDir.trim());
}
const override = process.env.OPENWORK_SERVER_V2_WORKDIR?.trim();
if (override) {
return path.resolve(override);
}
const sharedDataDir = process.env.OPENWORK_DATA_DIR?.trim();
if (sharedDataDir) {
return path.join(path.resolve(sharedDataDir), "server-v2");
}
if (options.environment === "test") {
return path.join(process.cwd(), ".openwork-server-v2-test");
}
return path.join(resolvePlatformDataRoot(), "server-v2");
}
export function resolveServerWorkingDirectory(options: ResolveServerWorkingDirectoryOptions): ServerWorkingDirectory {
const rootDir = resolveRootDir(options);
const databaseDir = path.join(rootDir, "state");
const managedDir = path.join(rootDir, "managed");
return {
databaseDir,
databasePath: path.join(databaseDir, "openwork-server-v2.sqlite"),
importsDir: path.join(rootDir, "imports"),
managedDir,
managedMcpDir: path.join(managedDir, "mcps"),
managedPluginDir: path.join(managedDir, "plugins"),
managedProviderDir: path.join(managedDir, "providers"),
managedSkillDir: path.join(managedDir, "skills"),
rootDir,
runtimeDir: path.join(rootDir, "runtime"),
workspacesDir: path.join(rootDir, "workspaces"),
};
}
export function ensureServerWorkingDirectoryLayout(layout: ServerWorkingDirectory) {
for (const directory of [
layout.rootDir,
layout.databaseDir,
layout.importsDir,
layout.managedDir,
layout.managedMcpDir,
layout.managedPluginDir,
layout.managedProviderDir,
layout.managedSkillDir,
layout.runtimeDir,
layout.workspacesDir,
]) {
fs.mkdirSync(directory, { recursive: true });
}
}
export function resolveWorkspaceConfigDir(layout: ServerWorkingDirectory, workspaceId: string) {
return path.join(layout.workspacesDir, workspaceId, "config");
}
export function ensureWorkspaceConfigDir(layout: ServerWorkingDirectory, workspaceId: string) {
const directory = resolveWorkspaceConfigDir(layout, workspaceId);
fs.mkdirSync(directory, { recursive: true });
return directory;
}

View File

@@ -0,0 +1,350 @@
import { afterEach, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { createApp } from "./app.js";
import { createAppDependencies } from "./context/app-dependencies.js";
const tempRoots: string[] = [];
afterEach(() => {
while (tempRoots.length) {
const next = tempRoots.pop();
if (!next) continue;
fs.rmSync(next, { force: true, recursive: true });
}
});
function createTempRoot(label: string) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `${label}-`));
tempRoots.push(root);
return root;
}
function createTestApp() {
const root = createTempRoot("openwork-server-v2-phase7");
const dependencies = createAppDependencies({
environment: "test",
inMemory: true,
runtime: {
bootstrapPolicy: "disabled",
},
startedAt: new Date("2026-04-14T00:00:00.000Z"),
version: "0.0.0-test",
workingDirectory: path.join(root, "server-v2"),
});
return {
app: createApp({ dependencies }),
dependencies,
root,
};
}
test("local workspace creation and config routes use server-owned config directories", async () => {
const { app, dependencies, root } = createTestApp();
const workspaceRoot = path.join(root, "workspace-alpha");
const createResponse = await app.request("http://openwork.local/workspaces/local", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ folderPath: workspaceRoot, name: "Alpha", preset: "starter" }),
});
const created = await createResponse.json();
const workspaceId = created.data.id as string;
expect(createResponse.status).toBe(200);
expect(created.data.backend.local.dataDir).toBe(workspaceRoot);
expect(created.data.backend.local.configDir).toContain(`/workspaces/${workspaceId}/config`);
const configResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/config`);
const configBody = await configResponse.json();
expect(configResponse.status).toBe(200);
expect(configBody.data.stored.openwork.authorizedRoots).toEqual([]);
expect(configBody.data.effective.openwork.authorizedRoots).toEqual([]);
expect(configBody.data.effective.opencode.permission?.external_directory).toBeUndefined();
const patchResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/config`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
openwork: { reload: { auto: true } },
opencode: { permission: { external_directory: { [`${path.join(root, "shared-data")}/*`]: "allow" } } },
}),
});
const patched = await patchResponse.json();
expect(patchResponse.status).toBe(200);
expect(patched.data.stored.openwork.reload.auto).toBe(true);
expect(patched.data.stored.openwork.authorizedRoots).toEqual([]);
expect(patched.data.effective.openwork.authorizedRoots).toEqual([path.join(root, "shared-data")]);
expect(patched.data.effective.opencode.permission.external_directory[`${path.join(root, "shared-data")}/*`]).toBe("allow");
expect(patched.data.effective.opencode.permission.external_directory[`${workspaceRoot}/*`]).toBeUndefined();
const rawResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/config/opencode-raw?scope=project`);
const rawBody = await rawResponse.json();
expect(rawResponse.status).toBe(200);
expect(rawBody.data.content).toContain("external_directory");
expect(rawBody.data.path).toContain(`/workspaces/${workspaceId}/config/opencode.jsonc`);
const persistedWorkspace = dependencies.persistence.repositories.workspaces.getById(workspaceId);
expect(persistedWorkspace?.configDir).toBeTruthy();
expect(fs.existsSync(path.join(persistedWorkspace!.configDir!, "opencode.jsonc"))).toBe(true);
expect(fs.existsSync(path.join(workspaceRoot, "opencode.jsonc"))).toBe(true);
});
test("file routes cover simple content, file sessions, inbox, artifacts, and reload events", async () => {
const { app, root } = createTestApp();
const workspaceRoot = path.join(root, "workspace-beta");
const createResponse = await app.request("http://openwork.local/workspaces/local", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folderPath: workspaceRoot, name: "Beta", preset: "starter" }),
});
const created = await createResponse.json();
const workspaceId = created.data.id as string;
const contentWrite = await app.request(`http://openwork.local/workspaces/${workspaceId}/files/content`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "notes/today.md", content: "hello phase 7" }),
});
const contentWriteBody = await contentWrite.json();
expect(contentWrite.status).toBe(200);
expect(contentWriteBody.data.path).toBe("notes/today.md");
const contentRead = await app.request(`http://openwork.local/workspaces/${workspaceId}/files/content?path=notes/today.md`);
const contentReadBody = await contentRead.json();
expect(contentRead.status).toBe(200);
expect(contentReadBody.data.content).toBe("hello phase 7");
const sessionCreate = await app.request(`http://openwork.local/workspaces/${workspaceId}/file-sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ write: true }),
});
const sessionBody = await sessionCreate.json();
const fileSessionId = sessionBody.data.id as string;
expect(sessionCreate.status).toBe(200);
const writeBatch = await app.request(`http://openwork.local/workspaces/${workspaceId}/file-sessions/${fileSessionId}/write-batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ writes: [{ path: "docs/readme.txt", contentBase64: Buffer.from("file-session").toString("base64") }] }),
});
const writeBatchBody = await writeBatch.json();
expect(writeBatch.status).toBe(200);
const staleBatch = await app.request(`http://openwork.local/workspaces/${workspaceId}/file-sessions/${fileSessionId}/write-batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
writes: [{
path: "docs/readme.txt",
contentBase64: Buffer.from("stale").toString("base64"),
ifMatchRevision: "1:1",
}],
}),
});
const staleBatchBody = await staleBatch.json();
expect(staleBatch.status).toBe(200);
expect(staleBatchBody.data.items[0].code).toBe("conflict");
expect(staleBatchBody.data.items[0].currentRevision).toBe(writeBatchBody.data.items[0].revision);
const readBatch = await app.request(`http://openwork.local/workspaces/${workspaceId}/file-sessions/${fileSessionId}/read-batch`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths: ["docs/readme.txt"] }),
});
const readBatchBody = await readBatch.json();
expect(readBatch.status).toBe(200);
expect(Buffer.from(readBatchBody.data.items[0].contentBase64, "base64").toString("utf8")).toBe("file-session");
const ops = await app.request(`http://openwork.local/workspaces/${workspaceId}/file-sessions/${fileSessionId}/operations`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ operations: [{ type: "rename", from: "docs/readme.txt", to: "docs/renamed.txt" }] }),
});
expect(ops.status).toBe(200);
const catalog = await app.request(`http://openwork.local/workspaces/${workspaceId}/file-sessions/${fileSessionId}/catalog/snapshot?prefix=docs`);
const catalogBody = await catalog.json();
expect(catalog.status).toBe(200);
expect(catalogBody.data.items.some((item: any) => item.path === "docs/renamed.txt")).toBe(true);
const upload = await app.request(`http://openwork.local/workspaces/${workspaceId}/inbox`, {
method: "POST",
body: (() => {
const form = new FormData();
form.append("file", new File(["hello inbox"], "hello.txt", { type: "text/plain" }));
return form;
})(),
});
const uploadBody = await upload.json();
expect(upload.status).toBe(200);
expect(uploadBody.data.path).toBe("hello.txt");
const inboxList = await app.request(`http://openwork.local/workspaces/${workspaceId}/inbox`);
const inboxListBody = await inboxList.json();
expect(inboxList.status).toBe(200);
expect(inboxListBody.data.items[0].name).toBe("hello.txt");
const outboxDir = path.join(workspaceRoot, ".opencode", "openwork", "outbox");
fs.mkdirSync(outboxDir, { recursive: true });
fs.writeFileSync(path.join(outboxDir, "artifact.bin"), "artifact", "utf8");
const artifacts = await app.request(`http://openwork.local/workspaces/${workspaceId}/artifacts`);
const artifactsBody = await artifacts.json();
expect(artifacts.status).toBe(200);
expect(artifactsBody.data.items[0].path).toBe("artifact.bin");
const reloads = await app.request(`http://openwork.local/workspaces/${workspaceId}/reload-events`);
const reloadBody = await reloads.json();
expect(reloads.status).toBe(200);
expect(reloadBody.data.items.length).toBeGreaterThan(0);
expect(reloadBody.data.items.some((item: any) => item.reason === "config")).toBe(true);
const disposed = await app.request(`http://openwork.local/workspaces/${workspaceId}/dispose`, {
method: "POST",
});
const disposedBody = await disposed.json();
expect(disposed.status).toBe(200);
expect(disposedBody.data.disposed).toBe(true);
});
test("remote workspace config and file routes proxy through the local server", async () => {
const remote = Bun.serve({
fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/workspaces/remote-alpha/config" && request.method === "GET") {
return Response.json({
ok: true,
data: {
effective: { opencode: { permission: { external_directory: { "/srv/alpha/*": "allow" } } }, openwork: {} },
materialized: { compatibilityOpencodePath: null, compatibilityOpenworkPath: null, configDir: "/srv/config", configOpenworkPath: "/srv/config/.opencode/openwork.json", configOpencodePath: "/srv/config/opencode.jsonc" },
stored: { openwork: { reload: { auto: true } }, opencode: {} },
updatedAt: new Date().toISOString(),
workspaceId: "remote-alpha",
},
meta: { requestId: "owreq_remote_cfg_1", timestamp: new Date().toISOString() },
});
}
if (url.pathname === "/workspaces/remote-alpha/config" && request.method === "PATCH") {
return Response.json({
ok: true,
data: {
effective: { opencode: { permission: { external_directory: { "/srv/alpha/*": "allow", "/srv/shared/*": "allow" } } }, openwork: {} },
materialized: { compatibilityOpencodePath: null, compatibilityOpenworkPath: null, configDir: "/srv/config", configOpenworkPath: "/srv/config/.opencode/openwork.json", configOpencodePath: "/srv/config/opencode.jsonc" },
stored: { openwork: { reload: { auto: true } }, opencode: {} },
updatedAt: new Date().toISOString(),
workspaceId: "remote-alpha",
},
meta: { requestId: "owreq_remote_cfg_2", timestamp: new Date().toISOString() },
});
}
if (url.pathname === "/workspaces/remote-alpha/files/content" && request.method === "GET") {
return Response.json({ ok: true, data: { path: "notes.md", content: "remote hello", bytes: 12, updatedAt: 42 }, meta: { requestId: "owreq_remote_file_1", timestamp: new Date().toISOString() } });
}
if (url.pathname === "/workspaces/remote-alpha/files/content" && request.method === "POST") {
return Response.json({ ok: true, data: { path: "notes.md", bytes: 12, revision: "42:12", updatedAt: 43 }, meta: { requestId: "owreq_remote_file_2", timestamp: new Date().toISOString() } });
}
if (url.pathname === "/workspaces/remote-alpha/reload-events" && request.method === "GET") {
return Response.json({ ok: true, data: { cursor: 1, items: [{ id: "evt_remote_1", reason: "config", seq: 1, timestamp: Date.now(), workspaceId: "remote-alpha" }] }, meta: { requestId: "owreq_remote_reload_1", timestamp: new Date().toISOString() } });
}
return new Response("not found", { status: 404 });
},
hostname: "127.0.0.1",
port: 0,
});
try {
const { app, dependencies } = createTestApp();
const workspace = dependencies.persistence.registry.importRemoteWorkspace({
baseUrl: `http://127.0.0.1:${remote.port}`,
displayName: "Remote Alpha",
legacyNotes: {},
remoteType: "openwork",
remoteWorkspaceId: "remote-alpha",
serverAuth: { openworkToken: "remote-token" },
serverBaseUrl: `http://127.0.0.1:${remote.port}`,
serverHostingKind: "self_hosted",
serverLabel: `127.0.0.1:${remote.port}`,
workspaceStatus: "ready",
});
const config = await app.request(`http://openwork.local/workspaces/${workspace.id}/config`);
const configBody = await config.json();
expect(config.status).toBe(200);
expect(configBody.data.stored.openwork.reload.auto).toBe(true);
const patched = await app.request(`http://openwork.local/workspaces/${workspace.id}/config`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ opencode: { permission: { external_directory: { "/srv/shared/*": "allow" } } } }),
});
expect(patched.status).toBe(200);
const contentRead = await app.request(`http://openwork.local/workspaces/${workspace.id}/files/content?path=notes.md`);
const contentBody = await contentRead.json();
expect(contentRead.status).toBe(200);
expect(contentBody.data.content).toBe("remote hello");
const contentWrite = await app.request(`http://openwork.local/workspaces/${workspace.id}/files/content`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path: "notes.md", content: "remote hello" }),
});
const contentWriteBody = await contentWrite.json();
expect(contentWrite.status).toBe(200);
expect(contentWriteBody.data.revision).toBe("42:12");
const reloads = await app.request(`http://openwork.local/workspaces/${workspace.id}/reload-events`);
const reloadBody = await reloads.json();
expect(reloads.status).toBe(200);
expect(reloadBody.data.items[0].workspaceId).toBe("remote-alpha");
} finally {
remote.stop(true);
}
});
test("reconciliation absorbs recognized managed items from local workspace files", async () => {
const { dependencies, root } = createTestApp();
const workspaceRoot = path.join(root, "workspace-gamma");
fs.mkdirSync(path.join(workspaceRoot, ".opencode", "skills", "manual-skill"), { recursive: true });
fs.writeFileSync(path.join(workspaceRoot, "opencode.jsonc"), JSON.stringify({
$schema: "https://opencode.ai/config.json",
mcp: {
demo: { type: "local", command: ["demo"] },
},
plugin: ["demo-plugin"],
provider: {
openai: { options: { apiKey: "redacted" } },
},
}, null, 2), "utf8");
fs.writeFileSync(path.join(workspaceRoot, ".opencode", "skills", "manual-skill", "SKILL.md"), "---\nname: manual-skill\ndescription: Manual skill\n---\n\nhello\n", "utf8");
const workspace = dependencies.persistence.registry.importLocalWorkspace({
dataDir: workspaceRoot,
displayName: "Gamma",
status: "ready",
});
dependencies.services.config.reconcileAllWorkspaces();
const mcps = dependencies.persistence.repositories.workspaceMcps.listForWorkspace(workspace.id);
const plugins = dependencies.persistence.repositories.workspacePlugins.listForWorkspace(workspace.id);
const providers = dependencies.persistence.repositories.workspaceProviderConfigs.listForWorkspace(workspace.id);
const skills = dependencies.persistence.repositories.workspaceSkills.listForWorkspace(workspace.id);
const snapshot = await dependencies.services.config.getWorkspaceConfigSnapshot(workspace.id);
expect(mcps).toHaveLength(1);
expect(plugins).toHaveLength(1);
expect(providers).toHaveLength(1);
expect(skills).toHaveLength(1);
expect(snapshot.stored.opencode.mcp).toBeUndefined();
expect((snapshot.effective.opencode.mcp as any).demo.type).toBe("local");
expect(snapshot.effective.opencode.plugin).toContain("demo-plugin");
expect((snapshot.effective.opencode.provider as any).openai.options.apiKey).toBe("redacted");
});

View File

@@ -0,0 +1,80 @@
export type ResponseMeta = {
requestId: string;
timestamp: string;
};
export type SuccessResponse<TData> = {
ok: true;
data: TData;
meta: ResponseMeta;
};
export type ErrorCode =
| "bad_gateway"
| "conflict"
| "forbidden"
| "internal_error"
| "invalid_request"
| "not_found"
| "not_implemented"
| "service_unavailable"
| "unauthorized";
export type ErrorDetail = {
message: string;
path?: Array<string | number>;
};
export type ErrorResponse = {
ok: false;
error: {
code: ErrorCode;
message: string;
requestId: string;
details?: Array<ErrorDetail>;
};
};
export class RouteError extends Error {
constructor(
readonly status: number,
readonly code: ErrorCode,
message: string,
readonly details?: Array<ErrorDetail>,
) {
super(message);
this.name = "RouteError";
}
}
export function createResponseMeta(requestId: string, now: Date = new Date()): ResponseMeta {
return {
requestId,
timestamp: now.toISOString(),
};
}
export function buildSuccessResponse<TData>(requestId: string, data: TData, now: Date = new Date()): SuccessResponse<TData> {
return {
ok: true,
data,
meta: createResponseMeta(requestId, now),
};
}
export function buildErrorResponse(input: {
requestId: string;
code: ErrorCode;
message: string;
details?: Array<ErrorDetail>;
}): ErrorResponse {
return {
ok: false,
error: {
code: input.code,
message: input.message,
requestId: input.requestId,
details: input.details,
},
};
}

View File

@@ -0,0 +1,4 @@
export { app, createApp, type AppType, type CreateAppOptions } from "./app.js";
export { startServer, type StartServerOptions, type StartedServer } from "./bootstrap/server.js";
export { createAppDependencies, type AppDependencies } from "./context/app-dependencies.js";
export { routeNamespaces, routePaths, workspaceResourcePattern, workspaceRoutePath } from "./routes/route-paths.js";

View File

@@ -0,0 +1,304 @@
import { afterEach, expect, test } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createApp } from "./app.js";
import { createAppDependencies } from "./context/app-dependencies.js";
const tempRoots: string[] = [];
const envBackup = {
home: process.env.HOME,
publisherBaseUrl: process.env.OPENWORK_PUBLISHER_BASE_URL,
publisherOrigin: process.env.OPENWORK_PUBLISHER_REQUEST_ORIGIN,
};
const originalFetch = globalThis.fetch;
afterEach(() => {
while (tempRoots.length) {
const next = tempRoots.pop();
if (!next) continue;
fs.rmSync(next, { force: true, recursive: true });
}
process.env.OPENWORK_PUBLISHER_BASE_URL = envBackup.publisherBaseUrl;
process.env.OPENWORK_PUBLISHER_REQUEST_ORIGIN = envBackup.publisherOrigin;
process.env.HOME = envBackup.home;
globalThis.fetch = originalFetch;
});
function createTempRoot(label: string) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `${label}-`));
tempRoots.push(root);
return root;
}
function createTestApp(label: string) {
const root = createTempRoot(label);
const dependencies = createAppDependencies({
environment: "test",
inMemory: true,
runtime: {
bootstrapPolicy: "disabled",
},
startedAt: new Date("2026-04-15T00:00:00.000Z"),
version: "0.0.0-test",
workingDirectory: path.join(root, "server-v2"),
});
return {
app: createApp({ dependencies }),
dependencies,
root,
};
}
test("managed resource routes cover MCPs, plugins, skills, shares, export/import, cloud signin, bundles, and router state", async () => {
const { app, dependencies, root } = createTestApp("openwork-server-v2-phase8-managed");
const workspaceRoot = path.join(root, "workspace-managed");
fs.mkdirSync(path.join(workspaceRoot, ".opencode", "tools"), { recursive: true });
fs.writeFileSync(path.join(workspaceRoot, ".opencode", "tools", "demo.txt"), "tool-secret", "utf8");
const createResponse = await app.request("http://openwork.local/workspaces/local", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folderPath: workspaceRoot, name: "Managed", preset: "starter" }),
});
const created = await createResponse.json();
const workspaceId = created.data.id as string;
const mcpAdded = await app.request(`http://openwork.local/workspace/${workspaceId}/mcp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "demo", config: { command: ["demo"], type: "local" } }),
});
const pluginsAdded = await app.request(`http://openwork.local/workspace/${workspaceId}/plugins`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ spec: "demo-plugin" }),
});
const skillAdded = await app.request(`http://openwork.local/workspace/${workspaceId}/skills`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ content: "## When To Use\n- Demo\n", description: "Demo skill", name: "demo-skill" }),
});
const systemManagedMcps = await app.request("http://openwork.local/system/managed/mcps");
const shareExposed = await app.request(`http://openwork.local/workspaces/${workspaceId}/share`, { method: "POST" });
const shareBody = await shareExposed.json();
const exportConflict = await app.request(`http://openwork.local/workspaces/${workspaceId}/export?sensitive=auto`);
const exportSafe = await app.request(`http://openwork.local/workspaces/${workspaceId}/export?sensitive=exclude`);
const exportSafeBody = await exportSafe.json();
expect(mcpAdded.status).toBe(200);
expect((await mcpAdded.json()).items[0].name).toBe("demo");
expect(pluginsAdded.status).toBe(200);
expect((await pluginsAdded.json()).items[0].spec).toBe("demo-plugin");
expect(skillAdded.status).toBe(200);
expect((await skillAdded.json()).name).toBe("demo-skill");
expect(systemManagedMcps.status).toBe(200);
expect((await systemManagedMcps.json()).data.items[0].workspaceIds).toContain(workspaceId);
expect(shareExposed.status).toBe(200);
expect(shareBody.data.status).toBe("active");
expect(typeof shareBody.data.accessKey).toBe("string");
expect(exportConflict.status).toBe(409);
expect((await exportConflict.json()).code).toBe("workspace_export_requires_decision");
expect(exportSafe.status).toBe(200);
expect(exportSafeBody.data.skills[0].name).toBe("demo-skill");
const importRoot = path.join(root, "workspace-imported");
const createImportWorkspace = await app.request("http://openwork.local/workspaces/local", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folderPath: importRoot, name: "Imported", preset: "starter" }),
});
const importedWorkspaceId = (await createImportWorkspace.json()).data.id as string;
const importResult = await app.request(`http://openwork.local/workspaces/${importedWorkspaceId}/import`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(exportSafeBody.data),
});
const importedSkills = await app.request(`http://openwork.local/workspace/${importedWorkspaceId}/skills`);
expect(importResult.status).toBe(200);
expect((await importedSkills.json()).items[0].name).toBe("demo-skill");
const importedSkillRecord = dependencies.persistence.repositories.skills.list().find((item) => item.key === "demo-skill" && item.source === "imported");
expect(importedSkillRecord?.source).toBe("imported");
expect((importedSkillRecord?.metadata as any)?.importedVia).toBe("portable_bundle");
globalThis.fetch = Object.assign(
async (input: URL | RequestInfo, init?: RequestInit | BunFetchRequestInit) => {
const url = typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
if (url.includes("/skills/hub-skill/SKILL.md")) {
return new Response("---\nname: hub-skill\ndescription: Hub skill\ntrigger: Help with hub flows\n---\n\nUse for hub tasks\n", {
headers: { "Content-Type": "text/plain" },
status: 200,
});
}
return originalFetch(input, init);
},
{ preconnect: originalFetch.preconnect },
) as typeof fetch;
const hubInstall = await app.request(`http://openwork.local/workspace/${workspaceId}/skills/hub/hub-skill`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ overwrite: true }),
});
expect(hubInstall.status).toBe(200);
const hubSkillRecord = dependencies.persistence.repositories.skills.list().find((item) => item.key === "hub-skill");
expect(hubSkillRecord?.source).toBe("imported");
expect((hubSkillRecord?.metadata as any)?.install?.kind).toBe("hub");
globalThis.fetch = originalFetch;
const cloudServer = Bun.serve({
fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/v1/me") {
return Response.json({ user: { id: "usr_123" } });
}
return new Response("not found", { status: 404 });
},
hostname: "127.0.0.1",
port: 0,
});
tempRoots.push(path.join(root, `cloud-server-${cloudServer.port}`));
try {
const cloudPersist = await app.request("http://openwork.local/system/cloud-signin", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ auth: { authToken: "token-demo" }, cloudBaseUrl: `http://127.0.0.1:${cloudServer.port}` }),
});
const cloudValidated = await app.request("http://openwork.local/system/cloud-signin/validate", { method: "POST" });
const cloudCleared = await app.request("http://openwork.local/system/cloud-signin", { method: "DELETE" });
expect(cloudPersist.status).toBe(200);
expect(cloudValidated.status).toBe(200);
expect((await cloudValidated.json()).data.ok).toBe(true);
expect(cloudCleared.status).toBe(200);
expect((await cloudCleared.json()).data).toBeNull();
} finally {
cloudServer.stop(true);
}
const publisherServer = Bun.serve({
fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/v1/bundles" && request.method === "POST") {
return Response.json({ url: `${url.origin}/b/demo-bundle` });
}
if (url.pathname === "/b/demo-bundle/data" && request.method === "GET") {
return Response.json({ schemaVersion: 1, type: "skills-set", name: "Demo", skills: [] });
}
return new Response("not found", { status: 404 });
},
hostname: "127.0.0.1",
port: 0,
});
process.env.OPENWORK_PUBLISHER_BASE_URL = `http://127.0.0.1:${publisherServer.port}`;
process.env.OPENWORK_PUBLISHER_REQUEST_ORIGIN = "http://127.0.0.1:3000";
try {
const publish = await app.request("http://openwork.local/share/bundles/publish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundleType: "skills-set", payload: { ok: true } }),
});
const publishBody = await publish.json();
const fetchBundle = await app.request("http://openwork.local/share/bundles/fetch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ bundleUrl: publishBody.data.url }),
});
expect(publish.status).toBe(200);
expect(fetchBundle.status).toBe(200);
expect((await fetchBundle.json()).data.type).toBe("skills-set");
} finally {
publisherServer.stop(true);
}
const routerSendServer = Bun.serve({
fetch(request) {
const url = new URL(request.url);
if (url.pathname === "/send" && request.method === "POST") {
return Response.json({ attempted: 1, channel: "telegram", directory: workspaceRoot, ok: true, sent: 1 });
}
return new Response("not found", { status: 404 });
},
hostname: "127.0.0.1",
port: 0,
});
try {
dependencies.services.runtime.getRouterHealth = () => ({
baseUrl: `http://127.0.0.1:${routerSendServer.port}`,
binaryPath: null,
diagnostics: { combined: [], stderr: [], stdout: [], totalLines: 0, truncated: false },
enablement: { enabled: true, enabledBindingCount: 0, enabledIdentityCount: 0, forced: false, reason: "test" },
healthUrl: `http://127.0.0.1:${routerSendServer.port}`,
lastError: null,
lastExit: null,
lastReadyAt: null,
lastStartedAt: null,
manifest: null,
materialization: null,
pid: null,
running: true,
source: "development",
status: "running",
version: "test",
});
dependencies.services.runtime.applyRouterConfig = async () => dependencies.services.runtime.getRouterHealth();
const telegramIdentity = await app.request(`http://openwork.local/workspace/${workspaceId}/opencode-router/identities/telegram`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ access: "private", token: "123456:demo" }),
});
const bindings = await app.request(`http://openwork.local/workspace/${workspaceId}/opencode-router/bindings`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel: "telegram", directory: workspaceRoot, peerId: "peer-1" }),
});
const send = await app.request(`http://openwork.local/workspace/${workspaceId}/opencode-router/send`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ channel: "telegram", directory: workspaceRoot, text: "hello" }),
});
expect(telegramIdentity.status).toBe(200);
expect((await telegramIdentity.json()).telegram.pairingCode).toBeTruthy();
expect(bindings.status).toBe(200);
expect(send.status).toBe(200);
expect((await send.json()).sent).toBe(1);
} finally {
routerSendServer.stop(true);
}
});
test("scheduler routes list and delete jobs for a local workspace", async () => {
const root = createTempRoot("openwork-server-v2-scheduler");
process.env.HOME = root;
const { app } = createTestApp("openwork-server-v2-phase8-scheduler");
const workspaceRoot = path.join(root, "workspace-scheduler");
const jobsDir = path.join(root, ".config", "opencode", "jobs");
fs.mkdirSync(jobsDir, { recursive: true });
fs.writeFileSync(
path.join(jobsDir, "nightly-review.json"),
JSON.stringify({
createdAt: new Date("2026-04-16T00:00:00.000Z").toISOString(),
name: "Nightly Review",
schedule: "0 9 * * *",
slug: "nightly-review",
workdir: workspaceRoot,
}, null, 2),
"utf8",
);
const createResponse = await app.request("http://openwork.local/workspaces/local", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ folderPath: workspaceRoot, name: "Scheduler", preset: "starter" }),
});
const workspaceId = (await createResponse.json()).data.id as string;
const listResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/scheduler/jobs`);
expect(listResponse.status).toBe(200);
expect((await listResponse.json()).data.items[0].slug).toBe("nightly-review");
const deleteResponse = await app.request(`http://openwork.local/workspaces/${workspaceId}/scheduler/jobs/nightly-review`, {
method: "DELETE",
});
expect(deleteResponse.status).toBe(200);
expect((await deleteResponse.json()).data.job.slug).toBe("nightly-review");
expect(fs.existsSync(path.join(jobsDir, "nightly-review.json"))).toBe(false);
});

View File

@@ -0,0 +1,94 @@
import type { MiddlewareHandler } from "hono";
import { HTTPException } from "hono/http-exception";
import { ZodError } from "zod";
import { buildErrorResponse, RouteError } from "../http.js";
import type { AppBindings } from "../context/request-context.js";
export const errorHandlingMiddleware: MiddlewareHandler<AppBindings> = async (c, next) => {
try {
await next();
} catch (error) {
const requestId = c.get("requestId") ?? `owreq_${crypto.randomUUID()}`;
const routeLike = error && typeof error === "object"
? error as { code?: unknown; details?: unknown; message?: unknown; status?: unknown }
: null;
if (error instanceof HTTPException) {
const status = error.status;
const code = status === 401
? "unauthorized"
: status === 403
? "forbidden"
: status === 404
? "not_found"
: "invalid_request";
const body = buildErrorResponse({
requestId,
code,
message: error.message || (code === "not_found" ? "Route not found." : "Request failed."),
});
return c.json(body, status);
}
if (error instanceof RouteError) {
return c.json(
buildErrorResponse({
requestId,
code: error.code,
message: error.message,
details: error.details,
}),
error.status as any,
);
}
if (
routeLike
&& typeof routeLike.status === "number"
&& typeof routeLike.code === "string"
&& typeof routeLike.message === "string"
) {
return c.json(
buildErrorResponse({
requestId,
code: routeLike.code as any,
message: routeLike.message,
details: Array.isArray(routeLike.details) ? routeLike.details as any : undefined,
}),
routeLike.status as any,
);
}
if (error instanceof ZodError) {
const body = buildErrorResponse({
requestId,
code: "invalid_request",
message: "Request validation failed.",
details: error.issues.map((issue) => ({
message: issue.message,
path: issue.path.filter((segment): segment is string | number => typeof segment === "string" || typeof segment === "number"),
})),
});
return c.json(body, 400);
}
const message = error instanceof Error ? error.message : "Unexpected server error.";
console.error(
JSON.stringify({
message,
requestId,
scope: "openwork-server-v2.error",
}),
);
return c.json(
buildErrorResponse({
requestId,
code: "internal_error",
message: "Unexpected server error.",
}),
500,
);
}
};

View File

@@ -0,0 +1,20 @@
import type { MiddlewareHandler } from "hono";
import type { AppBindings } from "../context/request-context.js";
export const REQUEST_ID_HEADER = "X-Request-Id";
function normalizeIncomingRequestId(value: string | undefined) {
const trimmed = value?.trim();
if (!trimmed) {
return null;
}
return trimmed.slice(0, 200);
}
export const requestIdMiddleware: MiddlewareHandler<AppBindings> = async (c, next) => {
const requestId = normalizeIncomingRequestId(c.req.header(REQUEST_ID_HEADER)) ?? `owreq_${crypto.randomUUID()}`;
c.set("requestId", requestId);
await next();
};

View File

@@ -0,0 +1,22 @@
import type { MiddlewareHandler } from "hono";
import type { AppBindings } from "../context/request-context.js";
export const requestLoggerMiddleware: MiddlewareHandler<AppBindings> = async (c, next) => {
const startedAt = performance.now();
await next();
const durationMs = Number((performance.now() - startedAt).toFixed(1));
const url = new URL(c.req.url);
console.info(
JSON.stringify({
durationMs,
method: c.req.method,
path: url.pathname,
requestId: c.get("requestId"),
scope: "openwork-server-v2.request",
status: c.res.status,
}),
);
};

View File

@@ -0,0 +1,16 @@
import type { MiddlewareHandler } from "hono";
import type { AppBindings } from "../context/request-context.js";
import { REQUEST_ID_HEADER } from "./request-id.js";
export const responseFinalizerMiddleware: MiddlewareHandler<AppBindings> = async (c, next) => {
await next();
const requestId = c.get("requestId");
if (requestId) {
c.header(REQUEST_ID_HEADER, requestId);
}
if (!c.res.headers.has("Cache-Control")) {
c.header("Cache-Control", "no-store");
}
};

View File

@@ -0,0 +1,91 @@
import { resolver } from "hono-openapi";
import type { z } from "zod";
import {
forbiddenErrorSchema,
internalErrorSchema,
invalidRequestErrorSchema,
notFoundErrorSchema,
unauthorizedErrorSchema,
} from "./schemas/errors.js";
function toPascalCase(value: string) {
return value
.replace(/[^a-zA-Z0-9]+/g, " ")
.trim()
.split(/\s+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join("");
}
export function buildOperationId(method: string, path: string) {
const parts = path
.split("/")
.filter(Boolean)
.map((part) => {
if (part.startsWith(":")) {
return `by-${part.slice(1)}`;
}
if (part === "*") {
return "wildcard";
}
return part;
});
if (parts.length === 0) {
return `${method.toLowerCase()}Root`;
}
return [method.toLowerCase(), ...parts]
.map(toPascalCase)
.join("")
.replace(/^[A-Z]/, (char) => char.toLowerCase());
}
export function jsonResponse(description: string, schema: z.ZodTypeAny) {
return {
description,
content: {
"application/json": {
schema: resolver(schema),
},
},
};
}
export function withCommonErrorResponses<TResponses extends Record<number, unknown>>(
responses: TResponses,
options: {
includeForbidden?: boolean;
includeNotFound?: boolean;
includeInvalidRequest?: boolean;
includeUnauthorized?: boolean;
} = {},
) {
return {
...responses,
...(options.includeInvalidRequest
? {
400: jsonResponse("Request validation failed.", invalidRequestErrorSchema),
}
: {}),
...(options.includeUnauthorized
? {
401: jsonResponse("Authentication is required for this route.", unauthorizedErrorSchema),
}
: {}),
...(options.includeForbidden
? {
403: jsonResponse("The authenticated actor does not have access to this route.", forbiddenErrorSchema),
}
: {}),
...(options.includeNotFound
? {
404: jsonResponse("The requested route was not found.", notFoundErrorSchema),
}
: {}),
500: jsonResponse("The server failed to complete the request.", internalErrorSchema),
};
}

View File

@@ -0,0 +1,640 @@
import type { Context, Hono } from "hono";
import { describeRoute, resolver } from "hono-openapi";
import { HTTPException } from "hono/http-exception";
import { getRequestContext, type AppBindings } from "../context/request-context.js";
import { buildSuccessResponse } from "../http.js";
import { jsonResponse, withCommonErrorResponses } from "../openapi.js";
import {
rawOpencodeConfigQuerySchema,
rawOpencodeConfigResponseSchema,
rawOpencodeConfigWriteRequestSchema,
workspaceConfigPatchRequestSchema,
workspaceConfigResponseSchema,
} from "../schemas/config.js";
import {
binaryListResponseSchema,
binaryUploadResponseSchema,
engineReloadResponseSchema,
fileBatchReadRequestSchema,
fileBatchReadResponseSchema,
fileBatchWriteRequestSchema,
fileCatalogSnapshotResponseSchema,
fileMutationResultSchema,
fileOperationsRequestSchema,
fileSessionCreateRequestSchema,
fileSessionIdParamsSchema,
fileSessionResponseSchema,
reloadEventsResponseSchema,
simpleContentQuerySchema,
simpleContentResponseSchema,
simpleContentWriteRequestSchema,
workspaceActivationResponseSchema,
workspaceCreateLocalRequestSchema,
workspaceDisposeResponseSchema,
workspaceDeleteResponseSchema,
} from "../schemas/files.js";
import { workspaceDetailResponseSchema } from "../schemas/registry.js";
import { routePaths } from "./route-paths.js";
function parseQuery<T>(schema: { parse(input: unknown): T }, url: string) {
const searchParams = new URL(url).searchParams;
const query: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
query[key] = value;
}
return schema.parse(query);
}
async function parseJsonBody<T>(schema: { parse(input: unknown): T }, request: Request) {
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("application/json")) {
return schema.parse({});
}
return schema.parse(await request.json());
}
function readActorKey(c: Context<AppBindings>) {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
const authorization = c.req.raw.headers.get("authorization")?.trim() ?? "";
const hostToken = c.req.raw.headers.get("x-openwork-host-token")?.trim() ?? "";
return {
actorKey: requestContext.actor.kind === "host" ? hostToken || authorization : authorization,
actorKind: requestContext.actor.kind === "host" ? "host" as const : "client" as const,
requestContext,
};
}
function requireWorkspaceAccess(c: Context<AppBindings>) {
const { requestContext, actorKey, actorKind } = readActorKey(c);
const workspaceId = c.req.param("workspaceId") ?? "";
const workspace = requestContext.services.workspaceRegistry.getById(workspaceId, {
includeHidden: requestContext.actor.kind === "host",
});
if (!workspace) {
throw new HTTPException(404, { message: `Workspace not found: ${workspaceId}` });
}
return { actorKey, actorKind, requestContext, workspaceId };
}
function createBinaryResponse(inputValue: { buffer?: Uint8Array; filePath?: string; filename: string; size: number }) {
const headers = new Headers();
headers.set("Content-Type", "application/octet-stream");
headers.set("Content-Disposition", `attachment; filename="${inputValue.filename}"`);
headers.set("Content-Length", String(inputValue.size));
return new Response(inputValue.buffer ?? (Bun as any).file(inputValue.filePath!), { headers, status: 200 });
}
export function registerFileRoutes(app: Hono<AppBindings>) {
app.post(
routePaths.workspaces.createLocal,
describeRoute({
tags: ["Workspaces"],
summary: "Create a local workspace",
description: "Creates a local workspace, initializes starter files, creates the Server V2 config directory, and reconciles the new workspace into managed config state.",
responses: withCommonErrorResponses({
200: jsonResponse("Local workspace created successfully.", workspaceDetailResponseSchema),
}, { includeForbidden: true, includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireHost(requestContext.actor);
const body = await parseJsonBody(workspaceCreateLocalRequestSchema, c.req.raw);
const workspace = await requestContext.services.files.createLocalWorkspace({
folderPath: body.folderPath,
name: body.name,
preset: body.preset ?? "starter",
});
const detail = requestContext.services.workspaceRegistry.getById(workspace.id, { includeHidden: true });
return c.json(buildSuccessResponse(requestContext.requestId, detail));
},
);
app.post(
routePaths.workspaces.activate(),
describeRoute({
tags: ["Workspaces"],
summary: "Activate a workspace",
description: "Marks a workspace as the active local workspace for migration-era host flows that still expect an active workspace concept.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace activated successfully.", workspaceActivationResponseSchema),
}, { includeForbidden: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
const workspaceId = c.req.param("workspaceId") ?? "";
const activeWorkspaceId = requestContext.services.files.activateWorkspace(workspaceId);
return c.json(buildSuccessResponse(requestContext.requestId, { activeWorkspaceId }));
},
);
app.patch(
routePaths.workspaces.displayName(),
describeRoute({
tags: ["Workspaces"],
summary: "Update workspace display name",
description: "Updates the persisted display name for a workspace record during migration.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace detail returned successfully.", workspaceDetailResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext } = readActorKey(c);
const workspaceId = c.req.param("workspaceId") ?? "";
const body = await c.req.json();
const displayName = typeof body?.displayName === "string" ? body.displayName : null;
requestContext.services.auth.requireVisibleRead(requestContext.actor);
requestContext.services.files.updateWorkspaceDisplayName(workspaceId, displayName);
const detail = requestContext.services.workspaceRegistry.getById(workspaceId, { includeHidden: requestContext.actor.kind === "host" });
return c.json(buildSuccessResponse(requestContext.requestId, detail));
},
);
app.delete(
routePaths.workspaces.byId(),
describeRoute({
tags: ["Workspaces"],
summary: "Delete workspace",
description: "Deletes a workspace record from the local Server V2 registry during migration-era host flows.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace deleted successfully.", workspaceDeleteResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext } = readActorKey(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
const workspaceId = c.req.param("workspaceId") ?? "";
const result = requestContext.services.files.deleteWorkspace(workspaceId);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.dispose(),
describeRoute({
tags: ["Workspaces"],
summary: "Dispose workspace runtime instance",
description: "Disposes the runtime instance associated with the workspace through Server V2 and refreshes managed runtime supervision where required.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace runtime instance disposed successfully.", workspaceDisposeResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext } = readActorKey(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
const workspaceId = c.req.param("workspaceId") ?? "";
const result = await requestContext.services.files.disposeWorkspaceInstance(workspaceId);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.get(
routePaths.workspaces.config(),
describeRoute({
tags: ["Config"],
summary: "Read workspace config",
description: "Returns stored and effective workspace config along with the materialized config paths managed by Server V2.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace config returned successfully.", workspaceConfigResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const snapshot = await requestContext.services.config.getWorkspaceConfigSnapshot(workspaceId);
return c.json(buildSuccessResponse(requestContext.requestId, snapshot));
},
);
app.patch(
routePaths.workspaces.config(),
describeRoute({
tags: ["Config"],
summary: "Patch workspace config",
description: "Updates stored workspace OpenWork/OpenCode config, absorbs recognized managed sections into the database, and rematerializes the effective config files.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace config updated successfully.", workspaceConfigResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const body = await parseJsonBody(workspaceConfigPatchRequestSchema, c.req.raw);
const snapshot = await requestContext.services.config.patchWorkspaceConfig(workspaceId, body);
if (body.opencode) {
requestContext.services.files.emitReloadEvent(workspaceId, "config", {
action: "updated",
name: "opencode.jsonc",
path: snapshot.materialized.configOpencodePath ?? undefined,
type: "config",
});
}
if (body.openwork) {
requestContext.services.files.emitReloadEvent(workspaceId, "config", {
action: "updated",
name: "openwork.json",
path: snapshot.materialized.configOpenworkPath ?? undefined,
type: "config",
});
}
await requestContext.services.files.recordWorkspaceAudit(
workspaceId,
"config.patch",
snapshot.materialized.configOpencodePath ?? snapshot.materialized.configOpenworkPath ?? workspaceId,
"Patched workspace config through Server V2.",
);
return c.json(buildSuccessResponse(requestContext.requestId, snapshot));
},
);
app.get(
routePaths.workspaces.rawOpencodeConfig(),
describeRoute({
tags: ["Config"],
summary: "Read raw OpenCode config text",
description: "Returns the editable raw OpenCode config text for project or global scope, generated from the server-owned config state.",
responses: withCommonErrorResponses({
200: jsonResponse("Raw OpenCode config returned successfully.", rawOpencodeConfigResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const query = parseQuery(rawOpencodeConfigQuerySchema, c.req.url);
const result = await requestContext.services.config.readRawOpencodeConfig(workspaceId, query.scope ?? "project");
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.rawOpencodeConfig(),
describeRoute({
tags: ["Config"],
summary: "Write raw OpenCode config text",
description: "Parses raw OpenCode config text, absorbs recognized managed sections into the database, and rematerializes the effective config files.",
responses: withCommonErrorResponses({
200: jsonResponse("Raw OpenCode config written successfully.", rawOpencodeConfigResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const body = await parseJsonBody(rawOpencodeConfigWriteRequestSchema, c.req.raw);
const result = body.scope === "global"
? requestContext.services.config.writeGlobalOpencodeConfig(body.content)
: await requestContext.services.config.writeWorkspaceRawOpencodeConfig(workspaceId, body.content);
if (body.scope !== "global") {
requestContext.services.files.emitReloadEvent(workspaceId, "config", {
action: "updated",
name: "opencode.jsonc",
path: result.path ?? undefined,
type: "config",
});
await requestContext.services.files.recordWorkspaceAudit(
workspaceId,
"config.raw.write",
result.path ?? workspaceId,
"Updated raw OpenCode config through Server V2.",
);
}
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.get(
routePaths.workspaces.reloadEvents(),
describeRoute({
tags: ["Reload"],
summary: "List reload events",
description: "Returns workspace-scoped reload events emitted by Server V2 after config/file mutations, watched changes, or reconciliation work.",
responses: withCommonErrorResponses({
200: jsonResponse("Reload events returned successfully.", reloadEventsResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const since = Number(new URL(c.req.url).searchParams.get("since") ?? "0");
const result = await requestContext.services.files.getReloadEvents(workspaceId, Number.isFinite(since) ? since : 0);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.engineReload(),
describeRoute({
tags: ["Reload"],
summary: "Reload the local engine",
description: "Restarts the local OpenCode runtime through the Server V2 runtime supervisor for the selected local workspace.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace engine reloaded successfully.", engineReloadResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const result = await requestContext.services.files.reloadWorkspaceEngine(workspaceId);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.fileSessions.base(),
describeRoute({
tags: ["Files"],
summary: "Create a workspace file session",
description: "Creates a server-owned file session for a local workspace and returns the session metadata used for file catalog and mutation routes.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace file session created successfully.", fileSessionResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { actorKey, actorKind, requestContext, workspaceId } = requireWorkspaceAccess(c);
const body = await parseJsonBody(fileSessionCreateRequestSchema, c.req.raw);
const session = await requestContext.services.files.createWorkspaceFileSession(workspaceId, { actorKey, actorKind, ...body });
return c.json(buildSuccessResponse(requestContext.requestId, session));
},
);
app.post(
routePaths.workspaces.fileSessions.renew(),
describeRoute({
tags: ["Files"],
summary: "Renew a workspace file session",
description: "Extends the lifetime of an existing workspace file session.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace file session renewed successfully.", fileSessionResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { actorKey, actorKind, requestContext, workspaceId } = requireWorkspaceAccess(c);
const params = fileSessionIdParamsSchema.parse(c.req.param());
const body = await parseJsonBody(fileSessionCreateRequestSchema, c.req.raw);
const session = await requestContext.services.files.renewWorkspaceFileSession(workspaceId, params.fileSessionId, actorKey, actorKind, body.ttlSeconds);
return c.json(buildSuccessResponse(requestContext.requestId, session));
},
);
app.delete(
routePaths.workspaces.fileSessions.byId(),
describeRoute({
tags: ["Files"],
summary: "Close a workspace file session",
description: "Closes a workspace file session and releases its temporary server-side catalog state.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace file session closed successfully.", workspaceActivationResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { actorKey, actorKind, requestContext, workspaceId } = requireWorkspaceAccess(c);
const params = fileSessionIdParamsSchema.parse(c.req.param());
await requestContext.services.files.closeWorkspaceFileSession(workspaceId, params.fileSessionId, actorKey, actorKind);
return c.json(buildSuccessResponse(requestContext.requestId, { activeWorkspaceId: workspaceId }));
},
);
app.get(
routePaths.workspaces.fileSessions.catalogSnapshot(),
describeRoute({
tags: ["Files"],
summary: "Get a file catalog snapshot",
description: "Returns the file catalog snapshot for a workspace file session.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace file catalog returned successfully.", fileCatalogSnapshotResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { actorKey, actorKind, requestContext, workspaceId } = requireWorkspaceAccess(c);
const params = fileSessionIdParamsSchema.parse(c.req.param());
const query = new URL(c.req.url).searchParams;
const result = await requestContext.services.files.listFileSessionCatalogSnapshot(workspaceId, params.fileSessionId, actorKey, actorKind, {
after: query.get("after"),
includeDirs: query.get("includeDirs") !== "false",
limit: query.get("limit"),
prefix: query.get("prefix"),
});
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.get(
routePaths.workspaces.fileSessions.catalogEvents(),
describeRoute({
tags: ["Files"],
summary: "List file session catalog events",
description: "Returns file mutation events recorded for a workspace file session since the requested cursor.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace file session events returned successfully.", fileMutationResultSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const { actorKey, actorKind, requestContext, workspaceId } = requireWorkspaceAccess(c);
const params = fileSessionIdParamsSchema.parse(c.req.param());
const result = requestContext.services.files.listFileSessionEvents(workspaceId, params.fileSessionId, actorKey, actorKind, new URL(c.req.url).searchParams.get("since"));
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.fileSessions.readBatch(),
describeRoute({
tags: ["Files"],
summary: "Read a batch of files",
description: "Reads a batch of files through the server-owned file session model.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace files read successfully.", fileBatchReadResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { actorKey, actorKind, requestContext, workspaceId } = requireWorkspaceAccess(c);
const params = fileSessionIdParamsSchema.parse(c.req.param());
const body = await parseJsonBody(fileBatchReadRequestSchema, c.req.raw);
const result = await requestContext.services.files.readWorkspaceFiles(workspaceId, params.fileSessionId, actorKey, actorKind, body.paths);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.fileSessions.writeBatch(),
describeRoute({
tags: ["Files"],
summary: "Write a batch of files",
description: "Writes a batch of files with revision-aware conflict handling through the server-owned file session model.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace files written successfully.", fileMutationResultSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { actorKey, actorKind, requestContext, workspaceId } = requireWorkspaceAccess(c);
const params = fileSessionIdParamsSchema.parse(c.req.param());
const body = await parseJsonBody(fileBatchWriteRequestSchema, c.req.raw);
const result = await requestContext.services.files.writeWorkspaceFiles(workspaceId, params.fileSessionId, actorKey, actorKind, body.writes);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.fileSessions.operations(),
describeRoute({
tags: ["Files"],
summary: "Run file operations",
description: "Runs mkdir, rename, and delete operations through the server-owned file session model.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace file operations applied successfully.", fileMutationResultSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { actorKey, actorKind, requestContext, workspaceId } = requireWorkspaceAccess(c);
const params = fileSessionIdParamsSchema.parse(c.req.param());
const body = await parseJsonBody(fileOperationsRequestSchema, c.req.raw);
const result = await requestContext.services.files.workspaceFileOperations(workspaceId, params.fileSessionId, actorKey, actorKind, body.operations);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.get(
routePaths.workspaces.simpleContent(),
describeRoute({
tags: ["Files"],
summary: "Read simple content",
description: "Reads markdown-oriented content for lighter file flows without using the full file session model.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace content returned successfully.", simpleContentResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const query = parseQuery(simpleContentQuerySchema, c.req.url);
const result = await requestContext.services.files.readSimpleContent(workspaceId, query.path);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.simpleContent(),
describeRoute({
tags: ["Files"],
summary: "Write simple content",
description: "Writes markdown-oriented content with basic conflict handling for lighter file flows.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace content written successfully.", simpleContentResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const body = await parseJsonBody(simpleContentWriteRequestSchema, c.req.raw);
const result = await requestContext.services.files.writeSimpleContent(workspaceId, body);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.get(
routePaths.workspaces.inbox.base(),
describeRoute({
tags: ["Files"],
summary: "List inbox items",
description: "Returns uploadable inbox items for the selected workspace.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace inbox returned successfully.", binaryListResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const result = await requestContext.services.files.listInbox(workspaceId);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.get(
routePaths.workspaces.inbox.byId(),
describeRoute({
tags: ["Files"],
summary: "Download inbox item",
description: "Downloads one inbox file for the selected workspace.",
responses: withCommonErrorResponses({
200: {
description: "Inbox item downloaded successfully.",
content: {
"application/octet-stream": {
schema: resolver(simpleContentResponseSchema),
},
},
},
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const result = await requestContext.services.files.downloadInboxItem(workspaceId, c.req.param("inboxId") ?? "");
return createBinaryResponse({
buffer: (result as { buffer?: Uint8Array }).buffer,
filePath: (result as { absolutePath?: string }).absolutePath,
filename: result.filename,
size: result.size,
});
},
);
app.post(
routePaths.workspaces.inbox.base(),
describeRoute({
tags: ["Files"],
summary: "Upload inbox item",
description: "Uploads one file into the managed inbox area for the selected workspace.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace inbox item uploaded successfully.", binaryUploadResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const form = await c.req.raw.formData();
const file = form.get("file");
if (!(file instanceof File)) {
throw new HTTPException(400, { message: "Form field 'file' is required." });
}
const requestedPath = (new URL(c.req.url).searchParams.get("path") ?? String(form.get("path") ?? "")).trim();
const result = await requestContext.services.files.uploadInboxItem(workspaceId, requestedPath, file);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.get(
routePaths.workspaces.artifacts.base(),
describeRoute({
tags: ["Files"],
summary: "List artifacts",
description: "Returns downloadable artifact items for the selected workspace.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace artifacts returned successfully.", binaryListResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const result = await requestContext.services.files.listArtifacts(workspaceId);
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.get(
routePaths.workspaces.artifacts.byId(),
describeRoute({
tags: ["Files"],
summary: "Download artifact",
description: "Downloads one artifact file for the selected workspace.",
responses: withCommonErrorResponses({
200: {
description: "Artifact downloaded successfully.",
content: {
"application/octet-stream": {
schema: resolver(simpleContentResponseSchema),
},
},
},
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspaceAccess(c);
const result = await requestContext.services.files.downloadArtifact(workspaceId, c.req.param("artifactId") ?? "");
return createBinaryResponse({
buffer: (result as { buffer?: Uint8Array }).buffer,
filePath: (result as { absolutePath?: string }).absolutePath,
filename: result.filename,
size: result.size,
});
},
);
}

View File

@@ -0,0 +1,18 @@
import type { Hono } from "hono";
import type { AppDependencies } from "../context/app-dependencies.js";
import type { AppBindings } from "../context/request-context.js";
import { registerFileRoutes } from "./files.js";
import { registerManagedRoutes } from "./managed.js";
import { registerRuntimeRoutes } from "./runtime.js";
import { registerSessionRoutes } from "./sessions.js";
import { registerSystemRoutes } from "./system.js";
import { registerWorkspaceRoutes } from "./workspaces.js";
export function registerRoutes(app: Hono<AppBindings>, dependencies: AppDependencies) {
registerSystemRoutes(app, dependencies);
registerRuntimeRoutes(app);
registerWorkspaceRoutes(app);
registerFileRoutes(app);
registerManagedRoutes(app);
registerSessionRoutes(app);
}

View File

@@ -0,0 +1,806 @@
import type { Context, Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { getRequestContext, type AppBindings } from "../context/request-context.js";
import { buildSuccessResponse, RouteError } from "../http.js";
import { jsonResponse, withCommonErrorResponses } from "../openapi.js";
import {
cloudSigninResponseSchema,
cloudSigninValidationResponseSchema,
cloudSigninWriteSchema,
hubSkillInstallResponseSchema,
hubSkillInstallWriteSchema,
hubSkillListResponseSchema,
managedAssignmentWriteSchema,
managedDeleteResponseSchema,
managedItemListResponseSchema,
managedItemResponseSchema,
managedItemWriteSchema,
routerBindingListResponseSchema,
routerBindingWriteSchema,
routerHealthResponseSchemaCompat,
routerIdentityListResponseSchema,
routerMutationResponseSchema,
routerSendWriteSchema,
routerSlackWriteSchema,
routerTelegramInfoResponseSchema,
routerTelegramWriteSchema,
scheduledJobDeleteResponseSchema,
scheduledJobListResponseSchema,
sharedBundleFetchResponseSchema,
sharedBundleFetchWriteSchema,
sharedBundlePublishResponseSchema,
sharedBundlePublishWriteSchema,
workspaceExportResponseSchema,
workspaceImportResponseSchema,
workspaceImportWriteSchema,
workspaceMcpListResponseSchema,
workspaceMcpWriteSchema,
workspacePluginListResponseSchema,
workspacePluginWriteSchema,
workspaceShareResponseSchema,
workspaceSkillDeleteResponseSchema,
workspaceSkillListResponseSchema,
workspaceSkillResponseSchema,
workspaceSkillWriteSchema,
} from "../schemas/managed.js";
import { routePaths } from "./route-paths.js";
function parseJsonBody<T>(schema: { parse(input: unknown): T }, request: Request) {
return request.json().then((body) => schema.parse(body));
}
function requireVisible(c: Context<AppBindings>) {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
return requestContext;
}
function requireWorkspace(c: Context<AppBindings>) {
const requestContext = requireVisible(c);
const workspaceId = c.req.param("workspaceId") ?? "";
return { requestContext, workspaceId };
}
function addCompatibilityRoute(
app: Hono<AppBindings>,
method: "DELETE" | "GET" | "PATCH" | "POST" | "PUT",
path: string,
handler: (c: Context<AppBindings>) => Promise<Response> | Response,
) {
if (method === "GET") app.get(path, handler);
if (method === "POST") app.post(path, handler);
if (method === "PUT") app.put(path, handler);
if (method === "PATCH") app.patch(path, handler);
if (method === "DELETE") app.delete(path, handler);
}
export function registerManagedRoutes(app: Hono<AppBindings>) {
for (const kind of ["mcps", "plugins", "providerConfigs", "skills"] as const) {
app.get(
routePaths.system.managed.list(kind),
describeRoute({
tags: ["Managed"],
summary: `List managed ${kind}`,
description: `Returns the server-owned ${kind} records and explicit workspace assignments.`,
responses: withCommonErrorResponses({
200: jsonResponse(`Managed ${kind} returned successfully.`, managedItemListResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, { items: requestContext.services.managed.listManaged(kind) }));
},
);
app.post(
routePaths.system.managed.list(kind),
describeRoute({
tags: ["Managed"],
summary: `Create managed ${kind.slice(0, -1)}`,
description: `Creates a server-owned ${kind.slice(0, -1)} record and optionally assigns it to workspaces.`,
responses: withCommonErrorResponses({
200: jsonResponse(`Managed ${kind.slice(0, -1)} created successfully.`, managedItemResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const body = await parseJsonBody(managedItemWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.createManaged(kind, body)));
},
);
app.put(
routePaths.system.managed.item(kind),
describeRoute({
tags: ["Managed"],
summary: `Update managed ${kind.slice(0, -1)}`,
description: `Updates a server-owned ${kind.slice(0, -1)} record.`,
responses: withCommonErrorResponses({
200: jsonResponse(`Managed ${kind.slice(0, -1)} updated successfully.`, managedItemResponseSchema),
}, { includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const itemId = c.req.param("itemId") ?? "";
const body = await parseJsonBody(managedItemWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.updateManaged(kind, itemId, body)));
},
);
app.put(
routePaths.system.managed.assignments(kind),
describeRoute({
tags: ["Managed"],
summary: `Assign managed ${kind.slice(0, -1)} to workspaces`,
description: `Replaces the workspace assignments for a server-owned managed item.`,
responses: withCommonErrorResponses({
200: jsonResponse(`Managed ${kind.slice(0, -1)} assignments updated successfully.`, managedItemResponseSchema),
}, { includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const itemId = c.req.param("itemId") ?? "";
const body = await parseJsonBody(managedAssignmentWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.updateAssignments(kind, itemId, body.workspaceIds)));
},
);
app.delete(
routePaths.system.managed.item(kind),
describeRoute({
tags: ["Managed"],
summary: `Delete managed ${kind.slice(0, -1)}`,
description: `Deletes a server-owned managed item and removes its workspace assignments.`,
responses: withCommonErrorResponses({
200: jsonResponse(`Managed ${kind.slice(0, -1)} deleted successfully.`, managedDeleteResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const itemId = c.req.param("itemId") ?? "";
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.deleteManaged(kind, itemId)));
},
);
}
app.get(
routePaths.system.cloudSignin,
describeRoute({
tags: ["Cloud"],
summary: "Read cloud signin state",
description: "Returns the server-owned cloud signin record when one is configured.",
responses: withCommonErrorResponses({
200: jsonResponse("Cloud signin returned successfully.", cloudSigninResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.getCloudSignin()));
},
);
app.put(
routePaths.system.cloudSignin,
describeRoute({
tags: ["Cloud"],
summary: "Persist cloud signin state",
description: "Stores cloud signin metadata in the server-owned database.",
responses: withCommonErrorResponses({
200: jsonResponse("Cloud signin persisted successfully.", cloudSigninResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const body = await parseJsonBody(cloudSigninWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.upsertCloudSignin(body)));
},
);
app.post(
"/system/cloud-signin/validate",
describeRoute({
tags: ["Cloud"],
summary: "Validate cloud signin state",
description: "Validates the stored cloud signin token against the configured cloud base URL.",
responses: withCommonErrorResponses({
200: jsonResponse("Cloud signin validated successfully.", cloudSigninValidationResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.validateCloudSignin()));
},
);
app.delete(
routePaths.system.cloudSignin,
describeRoute({
tags: ["Cloud"],
summary: "Clear cloud signin state",
description: "Removes the server-owned cloud signin record for the current OpenWork server.",
responses: withCommonErrorResponses({
200: jsonResponse("Cloud signin cleared successfully.", cloudSigninResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.clearCloudSignin()));
},
);
app.get(
routePaths.system.router.health,
describeRoute({
tags: ["Router"],
summary: "Read router product health",
description: "Returns the product-facing router health snapshot built from server-owned router state.",
responses: withCommonErrorResponses({
200: jsonResponse("Router product health returned successfully.", routerHealthResponseSchemaCompat),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.router.getHealth()));
},
);
app.post(
routePaths.system.router.apply,
describeRoute({
tags: ["Router"],
summary: "Apply router state",
description: "Rematerializes the effective router config and reconciles the supervised router process.",
responses: withCommonErrorResponses({
200: jsonResponse("Router state applied successfully.", routerMutationResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.router.apply()));
},
);
app.get(
routePaths.system.router.identities("telegram"),
describeRoute({
tags: ["Router"],
summary: "List Telegram identities",
description: "Returns the server-owned Telegram router identities.",
responses: withCommonErrorResponses({
200: jsonResponse("Telegram identities returned successfully.", routerIdentityListResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.router.listTelegramIdentities()));
},
);
app.post(
routePaths.system.router.identities("telegram"),
describeRoute({
tags: ["Router"],
summary: "Upsert Telegram identity",
description: "Creates or updates a server-owned Telegram router identity.",
responses: withCommonErrorResponses({
200: jsonResponse("Telegram identity upserted successfully.", routerMutationResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const body = await parseJsonBody(routerTelegramWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.router.upsertTelegramIdentity(body)));
},
);
app.get(
routePaths.system.router.identities("slack"),
describeRoute({
tags: ["Router"],
summary: "List Slack identities",
description: "Returns the server-owned Slack router identities.",
responses: withCommonErrorResponses({
200: jsonResponse("Slack identities returned successfully.", routerIdentityListResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.router.listSlackIdentities()));
},
);
app.post(
routePaths.system.router.identities("slack"),
describeRoute({
tags: ["Router"],
summary: "Upsert Slack identity",
description: "Creates or updates a server-owned Slack router identity.",
responses: withCommonErrorResponses({
200: jsonResponse("Slack identity upserted successfully.", routerMutationResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const body = await parseJsonBody(routerSlackWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.router.upsertSlackIdentity(body)));
},
);
app.get(
routePaths.system.router.telegram,
describeRoute({
tags: ["Router"],
summary: "Read Telegram router info",
description: "Returns the current Telegram identity readiness summary for the router product surface.",
responses: withCommonErrorResponses({
200: jsonResponse("Telegram router info returned successfully.", routerTelegramInfoResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.router.getTelegramInfo()));
},
);
app.get(
routePaths.system.router.bindings,
describeRoute({
tags: ["Router"],
summary: "List router bindings",
description: "Returns the effective server-owned router bindings.",
responses: withCommonErrorResponses({
200: jsonResponse("Router bindings returned successfully.", routerBindingListResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = requireVisible(c);
const url = new URL(c.req.url);
const channel = url.searchParams.get("channel")?.trim() || undefined;
const identityId = url.searchParams.get("identityId")?.trim() || undefined;
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.router.listBindings({ channel, identityId })));
},
);
app.post(
routePaths.system.router.bindings,
describeRoute({
tags: ["Router"],
summary: "Set router binding",
description: "Creates or updates a server-owned router binding.",
responses: withCommonErrorResponses({
200: jsonResponse("Router binding updated successfully.", routerMutationResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const body = await parseJsonBody(routerBindingWriteSchema, c.req.raw);
if (!body.directory?.trim()) {
throw new RouteError(400, "invalid_request", "System router binding writes require a directory.");
}
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.router.setBinding({ channel: body.channel, directory: body.directory, identityId: body.identityId, peerId: body.peerId })));
},
);
app.post(
routePaths.system.router.send,
describeRoute({
tags: ["Router"],
summary: "Send router message",
description: "Sends an outbound router message through the supervised router runtime.",
responses: withCommonErrorResponses({
200: jsonResponse("Router message delivered successfully.", routerMutationResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const body = await parseJsonBody(routerSendWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.router.sendMessage(body)));
},
);
app.get(
routePaths.workspaces.share(),
describeRoute({
tags: ["Shares"],
summary: "Read workspace share",
description: "Returns the current workspace-scoped share record for a local workspace.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace share returned successfully.", workspaceShareResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.getWorkspaceShare(workspaceId)));
},
);
app.post(
routePaths.workspaces.share(),
describeRoute({
tags: ["Shares"],
summary: "Expose workspace share",
description: "Creates or rotates a workspace-scoped share access key for a local workspace.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace share exposed successfully.", workspaceShareResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.exposeWorkspaceShare(workspaceId)));
},
);
app.delete(
routePaths.workspaces.share(),
describeRoute({
tags: ["Shares"],
summary: "Revoke workspace share",
description: "Revokes the current workspace-scoped share access key for a local workspace.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace share revoked successfully.", workspaceShareResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
(c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.revokeWorkspaceShare(workspaceId)));
},
);
app.get(
routePaths.workspaces.export(),
describeRoute({
tags: ["Bundles"],
summary: "Export workspace",
description: "Builds a portable workspace export from the server-owned config and managed-resource state.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace exported successfully.", workspaceExportResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const sensitiveMode = (new URL(c.req.url).searchParams.get("sensitive")?.trim() as "auto" | "exclude" | "include" | null) ?? "auto";
const result = await requestContext.services.managed.exportWorkspace(workspaceId, { sensitiveMode: sensitiveMode === "exclude" || sensitiveMode === "include" || sensitiveMode === "auto" ? sensitiveMode : "auto" });
if ("conflict" in result) {
return c.json({ code: "workspace_export_requires_decision", details: { warnings: result.warnings }, message: "This workspace includes sensitive config. Choose whether to exclude it or include it before exporting." }, 409);
}
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
app.post(
routePaths.workspaces.import(),
describeRoute({
tags: ["Bundles"],
summary: "Import workspace",
description: "Applies a portable workspace import through the server-owned config and managed-resource model.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace imported successfully.", workspaceImportResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(workspaceImportWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.importWorkspace(workspaceId, body)));
},
);
app.post(
"/share/bundles/publish",
describeRoute({
tags: ["Bundles"],
summary: "Publish shared bundle",
description: "Publishes a trusted shared bundle through the configured OpenWork bundle publisher.",
responses: withCommonErrorResponses({
200: jsonResponse("Shared bundle published successfully.", sharedBundlePublishResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const body = await parseJsonBody(sharedBundlePublishWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.publishSharedBundle(body)));
},
);
app.post(
"/share/bundles/fetch",
describeRoute({
tags: ["Bundles"],
summary: "Fetch shared bundle",
description: "Fetches a trusted shared bundle through the configured OpenWork bundle publisher.",
responses: withCommonErrorResponses({
200: jsonResponse("Shared bundle fetched successfully.", sharedBundleFetchResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const body = await parseJsonBody(sharedBundleFetchWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.fetchSharedBundle(body.bundleUrl, { timeoutMs: body.timeoutMs })));
},
);
app.get(
routePaths.workspaces.mcp(),
describeRoute({
tags: ["Managed"],
summary: "List workspace MCPs",
description: "Returns the effective workspace MCP records backed by server-owned managed state.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace MCPs returned successfully.", workspaceMcpListResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(buildSuccessResponse(requestContext.requestId, { items: requestContext.services.managed.listWorkspaceMcp(workspaceId) }));
},
);
app.post(
routePaths.workspaces.mcp(),
describeRoute({
tags: ["Managed"],
summary: "Add workspace MCP",
description: "Creates or updates a workspace-scoped MCP through server-owned managed state.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace MCP updated successfully.", workspaceMcpListResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(workspaceMcpWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.addWorkspaceMcp(workspaceId, body)));
},
);
app.get(
routePaths.workspaces.plugins(),
describeRoute({
tags: ["Managed"],
summary: "List workspace plugins",
description: "Returns the effective workspace plugins backed by server-owned managed state.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace plugins returned successfully.", workspacePluginListResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.managed.listWorkspacePlugins(workspaceId)));
},
);
app.post(
routePaths.workspaces.plugins(),
describeRoute({
tags: ["Managed"],
summary: "Add workspace plugin",
description: "Creates or updates a workspace-scoped plugin through server-owned managed state.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace plugins updated successfully.", workspacePluginListResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(workspacePluginWriteSchema, c.req.raw);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.addWorkspacePlugin(workspaceId, body.spec)));
},
);
app.get(
routePaths.workspaces.scheduler.base(),
describeRoute({
tags: ["Managed"],
summary: "List scheduled jobs",
description: "Returns the scheduled jobs for a local workspace via the desktop scheduler store.",
responses: withCommonErrorResponses({
200: jsonResponse("Scheduled jobs returned successfully.", scheduledJobListResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.scheduler.listWorkspaceJobs(workspaceId)));
},
);
app.delete(
routePaths.workspaces.scheduler.byName(),
describeRoute({
tags: ["Managed"],
summary: "Delete scheduled job",
description: "Deletes a scheduled job for a local workspace via the desktop scheduler store.",
responses: withCommonErrorResponses({
200: jsonResponse("Scheduled job deleted successfully.", scheduledJobDeleteResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.scheduler.deleteWorkspaceJob(workspaceId, c.req.param("name") ?? "")));
},
);
app.get(
routePaths.workspaces.skills(),
describeRoute({
tags: ["Managed"],
summary: "List workspace skills",
description: "Returns the effective workspace skills backed by server-owned managed state.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace skills returned successfully.", workspaceSkillListResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(buildSuccessResponse(requestContext.requestId, { items: requestContext.services.managed.listWorkspaceSkills(workspaceId) }));
},
);
app.post(
routePaths.workspaces.skills(),
describeRoute({
tags: ["Managed"],
summary: "Upsert workspace skill",
description: "Creates or updates a workspace-scoped skill through server-owned managed state.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace skill updated successfully.", workspaceSkillResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(workspaceSkillWriteSchema, c.req.raw);
const item = await requestContext.services.managed.upsertWorkspaceSkill(workspaceId, body);
return c.json(buildSuccessResponse(requestContext.requestId, { content: requestContext.services.managed.getWorkspaceSkill(workspaceId, item.name).content, item }));
},
);
app.get(
routePaths.workspaces.hubSkills,
describeRoute({
tags: ["Managed"],
summary: "List hub skills",
description: "Returns the available Skill Hub catalog backed by trusted GitHub sources.",
responses: withCommonErrorResponses({
200: jsonResponse("Hub skills returned successfully.", hubSkillListResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const requestContext = requireVisible(c);
const url = new URL(c.req.url);
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.listHubSkills({ owner: url.searchParams.get("owner") ?? undefined, ref: url.searchParams.get("ref") ?? undefined, repo: url.searchParams.get("repo") ?? undefined })));
},
);
app.post(
`${routePaths.workspaces.skills()}/hub/:name`,
describeRoute({
tags: ["Managed"],
summary: "Install hub skill",
description: "Installs a trusted Skill Hub skill into server-owned managed state for a workspace.",
responses: withCommonErrorResponses({
200: jsonResponse("Hub skill installed successfully.", hubSkillInstallResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(hubSkillInstallWriteSchema, c.req.raw).catch(() => ({} as any));
return c.json(buildSuccessResponse(requestContext.requestId, await requestContext.services.managed.installHubSkill(workspaceId, { name: c.req.param("name") ?? "", overwrite: body.overwrite, repo: body.repo })));
},
);
addCompatibilityRoute(app, "GET", "/workspace/:workspaceId/mcp", (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json({ items: requestContext.services.managed.listWorkspaceMcp(workspaceId) });
});
addCompatibilityRoute(app, "POST", "/workspace/:workspaceId/mcp", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(workspaceMcpWriteSchema, c.req.raw);
return c.json(await requestContext.services.managed.addWorkspaceMcp(workspaceId, body));
});
addCompatibilityRoute(app, "DELETE", "/workspace/:workspaceId/mcp/:name", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(await requestContext.services.managed.removeWorkspaceMcp(workspaceId, c.req.param("name") ?? ""));
});
addCompatibilityRoute(app, "DELETE", "/workspace/:workspaceId/mcp/:name/auth", (c) => c.json({ ok: true }));
addCompatibilityRoute(app, "GET", "/workspace/:workspaceId/plugins", (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(requestContext.services.managed.listWorkspacePlugins(workspaceId));
});
addCompatibilityRoute(app, "POST", "/workspace/:workspaceId/plugins", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(workspacePluginWriteSchema, c.req.raw);
return c.json(await requestContext.services.managed.addWorkspacePlugin(workspaceId, body.spec));
});
addCompatibilityRoute(app, "DELETE", "/workspace/:workspaceId/plugins/:name", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(await requestContext.services.managed.removeWorkspacePlugin(workspaceId, c.req.param("name") ?? ""));
});
addCompatibilityRoute(app, "GET", "/workspace/:workspaceId/skills", (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json({ items: requestContext.services.managed.listWorkspaceSkills(workspaceId) });
});
addCompatibilityRoute(app, "GET", "/workspace/:workspaceId/scheduler/jobs", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(await requestContext.services.scheduler.listWorkspaceJobs(workspaceId));
});
addCompatibilityRoute(app, "DELETE", "/workspace/:workspaceId/scheduler/jobs/:name", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(await requestContext.services.scheduler.deleteWorkspaceJob(workspaceId, c.req.param("name") ?? ""));
});
addCompatibilityRoute(app, "GET", "/workspace/:workspaceId/skills/:name", (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(requestContext.services.managed.getWorkspaceSkill(workspaceId, c.req.param("name") ?? ""));
});
addCompatibilityRoute(app, "POST", "/workspace/:workspaceId/skills", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(workspaceSkillWriteSchema, c.req.raw);
return c.json(await requestContext.services.managed.upsertWorkspaceSkill(workspaceId, body));
});
addCompatibilityRoute(app, "DELETE", "/workspace/:workspaceId/skills/:name", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(await requestContext.services.managed.deleteWorkspaceSkill(workspaceId, c.req.param("name") ?? ""));
});
addCompatibilityRoute(app, "GET", "/hub/skills", async (c) => {
const requestContext = requireVisible(c);
const url = new URL(c.req.url);
return c.json(await requestContext.services.managed.listHubSkills({ owner: url.searchParams.get("owner") ?? undefined, ref: url.searchParams.get("ref") ?? undefined, repo: url.searchParams.get("repo") ?? undefined }));
});
addCompatibilityRoute(app, "POST", "/workspace/:workspaceId/skills/hub/:name", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const body = await parseJsonBody(hubSkillInstallWriteSchema, c.req.raw).catch(() => ({} as any));
return c.json({ ok: true, ...(await requestContext.services.managed.installHubSkill(workspaceId, { name: c.req.param("name") ?? "", overwrite: body.overwrite, repo: body.repo })) });
});
const workspaceRouterPaths = [
"/workspace/:workspaceId/opencode-router",
routePaths.workspaces.router.base(),
];
for (const basePath of workspaceRouterPaths) {
addCompatibilityRoute(app, "GET", `${basePath}/health`, (c) => {
requireWorkspace(c);
return c.json(getRequestContext(c).services.router.getHealth());
});
addCompatibilityRoute(app, "POST", `${basePath}/telegram-token`, async (c) => c.json(await getRequestContext(c).services.router.setTelegramToken((await parseJsonBody(routerTelegramWriteSchema, c.req.raw)).token)));
addCompatibilityRoute(app, "GET", `${basePath}/telegram`, async (c) => c.json(await getRequestContext(c).services.router.getTelegramInfo()));
addCompatibilityRoute(app, "POST", `${basePath}/telegram-enabled`, async (c) => {
const body = await c.req.json();
return c.json(await getRequestContext(c).services.router.setTelegramEnabled(body.enabled === true, { clearToken: body.clearToken === true }));
});
addCompatibilityRoute(app, "GET", `${basePath}/identities/telegram`, (c) => c.json(getRequestContext(c).services.router.listTelegramIdentities()));
addCompatibilityRoute(app, "POST", `${basePath}/identities/telegram`, async (c) => c.json(await getRequestContext(c).services.router.upsertTelegramIdentity(await parseJsonBody(routerTelegramWriteSchema, c.req.raw))));
addCompatibilityRoute(app, "DELETE", `${basePath}/identities/telegram/:identityId`, async (c) => c.json(await getRequestContext(c).services.router.deleteTelegramIdentity(c.req.param("identityId") ?? "")));
addCompatibilityRoute(app, "GET", `${basePath}/identities/slack`, (c) => c.json(getRequestContext(c).services.router.listSlackIdentities()));
addCompatibilityRoute(app, "POST", `${basePath}/identities/slack`, async (c) => c.json(await getRequestContext(c).services.router.upsertSlackIdentity(await parseJsonBody(routerSlackWriteSchema, c.req.raw))));
addCompatibilityRoute(app, "DELETE", `${basePath}/identities/slack/:identityId`, async (c) => c.json(await getRequestContext(c).services.router.deleteSlackIdentity(c.req.param("identityId") ?? "")));
addCompatibilityRoute(app, "POST", `${basePath}/slack-tokens`, async (c) => {
const body = await parseJsonBody(routerSlackWriteSchema, c.req.raw);
return c.json(await getRequestContext(c).services.router.setSlackTokens(body.botToken, body.appToken));
});
addCompatibilityRoute(app, "GET", `${basePath}/bindings`, (c) => {
const requestContext = getRequestContext(c);
const url = new URL(c.req.url);
return c.json(requestContext.services.router.listBindings({ channel: url.searchParams.get("channel") ?? undefined, identityId: url.searchParams.get("identityId") ?? undefined }));
});
addCompatibilityRoute(app, "POST", `${basePath}/bindings`, async (c) => {
const requestContext = getRequestContext(c);
const body = await parseJsonBody(routerBindingWriteSchema, c.req.raw);
const workspaceId = c.req.param("workspaceId") ?? "";
const workspace = requestContext.services.workspaceRegistry.getById(workspaceId, { includeHidden: true });
const directory = body.directory?.trim() || workspace?.backend.local?.dataDir || "";
return c.json(await requestContext.services.router.setBinding({ channel: body.channel, directory, identityId: body.identityId, peerId: body.peerId }));
});
addCompatibilityRoute(app, "POST", `${basePath}/send`, async (c) => c.json(await getRequestContext(c).services.router.sendMessage(await parseJsonBody(routerSendWriteSchema, c.req.raw))));
}
addCompatibilityRoute(app, "GET", "/workspace/:workspaceId/export", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
const sensitiveMode = (new URL(c.req.url).searchParams.get("sensitive")?.trim() as "auto" | "exclude" | "include" | null) ?? "auto";
const result = await requestContext.services.managed.exportWorkspace(workspaceId, { sensitiveMode: sensitiveMode === "exclude" || sensitiveMode === "include" || sensitiveMode === "auto" ? sensitiveMode : "auto" });
if ("conflict" in result) {
return c.json({ code: "workspace_export_requires_decision", details: { warnings: result.warnings }, message: "This workspace includes sensitive config. Choose whether to exclude it or include it before exporting." }, 409);
}
return c.json(result);
});
addCompatibilityRoute(app, "POST", "/workspace/:workspaceId/import", async (c) => {
const { requestContext, workspaceId } = requireWorkspace(c);
return c.json(await requestContext.services.managed.importWorkspace(workspaceId, await c.req.json()));
});
}

View File

@@ -0,0 +1,187 @@
const WORKSPACE_ID_PARAMETER = ":workspaceId";
export const routeNamespaces = {
root: "/",
openapi: "/openapi.json",
system: "/system",
workspaces: "/workspaces",
} as const;
export function workspaceRoutePath(workspaceId: string = WORKSPACE_ID_PARAMETER) {
return `${routeNamespaces.workspaces}/${workspaceId}`;
}
function workspaceSessionsBasePath(workspaceId: string = WORKSPACE_ID_PARAMETER) {
return `${workspaceRoutePath(workspaceId)}/sessions`;
}
function workspaceSessionPath(sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) {
return `${workspaceSessionsBasePath(workspaceId)}/${sessionId}`;
}
function workspaceSessionMessagesPath(sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) {
return `${workspaceSessionPath(sessionId, workspaceId)}/messages`;
}
function workspaceFileSessionsBasePath(workspaceId: string = WORKSPACE_ID_PARAMETER) {
return `${workspaceRoutePath(workspaceId)}/file-sessions`;
}
function workspaceFileSessionPath(fileSessionId: string = ":fileSessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) {
return `${workspaceFileSessionsBasePath(workspaceId)}/${fileSessionId}`;
}
function workspaceSessionMessagePath(
messageId: string = ":messageId",
sessionId: string = ":sessionId",
workspaceId: string = WORKSPACE_ID_PARAMETER,
) {
return `${workspaceSessionMessagesPath(sessionId, workspaceId)}/${messageId}`;
}
export const workspaceResourcePattern = workspaceRoutePath();
export const routePaths = {
root: routeNamespaces.root,
openapiDocument: routeNamespaces.openapi,
system: {
base: routeNamespaces.system,
capabilities: `${routeNamespaces.system}/capabilities`,
cloudSignin: `${routeNamespaces.system}/cloud-signin`,
health: `${routeNamespaces.system}/health`,
managed: {
item: (kind: string, itemId: string = ":itemId") => `${routeNamespaces.system}/managed/${kind}/${itemId}`,
list: (kind: string) => `${routeNamespaces.system}/managed/${kind}`,
assignments: (kind: string, itemId: string = ":itemId") => `${routeNamespaces.system}/managed/${kind}/${itemId}/assignments`,
},
meta: `${routeNamespaces.system}/meta`,
opencodeHealth: `${routeNamespaces.system}/opencode/health`,
router: {
apply: `${routeNamespaces.system}/router/apply`,
bindings: `${routeNamespaces.system}/router/bindings`,
health: `${routeNamespaces.system}/router/product-health`,
identities: (kind: string) => `${routeNamespaces.system}/router/identities/${kind}`,
telegram: `${routeNamespaces.system}/router/telegram`,
send: `${routeNamespaces.system}/router/send`,
},
routerHealth: `${routeNamespaces.system}/router/health`,
servers: `${routeNamespaces.system}/servers`,
serverById: (serverId: string = ":serverId") => `${routeNamespaces.system}/servers/${serverId}`,
serverConnect: `${routeNamespaces.system}/servers/connect`,
serverSync: (serverId: string = ":serverId") => `${routeNamespaces.system}/servers/${serverId}/sync`,
status: `${routeNamespaces.system}/status`,
runtime: {
upgrade: `${routeNamespaces.system}/runtime/upgrade`,
summary: `${routeNamespaces.system}/runtime/summary`,
versions: `${routeNamespaces.system}/runtime/versions`,
},
},
workspaces: {
base: routeNamespaces.workspaces,
createLocal: `${routeNamespaces.workspaces}/local`,
byId: workspaceRoutePath,
dispose: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/dispose`,
events: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/events`,
activate: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/activate`,
displayName: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/display-name`,
artifacts: {
base: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/artifacts`,
byId: (artifactId: string = ":artifactId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceRoutePath(workspaceId)}/artifacts/${artifactId}`,
},
config: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/config`,
engineReload: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/engine/reload`,
export: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/export`,
fileSessions: {
base: workspaceFileSessionsBasePath,
byId: workspaceFileSessionPath,
renew: (fileSessionId: string = ":fileSessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceFileSessionPath(fileSessionId, workspaceId)}/renew`,
catalogSnapshot: (fileSessionId: string = ":fileSessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceFileSessionPath(fileSessionId, workspaceId)}/catalog/snapshot`,
catalogEvents: (fileSessionId: string = ":fileSessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceFileSessionPath(fileSessionId, workspaceId)}/catalog/events`,
readBatch: (fileSessionId: string = ":fileSessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceFileSessionPath(fileSessionId, workspaceId)}/read-batch`,
writeBatch: (fileSessionId: string = ":fileSessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceFileSessionPath(fileSessionId, workspaceId)}/write-batch`,
operations: (fileSessionId: string = ":fileSessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceFileSessionPath(fileSessionId, workspaceId)}/operations`,
},
inbox: {
base: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/inbox`,
byId: (inboxId: string = ":inboxId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceRoutePath(workspaceId)}/inbox/${inboxId}`,
},
import: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/import`,
mcp: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/mcp`,
plugins: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/plugins`,
rawOpencodeConfig: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/config/opencode-raw`,
reloadEvents: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/reload-events`,
router: {
base: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router`,
bindings: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/bindings`,
health: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/health`,
identities: {
slack: (workspaceId: string = WORKSPACE_ID_PARAMETER, identityId: string = ":identityId") => `${workspaceRoutePath(workspaceId)}/opencode-router/identities/slack/${identityId}`,
slackBase: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/identities/slack`,
telegram: (workspaceId: string = WORKSPACE_ID_PARAMETER, identityId: string = ":identityId") => `${workspaceRoutePath(workspaceId)}/opencode-router/identities/telegram/${identityId}`,
telegramBase: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/identities/telegram`,
},
send: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/send`,
slackTokens: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/slack-tokens`,
telegram: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/telegram`,
telegramEnabled: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/telegram-enabled`,
telegramToken: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/opencode-router/telegram-token`,
},
share: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/share`,
sessions: {
base: workspaceSessionsBasePath,
byId: workspaceSessionPath,
statuses: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceSessionsBasePath(workspaceId)}/status`,
messages: {
base: workspaceSessionMessagesPath,
byId: workspaceSessionMessagePath,
partById: (
partId: string = ":partId",
messageId: string = ":messageId",
sessionId: string = ":sessionId",
workspaceId: string = WORKSPACE_ID_PARAMETER,
) => `${workspaceSessionMessagePath(messageId, sessionId, workspaceId)}/parts/${partId}`,
},
promptAsync: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/prompt_async`,
command: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/command`,
shell: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/shell`,
todo: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/todo`,
status: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/status`,
snapshot: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/snapshot`,
init: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/init`,
fork: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/fork`,
abort: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/abort`,
share: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/share`,
summarize: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/summarize`,
revert: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/revert`,
unrevert: (sessionId: string = ":sessionId", workspaceId: string = WORKSPACE_ID_PARAMETER) =>
`${workspaceSessionPath(sessionId, workspaceId)}/unrevert`,
},
scheduler: {
base: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/scheduler/jobs`,
byName: (name: string = ":name", workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/scheduler/jobs/${name}`,
},
skills: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/skills`,
hubSkills: "/hub/skills",
simpleContent: (workspaceId: string = WORKSPACE_ID_PARAMETER) => `${workspaceRoutePath(workspaceId)}/files/content`,
},
} as const;

View File

@@ -0,0 +1,101 @@
import type { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { getRequestContext, type AppBindings } from "../context/request-context.js";
import { buildSuccessResponse } from "../http.js";
import { jsonResponse, withCommonErrorResponses } from "../openapi.js";
import {
opencodeHealthResponseSchema,
routerHealthResponseSchema,
runtimeSummaryResponseSchema,
runtimeUpgradeResponseSchema,
runtimeVersionsResponseSchema,
} from "../schemas/runtime.js";
import { routePaths } from "./route-paths.js";
export function registerRuntimeRoutes(app: Hono<AppBindings>) {
app.get(
routePaths.system.opencodeHealth,
describeRoute({
tags: ["Runtime"],
summary: "Get OpenCode health",
description: "Returns the server-owned OpenCode runtime health, version, URL, and recent diagnostics.",
responses: withCommonErrorResponses({
200: jsonResponse("OpenCode runtime health returned successfully.", opencodeHealthResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.runtime.getOpencodeHealth()));
},
);
app.get(
routePaths.system.routerHealth,
describeRoute({
tags: ["Runtime"],
summary: "Get router health",
description: "Returns the server-owned opencode-router health, enablement decision, and recent diagnostics.",
responses: withCommonErrorResponses({
200: jsonResponse("Router runtime health returned successfully.", routerHealthResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.runtime.getRouterHealth()));
},
);
app.get(
routePaths.system.runtime.summary,
describeRoute({
tags: ["Runtime"],
summary: "Get runtime summary",
description: "Returns the current runtime supervision summary, manifest, restart policy, and child process state.",
responses: withCommonErrorResponses({
200: jsonResponse("Runtime summary returned successfully.", runtimeSummaryResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.runtime.getRuntimeSummary()));
},
);
app.get(
routePaths.system.runtime.versions,
describeRoute({
tags: ["Runtime"],
summary: "Get runtime versions",
description: "Returns the active and pinned runtime versions that Server V2 resolved for OpenCode and opencode-router.",
responses: withCommonErrorResponses({
200: jsonResponse("Runtime versions returned successfully.", runtimeVersionsResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.runtime.getRuntimeVersions()));
},
);
app.post(
routePaths.system.runtime.upgrade,
describeRoute({
tags: ["Runtime"],
summary: "Upgrade runtime assets",
description: "Re-resolves the pinned runtime bundle through Server V2, restarts managed children, and returns the resulting runtime summary plus upgrade state.",
responses: withCommonErrorResponses({
200: jsonResponse("Runtime upgraded successfully.", runtimeUpgradeResponseSchema),
}, { includeForbidden: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireHost(requestContext.actor);
const result = await requestContext.services.runtime.upgradeRuntime();
return c.json(buildSuccessResponse(requestContext.requestId, result));
},
);
}

View File

@@ -0,0 +1,531 @@
import type { Context, Hono } from "hono";
import { describeRoute, resolver } from "hono-openapi";
import { HTTPException } from "hono/http-exception";
import { TextEncoder } from "node:util";
import { getRequestContext, type AppBindings } from "../context/request-context.js";
import { buildSuccessResponse } from "../http.js";
import { jsonResponse, withCommonErrorResponses } from "../openapi.js";
import {
acceptedActionResponseSchema,
commandRequestSchema,
deletedActionResponseSchema,
messageIdParamsSchema,
messageListResponseSchema,
messagePartParamsSchema,
messagePartUpdateRequestSchema,
messageResponseSchema,
messageSendRequestSchema,
promptAsyncRequestSchema,
revertRequestSchema,
sessionCreateRequestSchema,
sessionForkRequestSchema,
sessionIdParamsSchema,
sessionListQuerySchema,
sessionListResponseSchema,
sessionMessagesQuerySchema,
sessionResponseSchema,
sessionSnapshotResponseSchema,
sessionStatusResponseSchema,
sessionStatusesResponseSchema,
sessionSummarizeRequestSchema,
sessionTodoListResponseSchema,
sessionUpdateRequestSchema,
shellRequestSchema,
workspaceEventSchema,
} from "../schemas/sessions.js";
import { routePaths } from "./route-paths.js";
function parseQuery<T>(schema: { parse(input: unknown): T }, url: string) {
const searchParams = new URL(url).searchParams;
const query: Record<string, string> = {};
for (const [key, value] of searchParams.entries()) {
query[key] = value;
}
return schema.parse(query);
}
async function parseBody<T>(schema: { parse(input: unknown): T }, request: Request) {
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("application/json")) {
return schema.parse({});
}
return schema.parse(await request.json());
}
function requireReadableWorkspace(c: Context<AppBindings>) {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
const workspaceId = c.req.param("workspaceId") ?? "";
const workspace = requestContext.services.workspaceRegistry.getById(
workspaceId,
{ includeHidden: requestContext.actor.kind === "host" },
);
if (!workspace) {
throw new HTTPException(404, { message: `Workspace not found: ${workspaceId}` });
}
return { requestContext, workspaceId };
}
function createSseResponse(stream: AsyncIterable<unknown>, signal?: AbortSignal) {
const encoder = new TextEncoder();
let eventId = 0;
const iterator = stream[Symbol.asyncIterator]();
return new Response(new ReadableStream({
async start(controller) {
try {
while (true) {
const next = await iterator.next();
if (next.done) {
controller.close();
return;
}
eventId += 1;
controller.enqueue(encoder.encode(`id: ${eventId}\ndata: ${JSON.stringify(next.value)}\n\n`));
}
} catch (error) {
controller.error(error);
}
},
async cancel() {
if (typeof iterator.return === "function") {
await iterator.return();
}
},
}), {
headers: {
"Cache-Control": "no-cache, no-transform",
Connection: "keep-alive",
"Content-Type": "text/event-stream",
},
});
}
export function registerSessionRoutes(app: Hono<AppBindings>) {
app.get(
routePaths.workspaces.sessions.base(),
describeRoute({
tags: ["Sessions"],
summary: "List workspace sessions",
description: "Returns the normalized session inventory for the resolved local OpenCode or remote OpenWork workspace backend.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace sessions returned successfully.", sessionListResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const query = parseQuery(sessionListQuerySchema, c.req.url);
const items = await requestContext.services.sessions.listSessions(workspaceId, query);
return c.json(buildSuccessResponse(requestContext.requestId, { items }));
},
);
app.get(
routePaths.workspaces.sessions.statuses(),
describeRoute({
tags: ["Sessions"],
summary: "List workspace session statuses",
description: "Returns the latest normalized session status map for the resolved workspace backend.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session statuses returned successfully.", sessionStatusesResponseSchema),
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const items = await requestContext.services.sessions.listSessionStatuses(workspaceId);
return c.json(buildSuccessResponse(requestContext.requestId, { items }));
},
);
app.post(
routePaths.workspaces.sessions.base(),
describeRoute({
tags: ["Sessions"],
summary: "Create a workspace session",
description: "Creates a new session inside the resolved workspace backend.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session created successfully.", sessionResponseSchema),
}, { includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const body = await parseBody(sessionCreateRequestSchema, c.req.raw);
const session = await requestContext.services.sessions.createSession(workspaceId, body as Record<string, unknown>);
return c.json(buildSuccessResponse(requestContext.requestId, session));
},
);
app.get(
routePaths.workspaces.sessions.byId(),
describeRoute({
tags: ["Sessions"],
summary: "Get workspace session detail",
description: "Returns one normalized session by workspace and session identifier.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session returned successfully.", sessionResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
const session = await requestContext.services.sessions.getSession(workspaceId, params.sessionId);
return c.json(buildSuccessResponse(requestContext.requestId, session));
},
);
app.patch(
routePaths.workspaces.sessions.byId(),
describeRoute({
tags: ["Sessions"],
summary: "Update a workspace session",
description: "Updates a normalized session inside the resolved workspace backend.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session updated successfully.", sessionResponseSchema),
}, { includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
const body = await parseBody(sessionUpdateRequestSchema, c.req.raw);
const session = await requestContext.services.sessions.updateSession(workspaceId, params.sessionId, body as Record<string, unknown>);
return c.json(buildSuccessResponse(requestContext.requestId, session));
},
);
app.delete(
routePaths.workspaces.sessions.byId(),
describeRoute({
tags: ["Sessions"],
summary: "Delete a workspace session",
description: "Deletes a session inside the resolved workspace backend.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session deleted successfully.", deletedActionResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
await requestContext.services.sessions.deleteSession(workspaceId, params.sessionId);
return c.json(buildSuccessResponse(requestContext.requestId, { deleted: true }));
},
);
app.get(
routePaths.workspaces.sessions.status(),
describeRoute({
tags: ["Sessions"],
summary: "Get one session status",
description: "Returns the normalized status for a single session.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session status returned successfully.", sessionStatusResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
const status = await requestContext.services.sessions.getSessionStatus(workspaceId, params.sessionId);
return c.json(buildSuccessResponse(requestContext.requestId, status));
},
);
app.get(
routePaths.workspaces.sessions.todo(),
describeRoute({
tags: ["Sessions"],
summary: "List one session todos",
description: "Returns the normalized todo list for a single session.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session todos returned successfully.", sessionTodoListResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
const items = await requestContext.services.sessions.listTodos(workspaceId, params.sessionId);
return c.json(buildSuccessResponse(requestContext.requestId, { items }));
},
);
app.get(
routePaths.workspaces.sessions.snapshot(),
describeRoute({
tags: ["Sessions"],
summary: "Get one session snapshot",
description: "Returns session detail, messages, todos, and status in one normalized payload for detail surfaces.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session snapshot returned successfully.", sessionSnapshotResponseSchema),
}, { includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
const query = parseQuery(sessionMessagesQuerySchema, c.req.url);
const snapshot = await requestContext.services.sessions.getSessionSnapshot(workspaceId, params.sessionId, query);
return c.json(buildSuccessResponse(requestContext.requestId, snapshot));
},
);
app.get(
routePaths.workspaces.sessions.messages.base(),
describeRoute({
tags: ["Messages"],
summary: "List session messages",
description: "Returns the normalized message list for a single session.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session messages returned successfully.", messageListResponseSchema),
}, { includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
const query = parseQuery(sessionMessagesQuerySchema, c.req.url);
const items = await requestContext.services.sessions.listMessages(workspaceId, params.sessionId, query);
return c.json(buildSuccessResponse(requestContext.requestId, { items }));
},
);
app.get(
routePaths.workspaces.sessions.messages.byId(),
describeRoute({
tags: ["Messages"],
summary: "Get one session message",
description: "Returns one normalized message by workspace, session, and message identifier.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session message returned successfully.", messageResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = messageIdParamsSchema.parse(c.req.param());
const message = await requestContext.services.sessions.getMessage(workspaceId, params.sessionId, params.messageId);
return c.json(buildSuccessResponse(requestContext.requestId, message));
},
);
app.post(
routePaths.workspaces.sessions.messages.base(),
describeRoute({
tags: ["Messages"],
summary: "Send a session message",
description: "Sends a normalized message payload to the resolved workspace backend.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session message accepted successfully.", acceptedActionResponseSchema),
}, { includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
const body = await parseBody(messageSendRequestSchema, c.req.raw);
await requestContext.services.sessions.sendMessage(workspaceId, params.sessionId, body as Record<string, unknown>);
return c.json(buildSuccessResponse(requestContext.requestId, { accepted: true }));
},
);
app.delete(
routePaths.workspaces.sessions.messages.byId(),
describeRoute({
tags: ["Messages"],
summary: "Delete a session message",
description: "Deletes one message inside the resolved session backend.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session message deleted successfully.", deletedActionResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = messageIdParamsSchema.parse(c.req.param());
await requestContext.services.sessions.deleteMessage(workspaceId, params.sessionId, params.messageId);
return c.json(buildSuccessResponse(requestContext.requestId, { deleted: true }));
},
);
app.patch(
routePaths.workspaces.sessions.messages.partById(),
describeRoute({
tags: ["Messages"],
summary: "Update a session message part",
description: "Updates one message part inside the resolved session backend where the upstream backend supports it.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session message part updated successfully.", acceptedActionResponseSchema),
}, { includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c as never);
const params = messagePartParamsSchema.parse(c.req.param());
const body = await parseBody(messagePartUpdateRequestSchema, c.req.raw);
await requestContext.services.sessions.updateMessagePart(
workspaceId,
params.sessionId,
params.messageId,
params.partId,
body as Record<string, unknown>,
);
return c.json(buildSuccessResponse(requestContext.requestId, { accepted: true }));
},
);
app.delete(
routePaths.workspaces.sessions.messages.partById(),
describeRoute({
tags: ["Messages"],
summary: "Delete a session message part",
description: "Deletes one message part inside the resolved session backend where the upstream backend supports it.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace session message part deleted successfully.", deletedActionResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c as never);
const params = messagePartParamsSchema.parse(c.req.param());
await requestContext.services.sessions.deleteMessagePart(workspaceId, params.sessionId, params.messageId, params.partId);
return c.json(buildSuccessResponse(requestContext.requestId, { deleted: true }));
},
);
const actionRoute = (
path: string,
summary: string,
description: string,
handler: (input: {
body: Record<string, unknown>;
requestContext: ReturnType<typeof getRequestContext>;
sessionId: string;
workspaceId: string;
}) => Promise<unknown>,
bodySchema?: { parse(input: unknown): Record<string, unknown> },
responseSchema: any = acceptedActionResponseSchema,
) => {
app.post(
path,
describeRoute({
tags: ["Sessions"],
summary,
description,
responses: withCommonErrorResponses({
200: jsonResponse(`${summary} completed successfully.`, responseSchema),
}, { includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
const body = bodySchema ? await parseBody(bodySchema, c.req.raw) : {};
const result = await handler({ body, requestContext, sessionId: params.sessionId, workspaceId });
return c.json(buildSuccessResponse(requestContext.requestId, result ?? { accepted: true }));
},
);
};
actionRoute(
routePaths.workspaces.sessions.init(),
"Initialize a session",
"Runs the upstream session init primitive through the workspace-first API.",
({ body, requestContext, sessionId, workspaceId }) => requestContext.services.sessions.initSession(workspaceId, sessionId, body),
);
actionRoute(
routePaths.workspaces.sessions.fork(),
"Fork a session",
"Forks a session inside the resolved workspace backend.",
({ body, requestContext, sessionId, workspaceId }) => requestContext.services.sessions.forkSession(workspaceId, sessionId, body),
sessionForkRequestSchema as never,
sessionResponseSchema,
);
actionRoute(
routePaths.workspaces.sessions.abort(),
"Abort a session",
"Aborts an in-flight session run through the workspace-first API.",
({ requestContext, sessionId, workspaceId }) => requestContext.services.sessions.abortSession(workspaceId, sessionId),
);
actionRoute(
routePaths.workspaces.sessions.share(),
"Share a session",
"Calls the upstream share primitive when the resolved backend supports it.",
({ requestContext, sessionId, workspaceId }) => requestContext.services.sessions.shareSession(workspaceId, sessionId),
);
app.delete(
routePaths.workspaces.sessions.share(),
describeRoute({
tags: ["Sessions"],
summary: "Unshare a session",
description: "Calls the upstream unshare primitive when the resolved backend supports it.",
responses: withCommonErrorResponses({
200: jsonResponse("Session unshared successfully.", acceptedActionResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const params = sessionIdParamsSchema.parse(c.req.param());
await requestContext.services.sessions.unshareSession(workspaceId, params.sessionId);
return c.json(buildSuccessResponse(requestContext.requestId, { accepted: true }));
},
);
actionRoute(
routePaths.workspaces.sessions.summarize(),
"Summarize a session",
"Runs the upstream summarize or compact primitive for the selected session.",
({ body, requestContext, sessionId, workspaceId }) => requestContext.services.sessions.summarizeSession(workspaceId, sessionId, body),
sessionSummarizeRequestSchema as never,
);
actionRoute(
routePaths.workspaces.sessions.promptAsync(),
"Send an async prompt",
"Sends a prompt_async request to the resolved session backend for composer flows.",
({ body, requestContext, sessionId, workspaceId }) => requestContext.services.sessions.promptAsync(workspaceId, sessionId, body),
promptAsyncRequestSchema as never,
);
actionRoute(
routePaths.workspaces.sessions.command(),
"Run a session command",
"Runs a slash-command style session command through the workspace-first API.",
({ body, requestContext, sessionId, workspaceId }) => requestContext.services.sessions.command(workspaceId, sessionId, body),
commandRequestSchema as never,
);
actionRoute(
routePaths.workspaces.sessions.shell(),
"Run a session shell command",
"Runs a shell command inside the resolved session backend.",
({ body, requestContext, sessionId, workspaceId }) => requestContext.services.sessions.shell(workspaceId, sessionId, body),
shellRequestSchema as never,
);
actionRoute(
routePaths.workspaces.sessions.revert(),
"Revert session history",
"Reverts a session to the requested message boundary.",
({ body, requestContext, sessionId, workspaceId }) =>
requestContext.services.sessions.revert(workspaceId, sessionId, body as { messageID: string }),
revertRequestSchema as never,
sessionResponseSchema,
);
actionRoute(
routePaths.workspaces.sessions.unrevert(),
"Restore reverted session history",
"Restores previously reverted session history.",
({ requestContext, sessionId, workspaceId }) => requestContext.services.sessions.unrevert(workspaceId, sessionId),
undefined,
sessionResponseSchema,
);
app.get(
routePaths.workspaces.events(),
describeRoute({
tags: ["Sessions"],
summary: "Stream workspace events",
description: "Streams normalized session and message events for one workspace over Server-Sent Events.",
responses: withCommonErrorResponses({
200: {
description: "Workspace events streamed successfully.",
content: {
"text/event-stream": {
schema: resolver(workspaceEventSchema),
},
},
},
}, { includeUnauthorized: true }),
}),
async (c) => {
const { requestContext, workspaceId } = requireReadableWorkspace(c);
const abort = new AbortController();
c.req.raw.signal.addEventListener("abort", () => abort.abort(), { once: true });
const stream = await requestContext.services.sessions.streamWorkspaceEvents(workspaceId, abort.signal);
return createSseResponse(stream, abort.signal);
},
);
}

View File

@@ -0,0 +1,319 @@
import type { Hono } from "hono";
import { describeRoute, openAPIRouteHandler } from "hono-openapi";
import { HTTPException } from "hono/http-exception";
import type { AppDependencies } from "../context/app-dependencies.js";
import { getRequestContext, type AppBindings } from "../context/request-context.js";
import { buildErrorResponse, buildSuccessResponse, RouteError } from "../http.js";
import { buildOperationId, jsonResponse, withCommonErrorResponses } from "../openapi.js";
import {
capabilitiesResponseSchema,
remoteServerConnectRequestSchema,
remoteServerConnectResponseSchema,
remoteServerSyncRequestSchema,
serverInventoryListResponseSchema,
systemStatusResponseSchema,
} from "../schemas/registry.js";
import { healthResponseSchema, metadataResponseSchema, openApiDocumentSchema, rootInfoResponseSchema } from "../schemas/system.js";
import { routePaths } from "./route-paths.js";
type ServerV2App = Hono<AppBindings>;
function toWorkspaceSummary(workspace: ReturnType<AppDependencies["services"]["workspaceRegistry"]["serializeWorkspace"]>) {
const { notes: _notes, ...summary } = workspace;
return summary;
}
async function parseJsonBody<T>(schema: { parse(input: unknown): T }, request: Request) {
const contentType = request.headers.get("content-type")?.toLowerCase() ?? "";
if (!contentType.includes("application/json")) {
return schema.parse({});
}
return schema.parse(await request.json());
}
function buildRouteErrorJson(requestId: string, error: unknown) {
if (error instanceof HTTPException) {
const status = error.status;
const code = status === 401
? "unauthorized"
: status === 403
? "forbidden"
: status === 404
? "not_found"
: "invalid_request";
return {
body: buildErrorResponse({
code,
message: error.message || (code === "not_found" ? "Route not found." : "Request failed."),
requestId,
}),
status,
};
}
if (error instanceof RouteError) {
return {
body: buildErrorResponse({
code: error.code,
details: error.details,
message: error.message,
requestId,
}),
status: error.status,
};
}
const routeLike = error && typeof error === "object"
? error as { code?: unknown; details?: unknown; message?: unknown; status?: unknown }
: null;
if (routeLike && typeof routeLike.status === "number" && typeof routeLike.code === "string" && typeof routeLike.message === "string") {
return {
body: buildErrorResponse({
code: routeLike.code as any,
details: Array.isArray(routeLike.details) ? routeLike.details as any : undefined,
message: routeLike.message,
requestId,
}),
status: routeLike.status,
};
}
return null;
}
function createOpenApiDocumentation(version: string) {
return {
openapi: "3.1.0",
info: {
title: "OpenWork Server V2",
version,
description: [
"OpenAPI contract for the standalone OpenWork Server V2 runtime and durable registry state.",
"",
"Phase 10 makes Server V2 the default runtime, keeps release/runtime assets in a managed extracted directory, and closes the remaining cutover tooling around the standalone contract.",
].join("\n"),
},
servers: [{ url: "/" }],
tags: [
{
name: "System",
description: "Server-level operational routes and contract metadata.",
},
{
name: "Workspaces",
description: "Workspace-first resources will live under /workspaces/:workspaceId.",
},
{
name: "Runtime",
description: "Server-owned runtime supervision, versions, and child process health.",
},
{
name: "Sessions",
description: "Workspace-first session and streaming primitives backed by OpenCode or remote OpenWork servers.",
},
{
name: "Messages",
description: "Workspace-first message history and mutation primitives nested under sessions.",
},
{
name: "Config",
description: "Workspace-scoped config projection, raw config editing, and materialization owned by Server V2.",
},
{
name: "Files",
description: "Workspace-scoped file sessions, simple content routes, inbox, and artifact surfaces owned by Server V2.",
},
{
name: "Reload",
description: "Workspace-scoped reload events, reconciliation, and explicit runtime reload controls.",
},
],
};
}
export function registerSystemRoutes(app: ServerV2App, dependencies: AppDependencies) {
app.get(
routePaths.root,
describeRoute({
tags: ["System"],
summary: "Get server root information",
description: "Returns the root metadata for the standalone Server V2 process and its route conventions.",
responses: withCommonErrorResponses({
200: jsonResponse("Server root information returned successfully.", rootInfoResponseSchema),
}),
}),
(c) => {
const requestContext = getRequestContext(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.system.getRootInfo()));
},
);
app.get(
routePaths.system.health,
describeRoute({
tags: ["System"],
summary: "Check Server V2 health",
description: "Returns a lightweight health response for the standalone Server V2 process.",
responses: withCommonErrorResponses({
200: jsonResponse("Server health returned successfully.", healthResponseSchema),
}),
}),
(c) => {
const requestContext = getRequestContext(c);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.system.getHealth()));
},
);
app.get(
routePaths.system.meta,
describeRoute({
tags: ["System"],
summary: "Get foundation metadata",
description: "Returns middleware ordering, route namespace conventions, sqlite bootstrap status, and startup import diagnostics.",
responses: withCommonErrorResponses({
200: jsonResponse("Server metadata returned successfully.", metadataResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.system.getMetadata(requestContext.actor)));
},
);
app.get(
routePaths.system.capabilities,
describeRoute({
tags: ["System"],
summary: "Get server capabilities",
description: "Returns the typed Server V2 capability model, including auth requirements and migrated registry/runtime read slices.",
responses: withCommonErrorResponses({
200: jsonResponse("Server capabilities returned successfully.", capabilitiesResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.system.getCapabilities(requestContext.actor)));
},
);
app.get(
routePaths.system.status,
describeRoute({
tags: ["System"],
summary: "Get normalized system status",
description: "Returns normalized status, registry summary, auth requirements, runtime summary, and capabilities for app startup and settings surfaces.",
responses: withCommonErrorResponses({
200: jsonResponse("System status returned successfully.", systemStatusResponseSchema),
}, { includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.system.getStatus(requestContext.actor)));
},
);
app.get(
routePaths.system.servers,
describeRoute({
tags: ["System"],
summary: "List known server targets",
description: "Returns the local server registry inventory. This is host-scoped because it can reveal internal server connection metadata.",
responses: withCommonErrorResponses({
200: jsonResponse("Server inventory returned successfully.", serverInventoryListResponseSchema),
}, { includeForbidden: true, includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireHost(requestContext.actor);
return c.json(buildSuccessResponse(requestContext.requestId, requestContext.services.system.listServers()));
},
);
app.post(
routePaths.system.serverConnect,
describeRoute({
tags: ["System"],
summary: "Connect a remote OpenWork server",
description: "Validates a remote OpenWork server through the local Server V2 process, stores the remote connection metadata, and syncs the discovered remote workspaces into the local canonical registry.",
responses: withCommonErrorResponses({
200: jsonResponse("Remote OpenWork server connected successfully.", remoteServerConnectResponseSchema),
}, { includeForbidden: true, includeInvalidRequest: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireHost(requestContext.actor);
const body = await parseJsonBody(remoteServerConnectRequestSchema, c.req.raw);
let result;
try {
result = await requestContext.services.remoteServers.connect(body);
} catch (error) {
const resolved = buildRouteErrorJson(requestContext.requestId, error);
if (resolved) {
return c.json(resolved.body, resolved.status as any);
}
throw error;
}
return c.json(buildSuccessResponse(requestContext.requestId, {
selectedWorkspaceId: result.selectedWorkspaceId,
server: requestContext.services.serverRegistry.serialize(result.server, { includeBaseUrl: true }),
workspaces: result.workspaces.map((workspace) => toWorkspaceSummary(requestContext.services.workspaceRegistry.serializeWorkspace(workspace))),
}));
},
);
app.post(
routePaths.system.serverSync(),
describeRoute({
tags: ["System"],
summary: "Sync a remote OpenWork server",
description: "Refreshes the remote workspace inventory for a stored remote OpenWork server and updates the local canonical registry mapping.",
responses: withCommonErrorResponses({
200: jsonResponse("Remote OpenWork server synced successfully.", remoteServerConnectResponseSchema),
}, { includeForbidden: true, includeInvalidRequest: true, includeNotFound: true, includeUnauthorized: true }),
}),
async (c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireHost(requestContext.actor);
const body = await parseJsonBody(remoteServerSyncRequestSchema, c.req.raw);
const serverId = c.req.param("serverId") ?? "";
let result;
try {
result = await requestContext.services.remoteServers.sync(serverId, body);
} catch (error) {
const resolved = buildRouteErrorJson(requestContext.requestId, error);
if (resolved) {
return c.json(resolved.body, resolved.status as any);
}
throw error;
}
return c.json(buildSuccessResponse(requestContext.requestId, {
selectedWorkspaceId: result.selectedWorkspaceId,
server: requestContext.services.serverRegistry.serialize(result.server, { includeBaseUrl: true }),
workspaces: result.workspaces.map((workspace) => toWorkspaceSummary(requestContext.services.workspaceRegistry.serializeWorkspace(workspace))),
}));
},
);
app.get(
routePaths.openapiDocument,
describeRoute({
tags: ["System"],
summary: "Get the OpenAPI document",
description: "Returns the machine-readable OpenAPI 3.1 document generated from the Hono route definitions.",
responses: withCommonErrorResponses({
200: jsonResponse("OpenAPI document returned successfully.", openApiDocumentSchema),
}),
}),
openAPIRouteHandler(app, {
documentation: createOpenApiDocumentation(dependencies.version),
includeEmptyPaths: true,
exclude: [routePaths.openapiDocument],
excludeMethods: ["OPTIONS"],
defaultOptions: {
ALL: {
operationId: (route) => buildOperationId(route.method, route.path),
},
},
}),
);
}

View File

@@ -0,0 +1,73 @@
import type { Hono } from "hono";
import { describeRoute } from "hono-openapi";
import { HTTPException } from "hono/http-exception";
import { getRequestContext, type AppBindings } from "../context/request-context.js";
import { buildSuccessResponse } from "../http.js";
import { jsonResponse, withCommonErrorResponses } from "../openapi.js";
import { workspaceDetailResponseSchema, workspaceListResponseSchema } from "../schemas/registry.js";
import { routePaths } from "./route-paths.js";
function readIncludeHidden(url: string) {
const value = new URL(url).searchParams.get("includeHidden")?.trim().toLowerCase();
return value === "1" || value === "true" || value === "yes";
}
export function registerWorkspaceRoutes(app: Hono<AppBindings>) {
app.get(
routePaths.workspaces.base,
describeRoute({
tags: ["Workspaces"],
summary: "List workspaces",
description: "Returns the canonical workspace inventory from the server-owned registry. Hidden control/help workspaces are excluded unless the caller asks for them with host scope.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace inventory returned successfully.", workspaceListResponseSchema),
}, { includeForbidden: true, includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
const includeHidden = readIncludeHidden(c.req.url);
if (includeHidden) {
requestContext.services.auth.requireHost(requestContext.actor);
}
return c.json(
buildSuccessResponse(
requestContext.requestId,
requestContext.services.system.listWorkspaces({ includeHidden }),
),
);
},
);
app.get(
routePaths.workspaces.byId(),
describeRoute({
tags: ["Workspaces"],
summary: "Get workspace detail",
description: "Returns the canonical workspace detail shape for a single workspace, including backend resolution and runtime summary fields.",
responses: withCommonErrorResponses({
200: jsonResponse("Workspace detail returned successfully.", workspaceDetailResponseSchema),
}, { includeNotFound: true, includeUnauthorized: true }),
}),
(c) => {
const requestContext = getRequestContext(c);
requestContext.services.auth.requireVisibleRead(requestContext.actor);
const workspaceId = c.req.param("workspaceId") ?? "";
const workspace = requestContext.services.system.getWorkspace(
workspaceId,
{ includeHidden: requestContext.actor.kind === "host" },
);
if (!workspace) {
throw new HTTPException(404, {
message: `Workspace not found: ${workspaceId}`,
});
}
return c.json(buildSuccessResponse(requestContext.requestId, workspace));
},
);
}

View File

@@ -0,0 +1,275 @@
import { afterEach, expect, test } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import { createRuntimeAssetService } from "./assets.js";
import { registerEmbeddedRuntimeBundle } from "./embedded.js";
import { resolveRuntimeTarget, type RuntimeManifest } from "./manifest.js";
const cleanupPaths: string[] = [];
const ENV_KEYS = [
"OPENWORK_SERVER_V2_RUNTIME_BUNDLE_DIR",
"OPENWORK_SERVER_V2_RUNTIME_SOURCE",
"OPENWORK_SERVER_V2_RUNTIME_RELEASE_DIR",
"OPENWORK_SERVER_V2_RUNTIME_MANIFEST_PATH",
];
const originalEnv = new Map(ENV_KEYS.map((key) => [key, process.env[key]]));
afterEach(() => {
for (const [key, value] of originalEnv.entries()) {
if (typeof value === "string") {
process.env[key] = value;
} else {
delete process.env[key];
}
}
while (cleanupPaths.length > 0) {
const target = cleanupPaths.pop();
if (target) {
fs.rmSync(target, { force: true, recursive: true });
}
}
registerEmbeddedRuntimeBundle(undefined);
});
function makeTempDir(name: string) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
cleanupPaths.push(directory);
return directory;
}
async function sha256(filePath: string) {
const contents = await Bun.file(filePath).arrayBuffer();
return createHash("sha256").update(Buffer.from(contents)).digest("hex");
}
function writeVersionedBinary(filePath: string, version: string) {
const script = [
"#!/bin/sh",
'if [ "$1" = "--version" ]; then',
` echo ${JSON.stringify(version)}`,
" exit 0",
"fi",
"exit 0",
"",
].join("\n");
fs.writeFileSync(filePath, script, "utf8");
fs.chmodSync(filePath, 0o755);
}
test("release runtime assets use manifest versions without reading repo metadata", async () => {
const target = resolveRuntimeTarget();
if (!target) {
throw new Error("Unsupported test target.");
}
const releaseRoot = makeTempDir("openwork-server-v2-release-assets");
const opencodePath = path.join(releaseRoot, process.platform === "win32" ? "opencode.exe" : "opencode");
const routerPath = path.join(releaseRoot, process.platform === "win32" ? "opencode-router.exe" : "opencode-router");
writeVersionedBinary(opencodePath, "1.2.27");
writeVersionedBinary(routerPath, "0.11.206");
const manifest: RuntimeManifest = {
files: {
opencode: {
path: path.basename(opencodePath),
sha256: await sha256(opencodePath),
size: fs.statSync(opencodePath).size,
},
"opencode-router": {
path: path.basename(routerPath),
sha256: await sha256(routerPath),
size: fs.statSync(routerPath).size,
},
},
generatedAt: new Date().toISOString(),
manifestVersion: 1,
opencodeVersion: "1.2.27",
rootDir: releaseRoot,
routerVersion: "0.11.206",
serverVersion: "0.0.0-test",
source: "release",
target,
};
const manifestPath = path.join(releaseRoot, "manifest.json");
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
process.env.OPENWORK_SERVER_V2_RUNTIME_SOURCE = "release";
process.env.OPENWORK_SERVER_V2_RUNTIME_RELEASE_DIR = releaseRoot;
process.env.OPENWORK_SERVER_V2_RUNTIME_MANIFEST_PATH = manifestPath;
const service = createRuntimeAssetService({
environment: "test",
serverVersion: "0.0.0-test",
workingDirectory: {
databaseDir: releaseRoot,
databasePath: path.join(releaseRoot, "db.sqlite"),
importsDir: path.join(releaseRoot, "imports"),
managedDir: path.join(releaseRoot, "managed"),
managedMcpDir: path.join(releaseRoot, "managed", "mcps"),
managedPluginDir: path.join(releaseRoot, "managed", "plugins"),
managedProviderDir: path.join(releaseRoot, "managed", "providers"),
managedSkillDir: path.join(releaseRoot, "managed", "skills"),
rootDir: releaseRoot,
runtimeDir: releaseRoot,
workspacesDir: path.join(releaseRoot, "workspaces"),
},
});
const bundle = await service.resolveRuntimeBundle();
expect(bundle.opencode.version).toBe("1.2.27");
expect(bundle.router.version).toBe("0.11.206");
expect(bundle.manifest.source).toBe("release");
});
test("release runtime assets extract into the managed runtime directory and survive source bundle removal", async () => {
const target = resolveRuntimeTarget();
if (!target) {
throw new Error("Unsupported test target.");
}
const bundleRoot = makeTempDir("openwork-server-v2-release-bundle");
const runtimeRoot = makeTempDir("openwork-server-v2-runtime-root");
const runtimeDir = path.join(runtimeRoot, "runtime");
fs.mkdirSync(runtimeDir, { recursive: true });
const opencodePath = path.join(bundleRoot, process.platform === "win32" ? "opencode.exe" : "opencode");
const routerPath = path.join(bundleRoot, process.platform === "win32" ? "opencode-router.exe" : "opencode-router");
writeVersionedBinary(opencodePath, "1.2.27");
writeVersionedBinary(routerPath, "0.11.206");
const manifest: RuntimeManifest = {
files: {
opencode: {
path: path.basename(opencodePath),
sha256: await sha256(opencodePath),
size: fs.statSync(opencodePath).size,
},
"opencode-router": {
path: path.basename(routerPath),
sha256: await sha256(routerPath),
size: fs.statSync(routerPath).size,
},
},
generatedAt: new Date().toISOString(),
manifestVersion: 1,
opencodeVersion: "1.2.27",
rootDir: bundleRoot,
routerVersion: "0.11.206",
serverVersion: "0.0.0-test",
source: "release",
target,
};
fs.writeFileSync(path.join(bundleRoot, "manifest.json"), `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
process.env.OPENWORK_SERVER_V2_RUNTIME_SOURCE = "release";
process.env.OPENWORK_SERVER_V2_RUNTIME_BUNDLE_DIR = bundleRoot;
const workingDirectory = {
databaseDir: runtimeRoot,
databasePath: path.join(runtimeRoot, "db.sqlite"),
importsDir: path.join(runtimeRoot, "imports"),
managedDir: path.join(runtimeRoot, "managed"),
managedMcpDir: path.join(runtimeRoot, "managed", "mcps"),
managedPluginDir: path.join(runtimeRoot, "managed", "plugins"),
managedProviderDir: path.join(runtimeRoot, "managed", "providers"),
managedSkillDir: path.join(runtimeRoot, "managed", "skills"),
rootDir: runtimeRoot,
runtimeDir,
workspacesDir: path.join(runtimeRoot, "workspaces"),
};
const service = createRuntimeAssetService({
environment: "test",
serverVersion: "0.0.0-test",
workingDirectory,
});
const firstBundle = await service.resolveRuntimeBundle();
const extractedRoot = path.join(runtimeDir, "0.0.0-test");
expect(firstBundle.opencode.absolutePath).toBe(path.join(extractedRoot, path.basename(opencodePath)));
expect(firstBundle.router.absolutePath).toBe(path.join(extractedRoot, path.basename(routerPath)));
expect(fs.existsSync(path.join(extractedRoot, "manifest.json"))).toBe(true);
fs.rmSync(bundleRoot, { recursive: true, force: true });
const secondBundle = await service.resolveRuntimeBundle();
expect(secondBundle.opencode.absolutePath).toBe(firstBundle.opencode.absolutePath);
expect(secondBundle.router.absolutePath).toBe(firstBundle.router.absolutePath);
});
test("release runtime assets can extract from an embedded runtime bundle", async () => {
const target = resolveRuntimeTarget();
if (!target) {
throw new Error("Unsupported test target.");
}
const bundleRoot = makeTempDir("openwork-server-v2-embedded-bundle");
const runtimeRoot = makeTempDir("openwork-server-v2-embedded-runtime-root");
const runtimeDir = path.join(runtimeRoot, "runtime");
fs.mkdirSync(runtimeDir, { recursive: true });
const opencodePath = path.join(bundleRoot, process.platform === "win32" ? "opencode.exe" : "opencode");
const routerPath = path.join(bundleRoot, process.platform === "win32" ? "opencode-router.exe" : "opencode-router");
const manifestPath = path.join(bundleRoot, "manifest.json");
writeVersionedBinary(opencodePath, "1.2.27");
writeVersionedBinary(routerPath, "0.11.206");
const manifest: RuntimeManifest = {
files: {
opencode: {
path: path.basename(opencodePath),
sha256: await sha256(opencodePath),
size: fs.statSync(opencodePath).size,
},
"opencode-router": {
path: path.basename(routerPath),
sha256: await sha256(routerPath),
size: fs.statSync(routerPath).size,
},
},
generatedAt: new Date().toISOString(),
manifestVersion: 1,
opencodeVersion: "1.2.27",
rootDir: bundleRoot,
routerVersion: "0.11.206",
serverVersion: "0.0.0-test",
source: "release",
target,
};
fs.writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
process.env.OPENWORK_SERVER_V2_RUNTIME_SOURCE = "release";
delete process.env.OPENWORK_SERVER_V2_RUNTIME_BUNDLE_DIR;
registerEmbeddedRuntimeBundle({
manifestPath,
opencodePath,
routerPath,
});
const service = createRuntimeAssetService({
environment: "test",
serverVersion: "0.0.0-test",
workingDirectory: {
databaseDir: runtimeRoot,
databasePath: path.join(runtimeRoot, "db.sqlite"),
importsDir: path.join(runtimeRoot, "imports"),
managedDir: path.join(runtimeRoot, "managed"),
managedMcpDir: path.join(runtimeRoot, "managed", "mcps"),
managedPluginDir: path.join(runtimeRoot, "managed", "plugins"),
managedProviderDir: path.join(runtimeRoot, "managed", "providers"),
managedSkillDir: path.join(runtimeRoot, "managed", "skills"),
rootDir: runtimeRoot,
runtimeDir,
workspacesDir: path.join(runtimeRoot, "workspaces"),
},
});
const bundle = await service.resolveRuntimeBundle();
const extractedRoot = path.join(runtimeDir, "0.0.0-test");
expect(bundle.opencode.absolutePath).toBe(path.join(extractedRoot, path.basename(opencodePath)));
expect(bundle.router.absolutePath).toBe(path.join(extractedRoot, path.basename(routerPath)));
expect(fs.existsSync(path.join(extractedRoot, "manifest.json"))).toBe(true);
});

View File

@@ -0,0 +1,867 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import { chmod, copyFile, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
import type { ServerWorkingDirectory } from "../database/working-directory.js";
import { getEmbeddedRuntimeBundle, type EmbeddedRuntimeBundle } from "./embedded.js";
import {
resolveBunTarget,
resolveRuntimeTarget,
runtimeBinaryFilename,
type ResolvedRuntimeBinary,
type ResolvedRuntimeBundle,
type RuntimeAssetName,
type RuntimeAssetSource,
type RuntimeManifest,
type RuntimeTarget,
} from "./manifest.js";
type RuntimeAssetServiceOptions = {
environment: string;
serverVersion: string;
workingDirectory: ServerWorkingDirectory;
};
type ReleaseBundleSource =
| { kind: "directory"; rootDir: string }
| { bundle: EmbeddedRuntimeBundle; kind: "embedded" };
function isTruthy(value: string | undefined) {
if (!value) {
return false;
}
return ["1", "true", "yes", "on"].includes(value.trim().toLowerCase());
}
function normalizeVersion(value: string) {
const trimmed = value.trim();
return trimmed.startsWith("v") ? trimmed.slice(1) : trimmed;
}
function dirnameFromMetaUrl(metaUrl: string) {
return path.dirname(fileURLToPath(metaUrl));
}
function findRepoRoot(startDir: string) {
let current = startDir;
while (true) {
const constantsPath = path.join(current, "constants.json");
const serverV2PackagePath = path.join(current, "apps", "server-v2", "package.json");
if (fs.existsSync(constantsPath) && fs.existsSync(serverV2PackagePath)) {
return current;
}
const parent = path.dirname(current);
if (parent === current) {
return null;
}
current = parent;
}
}
async function readJson<T>(filePath: string): Promise<T> {
return JSON.parse(await readFile(filePath, "utf8")) as T;
}
async function sha256File(filePath: string) {
const contents = await readFile(filePath);
return createHash("sha256").update(contents).digest("hex");
}
async function ensureExecutable(filePath: string) {
if (process.platform === "win32") {
return;
}
await chmod(filePath, 0o755);
}
async function sleep(ms: number) {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function fileExists(filePath: string) {
try {
await stat(filePath);
return true;
} catch {
return false;
}
}
async function directoryExists(directoryPath: string) {
try {
const details = await stat(directoryPath);
return details.isDirectory();
} catch {
return false;
}
}
async function readJsonOrNull<T>(filePath: string): Promise<T | null> {
try {
return JSON.parse(await readFile(filePath, "utf8")) as T;
} catch {
return null;
}
}
function isProcessAlive(pid: number | null | undefined) {
if (!pid || !Number.isInteger(pid) || pid <= 0) {
return false;
}
try {
process.kill(pid, 0);
return true;
} catch (error) {
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === "EPERM";
}
}
async function captureProcess(command: string[], options: { cwd?: string; env?: Record<string, string | undefined>; timeoutMs?: number } = {}) {
const child = Bun.spawn(command, {
cwd: options.cwd,
env: {
...process.env,
...options.env,
},
stderr: "pipe",
stdout: "pipe",
});
const stdoutPromise = new Response(child.stdout).text();
const stderrPromise = new Response(child.stderr).text();
const timeoutMs = options.timeoutMs ?? 120_000;
const timeout = setTimeout(() => {
child.kill();
}, timeoutMs);
try {
const exitCode = await child.exited;
const [stdout, stderr] = await Promise.all([stdoutPromise, stderrPromise]);
if (exitCode !== 0) {
const message = [stdout.trim(), stderr.trim()].filter(Boolean).join("\n");
throw new Error(message || `Command failed with exit code ${exitCode}: ${command.join(" ")}`);
}
return { stderr, stdout };
} finally {
clearTimeout(timeout);
}
}
function parseVersion(output: string) {
const match = output.match(/\d+\.\d+\.\d+(?:-[\w.-]+)?/);
return match?.[0] ?? null;
}
async function readBinaryVersion(binaryPath: string) {
try {
const result = await captureProcess([binaryPath, "--version"], { cwd: os.tmpdir(), timeoutMs: 4_000 });
return parseVersion(`${result.stdout}\n${result.stderr}`);
} catch {
return null;
}
}
function resolveOpencodeAsset(target: RuntimeTarget) {
const assets: Record<RuntimeTarget, string> = {
"darwin-arm64": "opencode-darwin-arm64.zip",
"darwin-x64": "opencode-darwin-x64-baseline.zip",
"linux-arm64": "opencode-linux-arm64.tar.gz",
"linux-x64": "opencode-linux-x64-baseline.tar.gz",
"windows-arm64": "opencode-windows-arm64.zip",
"windows-x64": "opencode-windows-x64-baseline.zip",
};
return assets[target];
}
async function downloadToPath(url: string, destinationPath: string) {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Failed to download ${url} (HTTP ${response.status}).`);
}
const contents = Buffer.from(await response.arrayBuffer());
await mkdir(path.dirname(destinationPath), { recursive: true });
const temporaryPath = `${destinationPath}.tmp-${Date.now()}`;
await writeFile(temporaryPath, contents);
await rename(temporaryPath, destinationPath);
}
async function extractOpencodeArchive(archivePath: string, extractDir: string) {
if (process.platform === "win32") {
const quotedArchive = `'${archivePath.replace(/'/g, "''")}'`;
const quotedExtract = `'${extractDir.replace(/'/g, "''")}'`;
await captureProcess([
"powershell",
"-NoProfile",
"-Command",
`$ErrorActionPreference = 'Stop'; Expand-Archive -Path ${quotedArchive} -DestinationPath ${quotedExtract} -Force`,
]);
return;
}
if (archivePath.endsWith(".zip")) {
await captureProcess(["unzip", "-q", archivePath, "-d", extractDir]);
return;
}
if (archivePath.endsWith(".tar.gz")) {
await captureProcess(["tar", "-xzf", archivePath, "-C", extractDir]);
return;
}
throw new Error(`Unsupported OpenCode archive format: ${archivePath}`);
}
async function findFileRecursively(rootDir: string, matcher: (fileName: string) => boolean): Promise<string | null> {
const queue = [rootDir];
while (queue.length > 0) {
const current = queue.shift();
if (!current) {
continue;
}
const entries = await fs.promises.readdir(current, { withFileTypes: true });
for (const entry of entries) {
const absolutePath = path.join(current, entry.name);
if (entry.isDirectory()) {
queue.push(absolutePath);
continue;
}
if (matcher(entry.name)) {
return absolutePath;
}
}
}
return null;
}
async function writeIfChanged(filePath: string, contents: string) {
const existing = await readFile(filePath, "utf8").catch(() => null);
if (existing === contents) {
return;
}
await mkdir(path.dirname(filePath), { recursive: true });
await writeFile(filePath, contents);
}
export type RuntimeAssetService = ReturnType<typeof createRuntimeAssetService>;
export function createRuntimeAssetService(options: RuntimeAssetServiceOptions) {
const runtimeTarget = resolveRuntimeTarget();
if (!runtimeTarget) {
throw new Error(`Unsupported runtime target ${process.platform}/${process.arch} for Server V2 runtime assets.`);
}
const serverVersion = options.serverVersion;
const repoRoot = findRepoRoot(path.resolve(dirnameFromMetaUrl(import.meta.url), "..", "..", "..", ".."));
const runtimeSourcePreference = process.env.OPENWORK_SERVER_V2_RUNTIME_SOURCE?.trim().toLowerCase();
const bundleRootOverride = process.env.OPENWORK_SERVER_V2_RUNTIME_BUNDLE_DIR?.trim();
const releaseRootOverride = process.env.OPENWORK_SERVER_V2_RUNTIME_RELEASE_DIR?.trim();
const manifestPathOverride = process.env.OPENWORK_SERVER_V2_RUNTIME_MANIFEST_PATH?.trim();
const developmentRoot = repoRoot ? path.join(repoRoot, ".local", "runtime-assets") : null;
const releaseRoot = releaseRootOverride?.trim()
? path.resolve(releaseRootOverride)
: path.join(options.workingDirectory.runtimeDir, serverVersion);
let registeredLeaseCleanup = false;
let releaseRuntimeRootPromise: Promise<{ manifest: RuntimeManifest; rootDir: string }> | null = null;
let cachedReleaseBundleSource: ReleaseBundleSource | null | undefined;
const resolveAdjacentBundleRoot = () => {
const candidates = [path.dirname(process.execPath)];
for (const candidate of candidates) {
const manifestPath = manifestPathOverride?.trim()
? path.resolve(manifestPathOverride)
: path.join(candidate, "manifest.json");
const manifestExists = fs.existsSync(manifestPath);
if (!manifestExists) {
continue;
}
return candidate;
}
return null;
};
const resolveReleaseBundleRoot = (): ReleaseBundleSource | null => {
if (cachedReleaseBundleSource !== undefined) {
return cachedReleaseBundleSource;
}
if (bundleRootOverride?.trim()) {
cachedReleaseBundleSource = {
kind: "directory",
rootDir: path.resolve(bundleRootOverride),
};
return cachedReleaseBundleSource;
}
const adjacentBundleRoot = resolveAdjacentBundleRoot();
if (adjacentBundleRoot) {
cachedReleaseBundleSource = {
kind: "directory",
rootDir: adjacentBundleRoot,
};
return cachedReleaseBundleSource;
}
const embeddedBundle = getEmbeddedRuntimeBundle();
if (embeddedBundle) {
cachedReleaseBundleSource = {
bundle: embeddedBundle,
kind: "embedded",
};
return cachedReleaseBundleSource;
}
cachedReleaseBundleSource = null;
return cachedReleaseBundleSource;
};
const resolveSource = (): RuntimeAssetSource => {
if (runtimeSourcePreference === "development") {
if (!developmentRoot) {
throw new Error("Development runtime assets requested, but the repo root could not be resolved.");
}
return "development";
}
if (runtimeSourcePreference === "release") {
return "release";
}
if (resolveReleaseBundleRoot()) {
return "release";
}
if (developmentRoot) {
return "development";
}
return "release";
};
const resolveRootDir = (source: RuntimeAssetSource) => (source === "development" ? developmentRoot! : releaseRoot);
const readPinnedOpencodeVersion = async () => {
const candidates = [
repoRoot ? path.join(repoRoot, "constants.json") : null,
path.resolve(dirnameFromMetaUrl(import.meta.url), "..", "..", "..", "..", "constants.json"),
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) {
continue;
}
const parsed = await readJson<{ opencodeVersion?: string }>(candidate);
const value = parsed.opencodeVersion?.trim() ?? "";
if (value) {
return normalizeVersion(value);
}
}
throw new Error("Unable to resolve the pinned OpenCode version from constants.json.");
};
const readRouterVersion = async () => {
const candidates = [
repoRoot ? path.join(repoRoot, "apps", "opencode-router", "package.json") : null,
path.resolve(dirnameFromMetaUrl(import.meta.url), "..", "..", "..", "opencode-router", "package.json"),
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (!fs.existsSync(candidate)) {
continue;
}
const parsed = await readJson<{ version?: string }>(candidate);
const value = parsed.version?.trim() ?? "";
if (value) {
return normalizeVersion(value);
}
}
throw new Error("Unable to resolve the local opencode-router version.");
};
const materializeManifest = async (source: RuntimeAssetSource, opencode: ResolvedRuntimeBinary, router: ResolvedRuntimeBinary) => {
const rootDir = resolveRootDir(source);
const manifest: RuntimeManifest = {
files: {
opencode: {
path: path.relative(rootDir, opencode.absolutePath),
sha256: opencode.sha256,
size: opencode.size,
},
"opencode-router": {
path: path.relative(rootDir, router.absolutePath),
sha256: router.sha256,
size: router.size,
},
},
generatedAt: new Date().toISOString(),
manifestVersion: 1,
opencodeVersion: opencode.version,
rootDir,
routerVersion: router.version,
serverVersion,
source,
target: runtimeTarget,
};
const manifestPath =
source === "release"
? path.join(rootDir, "manifest.json")
: path.join(rootDir, "manifests", runtimeTarget, `openwork-server-v2-${serverVersion}.json`);
await writeIfChanged(`${manifestPath}`, `${JSON.stringify(manifest, null, 2)}\n`);
return manifest;
};
const releaseManifestPath = (rootDir: string) => path.join(rootDir, "manifest.json");
const sourceManifestPath = (rootDir: string) =>
manifestPathOverride?.trim() ? path.resolve(manifestPathOverride) : releaseManifestPath(rootDir);
const validateManifestRoot = async (rootDir: string, manifest: RuntimeManifest) => {
for (const name of ["opencode", "opencode-router"] as const) {
const entry = manifest.files[name];
if (!entry) {
return false;
}
const binaryPath = path.resolve(rootDir, entry.path);
if (!(await fileExists(binaryPath))) {
return false;
}
const checksum = await sha256File(binaryPath);
if (checksum !== entry.sha256) {
return false;
}
}
return true;
};
const leasePathForRoot = (rootDir: string) => path.join(rootDir, ".runtime-lease.json");
const cleanupLease = async (rootDir: string) => {
await rm(leasePathForRoot(rootDir), { force: true });
};
const markRuntimeLease = async (rootDir: string) => {
await writeFile(
leasePathForRoot(rootDir),
`${JSON.stringify({ pid: process.pid, serverVersion, updatedAt: new Date().toISOString() }, null, 2)}\n`,
"utf8",
);
if (!registeredLeaseCleanup) {
registeredLeaseCleanup = true;
for (const signal of ["SIGINT", "SIGTERM", "beforeExit", "exit"] as const) {
process.once(signal, () => {
void cleanupLease(rootDir);
});
}
}
};
const isLiveLease = async (rootDir: string) => {
const lease = await readJsonOrNull<{ pid?: number }>(leasePathForRoot(rootDir));
return isProcessAlive(typeof lease?.pid === "number" ? lease.pid : null);
};
const cleanupReleaseArtifacts = async (currentRoot: string) => {
const parentDir = path.dirname(currentRoot);
if (!(await directoryExists(parentDir))) {
return;
}
const entries = await readdir(parentDir, { withFileTypes: true });
const runtimeRoots: Array<{ absolutePath: string; mtimeMs: number }> = [];
for (const entry of entries) {
if (!entry.isDirectory()) {
continue;
}
const absolutePath = path.join(parentDir, entry.name);
if (absolutePath === currentRoot) {
continue;
}
if (entry.name.startsWith(`${path.basename(currentRoot)}.extract-`) || entry.name.startsWith(`${path.basename(currentRoot)}.replace-`)) {
await rm(absolutePath, { force: true, recursive: true });
continue;
}
const manifest = await readJsonOrNull<RuntimeManifest>(path.join(absolutePath, "manifest.json"));
if (!manifest) {
continue;
}
const details = await stat(absolutePath).catch(() => null);
runtimeRoots.push({
absolutePath,
mtimeMs: details?.mtimeMs ?? 0,
});
}
runtimeRoots.sort((left, right) => right.mtimeMs - left.mtimeMs);
const keep = new Set(runtimeRoots.slice(0, 2).map((item) => item.absolutePath));
for (const candidate of runtimeRoots) {
if (keep.has(candidate.absolutePath)) {
continue;
}
if (await isLiveLease(candidate.absolutePath)) {
continue;
}
await rm(candidate.absolutePath, { force: true, recursive: true });
}
};
const readReleaseManifest = async (source: ReleaseBundleSource | { kind: "directory"; rootDir: string }) => {
const manifestPath = source.kind === "embedded"
? source.bundle.manifestPath
: sourceManifestPath(source.rootDir);
if (!fs.existsSync(manifestPath)) {
throw new Error(`Release runtime manifest not found at ${manifestPath}.`);
}
return readJson<RuntimeManifest>(manifestPath);
};
const resolveReleaseSourceBinary = (source: ReleaseBundleSource, name: RuntimeAssetName, relativePath: string) => {
if (source.kind === "embedded") {
return name === "opencode" ? source.bundle.opencodePath : source.bundle.routerPath;
}
return path.resolve(source.rootDir, relativePath);
};
const copyReleaseSourceBinary = async (sourcePath: string, targetPath: string) => {
const contents = await readFile(sourcePath);
await mkdir(path.dirname(targetPath), { recursive: true });
await writeFile(targetPath, contents);
};
const acquireExtractionLock = async (rootDir: string) => {
const lockDir = `${rootDir}.lock`;
const ownerPath = path.join(lockDir, "owner.json");
const startedAt = Date.now();
await mkdir(path.dirname(lockDir), { recursive: true });
while (Date.now() - startedAt < 15_000) {
try {
await mkdir(lockDir);
await writeFile(
ownerPath,
`${JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2)}\n`,
"utf8",
);
return async () => {
await rm(lockDir, { force: true, recursive: true });
};
} catch (error) {
const code = error instanceof Error && "code" in error ? (error as NodeJS.ErrnoException).code : null;
if (code !== "EEXIST") {
throw error;
}
const owner = await readJsonOrNull<{ createdAt?: string; pid?: number }>(ownerPath);
const ownerAgeMs = owner?.createdAt ? Date.now() - Date.parse(owner.createdAt) : Number.POSITIVE_INFINITY;
if (!isProcessAlive(typeof owner?.pid === "number" ? owner.pid : null) && ownerAgeMs > 5_000) {
await rm(lockDir, { force: true, recursive: true });
continue;
}
await sleep(100);
}
}
throw new Error(`Timed out waiting for the runtime extraction lock at ${lockDir}.`);
};
const ensureReleaseRuntimeRoot = async () => {
if (releaseRuntimeRootPromise) {
return releaseRuntimeRootPromise;
}
releaseRuntimeRootPromise = (async () => {
const rootDir = resolveRootDir("release");
const bundleSource = resolveReleaseBundleRoot();
if (!bundleSource || (bundleSource.kind === "directory" && path.resolve(bundleSource.rootDir) === path.resolve(rootDir))) {
const manifest = await readReleaseManifest({ kind: "directory", rootDir });
if (!(await validateManifestRoot(rootDir, manifest))) {
throw new Error(`Release runtime manifest at ${releaseManifestPath(rootDir)} does not match the extracted runtime contents.`);
}
await markRuntimeLease(rootDir);
await cleanupReleaseArtifacts(rootDir);
return { manifest, rootDir };
}
const unlock = await acquireExtractionLock(rootDir);
try {
const existingManifest = await readJsonOrNull<RuntimeManifest>(releaseManifestPath(rootDir));
if (existingManifest && (await validateManifestRoot(rootDir, existingManifest))) {
await markRuntimeLease(rootDir);
await cleanupReleaseArtifacts(rootDir);
return { manifest: existingManifest, rootDir };
}
const sourceManifest = await readReleaseManifest(bundleSource);
const bundleLabel = bundleSource.kind === "embedded" ? "embedded runtime bundle" : bundleSource.rootDir;
const tempRoot = `${rootDir}.extract-${process.pid}-${Date.now()}`;
const backupRoot = `${rootDir}.replace-${Date.now()}`;
await rm(tempRoot, { force: true, recursive: true });
await mkdir(tempRoot, { recursive: true });
for (const name of ["opencode", "opencode-router"] as const) {
const entry = sourceManifest.files[name];
if (!entry) {
throw new Error(`Release runtime manifest in ${bundleLabel} is missing the ${name} entry.`);
}
const sourcePath = resolveReleaseSourceBinary(bundleSource, name, entry.path);
if (!(await fileExists(sourcePath))) {
throw new Error(`Release runtime source binary for ${name} was expected at ${sourcePath}, but it was not found.`);
}
const targetPath = path.resolve(tempRoot, entry.path);
await copyReleaseSourceBinary(sourcePath, targetPath);
await ensureExecutable(targetPath);
}
const extractedManifest: RuntimeManifest = {
...sourceManifest,
generatedAt: new Date().toISOString(),
rootDir,
};
await writeFile(releaseManifestPath(tempRoot), `${JSON.stringify(extractedManifest, null, 2)}\n`, "utf8");
if (await directoryExists(rootDir)) {
await rm(backupRoot, { force: true, recursive: true });
await rename(rootDir, backupRoot);
}
await rename(tempRoot, rootDir);
await rm(backupRoot, { force: true, recursive: true });
await markRuntimeLease(rootDir);
await cleanupReleaseArtifacts(rootDir);
return { manifest: extractedManifest, rootDir };
} finally {
await unlock();
}
})();
try {
return await releaseRuntimeRootPromise;
} catch (error) {
releaseRuntimeRootPromise = null;
throw error;
}
};
const buildResolvedBinary = async (
source: RuntimeAssetSource,
name: RuntimeAssetName,
absolutePath: string,
version: string,
): Promise<ResolvedRuntimeBinary> => {
const details = await stat(absolutePath);
return {
absolutePath,
name,
sha256: await sha256File(absolutePath),
size: details.size,
source,
stagedRoot: resolveRootDir(source),
target: runtimeTarget,
version,
};
};
const ensureDevelopmentOpencodeBinary = async (version: string) => {
const rootDir = resolveRootDir("development");
const targetDir = path.join(rootDir, "opencode", runtimeTarget, `v${version}`);
const targetPath = path.join(targetDir, runtimeBinaryFilename("opencode", runtimeTarget));
if (await fileExists(targetPath)) {
const actualVersion = await readBinaryVersion(targetPath);
if (!actualVersion || actualVersion === version) {
await ensureExecutable(targetPath);
return targetPath;
}
await rm(targetPath, { force: true });
}
const asset = resolveOpencodeAsset(runtimeTarget);
const archivePath = path.join(os.tmpdir(), `openwork-server-v2-opencode-${Date.now()}-${asset}`);
const extractDir = await mkdtemp(path.join(os.tmpdir(), "openwork-server-v2-opencode-"));
const downloadUrl = `https://github.com/anomalyco/opencode/releases/download/v${version}/${asset}`;
try {
await downloadToPath(downloadUrl, archivePath);
await extractOpencodeArchive(archivePath, extractDir);
const extractedBinary = await findFileRecursively(extractDir, (fileName) => fileName === "opencode" || fileName === "opencode.exe");
if (!extractedBinary) {
throw new Error(`Downloaded OpenCode archive did not contain an opencode binary for ${runtimeTarget}.`);
}
await mkdir(targetDir, { recursive: true });
await copyFile(extractedBinary, targetPath);
await ensureExecutable(targetPath);
return targetPath;
} catch (error) {
throw new Error(
`Failed to download the pinned OpenCode ${version} artifact for ${runtimeTarget}: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
await rm(extractDir, { force: true, recursive: true });
await rm(archivePath, { force: true });
}
};
const ensureDevelopmentRouterBinary = async (version: string) => {
if (!repoRoot) {
throw new Error("Cannot build opencode-router in development mode because the repo root could not be resolved.");
}
const rootDir = resolveRootDir("development");
const targetDir = path.join(rootDir, "opencode-router", runtimeTarget, `v${version}`);
const targetPath = path.join(targetDir, runtimeBinaryFilename("opencode-router", runtimeTarget));
if (await fileExists(targetPath)) {
const actualVersion = await readBinaryVersion(targetPath);
if (!actualVersion || actualVersion === version) {
await ensureExecutable(targetPath);
return targetPath;
}
await rm(targetPath, { force: true });
}
await mkdir(targetDir, { recursive: true });
const packageDir = path.join(repoRoot, "apps", "opencode-router");
const entrypoint = path.join(packageDir, "src", "cli.ts");
const outfile = targetPath;
const bunCommand = [
process.execPath,
"build",
entrypoint,
"--compile",
"--outfile",
outfile,
"--target",
resolveBunTarget(runtimeTarget),
"--define",
`__OPENCODE_ROUTER_VERSION__=\"${version}\"`,
];
try {
await captureProcess(bunCommand, { cwd: packageDir, timeoutMs: 300_000 });
await ensureExecutable(outfile);
return outfile;
} catch (error) {
throw new Error(
`Failed to build the local opencode-router ${version} binary for ${runtimeTarget}: ${error instanceof Error ? error.message : String(error)}`,
);
}
};
const ensureReleaseBinary = async (name: RuntimeAssetName, version: string) => {
const { manifest, rootDir } = await ensureReleaseRuntimeRoot();
const entry = manifest.files[name];
if (!entry) {
throw new Error(`Release runtime manifest is missing the ${name} entry.`);
}
const binaryPath = path.resolve(rootDir, entry.path);
if (!(await fileExists(binaryPath))) {
throw new Error(`Release runtime binary for ${name} was expected at ${binaryPath}, but it was not found.`);
}
await ensureExecutable(binaryPath);
const checksum = await sha256File(binaryPath);
if (checksum !== entry.sha256) {
throw new Error(`Release runtime binary checksum mismatch for ${name} at ${binaryPath}.`);
}
const actualVersion = await readBinaryVersion(binaryPath);
if (actualVersion && actualVersion !== version) {
throw new Error(`Release runtime ${name} version mismatch: expected ${version}, got ${actualVersion}.`);
}
return binaryPath;
};
const readReleaseManifestVersion = async (name: RuntimeAssetName) => {
const { manifest } = await ensureReleaseRuntimeRoot();
return name === "opencode" ? manifest.opencodeVersion : manifest.routerVersion;
};
const ensureBinary = async (name: RuntimeAssetName) => {
const source = resolveSource();
const version = source === "release"
? await readReleaseManifestVersion(name)
: name === "opencode"
? await readPinnedOpencodeVersion()
: await readRouterVersion();
const absolutePath = source === "development"
? name === "opencode"
? await ensureDevelopmentOpencodeBinary(version)
: await ensureDevelopmentRouterBinary(version)
: await ensureReleaseBinary(name, version);
return buildResolvedBinary(source, name, absolutePath, version);
};
return {
async ensureOpencodeBinary() {
return ensureBinary("opencode");
},
async ensureRouterBinary() {
return ensureBinary("opencode-router");
},
async getPinnedOpencodeVersion() {
return readPinnedOpencodeVersion();
},
async getRouterVersion() {
return readRouterVersion();
},
getSource() {
return resolveSource();
},
getTarget() {
return runtimeTarget;
},
getDevelopmentRoot() {
return developmentRoot;
},
getReleaseRoot() {
return releaseRoot;
},
async resolveRuntimeBundle(): Promise<ResolvedRuntimeBundle> {
const [opencode, router] = await Promise.all([this.ensureOpencodeBinary(), this.ensureRouterBinary()]);
const manifest = await materializeManifest(opencode.source, opencode, router);
return {
manifest,
opencode,
router,
};
},
};
}

View File

@@ -0,0 +1,19 @@
export type EmbeddedRuntimeBundle = {
manifestPath: string;
opencodePath: string;
routerPath: string;
};
declare global {
var __OPENWORK_SERVER_V2_EMBEDDED_RUNTIME__:
| EmbeddedRuntimeBundle
| undefined;
}
export function registerEmbeddedRuntimeBundle(bundle: EmbeddedRuntimeBundle | undefined) {
globalThis.__OPENWORK_SERVER_V2_EMBEDDED_RUNTIME__ = bundle;
}
export function getEmbeddedRuntimeBundle() {
return globalThis.__OPENWORK_SERVER_V2_EMBEDDED_RUNTIME__ ?? null;
}

View File

@@ -0,0 +1,88 @@
export type RuntimeTarget =
| "darwin-arm64"
| "darwin-x64"
| "linux-arm64"
| "linux-x64"
| "windows-arm64"
| "windows-x64";
export type RuntimeAssetName = "opencode" | "opencode-router";
export type RuntimeAssetSource = "development" | "release";
export type RuntimeManifestFile = {
path: string;
sha256: string;
size: number;
};
export type RuntimeManifest = {
files: Record<RuntimeAssetName, RuntimeManifestFile>;
generatedAt: string;
manifestVersion: 1;
opencodeVersion: string;
rootDir: string;
routerVersion: string;
serverVersion: string;
source: RuntimeAssetSource;
target: RuntimeTarget;
};
export type ResolvedRuntimeBinary = {
absolutePath: string;
name: RuntimeAssetName;
sha256: string;
size: number;
source: RuntimeAssetSource;
stagedRoot: string;
target: RuntimeTarget;
version: string;
};
export type ResolvedRuntimeBundle = {
manifest: RuntimeManifest;
opencode: ResolvedRuntimeBinary;
router: ResolvedRuntimeBinary;
};
export function resolveRuntimeTarget(): RuntimeTarget | null {
if (process.platform === "darwin") {
if (process.arch === "arm64") {
return "darwin-arm64";
}
if (process.arch === "x64") {
return "darwin-x64";
}
return null;
}
if (process.platform === "linux") {
if (process.arch === "arm64") {
return "linux-arm64";
}
if (process.arch === "x64") {
return "linux-x64";
}
return null;
}
if (process.platform === "win32") {
if (process.arch === "arm64") {
return "windows-arm64";
}
if (process.arch === "x64") {
return "windows-x64";
}
return null;
}
return null;
}
export function runtimeBinaryFilename(name: RuntimeAssetName, target: RuntimeTarget) {
const base = name === "opencode" ? "opencode" : "opencode-router";
return target.startsWith("windows") ? `${base}.exe` : base;
}
export function resolveBunTarget(target: RuntimeTarget) {
return `bun-${target}`;
}

View File

@@ -0,0 +1,127 @@
export type RuntimeOutputStream = "stdout" | "stderr";
export type RuntimeOutputLine = {
at: string;
stream: RuntimeOutputStream;
text: string;
};
export type RuntimeOutputSnapshot = {
combined: RuntimeOutputLine[];
stderr: string[];
stdout: string[];
totalLines: number;
truncated: boolean;
};
type CreateBoundedOutputCollectorOptions = {
maxBytes?: number;
maxLines?: number;
onLine?: (line: RuntimeOutputLine) => void;
};
function byteLength(value: string) {
return Buffer.byteLength(value, "utf8");
}
export function createBoundedOutputCollector(options: CreateBoundedOutputCollectorOptions = {}) {
const maxLines = Math.max(1, options.maxLines ?? 200);
const maxBytes = Math.max(256, options.maxBytes ?? 16_384);
const combined: RuntimeOutputLine[] = [];
const partials: Record<RuntimeOutputStream, string> = {
stderr: "",
stdout: "",
};
let totalBytes = 0;
let truncated = false;
const appendLine = (stream: RuntimeOutputStream, text: string) => {
const line: RuntimeOutputLine = {
at: new Date().toISOString(),
stream,
text,
};
combined.push(line);
totalBytes += byteLength(text);
options.onLine?.(line);
while (combined.length > maxLines || totalBytes > maxBytes) {
const removed = combined.shift();
if (!removed) {
break;
}
totalBytes -= byteLength(removed.text);
truncated = true;
}
};
const flushPartial = (stream: RuntimeOutputStream) => {
const partial = partials[stream];
if (!partial) {
return;
}
partials[stream] = "";
appendLine(stream, partial);
};
return {
finish(stream: RuntimeOutputStream) {
flushPartial(stream);
},
pushChunk(stream: RuntimeOutputStream, chunk: string) {
if (!chunk) {
return;
}
let buffer = partials[stream] + chunk;
while (true) {
const newlineIndex = buffer.search(/\r?\n/);
if (newlineIndex < 0) {
break;
}
const newlineWidth = buffer[newlineIndex] === "\r" && buffer[newlineIndex + 1] === "\n" ? 2 : 1;
const line = buffer.slice(0, newlineIndex);
appendLine(stream, line);
buffer = buffer.slice(newlineIndex + newlineWidth);
}
partials[stream] = buffer;
},
snapshot(): RuntimeOutputSnapshot {
const stdout: string[] = [];
const stderr: string[] = [];
for (const line of combined) {
if (line.stream === "stdout") {
stdout.push(line.text);
} else {
stderr.push(line.text);
}
}
return {
combined: combined.map((line) => ({ ...line })),
stderr,
stdout,
totalLines: combined.length,
truncated,
};
},
};
}
export function formatRuntimeOutput(snapshot: RuntimeOutputSnapshot) {
if (snapshot.combined.length === 0) {
return "(no child output captured)";
}
const lines = snapshot.combined.map((line) => `${line.stream}: ${line.text}`);
if (snapshot.truncated) {
lines.unshift("(bounded output buffer truncated older lines)");
}
return lines.join("\n");
}

View File

@@ -0,0 +1,29 @@
import { z } from "zod";
export const requestIdSchema = z.string().min(1).meta({ ref: "OpenWorkServerV2RequestId" });
export const identifierSchema = z.string().min(1).max(200).meta({ ref: "OpenWorkServerV2Identifier" });
export const isoTimestampSchema = z.string().datetime({ offset: true }).meta({ ref: "OpenWorkServerV2IsoTimestamp" });
export const responseMetaSchema = z.object({
requestId: requestIdSchema,
timestamp: isoTimestampSchema,
}).meta({ ref: "OpenWorkServerV2ResponseMeta" });
export const workspaceIdParamsSchema = z.object({
workspaceId: identifierSchema.describe("Stable OpenWork workspace identifier."),
}).meta({ ref: "OpenWorkServerV2WorkspaceIdParams" });
export const paginationQuerySchema = z.object({
cursor: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(100).optional(),
}).meta({ ref: "OpenWorkServerV2PaginationQuery" });
export function successResponseSchema<TSchema extends z.ZodTypeAny>(ref: string, data: TSchema) {
return z.object({
ok: z.literal(true),
data,
meta: responseMetaSchema,
}).meta({ ref });
}

View File

@@ -0,0 +1,57 @@
import { z } from "zod";
import { identifierSchema, successResponseSchema, workspaceIdParamsSchema } from "./common.js";
const jsonRecordSchema = z.record(z.string(), z.unknown());
export const workspaceConfigSnapshotSchema = z.object({
effective: z.object({
opencode: jsonRecordSchema,
openwork: jsonRecordSchema,
}),
materialized: z.object({
compatibilityOpencodePath: z.string().nullable(),
compatibilityOpenworkPath: z.string().nullable(),
configDir: z.string().nullable(),
configOpencodePath: z.string().nullable(),
configOpenworkPath: z.string().nullable(),
}),
stored: z.object({
opencode: jsonRecordSchema,
openwork: jsonRecordSchema,
}),
updatedAt: z.string(),
workspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceConfigSnapshot" });
export const workspaceConfigPatchRequestSchema = z.object({
opencode: jsonRecordSchema.optional(),
openwork: jsonRecordSchema.optional(),
}).meta({ ref: "OpenWorkServerV2WorkspaceConfigPatchRequest" });
export const rawOpencodeConfigQuerySchema = z.object({
scope: z.enum(["global", "project"]).optional(),
}).meta({ ref: "OpenWorkServerV2RawOpencodeConfigQuery" });
export const rawOpencodeConfigWriteRequestSchema = z.object({
content: z.string(),
scope: z.enum(["global", "project"]).optional(),
}).meta({ ref: "OpenWorkServerV2RawOpencodeConfigWriteRequest" });
export const rawOpencodeConfigDataSchema = z.object({
content: z.string(),
exists: z.boolean(),
path: z.string().nullable(),
updatedAt: z.string(),
}).meta({ ref: "OpenWorkServerV2RawOpencodeConfigData" });
export const workspaceConfigResponseSchema = successResponseSchema(
"OpenWorkServerV2WorkspaceConfigResponse",
workspaceConfigSnapshotSchema,
);
export const rawOpencodeConfigResponseSchema = successResponseSchema(
"OpenWorkServerV2RawOpencodeConfigResponse",
rawOpencodeConfigDataSchema,
);
export const rawOpencodeConfigParamsSchema = workspaceIdParamsSchema.meta({ ref: "OpenWorkServerV2RawOpencodeConfigParams" });

View File

@@ -0,0 +1,48 @@
import { z } from "zod";
import { requestIdSchema } from "./common.js";
export const errorDetailSchema = z.object({
message: z.string(),
path: z.array(z.union([z.string(), z.number()])).optional(),
}).meta({ ref: "OpenWorkServerV2ErrorDetail" });
const baseErrorSchema = z.object({
message: z.string(),
requestId: requestIdSchema,
details: z.array(errorDetailSchema).optional(),
});
export const invalidRequestErrorSchema = z.object({
ok: z.literal(false),
error: baseErrorSchema.extend({
code: z.literal("invalid_request"),
}),
}).meta({ ref: "OpenWorkServerV2InvalidRequestError" });
export const unauthorizedErrorSchema = z.object({
ok: z.literal(false),
error: baseErrorSchema.extend({
code: z.literal("unauthorized"),
}),
}).meta({ ref: "OpenWorkServerV2UnauthorizedError" });
export const forbiddenErrorSchema = z.object({
ok: z.literal(false),
error: baseErrorSchema.extend({
code: z.literal("forbidden"),
}),
}).meta({ ref: "OpenWorkServerV2ForbiddenError" });
export const notFoundErrorSchema = z.object({
ok: z.literal(false),
error: baseErrorSchema.extend({
code: z.literal("not_found"),
}),
}).meta({ ref: "OpenWorkServerV2NotFoundError" });
export const internalErrorSchema = z.object({
ok: z.literal(false),
error: baseErrorSchema.extend({
code: z.literal("internal_error"),
}),
}).meta({ ref: "OpenWorkServerV2InternalError" });

View File

@@ -0,0 +1,191 @@
import { z } from "zod";
import { identifierSchema, successResponseSchema, workspaceIdParamsSchema } from "./common.js";
const fileSessionIdParamsSchema = workspaceIdParamsSchema.extend({
fileSessionId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2FileSessionIdParams" });
const jsonRecordSchema = z.record(z.string(), z.unknown());
export const workspaceActivationDataSchema = z.object({
activeWorkspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceActivationData" });
export const engineReloadDataSchema = z.object({
reloadedAt: z.number().int().nonnegative(),
}).meta({ ref: "OpenWorkServerV2EngineReloadData" });
export const workspaceDeleteDataSchema = z.object({
deleted: z.boolean(),
workspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceDeleteData" });
export const workspaceDisposeDataSchema = z.object({
disposed: z.boolean(),
workspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceDisposeData" });
export const workspaceCreateLocalRequestSchema = z.object({
folderPath: z.string().min(1),
name: z.string().min(1),
preset: z.string().min(1).optional(),
}).meta({ ref: "OpenWorkServerV2WorkspaceCreateLocalRequest" });
export const reloadEventSchema = z.object({
id: identifierSchema,
reason: z.enum(["agents", "commands", "config", "mcp", "plugins", "skills"]),
seq: z.number().int().nonnegative(),
timestamp: z.number().int().nonnegative(),
trigger: z.object({
action: z.enum(["added", "removed", "updated"]).optional(),
name: z.string().optional(),
path: z.string().optional(),
type: z.enum(["agent", "command", "config", "mcp", "plugin", "skill"]),
}).optional(),
workspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2ReloadEvent" });
export const reloadEventsDataSchema = z.object({
cursor: z.number().int().nonnegative(),
items: z.array(reloadEventSchema),
}).meta({ ref: "OpenWorkServerV2ReloadEventsData" });
export const fileSessionCreateRequestSchema = z.object({
ttlSeconds: z.number().positive().optional(),
write: z.boolean().optional(),
}).meta({ ref: "OpenWorkServerV2FileSessionCreateRequest" });
export const fileSessionDataSchema = z.object({
canWrite: z.boolean(),
createdAt: z.number().int().nonnegative(),
expiresAt: z.number().int().nonnegative(),
id: identifierSchema,
ttlMs: z.number().int().nonnegative(),
workspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2FileSessionData" });
export const fileCatalogSnapshotSchema = z.object({
cursor: z.number().int().nonnegative(),
generatedAt: z.number().int().nonnegative(),
items: z.array(z.object({
kind: z.enum(["dir", "file"]),
mtimeMs: z.number(),
path: z.string(),
revision: z.string(),
size: z.number().int().nonnegative(),
})),
nextAfter: z.string().optional(),
sessionId: identifierSchema,
total: z.number().int().nonnegative(),
truncated: z.boolean(),
workspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2FileCatalogSnapshot" });
export const fileBatchReadRequestSchema = z.object({
paths: z.array(z.string()).min(1),
}).meta({ ref: "OpenWorkServerV2FileBatchReadRequest" });
export const fileBatchReadResponseSchema = successResponseSchema(
"OpenWorkServerV2FileBatchReadResponse",
z.object({ items: z.array(jsonRecordSchema) }),
);
export const fileBatchWriteRequestSchema = z.object({
writes: z.array(jsonRecordSchema).min(1),
}).meta({ ref: "OpenWorkServerV2FileBatchWriteRequest" });
export const fileOperationsRequestSchema = z.object({
operations: z.array(jsonRecordSchema).min(1),
}).meta({ ref: "OpenWorkServerV2FileOperationsRequest" });
export const fileMutationResultSchema = successResponseSchema(
"OpenWorkServerV2FileMutationResult",
z.object({
cursor: z.number().int().nonnegative(),
items: z.array(jsonRecordSchema),
}),
);
export const simpleContentQuerySchema = z.object({
path: z.string().min(1),
}).meta({ ref: "OpenWorkServerV2SimpleContentQuery" });
export const simpleContentWriteRequestSchema = z.object({
baseUpdatedAt: z.number().nullable().optional(),
content: z.string(),
force: z.boolean().optional(),
path: z.string().min(1),
}).meta({ ref: "OpenWorkServerV2SimpleContentWriteRequest" });
export const simpleContentDataSchema = z.object({
bytes: z.number().int().nonnegative(),
content: z.string(),
path: z.string(),
revision: z.string().optional(),
updatedAt: z.number(),
}).meta({ ref: "OpenWorkServerV2SimpleContentData" });
export const binaryItemSchema = z.object({
id: z.string(),
name: z.string().optional(),
path: z.string(),
size: z.number().int().nonnegative(),
updatedAt: z.number(),
}).meta({ ref: "OpenWorkServerV2BinaryItem" });
export const binaryListResponseSchema = successResponseSchema(
"OpenWorkServerV2BinaryListResponse",
z.object({ items: z.array(binaryItemSchema) }),
);
export const binaryUploadDataSchema = z.object({
bytes: z.number().int().nonnegative(),
path: z.string(),
}).meta({ ref: "OpenWorkServerV2BinaryUploadData" });
export const workspaceActivationResponseSchema = successResponseSchema(
"OpenWorkServerV2WorkspaceActivationResponse",
workspaceActivationDataSchema,
);
export const engineReloadResponseSchema = successResponseSchema(
"OpenWorkServerV2EngineReloadResponse",
engineReloadDataSchema,
);
export const workspaceDeleteResponseSchema = successResponseSchema(
"OpenWorkServerV2WorkspaceDeleteResponse",
workspaceDeleteDataSchema,
);
export const workspaceDisposeResponseSchema = successResponseSchema(
"OpenWorkServerV2WorkspaceDisposeResponse",
workspaceDisposeDataSchema,
);
export const reloadEventsResponseSchema = successResponseSchema(
"OpenWorkServerV2ReloadEventsResponse",
reloadEventsDataSchema,
);
export const fileSessionResponseSchema = successResponseSchema(
"OpenWorkServerV2FileSessionResponse",
fileSessionDataSchema,
);
export const fileCatalogSnapshotResponseSchema = successResponseSchema(
"OpenWorkServerV2FileCatalogSnapshotResponse",
fileCatalogSnapshotSchema,
);
export const simpleContentResponseSchema = successResponseSchema(
"OpenWorkServerV2SimpleContentResponse",
simpleContentDataSchema,
);
export const binaryUploadResponseSchema = successResponseSchema(
"OpenWorkServerV2BinaryUploadResponse",
binaryUploadDataSchema,
);
export { fileSessionIdParamsSchema };

View File

@@ -0,0 +1,300 @@
import { z } from "zod";
import { identifierSchema, isoTimestampSchema, successResponseSchema, workspaceIdParamsSchema } from "./common.js";
const jsonObjectSchema = z.record(z.string(), z.unknown());
export const managedKindSchema = z.enum(["mcps", "plugins", "providerConfigs", "skills"]);
export const managedItemSchema = z.object({
auth: jsonObjectSchema.nullable(),
cloudItemId: z.string().nullable(),
config: jsonObjectSchema,
createdAt: isoTimestampSchema,
displayName: z.string(),
id: identifierSchema,
key: z.string().nullable(),
metadata: jsonObjectSchema.nullable(),
source: z.enum(["cloud_synced", "discovered", "imported", "openwork_managed"]),
updatedAt: isoTimestampSchema,
workspaceIds: z.array(identifierSchema),
}).meta({ ref: "OpenWorkServerV2ManagedItem" });
export const managedItemWriteSchema = z.object({
auth: jsonObjectSchema.nullable().optional(),
cloudItemId: z.string().nullable().optional(),
config: jsonObjectSchema.optional(),
displayName: z.string(),
key: z.string().nullable().optional(),
metadata: jsonObjectSchema.nullable().optional(),
source: z.enum(["cloud_synced", "discovered", "imported", "openwork_managed"]).optional(),
workspaceIds: z.array(identifierSchema).optional(),
}).meta({ ref: "OpenWorkServerV2ManagedItemWrite" });
export const managedAssignmentWriteSchema = z.object({
workspaceIds: z.array(identifierSchema),
}).meta({ ref: "OpenWorkServerV2ManagedAssignmentWrite" });
export const managedItemListResponseSchema = successResponseSchema(
"OpenWorkServerV2ManagedItemListResponse",
z.object({ items: z.array(managedItemSchema) }),
);
export const managedItemResponseSchema = successResponseSchema("OpenWorkServerV2ManagedItemResponse", managedItemSchema);
export const managedDeleteResponseSchema = successResponseSchema(
"OpenWorkServerV2ManagedDeleteResponse",
z.object({ deleted: z.boolean(), id: identifierSchema }),
);
export const workspaceMcpItemSchema = z.object({
config: jsonObjectSchema,
disabledByTools: z.boolean().optional(),
name: z.string(),
source: z.enum(["config.global", "config.project", "config.remote"]),
}).meta({ ref: "OpenWorkServerV2WorkspaceMcpItem" });
export const workspaceMcpListResponseSchema = successResponseSchema(
"OpenWorkServerV2WorkspaceMcpListResponse",
z.object({ items: z.array(workspaceMcpItemSchema) }),
);
export const workspaceMcpWriteSchema = z.object({
config: jsonObjectSchema,
name: z.string(),
}).meta({ ref: "OpenWorkServerV2WorkspaceMcpWrite" });
export const workspacePluginItemSchema = z.object({
path: z.string().optional(),
scope: z.enum(["global", "project"]),
source: z.enum(["config", "dir.project", "dir.global"]),
spec: z.string(),
}).meta({ ref: "OpenWorkServerV2WorkspacePluginItem" });
export const workspacePluginListResponseSchema = successResponseSchema(
"OpenWorkServerV2WorkspacePluginListResponse",
z.object({ items: z.array(workspacePluginItemSchema), loadOrder: z.array(z.string()) }),
);
export const workspacePluginWriteSchema = z.object({ spec: z.string() }).meta({ ref: "OpenWorkServerV2WorkspacePluginWrite" });
export const scheduledJobRunSchema = z.object({
agent: z.string().optional(),
arguments: z.string().optional(),
attachUrl: z.string().optional(),
command: z.string().optional(),
continue: z.boolean().optional(),
files: z.array(z.string()).optional(),
model: z.string().optional(),
port: z.number().int().optional(),
prompt: z.string().optional(),
runFormat: z.string().optional(),
session: z.string().optional(),
share: z.boolean().optional(),
timeoutSeconds: z.number().int().optional(),
title: z.string().optional(),
variant: z.string().optional(),
}).meta({ ref: "OpenWorkServerV2ScheduledJobRun" });
export const scheduledJobSchema = z.object({
attachUrl: z.string().optional(),
createdAt: isoTimestampSchema,
invocation: z.object({ args: z.array(z.string()), command: z.string() }).optional(),
lastRunAt: isoTimestampSchema.optional(),
lastRunError: z.string().optional(),
lastRunExitCode: z.number().int().optional(),
lastRunSource: z.string().optional(),
lastRunStatus: z.string().optional(),
name: z.string(),
prompt: z.string().optional(),
run: scheduledJobRunSchema.optional(),
schedule: z.string(),
scopeId: z.string().optional(),
slug: z.string(),
source: z.string().optional(),
timeoutSeconds: z.number().int().optional(),
updatedAt: isoTimestampSchema.optional(),
workdir: z.string().optional(),
}).meta({ ref: "OpenWorkServerV2ScheduledJob" });
export const scheduledJobListResponseSchema = successResponseSchema(
"OpenWorkServerV2ScheduledJobListResponse",
z.object({ items: z.array(scheduledJobSchema) }),
);
export const scheduledJobDeleteResponseSchema = successResponseSchema(
"OpenWorkServerV2ScheduledJobDeleteResponse",
z.object({ job: scheduledJobSchema }),
);
export const workspaceSkillItemSchema = z.object({
description: z.string(),
name: z.string(),
path: z.string(),
scope: z.enum(["global", "project"]),
trigger: z.string().optional(),
}).meta({ ref: "OpenWorkServerV2WorkspaceSkillItem" });
export const workspaceSkillContentSchema = z.object({
content: z.string(),
item: workspaceSkillItemSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceSkillContent" });
export const workspaceSkillListResponseSchema = successResponseSchema(
"OpenWorkServerV2WorkspaceSkillListResponse",
z.object({ items: z.array(workspaceSkillItemSchema) }),
);
export const workspaceSkillResponseSchema = successResponseSchema("OpenWorkServerV2WorkspaceSkillResponse", workspaceSkillContentSchema);
export const workspaceSkillWriteSchema = z.object({
content: z.string(),
description: z.string().optional(),
name: z.string(),
trigger: z.string().optional(),
}).meta({ ref: "OpenWorkServerV2WorkspaceSkillWrite" });
export const workspaceSkillDeleteResponseSchema = successResponseSchema(
"OpenWorkServerV2WorkspaceSkillDeleteResponse",
z.object({ path: z.string() }),
);
export const hubRepoSchema = z.object({
owner: z.string().optional(),
ref: z.string().optional(),
repo: z.string().optional(),
}).meta({ ref: "OpenWorkServerV2HubRepo" });
export const hubSkillItemSchema = z.object({
description: z.string(),
name: z.string(),
source: z.object({ owner: z.string(), path: z.string(), ref: z.string(), repo: z.string() }),
trigger: z.string().optional(),
}).meta({ ref: "OpenWorkServerV2HubSkillItem" });
export const hubSkillListResponseSchema = successResponseSchema(
"OpenWorkServerV2HubSkillListResponse",
z.object({ items: z.array(hubSkillItemSchema) }),
);
export const hubSkillInstallWriteSchema = z.object({
overwrite: z.boolean().optional(),
repo: hubRepoSchema.optional(),
}).meta({ ref: "OpenWorkServerV2HubSkillInstallWrite" });
export const hubSkillInstallResponseSchema = successResponseSchema(
"OpenWorkServerV2HubSkillInstallResponse",
z.object({
action: z.enum(["added", "updated"]),
name: z.string(),
path: z.string(),
skipped: z.number().int().nonnegative(),
written: z.number().int().nonnegative(),
}),
);
export const cloudSigninSchema = z.object({
auth: jsonObjectSchema.nullable(),
cloudBaseUrl: z.string(),
createdAt: isoTimestampSchema,
id: identifierSchema,
lastValidatedAt: isoTimestampSchema.nullable(),
metadata: jsonObjectSchema.nullable(),
orgId: z.string().nullable(),
serverId: identifierSchema,
updatedAt: isoTimestampSchema,
userId: z.string().nullable(),
}).meta({ ref: "OpenWorkServerV2CloudSignin" });
export const cloudSigninWriteSchema = z.object({
auth: jsonObjectSchema.nullable().optional(),
cloudBaseUrl: z.string(),
metadata: jsonObjectSchema.nullable().optional(),
orgId: z.string().nullable().optional(),
userId: z.string().nullable().optional(),
}).meta({ ref: "OpenWorkServerV2CloudSigninWrite" });
export const cloudSigninResponseSchema = successResponseSchema("OpenWorkServerV2CloudSigninResponse", cloudSigninSchema.nullable());
export const cloudSigninValidationResponseSchema = successResponseSchema(
"OpenWorkServerV2CloudSigninValidationResponse",
z.object({ lastValidatedAt: isoTimestampSchema.nullable(), ok: z.boolean(), record: cloudSigninSchema }),
);
export const workspaceShareSchema = z.object({
accessKey: z.string().nullable(),
audit: jsonObjectSchema.nullable(),
createdAt: isoTimestampSchema,
id: identifierSchema,
lastUsedAt: isoTimestampSchema.nullable(),
revokedAt: isoTimestampSchema.nullable(),
status: z.enum(["active", "disabled", "revoked"]),
updatedAt: isoTimestampSchema,
workspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceShare" });
export const workspaceShareResponseSchema = successResponseSchema("OpenWorkServerV2WorkspaceShareResponse", workspaceShareSchema.nullable());
export const workspaceExportWarningSchema = z.object({
detail: z.string(),
id: z.string(),
label: z.string(),
}).meta({ ref: "OpenWorkServerV2WorkspaceExportWarning" });
export const workspaceExportDataSchema = z.object({
commands: z.array(z.object({ description: z.string().optional(), name: z.string(), template: z.string() })),
exportedAt: z.number().int().nonnegative(),
files: z.array(z.object({ content: z.string(), path: z.string() })).optional(),
openwork: jsonObjectSchema,
opencode: jsonObjectSchema,
skills: z.array(z.object({ content: z.string(), description: z.string().optional(), name: z.string(), trigger: z.string().optional() })),
workspaceId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceExportData" });
export const workspaceExportResponseSchema = successResponseSchema("OpenWorkServerV2WorkspaceExportResponse", workspaceExportDataSchema);
export const workspaceImportWriteSchema = z.record(z.string(), z.unknown()).meta({ ref: "OpenWorkServerV2WorkspaceImportWrite" });
export const workspaceImportResponseSchema = successResponseSchema("OpenWorkServerV2WorkspaceImportResponse", z.object({ ok: z.boolean() }));
export const sharedBundlePublishWriteSchema = z.object({
bundleType: z.string(),
name: z.string().optional(),
payload: z.unknown(),
timeoutMs: z.number().int().positive().optional(),
}).meta({ ref: "OpenWorkServerV2SharedBundlePublishWrite" });
export const sharedBundleFetchWriteSchema = z.object({
bundleUrl: z.string(),
timeoutMs: z.number().int().positive().optional(),
}).meta({ ref: "OpenWorkServerV2SharedBundleFetchWrite" });
export const sharedBundlePublishResponseSchema = successResponseSchema(
"OpenWorkServerV2SharedBundlePublishResponse",
z.object({ url: z.string() }),
);
export const sharedBundleFetchResponseSchema = successResponseSchema(
"OpenWorkServerV2SharedBundleFetchResponse",
z.record(z.string(), z.unknown()),
);
export const routerIdentityItemSchema = z.object({
access: z.enum(["private", "public"]).optional(),
enabled: z.boolean(),
id: z.string(),
pairingRequired: z.boolean().optional(),
running: z.boolean(),
}).meta({ ref: "OpenWorkServerV2RouterIdentityItem" });
export const routerHealthSnapshotSchema = z.object({
config: z.object({ groupsEnabled: z.boolean() }),
channels: z.object({ slack: z.boolean(), telegram: z.boolean(), whatsapp: z.boolean() }),
ok: z.boolean(),
opencode: z.object({ healthy: z.boolean(), url: z.string(), version: z.string().optional() }),
}).meta({ ref: "OpenWorkServerV2RouterHealthSnapshot" });
export const routerIdentityListResponseSchema = successResponseSchema(
"OpenWorkServerV2RouterIdentityListResponse",
z.object({ items: z.array(routerIdentityItemSchema), ok: z.boolean() }),
);
export const routerTelegramInfoResponseSchema = successResponseSchema(
"OpenWorkServerV2RouterTelegramInfoResponse",
z.object({
bot: z.object({ id: z.number().int(), name: z.string().optional(), username: z.string().optional() }).nullable(),
configured: z.boolean(),
enabled: z.boolean(),
ok: z.boolean(),
}),
);
export const routerHealthResponseSchemaCompat = successResponseSchema("OpenWorkServerV2RouterHealthCompatResponse", routerHealthSnapshotSchema);
export const routerTelegramWriteSchema = z.object({ access: z.enum(["private", "public"]).optional(), enabled: z.boolean().optional(), id: z.string().optional(), token: z.string() }).meta({ ref: "OpenWorkServerV2RouterTelegramWrite" });
export const routerSlackWriteSchema = z.object({ appToken: z.string(), botToken: z.string(), enabled: z.boolean().optional(), id: z.string().optional() }).meta({ ref: "OpenWorkServerV2RouterSlackWrite" });
export const routerBindingWriteSchema = z.object({ channel: z.enum(["slack", "telegram"]), directory: z.string().optional(), identityId: z.string().optional(), peerId: z.string() }).meta({ ref: "OpenWorkServerV2RouterBindingWrite" });
export const routerBindingListResponseSchema = successResponseSchema(
"OpenWorkServerV2RouterBindingListResponse",
z.object({
items: z.array(z.object({ channel: z.string(), directory: z.string(), identityId: z.string(), peerId: z.string(), updatedAt: z.number().int().optional() })),
ok: z.boolean(),
}),
);
export const routerSendWriteSchema = z.object({ autoBind: z.boolean().optional(), channel: z.enum(["slack", "telegram"]), directory: z.string().optional(), identityId: z.string().optional(), peerId: z.string().optional(), text: z.string() }).meta({ ref: "OpenWorkServerV2RouterSendWrite" });
export const routerMutationResponseSchema = successResponseSchema(
"OpenWorkServerV2RouterMutationResponse",
z.record(z.string(), z.unknown()),
);
export const managedItemIdParamsSchema = z.object({ itemId: identifierSchema }).meta({ ref: "OpenWorkServerV2ManagedItemIdParams" });
export const workspaceNamedItemParamsSchema = workspaceIdParamsSchema.extend({ name: z.string() }).meta({ ref: "OpenWorkServerV2WorkspaceNamedItemParams" });
export const workspaceIdentityParamsSchema = workspaceIdParamsSchema.extend({ identityId: identifierSchema }).meta({ ref: "OpenWorkServerV2WorkspaceIdentityParams" });

View File

@@ -0,0 +1,261 @@
import { z } from "zod";
import { identifierSchema, isoTimestampSchema, successResponseSchema } from "./common.js";
const jsonObjectSchema = z.record(z.string(), z.unknown());
export const authSummarySchema = z.object({
actorKind: z.enum(["anonymous", "client", "host"]),
configured: z.object({
clientToken: z.boolean(),
hostToken: z.boolean(),
}),
headers: z.object({
authorization: z.literal("Authorization"),
hostToken: z.literal("X-OpenWork-Host-Token"),
}),
required: z.boolean(),
scopes: z.object({
hiddenWorkspaceReads: z.literal("host"),
serverInventory: z.literal("host"),
visibleRead: z.literal("client_or_host"),
}),
}).meta({ ref: "OpenWorkServerV2AuthSummary" });
export const serverInventoryItemSchema = z.object({
auth: z.object({
configured: z.boolean(),
scheme: z.enum(["bearer", "none"]),
}),
baseUrl: z.string().nullable(),
capabilities: jsonObjectSchema,
hostingKind: z.enum(["desktop", "self_hosted", "cloud"]),
id: identifierSchema,
isEnabled: z.boolean(),
isLocal: z.boolean(),
kind: z.enum(["local", "remote"]),
label: z.string(),
lastSeenAt: isoTimestampSchema.nullable(),
source: z.string(),
updatedAt: isoTimestampSchema,
}).meta({ ref: "OpenWorkServerV2ServerInventoryItem" });
export const registrySummarySchema = z.object({
hiddenWorkspaceCount: z.number().int().nonnegative(),
localServerId: identifierSchema,
remoteServerCount: z.number().int().nonnegative(),
totalServers: z.number().int().nonnegative(),
visibleWorkspaceCount: z.number().int().nonnegative(),
}).meta({ ref: "OpenWorkServerV2RegistrySummary" });
export const capabilitiesDataSchema = z.object({
auth: authSummarySchema,
bundles: z.object({
fetch: z.literal(true),
publish: z.literal(true),
workspaceExport: z.literal(true),
workspaceImport: z.literal(true),
}),
cloud: z.object({
persistence: z.literal(true),
validation: z.literal(true),
}),
config: z.object({
projection: z.literal(true),
rawRead: z.literal(true),
rawWrite: z.literal(true),
read: z.literal(true),
write: z.literal(true),
}),
files: z.object({
artifacts: z.literal(true),
contentRoutes: z.literal(true),
fileSessions: z.literal(true),
inbox: z.literal(true),
mutations: z.literal(true),
}),
managed: z.object({
assignments: z.literal(true),
mcps: z.literal(true),
plugins: z.literal(true),
providerConfigs: z.literal(true),
skills: z.literal(true),
}),
reload: z.object({
manualEngineReload: z.literal(true),
reconciliation: z.literal(true),
watch: z.literal(true),
workspaceEvents: z.literal(true),
}),
registry: z.object({
backendResolution: z.literal(true),
remoteServerConnections: z.literal(true),
remoteWorkspaceSync: z.literal(true),
hiddenWorkspaceFiltering: z.literal(true),
serverInventory: z.literal(true),
workspaceDetail: z.literal(true),
workspaceList: z.literal(true),
}),
sessions: z.object({
events: z.literal(true),
list: z.literal(true),
messages: z.literal(true),
mutations: z.literal(true),
promptAsync: z.literal(true),
revertHistory: z.literal(true),
}),
runtime: z.object({
opencodeHealth: z.literal(true),
routerHealth: z.literal(true),
runtimeSummary: z.literal(true),
runtimeUpgrade: z.literal(true),
runtimeVersions: z.literal(true),
}),
router: z.object({
bindings: z.literal(true),
identities: z.literal(true),
outboundSend: z.literal(true),
productRoutes: z.literal(true),
}),
shares: z.object({
workspaceScoped: z.literal(true),
}),
workspaces: z.object({
activate: z.literal(true),
createLocal: z.literal(true),
}),
transport: z.object({
rootMounted: z.literal(true),
sdkPackage: z.literal("@openwork/server-sdk"),
v2: z.literal(true),
}),
}).meta({ ref: "OpenWorkServerV2CapabilitiesData" });
const workspaceBackendSchema = z.object({
kind: z.enum(["local_opencode", "remote_openwork"]),
local: z.object({
configDir: z.string().nullable(),
dataDir: z.string().nullable(),
opencodeProjectId: z.string().nullable(),
}).nullable(),
remote: z.object({
directory: z.string().nullable(),
hostUrl: z.string().nullable(),
remoteType: z.enum(["openwork", "opencode"]),
remoteWorkspaceId: z.string().nullable(),
workspaceName: z.string().nullable(),
}).nullable(),
serverId: identifierSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceBackend" });
const workspaceRuntimeSummarySchema = z.object({
backendKind: z.enum(["local_opencode", "remote_openwork"]),
health: jsonObjectSchema.nullable(),
lastError: jsonObjectSchema.nullable(),
lastSessionRefreshAt: isoTimestampSchema.nullable(),
lastSyncAt: isoTimestampSchema.nullable(),
updatedAt: isoTimestampSchema.nullable(),
}).meta({ ref: "OpenWorkServerV2WorkspaceRuntimeSummary" });
export const workspaceSummaryDataSchema = z.object({
backend: workspaceBackendSchema,
createdAt: isoTimestampSchema,
displayName: z.string(),
hidden: z.boolean(),
id: identifierSchema,
kind: z.enum(["local", "remote", "control", "help"]),
preset: z.enum(["minimal", "remote", "starter"]),
runtime: workspaceRuntimeSummarySchema,
server: serverInventoryItemSchema,
slug: z.string(),
status: z.enum(["ready", "imported", "attention"]),
updatedAt: isoTimestampSchema,
}).meta({ ref: "OpenWorkServerV2WorkspaceSummaryData" });
export const workspaceDetailDataSchema = workspaceSummaryDataSchema.extend({
notes: jsonObjectSchema.nullable(),
}).meta({ ref: "OpenWorkServerV2WorkspaceDetailData" });
export const workspaceListDataSchema = z.object({
items: z.array(workspaceSummaryDataSchema),
}).meta({ ref: "OpenWorkServerV2WorkspaceListData" });
export const serverInventoryListDataSchema = z.object({
items: z.array(serverInventoryItemSchema),
}).meta({ ref: "OpenWorkServerV2ServerInventoryListData" });
export const remoteServerConnectRequestSchema = z.object({
baseUrl: z.string().min(1),
directory: z.string().nullable().optional(),
hostToken: z.string().nullable().optional(),
label: z.string().nullable().optional(),
token: z.string().nullable().optional(),
workspaceId: z.string().nullable().optional(),
}).meta({ ref: "OpenWorkServerV2RemoteServerConnectRequest" });
export const remoteServerSyncRequestSchema = z.object({
directory: z.string().nullable().optional(),
workspaceId: z.string().nullable().optional(),
}).meta({ ref: "OpenWorkServerV2RemoteServerSyncRequest" });
export const remoteServerConnectDataSchema = z.object({
selectedWorkspaceId: identifierSchema.nullable(),
server: serverInventoryItemSchema,
workspaces: z.array(workspaceSummaryDataSchema),
}).meta({ ref: "OpenWorkServerV2RemoteServerConnectData" });
export const systemStatusDataSchema = z.object({
auth: authSummarySchema,
capabilities: capabilitiesDataSchema,
database: z.object({
bootstrapMode: z.enum(["fresh", "existing"]),
configured: z.literal(true),
importWarnings: z.number().int().nonnegative(),
kind: z.literal("sqlite"),
migrations: z.object({
appliedThisRun: z.array(z.string()),
currentVersion: z.string(),
totalApplied: z.number().int().nonnegative(),
}),
path: z.string(),
phaseOwner: z.literal(2),
status: z.enum(["ready", "warning"]),
summary: z.string(),
workingDirectory: z.string(),
}),
environment: z.string(),
registry: registrySummarySchema,
runtime: z.object({
opencode: z.object({
baseUrl: z.string().nullable(),
running: z.boolean(),
status: z.enum(["crashed", "disabled", "error", "restart_scheduled", "running", "starting", "stopped"]),
version: z.string().nullable(),
}),
router: z.object({
baseUrl: z.string().nullable(),
running: z.boolean(),
status: z.enum(["crashed", "disabled", "error", "restart_scheduled", "running", "starting", "stopped"]),
version: z.string().nullable(),
}),
source: z.enum(["development", "release"]),
target: z.enum(["darwin-arm64", "darwin-x64", "linux-arm64", "linux-x64", "windows-arm64", "windows-x64"]),
}),
service: z.literal("openwork-server-v2"),
startedAt: isoTimestampSchema,
status: z.literal("ok"),
uptimeMs: z.number().int().nonnegative(),
version: z.string(),
}).meta({ ref: "OpenWorkServerV2SystemStatusData" });
export const capabilitiesResponseSchema = successResponseSchema("OpenWorkServerV2CapabilitiesResponse", capabilitiesDataSchema);
export const serverInventoryListResponseSchema = successResponseSchema(
"OpenWorkServerV2ServerInventoryListResponse",
serverInventoryListDataSchema,
);
export const remoteServerConnectResponseSchema = successResponseSchema(
"OpenWorkServerV2RemoteServerConnectResponse",
remoteServerConnectDataSchema,
);
export const systemStatusResponseSchema = successResponseSchema("OpenWorkServerV2SystemStatusResponse", systemStatusDataSchema);
export const workspaceDetailResponseSchema = successResponseSchema("OpenWorkServerV2WorkspaceDetailResponse", workspaceDetailDataSchema);
export const workspaceListResponseSchema = successResponseSchema("OpenWorkServerV2WorkspaceListResponse", workspaceListDataSchema);

View File

@@ -0,0 +1,156 @@
import { z } from "zod";
import { isoTimestampSchema, successResponseSchema } from "./common.js";
const runtimeOutputSnapshotSchema = z.object({
combined: z.array(z.object({
at: isoTimestampSchema,
stream: z.enum(["stdout", "stderr"]),
text: z.string(),
})),
stderr: z.array(z.string()),
stdout: z.array(z.string()),
totalLines: z.number().int().nonnegative(),
truncated: z.boolean(),
}).meta({ ref: "OpenWorkServerV2RuntimeOutputSnapshot" });
const runtimeTargetSchema = z.enum([
"darwin-arm64",
"darwin-x64",
"linux-arm64",
"linux-x64",
"windows-arm64",
"windows-x64",
]).meta({ ref: "OpenWorkServerV2RuntimeTarget" });
const runtimeManifestSchema = z.object({
files: z.object({
opencode: z.object({
path: z.string(),
sha256: z.string(),
size: z.number().int().nonnegative(),
}),
"opencode-router": z.object({
path: z.string(),
sha256: z.string(),
size: z.number().int().nonnegative(),
}),
}),
generatedAt: isoTimestampSchema,
manifestVersion: z.literal(1),
opencodeVersion: z.string(),
rootDir: z.string(),
routerVersion: z.string(),
serverVersion: z.string(),
source: z.enum(["development", "release"]),
target: runtimeTargetSchema,
}).meta({ ref: "OpenWorkServerV2RuntimeManifest" });
const lastExitSchema = z.object({
at: isoTimestampSchema,
code: z.number().int().nullable(),
output: runtimeOutputSnapshotSchema,
reason: z.string(),
signal: z.string().nullable(),
}).meta({ ref: "OpenWorkServerV2RuntimeLastExit" });
const routerEnablementSchema = z.object({
enabled: z.boolean(),
enabledBindingCount: z.number().int().nonnegative(),
enabledIdentityCount: z.number().int().nonnegative(),
forced: z.boolean(),
reason: z.string(),
}).meta({ ref: "OpenWorkServerV2RouterEnablement" });
const routerMaterializationSchema = z.object({
bindingCount: z.number().int().nonnegative(),
configPath: z.string(),
dataDir: z.string(),
dbPath: z.string(),
identityCount: z.number().int().nonnegative(),
logFile: z.string(),
}).meta({ ref: "OpenWorkServerV2RouterMaterialization" });
const runtimeChildStatusSchema = z.enum(["crashed", "disabled", "error", "restart_scheduled", "running", "starting", "stopped"]);
const runtimeUpgradeStateSchema = z.object({
error: z.string().nullable(),
finishedAt: isoTimestampSchema.nullable(),
startedAt: isoTimestampSchema.nullable(),
status: z.enum(["completed", "failed", "idle", "running"]),
}).meta({ ref: "OpenWorkServerV2RuntimeUpgradeState" });
export const opencodeHealthDataSchema = z.object({
baseUrl: z.string().nullable(),
binaryPath: z.string().nullable(),
diagnostics: runtimeOutputSnapshotSchema,
lastError: z.string().nullable(),
lastExit: lastExitSchema.nullable(),
lastReadyAt: isoTimestampSchema.nullable(),
lastStartedAt: isoTimestampSchema.nullable(),
manifest: runtimeManifestSchema.nullable(),
pid: z.number().int().nullable(),
running: z.boolean(),
source: z.enum(["development", "release"]),
status: runtimeChildStatusSchema,
version: z.string().nullable(),
}).meta({ ref: "OpenWorkServerV2OpencodeHealthData" });
export const routerHealthDataSchema = z.object({
baseUrl: z.string().nullable(),
binaryPath: z.string().nullable(),
diagnostics: runtimeOutputSnapshotSchema,
enablement: routerEnablementSchema,
healthUrl: z.string().nullable(),
lastError: z.string().nullable(),
lastExit: lastExitSchema.nullable(),
lastReadyAt: isoTimestampSchema.nullable(),
lastStartedAt: isoTimestampSchema.nullable(),
manifest: runtimeManifestSchema.nullable(),
materialization: routerMaterializationSchema.nullable(),
pid: z.number().int().nullable(),
running: z.boolean(),
source: z.enum(["development", "release"]),
status: runtimeChildStatusSchema,
version: z.string().nullable(),
}).meta({ ref: "OpenWorkServerV2RouterHealthData" });
export const runtimeSummaryDataSchema = z.object({
bootstrapPolicy: z.enum(["disabled", "eager", "manual"]),
manifest: runtimeManifestSchema.nullable(),
opencode: opencodeHealthDataSchema,
restartPolicy: z.object({
backoffMs: z.number().int().nonnegative(),
maxAttempts: z.number().int().nonnegative(),
windowMs: z.number().int().nonnegative(),
}),
router: routerHealthDataSchema,
upgrade: runtimeUpgradeStateSchema,
source: z.enum(["development", "release"]),
target: runtimeTargetSchema,
}).meta({ ref: "OpenWorkServerV2RuntimeSummaryData" });
export const runtimeUpgradeDataSchema = z.object({
state: runtimeUpgradeStateSchema,
summary: runtimeSummaryDataSchema,
}).meta({ ref: "OpenWorkServerV2RuntimeUpgradeData" });
export const runtimeVersionsDataSchema = z.object({
active: z.object({
opencodeVersion: z.string().nullable(),
routerVersion: z.string().nullable(),
serverVersion: z.string(),
}),
manifest: runtimeManifestSchema.nullable(),
pinned: z.object({
opencodeVersion: z.string().nullable(),
routerVersion: z.string().nullable(),
serverVersion: z.string(),
}),
target: runtimeTargetSchema,
}).meta({ ref: "OpenWorkServerV2RuntimeVersionsData" });
export const opencodeHealthResponseSchema = successResponseSchema("OpenWorkServerV2OpencodeHealthResponse", opencodeHealthDataSchema);
export const routerHealthResponseSchema = successResponseSchema("OpenWorkServerV2RouterHealthResponse", routerHealthDataSchema);
export const runtimeSummaryResponseSchema = successResponseSchema("OpenWorkServerV2RuntimeSummaryResponse", runtimeSummaryDataSchema);
export const runtimeVersionsResponseSchema = successResponseSchema("OpenWorkServerV2RuntimeVersionsResponse", runtimeVersionsDataSchema);
export const runtimeUpgradeResponseSchema = successResponseSchema("OpenWorkServerV2RuntimeUpgradeResponse", runtimeUpgradeDataSchema);

View File

@@ -0,0 +1,249 @@
import { z } from "zod";
import { identifierSchema, successResponseSchema, workspaceIdParamsSchema } from "./common.js";
const jsonRecordSchema = z.record(z.string(), z.unknown());
export const sessionStatusSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("idle") }),
z.object({ type: z.literal("busy") }),
z.object({
type: z.literal("retry"),
attempt: z.number(),
message: z.string(),
next: z.number(),
}),
]).meta({ ref: "OpenWorkServerV2SessionStatus" });
const sessionTimeSchema = z.object({
archived: z.number().optional(),
completed: z.number().optional(),
created: z.number().optional(),
updated: z.number().optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionTime" });
const sessionSummarySchema = z.object({
additions: z.number().optional(),
deletions: z.number().optional(),
files: z.number().optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionSummary" });
export const sessionSchema = z.object({
directory: z.string().nullish(),
id: identifierSchema,
parentID: z.string().nullish(),
revert: z.object({
messageID: identifierSchema,
}).partial().nullish(),
slug: z.string().nullish(),
summary: sessionSummarySchema.optional(),
time: sessionTimeSchema.optional(),
title: z.string().nullish(),
}).passthrough().meta({ ref: "OpenWorkServerV2Session" });
const sessionMessageInfoSchema = z.object({
id: identifierSchema,
parentID: z.string().nullish(),
role: z.string(),
sessionID: identifierSchema,
time: sessionTimeSchema.optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionMessageInfo" });
export const sessionMessagePartSchema = z.object({
id: identifierSchema,
messageID: identifierSchema,
sessionID: identifierSchema,
type: z.string().optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionMessagePart" });
export const sessionMessageSchema = z.object({
info: sessionMessageInfoSchema,
parts: z.array(sessionMessagePartSchema),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionMessage" });
export const sessionTodoSchema = z.object({
content: z.string(),
priority: z.string(),
status: z.string(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionTodo" });
export const sessionSnapshotSchema = z.object({
messages: z.array(sessionMessageSchema),
session: sessionSchema,
status: sessionStatusSchema,
todos: z.array(sessionTodoSchema),
}).meta({ ref: "OpenWorkServerV2SessionSnapshot" });
export const workspaceEventSchema = z.object({
properties: z.unknown().optional(),
type: z.string(),
}).meta({ ref: "OpenWorkServerV2WorkspaceEvent" });
export const sessionListQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).optional(),
roots: z.coerce.boolean().optional(),
search: z.string().trim().min(1).optional(),
start: z.coerce.number().int().nonnegative().optional(),
}).meta({ ref: "OpenWorkServerV2SessionListQuery" });
export const sessionMessagesQuerySchema = z.object({
limit: z.coerce.number().int().positive().max(500).optional(),
}).meta({ ref: "OpenWorkServerV2SessionMessagesQuery" });
export const sessionIdParamsSchema = workspaceIdParamsSchema.extend({
sessionId: identifierSchema.describe("Stable session identifier within the resolved workspace backend."),
}).meta({ ref: "OpenWorkServerV2SessionIdParams" });
export const messageIdParamsSchema = sessionIdParamsSchema.extend({
messageId: identifierSchema.describe("Stable message identifier within the resolved session."),
}).meta({ ref: "OpenWorkServerV2MessageIdParams" });
export const messagePartParamsSchema = messageIdParamsSchema.extend({
partId: identifierSchema.describe("Stable message part identifier within the resolved message."),
}).meta({ ref: "OpenWorkServerV2MessagePartParams" });
export const sessionCreateRequestSchema = z.object({
parentSessionId: identifierSchema.optional(),
title: z.string().trim().min(1).max(300).optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionCreateRequest" });
export const sessionUpdateRequestSchema = z.object({
archived: z.boolean().optional(),
title: z.string().trim().min(1).max(300).optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionUpdateRequest" });
export const sessionForkRequestSchema = z.object({
title: z.string().trim().min(1).max(300).optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionForkRequest" });
export const sessionSummarizeRequestSchema = z.object({
modelID: z.string().trim().min(1).optional(),
providerID: z.string().trim().min(1).optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2SessionSummarizeRequest" });
export const messageSendRequestSchema = z.object({
parts: z.array(z.unknown()).optional(),
role: z.string().optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2MessageSendRequest" });
export const promptAsyncRequestSchema = z.object({
agent: z.string().optional(),
messageID: identifierSchema.optional(),
model: z.object({
modelID: z.string(),
providerID: z.string(),
}).optional(),
noReply: z.boolean().optional(),
parts: z.array(z.unknown()).optional(),
reasoning_effort: z.string().optional(),
system: z.string().optional(),
tools: z.record(z.string(), z.boolean()).optional(),
variant: z.string().optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2PromptAsyncRequest" });
export const commandRequestSchema = z.object({
agent: z.string().optional(),
arguments: z.string().optional(),
command: z.string().min(1),
messageID: identifierSchema.optional(),
model: z.string().optional(),
parts: z.array(z.unknown()).optional(),
reasoning_effort: z.string().optional(),
variant: z.string().optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2CommandRequest" });
export const shellRequestSchema = z.object({
command: z.string().min(1),
}).passthrough().meta({ ref: "OpenWorkServerV2ShellRequest" });
export const revertRequestSchema = z.object({
messageID: identifierSchema,
}).meta({ ref: "OpenWorkServerV2RevertRequest" });
export const messagePartUpdateRequestSchema = z.object({
text: z.string().optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2MessagePartUpdateRequest" });
export const sessionListDataSchema = z.object({
items: z.array(sessionSchema),
}).meta({ ref: "OpenWorkServerV2SessionListData" });
export const sessionStatusesDataSchema = z.object({
items: z.record(z.string(), sessionStatusSchema),
}).meta({ ref: "OpenWorkServerV2SessionStatusesData" });
export const sessionTodoListDataSchema = z.object({
items: z.array(sessionTodoSchema),
}).meta({ ref: "OpenWorkServerV2SessionTodoListData" });
export const messageListDataSchema = z.object({
items: z.array(sessionMessageSchema),
}).meta({ ref: "OpenWorkServerV2MessageListData" });
export const acceptedActionDataSchema = z.object({
accepted: z.literal(true),
}).meta({ ref: "OpenWorkServerV2AcceptedActionData" });
export const deletedActionDataSchema = z.object({
deleted: z.literal(true),
}).meta({ ref: "OpenWorkServerV2DeletedActionData" });
export const sessionResponseSchema = successResponseSchema("OpenWorkServerV2SessionResponse", sessionSchema);
export const sessionListResponseSchema = successResponseSchema("OpenWorkServerV2SessionListResponse", sessionListDataSchema);
export const sessionStatusesResponseSchema = successResponseSchema(
"OpenWorkServerV2SessionStatusesResponse",
sessionStatusesDataSchema,
);
export const sessionStatusResponseSchema = successResponseSchema("OpenWorkServerV2SessionStatusResponse", sessionStatusSchema);
export const sessionTodoListResponseSchema = successResponseSchema(
"OpenWorkServerV2SessionTodoListResponse",
sessionTodoListDataSchema,
);
export const sessionSnapshotResponseSchema = successResponseSchema(
"OpenWorkServerV2SessionSnapshotResponse",
sessionSnapshotSchema,
);
export const messageResponseSchema = successResponseSchema("OpenWorkServerV2MessageResponse", sessionMessageSchema);
export const messageListResponseSchema = successResponseSchema("OpenWorkServerV2MessageListResponse", messageListDataSchema);
export const acceptedActionResponseSchema = successResponseSchema(
"OpenWorkServerV2AcceptedActionResponse",
acceptedActionDataSchema,
);
export const deletedActionResponseSchema = successResponseSchema(
"OpenWorkServerV2DeletedActionResponse",
deletedActionDataSchema,
);
export type SessionRecord = z.infer<typeof sessionSchema>;
export type SessionMessageRecord = z.infer<typeof sessionMessageSchema>;
export type SessionSnapshotRecord = z.infer<typeof sessionSnapshotSchema>;
export type SessionStatusRecord = z.infer<typeof sessionStatusSchema>;
export type SessionTodoRecord = z.infer<typeof sessionTodoSchema>;
export type WorkspaceEventRecord = z.infer<typeof workspaceEventSchema>;
export function parseSessionData(value: unknown) {
return sessionSchema.parse(value);
}
export function parseSessionListData(value: unknown) {
return z.array(sessionSchema).parse(value);
}
export function parseSessionMessageData(value: unknown) {
return sessionMessageSchema.parse(value);
}
export function parseSessionMessagesData(value: unknown) {
return z.array(sessionMessageSchema).parse(value);
}
export function parseSessionStatusesData(value: unknown) {
return z.record(z.string(), sessionStatusSchema).parse(value);
}
export function parseSessionTodosData(value: unknown) {
return z.array(sessionTodoSchema).parse(value);
}
export function parseWorkspaceEventData(value: unknown) {
return workspaceEventSchema.parse(value);
}

View File

@@ -0,0 +1,133 @@
import { z } from "zod";
import { identifierSchema, isoTimestampSchema, successResponseSchema } from "./common.js";
import { runtimeSummaryDataSchema } from "./runtime.js";
const jsonObjectSchema = z.record(z.string(), z.unknown());
export const routeNamespacesSchema = z.object({
root: z.literal("/"),
openapi: z.literal("/openapi.json"),
system: z.literal("/system"),
workspaces: z.literal("/workspaces"),
workspaceResource: z.string().startsWith("/workspaces/"),
}).meta({ ref: "OpenWorkServerV2RouteNamespaces" });
export const contractMetadataSchema = z.object({
source: z.literal("hono-openapi"),
openapiPath: z.literal("/openapi.json"),
sdkPackage: z.literal("@openwork/server-sdk"),
}).meta({ ref: "OpenWorkServerV2ContractMetadata" });
export const databaseStatusSchema = z.object({
bootstrapMode: z.enum(["fresh", "existing"]),
configured: z.literal(true),
importWarnings: z.number().int().nonnegative(),
kind: z.literal("sqlite"),
migrations: z.object({
appliedThisRun: z.array(z.string()),
currentVersion: z.string(),
totalApplied: z.number().int().nonnegative(),
}).meta({ ref: "OpenWorkServerV2MigrationStatus" }),
path: z.string(),
phaseOwner: z.literal(2),
status: z.enum(["ready", "warning"]),
summary: z.string(),
workingDirectory: z.string(),
}).meta({ ref: "OpenWorkServerV2DatabaseStatus" });
export const importSourceReportSchema = z.object({
details: jsonObjectSchema,
sourcePath: z.string().nullable(),
status: z.enum(["error", "imported", "skipped", "unavailable"]),
warnings: z.array(z.string()),
}).meta({ ref: "OpenWorkServerV2ImportSourceReport" });
export const startupDiagnosticsSchema = z.object({
completedAt: isoTimestampSchema,
importReports: z.object({
cloudSignin: importSourceReportSchema,
desktopWorkspaceState: importSourceReportSchema,
orchestratorAuth: importSourceReportSchema,
orchestratorState: importSourceReportSchema,
}).meta({ ref: "OpenWorkServerV2ImportReports" }),
legacyWorkspaceImport: z.object({
completedAt: isoTimestampSchema.nullable(),
skipped: z.boolean(),
}).meta({ ref: "OpenWorkServerV2LegacyWorkspaceImportState" }),
mode: z.enum(["fresh", "existing"]),
migrations: z.object({
applied: z.array(z.string()),
currentVersion: z.string(),
totalApplied: z.number().int().nonnegative(),
}).meta({ ref: "OpenWorkServerV2StartupMigrationSummary" }),
registry: z.object({
hiddenWorkspaceIds: z.array(identifierSchema),
localServerCreated: z.boolean(),
localServerId: identifierSchema,
totalServers: z.number().int().nonnegative(),
totalVisibleWorkspaces: z.number().int().nonnegative(),
}).meta({ ref: "OpenWorkServerV2StartupRegistrySummary" }),
warnings: z.array(z.string()),
workingDirectory: z.object({
databasePath: z.string(),
rootDir: z.string(),
workspacesDir: z.string(),
}).meta({ ref: "OpenWorkServerV2WorkingDirectory" }),
}).meta({ ref: "OpenWorkServerV2StartupDiagnostics" });
export const rootInfoDataSchema = z.object({
service: z.literal("openwork-server-v2"),
packageName: z.literal("openwork-server-v2"),
version: z.string(),
environment: z.string(),
routes: routeNamespacesSchema,
contract: contractMetadataSchema,
}).meta({ ref: "OpenWorkServerV2RootInfoData" });
export const healthDataSchema = z.object({
service: z.literal("openwork-server-v2"),
status: z.literal("ok"),
startedAt: isoTimestampSchema,
uptimeMs: z.number().int().nonnegative(),
database: databaseStatusSchema,
}).meta({ ref: "OpenWorkServerV2HealthData" });
export const runtimeInfoSchema = z.object({
environment: z.string(),
hostname: z.string(),
pid: z.number().int().nonnegative(),
platform: z.string(),
runtime: z.literal("bun"),
runtimeVersion: z.string().nullable(),
}).meta({ ref: "OpenWorkServerV2RuntimeInfo" });
export const metadataDataSchema = z.object({
foundation: z.object({
phase: z.literal(8),
middlewareOrder: z.array(identifierSchema).min(1),
routeNamespaces: routeNamespacesSchema,
database: databaseStatusSchema,
startup: startupDiagnosticsSchema,
}).meta({ ref: "OpenWorkServerV2FoundationInfo" }),
requestContext: z.object({
actorKind: z.enum(["anonymous", "client", "host"]),
requestIdHeader: z.literal("X-Request-Id"),
}).meta({ ref: "OpenWorkServerV2RequestContextInfo" }),
runtime: runtimeInfoSchema,
runtimeSupervisor: runtimeSummaryDataSchema,
contract: contractMetadataSchema,
}).meta({ ref: "OpenWorkServerV2MetadataData" });
export const rootInfoResponseSchema = successResponseSchema("OpenWorkServerV2RootInfoResponse", rootInfoDataSchema);
export const healthResponseSchema = successResponseSchema("OpenWorkServerV2HealthResponse", healthDataSchema);
export const metadataResponseSchema = successResponseSchema("OpenWorkServerV2MetadataResponse", metadataDataSchema);
export const openApiDocumentSchema = z.object({
openapi: z.string(),
info: z.object({
title: z.string(),
version: z.string(),
}).passthrough(),
paths: z.record(z.string(), z.unknown()),
components: z.object({}).passthrough().optional(),
}).passthrough().meta({ ref: "OpenWorkServerV2OpenApiDocument" });

View File

@@ -0,0 +1,124 @@
import { HTTPException } from "hono/http-exception";
export type RequestActor = {
kind: "anonymous" | "client" | "host";
};
export type AuthSummary = {
actorKind: RequestActor["kind"];
configured: {
clientToken: boolean;
hostToken: boolean;
};
headers: {
authorization: "Authorization";
hostToken: "X-OpenWork-Host-Token";
};
required: boolean;
scopes: {
hiddenWorkspaceReads: "host";
serverInventory: "host";
visibleRead: "client_or_host";
};
};
function readBearer(headers: Headers) {
const raw = headers.get("authorization")?.trim() ?? "";
const match = raw.match(/^Bearer\s+(.+)$/i);
return match?.[1]?.trim() ?? "";
}
function trimToken(value: string | undefined) {
const trimmed = value?.trim() ?? "";
return trimmed || null;
}
export type AuthService = ReturnType<typeof createAuthService>;
export function createAuthService() {
const clientToken = trimToken(
process.env.OPENWORK_SERVER_V2_CLIENT_TOKEN
?? process.env.OPENWORK_CLIENT_TOKEN
?? process.env.OPENWORK_TOKEN,
);
const hostToken = trimToken(
process.env.OPENWORK_SERVER_V2_HOST_TOKEN
?? process.env.OPENWORK_HOST_TOKEN,
);
const required = Boolean(clientToken || hostToken);
function resolveActor(headers: Headers): RequestActor {
const hostHeader = headers.get("x-openwork-host-token")?.trim() ?? "";
if (hostToken && hostHeader && hostHeader === hostToken) {
return { kind: "host" };
}
const bearer = readBearer(headers);
if (hostToken && bearer && bearer === hostToken) {
return { kind: "host" };
}
if (clientToken && bearer && bearer === clientToken) {
return { kind: "client" };
}
return { kind: "anonymous" };
}
function getSummary(actor: RequestActor): AuthSummary {
return {
actorKind: actor.kind,
configured: {
clientToken: Boolean(clientToken),
hostToken: Boolean(hostToken),
},
headers: {
authorization: "Authorization",
hostToken: "X-OpenWork-Host-Token",
},
required,
scopes: {
hiddenWorkspaceReads: "host",
serverInventory: "host",
visibleRead: "client_or_host",
},
};
}
function requireVisibleRead(actor: RequestActor) {
if (!required) {
return;
}
if (actor.kind === "anonymous") {
throw new HTTPException(401, {
message: "A client or host token is required for this route.",
});
}
}
function requireHost(actor: RequestActor) {
if (!required) {
return;
}
if (actor.kind === "anonymous") {
throw new HTTPException(401, {
message: "A host token is required for this route.",
});
}
if (actor.kind !== "host") {
throw new HTTPException(403, {
message: "Host scope is required for this route.",
});
}
}
return {
getSummary,
requireHost,
requireVisibleRead,
resolveActor,
};
}

View File

@@ -0,0 +1,182 @@
import type { RequestActor } from "./auth-service.js";
import type { RuntimeService } from "./runtime-service.js";
import type { AuthService } from "./auth-service.js";
export type CapabilitiesData = {
auth: ReturnType<AuthService["getSummary"]>;
bundles: {
fetch: true;
publish: true;
workspaceExport: true;
workspaceImport: true;
};
cloud: {
persistence: true;
validation: true;
};
config: {
projection: true;
rawRead: true;
rawWrite: true;
read: true;
write: true;
};
files: {
artifacts: true;
contentRoutes: true;
fileSessions: true;
inbox: true;
mutations: true;
};
managed: {
assignments: true;
mcps: true;
plugins: true;
providerConfigs: true;
skills: true;
};
reload: {
manualEngineReload: true;
reconciliation: true;
watch: true;
workspaceEvents: true;
};
registry: {
backendResolution: true;
hiddenWorkspaceFiltering: true;
remoteServerConnections: true;
remoteWorkspaceSync: true;
serverInventory: true;
workspaceDetail: true;
workspaceList: true;
};
sessions: {
events: true;
list: true;
messages: true;
mutations: true;
promptAsync: true;
revertHistory: true;
};
runtime: {
opencodeHealth: true;
routerHealth: true;
runtimeSummary: true;
runtimeUpgrade: true;
runtimeVersions: true;
};
router: {
bindings: true;
identities: true;
outboundSend: true;
productRoutes: true;
};
shares: {
workspaceScoped: true;
};
workspaces: {
activate: true;
createLocal: true;
};
transport: {
rootMounted: true;
sdkPackage: "@openwork/server-sdk";
v2: true;
};
};
export type CapabilitiesService = ReturnType<typeof createCapabilitiesService>;
export function createCapabilitiesService(input: {
auth: AuthService;
runtime: RuntimeService;
}) {
return {
getCapabilities(actor: RequestActor): CapabilitiesData {
const runtimeSummary = input.runtime.getRuntimeSummary();
void runtimeSummary;
return {
auth: input.auth.getSummary(actor),
bundles: {
fetch: true,
publish: true,
workspaceExport: true,
workspaceImport: true,
},
cloud: {
persistence: true,
validation: true,
},
config: {
projection: true,
rawRead: true,
rawWrite: true,
read: true,
write: true,
},
files: {
artifacts: true,
contentRoutes: true,
fileSessions: true,
inbox: true,
mutations: true,
},
managed: {
assignments: true,
mcps: true,
plugins: true,
providerConfigs: true,
skills: true,
},
reload: {
manualEngineReload: true,
reconciliation: true,
watch: true,
workspaceEvents: true,
},
registry: {
backendResolution: true,
hiddenWorkspaceFiltering: true,
remoteServerConnections: true,
remoteWorkspaceSync: true,
serverInventory: true,
workspaceDetail: true,
workspaceList: true,
},
sessions: {
events: true,
list: true,
messages: true,
mutations: true,
promptAsync: true,
revertHistory: true,
},
runtime: {
opencodeHealth: true,
routerHealth: true,
runtimeSummary: true,
runtimeUpgrade: true,
runtimeVersions: true,
},
router: {
bindings: true,
identities: true,
outboundSend: true,
productRoutes: true,
},
shares: {
workspaceScoped: true,
},
workspaces: {
activate: true,
createLocal: true,
},
transport: {
rootMounted: true,
sdkPackage: "@openwork/server-sdk",
v2: true,
},
};
},
};
}

View File

@@ -0,0 +1,832 @@
import fs from "node:fs";
import path from "node:path";
import { HTTPException } from "hono/http-exception";
import type { ServerRepositories } from "../database/repositories.js";
import type { JsonObject, ManagedConfigRecord, WorkspaceRecord } from "../database/types.js";
import type { ServerWorkingDirectory } from "../database/working-directory.js";
import { ensureWorkspaceConfigDir } from "../database/working-directory.js";
import { RouteError } from "../http.js";
import { requestRemoteOpenwork, resolveRemoteWorkspaceTarget } from "../adapters/remote-openwork.js";
const MANAGED_SKILL_DOMAIN = "openwork-managed";
const OPENWORK_CONFIG_VERSION = 1;
type WorkspaceConfigSnapshot = {
effective: {
opencode: JsonObject;
openwork: JsonObject;
};
materialized: {
compatibilityOpencodePath: string | null;
compatibilityOpenworkPath: string | null;
configDir: string | null;
configOpencodePath: string | null;
configOpenworkPath: string | null;
};
stored: {
opencode: JsonObject;
openwork: JsonObject;
};
updatedAt: string;
workspaceId: string;
};
function asObject(value: unknown): JsonObject {
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as JsonObject) } : {};
}
function normalizeStringArray(value: unknown) {
if (!Array.isArray(value)) {
return [] as string[];
}
return Array.from(new Set(value.filter((item): item is string => typeof item === "string").map((item) => item.trim()).filter(Boolean)));
}
function normalizeAuthorizedFolderPath(input: string | null | undefined) {
const trimmed = (input ?? "").trim();
if (!trimmed) return "";
return trimmed.replace(/[\\/]\*+$/, "");
}
function authorizedFolderToExternalDirectoryKey(folder: string) {
const normalized = normalizeAuthorizedFolderPath(folder);
if (!normalized) return "";
return normalized === "/" ? "/*" : `${normalized}/*`;
}
function externalDirectoryKeyToAuthorizedFolder(key: string, value: unknown) {
if (value !== "allow") return null;
const trimmed = key.trim();
if (!trimmed) return null;
if (trimmed === "/*") return "/";
if (!trimmed.endsWith("/*")) return null;
return normalizeAuthorizedFolderPath(trimmed.slice(0, -2));
}
function normalizeExternalDirectory(value: unknown) {
const folders = new Set<string>();
const hiddenEntries: JsonObject = {};
for (const folder of normalizeStringArray(value)) {
const normalized = normalizeAuthorizedFolderPath(folder);
if (normalized) {
folders.add(normalized);
}
}
if (value && typeof value === "object" && !Array.isArray(value)) {
for (const [key, entryValue] of Object.entries(value as JsonObject)) {
const folder = externalDirectoryKeyToAuthorizedFolder(key, entryValue);
if (folder) {
folders.add(folder);
} else {
hiddenEntries[key] = entryValue;
}
}
}
return {
folders: Array.from(folders),
hiddenEntries,
};
}
function buildExternalDirectory(folders: string[], hiddenEntries: JsonObject) {
const next: JsonObject = { ...hiddenEntries };
for (const folder of folders) {
const key = authorizedFolderToExternalDirectoryKey(folder);
if (!key) continue;
next[key] = "allow";
}
return Object.keys(next).length ? next : undefined;
}
function withoutWorkspaceRoot(folders: string[], workspace: WorkspaceRecord) {
const workspaceRoot = normalizeAuthorizedFolderPath(workspace.dataDir);
if (!workspaceRoot) {
return folders;
}
return folders.filter((folder) => normalizeAuthorizedFolderPath(folder) !== workspaceRoot);
}
function canonicalizeWorkspaceConfigState(workspace: WorkspaceRecord, config: { openwork: JsonObject; opencode: JsonObject }) {
const nextOpenwork = asObject(config.openwork);
nextOpenwork.authorizedRoots = withoutWorkspaceRoot(normalizeStringArray(nextOpenwork.authorizedRoots), workspace);
const nextOpencode = asObject(config.opencode);
const permission = asObject(nextOpencode.permission);
const externalDirectory = normalizeExternalDirectory(permission.external_directory);
const nextExternalDirectory = buildExternalDirectory(withoutWorkspaceRoot(externalDirectory.folders, workspace), externalDirectory.hiddenEntries);
if (nextExternalDirectory) {
permission.external_directory = nextExternalDirectory;
} else {
delete permission.external_directory;
}
if (Object.keys(permission).length) {
nextOpencode.permission = permission;
} else {
delete nextOpencode.permission;
}
return {
openwork: nextOpenwork,
opencode: nextOpencode,
};
}
function mergeObjects(base: JsonObject, patch: JsonObject): JsonObject {
const next: JsonObject = { ...base };
for (const [key, value] of Object.entries(patch)) {
if (
value &&
typeof value === "object" &&
!Array.isArray(value) &&
base[key] &&
typeof base[key] === "object" &&
!Array.isArray(base[key])
) {
next[key] = mergeObjects(asObject(base[key]), asObject(value));
continue;
}
next[key] = value;
}
return next;
}
function writeJsonFile(filePath: string, value: unknown) {
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
}
function readJsonFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return null;
}
try {
return JSON.parse(fs.readFileSync(filePath, "utf8")) as JsonObject;
} catch {
return null;
}
}
function readJsoncFile(filePath: string) {
if (!fs.existsSync(filePath)) {
return null;
}
try {
return asObject(parseJsoncText(fs.readFileSync(filePath, "utf8")));
} catch {
return null;
}
}
function parseJsoncText(content: string) {
const withoutLineComments = content.replace(/^\s*\/\/.*$/gm, "");
const withoutBlockComments = withoutLineComments.replace(/\/\*[\s\S]*?\*\//g, "");
const withoutTrailingCommas = withoutBlockComments.replace(/,\s*([}\]])/g, "$1");
return JSON.parse(withoutTrailingCommas);
}
function normalizePluginKey(spec: string) {
const trimmed = spec.trim();
if (!trimmed) {
return "";
}
if (trimmed.startsWith("@")) {
const atIndex = trimmed.indexOf("@", 1);
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
}
const atIndex = trimmed.indexOf("@");
return atIndex > 0 ? trimmed.slice(0, atIndex) : trimmed;
}
function nowIso() {
return new Date().toISOString();
}
function parseManagedSkillMetadata(content: string, fallbackName: string) {
const frontmatter = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
const nameMatch = frontmatter?.[1]?.match(/^name:\s*(.+)$/m);
const descriptionMatch = frontmatter?.[1]?.match(/^description:\s*(.+)$/m);
const displayName = nameMatch?.[1]?.trim() || fallbackName;
const key = displayName.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || fallbackName;
return {
description: descriptionMatch?.[1]?.trim() || displayName,
displayName,
key,
};
}
function readManagedSkillFiles(rootDir: string | null) {
if (!rootDir || !fs.existsSync(rootDir)) {
return [] as Array<{ content: string; key: string; path: string }>;
}
const items: Array<{ content: string; key: string; path: string }> = [];
const visit = (directory: string, depth: number) => {
if (depth > 2) {
return;
}
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
const direct = path.join(directory, entry.name, "SKILL.md");
if (fs.existsSync(direct) && fs.statSync(direct).isFile()) {
items.push({ content: fs.readFileSync(direct, "utf8"), key: entry.name, path: direct });
continue;
}
visit(path.join(directory, entry.name), depth + 1);
}
};
visit(rootDir, 0);
return items;
}
function extractRecognizedOpencodeSections(opencode: JsonObject) {
const base = { ...opencode };
const plugins = Array.isArray(base.plugin)
? base.plugin.filter((value): value is string => typeof value === "string" && Boolean(value.trim())).map((value) => value.trim())
: typeof base.plugin === "string" && base.plugin.trim()
? [base.plugin.trim()]
: [];
const mcps = asObject(base.mcp);
const providers = asObject((base as Record<string, unknown>).provider);
delete base.plugin;
delete base.mcp;
delete (base as Record<string, unknown>).provider;
return {
base,
mcps: Object.entries(mcps).map(([key, value]) => ({ config: asObject(value), displayName: key, key })),
plugins: plugins.map((spec) => ({
config: { spec },
displayName: normalizePluginKey(spec) || spec,
key: normalizePluginKey(spec) || spec,
})),
providers: Object.entries(providers).map(([key, value]) => ({ config: asObject(value), displayName: key, key })),
};
}
function dedupeAssignments(items: string[]) {
return Array.from(new Set(items.filter(Boolean)));
}
export type ConfigMaterializationService = ReturnType<typeof createConfigMaterializationService>;
export type { WorkspaceConfigSnapshot };
export function createConfigMaterializationService(input: {
repositories: ServerRepositories;
serverId: string;
workingDirectory: ServerWorkingDirectory;
}) {
function getWorkspaceOrThrow(workspaceId: string) {
const workspace = input.repositories.workspaces.getById(workspaceId);
if (!workspace) {
throw new HTTPException(404, { message: `Workspace not found: ${workspaceId}` });
}
return workspace;
}
function getRemoteServerOrThrow(workspace: WorkspaceRecord) {
const server = input.repositories.servers.getById(workspace.serverId);
if (!server) {
throw new RouteError(502, "bad_gateway", `Workspace ${workspace.id} points at missing remote server ${workspace.serverId}.`);
}
return server;
}
function ensureWorkspaceLocal(workspace: WorkspaceRecord) {
if (workspace.kind === "remote") {
throw new RouteError(
501,
"not_implemented",
"Phase 7 local file/config ownership currently supports local, control, and help workspaces only. Remote config and file mutation stay on the direct remote path during migration.",
);
}
}
function workspaceOpencodeConfigPath(workspace: WorkspaceRecord) {
const configDir = workspace.configDir?.trim();
if (!configDir) {
throw new RouteError(500, "internal_error", `Workspace ${workspace.id} is missing its config directory.`);
}
return path.join(configDir, "opencode.jsonc");
}
function workspaceOpenworkConfigPath(workspace: WorkspaceRecord) {
const configDir = workspace.configDir?.trim();
if (!configDir) {
throw new RouteError(500, "internal_error", `Workspace ${workspace.id} is missing its config directory.`);
}
return path.join(configDir, ".opencode", "openwork.json");
}
function compatibilityOpencodeConfigPath(workspace: WorkspaceRecord) {
const dataDir = workspace.dataDir?.trim();
return dataDir ? path.join(dataDir, "opencode.jsonc") : null;
}
function compatibilityOpenworkConfigPath(workspace: WorkspaceRecord) {
const dataDir = workspace.dataDir?.trim();
return dataDir ? path.join(dataDir, ".opencode", "openwork.json") : null;
}
function workspaceSkillRoots(workspace: WorkspaceRecord) {
const configDir = workspace.configDir?.trim();
const dataDir = workspace.dataDir?.trim();
return {
compatibility: dataDir ? path.join(dataDir, ".opencode", "skills", MANAGED_SKILL_DOMAIN) : null,
managedConfig: configDir ? path.join(configDir, ".opencode", "skills", MANAGED_SKILL_DOMAIN) : null,
sourceConfig: configDir ? path.join(configDir, ".opencode", "skills") : null,
sourceData: dataDir ? path.join(dataDir, ".opencode", "skills") : null,
};
}
function derivePreset(workspace: WorkspaceRecord) {
const notes = asObject(workspace.notes);
const legacyDesktop = asObject(notes.legacyDesktop);
const preset = typeof legacyDesktop.preset === "string" ? legacyDesktop.preset.trim() : "";
if (preset) {
return preset;
}
return workspace.kind === "local" ? "starter" : "remote";
}
function buildDefaultOpenwork(workspace: WorkspaceRecord) {
return {
authorizedRoots: [],
blueprint: null,
reload: null,
version: OPENWORK_CONFIG_VERSION,
workspace: {
configDir: workspace.configDir,
createdAt: Date.parse(workspace.createdAt) || Date.now(),
dataDir: workspace.dataDir,
name: workspace.displayName,
preset: derivePreset(workspace),
},
} satisfies JsonObject;
}
function buildDefaultOpencode() {
return {
$schema: "https://opencode.ai/config.json",
} satisfies JsonObject;
}
function ensureServerConfigState() {
const existing = input.repositories.serverConfigState.getByServerId(input.serverId);
if (existing) {
return existing;
}
return input.repositories.serverConfigState.upsert({
opencode: buildDefaultOpencode(),
serverId: input.serverId,
});
}
function readLegacyWorkspaceState(workspace: WorkspaceRecord) {
const openwork =
readJsonFile(workspaceOpenworkConfigPath(workspace))
?? (compatibilityOpenworkConfigPath(workspace) ? readJsonFile(compatibilityOpenworkConfigPath(workspace)!) : null)
?? buildDefaultOpenwork(workspace);
const opencode =
readJsoncFile(workspaceOpencodeConfigPath(workspace))
?? (compatibilityOpencodeConfigPath(workspace) ? readJsoncFile(compatibilityOpencodeConfigPath(workspace)!) : null)
?? buildDefaultOpencode();
return {
openwork: asObject(openwork),
opencode: asObject(opencode),
};
}
function ensureWorkspaceConfigState(workspace: WorkspaceRecord) {
ensureWorkspaceLocal(workspace);
ensureWorkspaceConfigDir(input.workingDirectory, workspace.id);
const existing = input.repositories.workspaceConfigState.getByWorkspaceId(workspace.id);
if (existing) {
return existing;
}
const legacy = readLegacyWorkspaceState(workspace);
const canonical = canonicalizeWorkspaceConfigState(workspace, legacy);
return input.repositories.workspaceConfigState.upsert({
openwork: canonical.openwork,
opencode: canonical.opencode,
workspaceId: workspace.id,
});
}
function upsertManagedRecords(
workspaceId: string,
kind: "mcps" | "plugins" | "providerConfigs",
items: Array<{ config: JsonObject; displayName: string; key: string }>,
) {
if (kind === "mcps") {
const ids = items.map((item) => input.repositories.mcps.upsert({
auth: null,
cloudItemId: null,
config: item.config,
displayName: item.displayName,
id: `mcp_${workspaceId}_${item.key}`,
key: item.key,
metadata: { absorbed: true, workspaceId },
source: "imported",
}).id);
input.repositories.workspaceMcps.replaceAssignments(workspaceId, dedupeAssignments(ids));
return;
}
if (kind === "plugins") {
const ids = items.map((item) => input.repositories.plugins.upsert({
auth: null,
cloudItemId: null,
config: item.config,
displayName: item.displayName,
id: `plugin_${workspaceId}_${item.key}`,
key: item.key,
metadata: { absorbed: true, workspaceId },
source: "imported",
}).id);
input.repositories.workspacePlugins.replaceAssignments(workspaceId, dedupeAssignments(ids));
return;
}
const ids = items.map((item) => input.repositories.providerConfigs.upsert({
auth: null,
cloudItemId: null,
config: item.config,
displayName: item.displayName,
id: `provider_${workspaceId}_${item.key}`,
key: item.key,
metadata: { absorbed: true, workspaceId },
source: "imported",
}).id);
input.repositories.workspaceProviderConfigs.replaceAssignments(workspaceId, dedupeAssignments(ids));
}
function absorbManagedSkills(workspace: WorkspaceRecord) {
const items = [
...readManagedSkillFiles(workspaceSkillRoots(workspace).sourceConfig),
...readManagedSkillFiles(workspaceSkillRoots(workspace).sourceData),
];
const seen = new Set<string>();
const ids: string[] = [];
for (const item of items) {
const meta = parseManagedSkillMetadata(item.content, item.key);
if (!meta.key || seen.has(meta.key)) {
continue;
}
seen.add(meta.key);
ids.push(input.repositories.skills.upsert({
auth: null,
cloudItemId: null,
config: { content: item.content },
displayName: meta.displayName,
id: `skill_${workspace.id}_${meta.key}`,
key: meta.key,
metadata: {
absorbed: true,
description: meta.description,
originPath: item.path,
workspaceId: workspace.id,
},
source: "imported",
}).id);
}
input.repositories.workspaceSkills.replaceAssignments(workspace.id, dedupeAssignments(ids));
}
function absorbWorkspaceConfigState(workspace: WorkspaceRecord) {
ensureWorkspaceConfigState(workspace);
const legacy = readLegacyWorkspaceState(workspace);
const recognized = extractRecognizedOpencodeSections(legacy.opencode);
upsertManagedRecords(workspace.id, "mcps", recognized.mcps);
upsertManagedRecords(workspace.id, "plugins", recognized.plugins);
upsertManagedRecords(workspace.id, "providerConfigs", recognized.providers);
absorbManagedSkills(workspace);
const canonical = canonicalizeWorkspaceConfigState(workspace, {
openwork: mergeObjects(buildDefaultOpenwork(workspace), legacy.openwork),
opencode: mergeObjects(buildDefaultOpencode(), recognized.base),
});
return input.repositories.workspaceConfigState.upsert({
openwork: canonical.openwork,
opencode: canonical.opencode,
workspaceId: workspace.id,
});
}
function listAssignedRecords(
workspaceId: string,
assignmentTable: "workspaceMcps" | "workspacePlugins" | "workspaceProviderConfigs",
repo: "mcps" | "plugins" | "providerConfigs",
) {
return input.repositories[assignmentTable]
.listForWorkspace(workspaceId)
.map((assignment) => input.repositories[repo].getById(assignment.itemId))
.filter(Boolean) as ManagedConfigRecord[];
}
function listAssignedSkills(workspaceId: string) {
return input.repositories.workspaceSkills
.listForWorkspace(workspaceId)
.map((assignment) => input.repositories.skills.getById(assignment.itemId))
.filter(Boolean) as ManagedConfigRecord[];
}
function computeSnapshot(workspace: WorkspaceRecord): WorkspaceConfigSnapshot {
const workspaceState = ensureWorkspaceConfigState(workspace);
const serverState = ensureServerConfigState();
const canonicalState = canonicalizeWorkspaceConfigState(workspace, {
openwork: workspaceState.openwork,
opencode: workspaceState.opencode,
});
const storedOpenwork = mergeObjects(buildDefaultOpenwork(workspace), canonicalState.openwork);
const storedOpencode = mergeObjects(buildDefaultOpencode(), canonicalState.opencode);
const effectiveOpenwork = mergeObjects(buildDefaultOpenwork(workspace), storedOpenwork);
const effectiveOpencode = mergeObjects(asObject(serverState.opencode), storedOpencode);
const mcps = listAssignedRecords(workspace.id, "workspaceMcps", "mcps");
if (mcps.length) {
effectiveOpencode.mcp = Object.fromEntries(mcps.map((item) => [item.key ?? item.displayName, item.config]));
}
const plugins = listAssignedRecords(workspace.id, "workspacePlugins", "plugins");
if (plugins.length) {
effectiveOpencode.plugin = plugins.map((item) => {
const config = asObject(item.config);
return typeof config.spec === "string" && config.spec.trim() ? config.spec.trim() : item.key ?? item.displayName;
}).filter(Boolean);
}
const providers = listAssignedRecords(workspace.id, "workspaceProviderConfigs", "providerConfigs");
if (providers.length) {
(effectiveOpencode as Record<string, unknown>).provider = Object.fromEntries(
providers.map((item) => [item.key ?? item.displayName, item.config]),
);
}
const permission = asObject(effectiveOpencode.permission);
const externalDirectory = normalizeExternalDirectory(permission.external_directory);
const authorizedRoots = withoutWorkspaceRoot(normalizeStringArray([
...normalizeStringArray(effectiveOpenwork.authorizedRoots),
...externalDirectory.folders,
]), workspace);
const nextExternalDirectory = buildExternalDirectory(authorizedRoots, externalDirectory.hiddenEntries);
if (nextExternalDirectory) {
permission.external_directory = nextExternalDirectory;
} else {
delete permission.external_directory;
}
effectiveOpencode.permission = permission;
effectiveOpenwork.authorizedRoots = authorizedRoots;
return {
effective: {
opencode: effectiveOpencode,
openwork: effectiveOpenwork,
},
materialized: {
compatibilityOpencodePath: compatibilityOpencodeConfigPath(workspace),
compatibilityOpenworkPath: compatibilityOpenworkConfigPath(workspace),
configDir: workspace.configDir,
configOpencodePath: workspaceOpencodeConfigPath(workspace),
configOpenworkPath: workspaceOpenworkConfigPath(workspace),
},
stored: {
opencode: storedOpencode,
openwork: storedOpenwork,
},
updatedAt: workspaceState.updatedAt,
workspaceId: workspace.id,
};
}
function materializeSkills(workspace: WorkspaceRecord) {
const skills = listAssignedSkills(workspace.id);
const roots = workspaceSkillRoots(workspace);
if (roots.managedConfig) {
fs.rmSync(roots.managedConfig, { force: true, recursive: true });
fs.mkdirSync(roots.managedConfig, { recursive: true });
}
if (roots.compatibility) {
fs.rmSync(roots.compatibility, { force: true, recursive: true });
fs.mkdirSync(roots.compatibility, { recursive: true });
}
for (const skill of skills) {
const content = typeof asObject(skill.config).content === "string" ? String(asObject(skill.config).content) : "";
if (!content) {
continue;
}
const skillKey = skill.key?.trim() || skill.id;
if (roots.managedConfig) {
const destination = path.join(roots.managedConfig, skillKey, "SKILL.md");
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, content.endsWith("\n") ? content : `${content}\n`, "utf8");
}
if (roots.compatibility) {
const meta = asObject(skill.metadata);
const originPath = typeof meta.originPath === "string" ? meta.originPath : "";
if (originPath && workspace.dataDir && originPath.startsWith(workspace.dataDir)) {
continue;
}
const destination = path.join(roots.compatibility, skillKey, "SKILL.md");
fs.mkdirSync(path.dirname(destination), { recursive: true });
fs.writeFileSync(destination, content.endsWith("\n") ? content : `${content}\n`, "utf8");
}
}
}
function materializeWorkspaceSnapshot(workspaceId: string) {
const workspace = getWorkspaceOrThrow(workspaceId);
ensureWorkspaceLocal(workspace);
const snapshot = computeSnapshot(workspace);
writeJsonFile(snapshot.materialized.configOpencodePath!, snapshot.effective.opencode);
writeJsonFile(snapshot.materialized.configOpenworkPath!, snapshot.effective.openwork);
if (snapshot.materialized.compatibilityOpencodePath) {
writeJsonFile(snapshot.materialized.compatibilityOpencodePath, snapshot.effective.opencode);
}
if (snapshot.materialized.compatibilityOpenworkPath) {
writeJsonFile(snapshot.materialized.compatibilityOpenworkPath, snapshot.effective.openwork);
}
materializeSkills(workspace);
return snapshot;
}
function readRawProjectOpencodeConfig(workspaceId: string) {
const snapshot = materializeWorkspaceSnapshot(workspaceId);
return {
content: `${JSON.stringify(snapshot.effective.opencode, null, 2)}\n`,
exists: true,
path: snapshot.materialized.configOpencodePath,
updatedAt: snapshot.updatedAt,
};
}
function readRawGlobalOpencodeConfig() {
const state = ensureServerConfigState();
const opencode = mergeObjects(buildDefaultOpencode(), state.opencode);
const filePath = path.join(input.workingDirectory.managedDir, "opencode.global.jsonc");
writeJsonFile(filePath, opencode);
return {
content: `${JSON.stringify(opencode, null, 2)}\n`,
exists: true,
path: filePath,
updatedAt: state.updatedAt,
};
}
return {
absorbWorkspaceConfig(workspaceId: string) {
const workspace = getWorkspaceOrThrow(workspaceId);
ensureWorkspaceLocal(workspace);
absorbWorkspaceConfigState(workspace);
return materializeWorkspaceSnapshot(workspaceId);
},
ensureWorkspaceConfig(workspaceId: string) {
const workspace = getWorkspaceOrThrow(workspaceId);
ensureWorkspaceLocal(workspace);
ensureWorkspaceConfigState(workspace);
return materializeWorkspaceSnapshot(workspaceId);
},
async getWorkspaceConfigSnapshot(workspaceId: string) {
const workspace = getWorkspaceOrThrow(workspaceId);
if (workspace.kind === "remote") {
const server = getRemoteServerOrThrow(workspace);
const target = resolveRemoteWorkspaceTarget(server, workspace);
return requestRemoteOpenwork<WorkspaceConfigSnapshot>({
path: `/workspaces/${encodeURIComponent(target.remoteWorkspaceId)}/config`,
server,
timeoutMs: 10_000,
});
}
ensureWorkspaceLocal(workspace);
return computeSnapshot(workspace);
},
listWatchRoots(workspaceId: string) {
const workspace = getWorkspaceOrThrow(workspaceId);
ensureWorkspaceLocal(workspace);
return [
workspace.configDir,
workspace.dataDir,
workspace.dataDir ? path.join(workspace.dataDir, ".opencode") : null,
].filter((value): value is string => Boolean(value));
},
async patchWorkspaceConfig(workspaceId: string, patch: { openwork?: JsonObject; opencode?: JsonObject }) {
const workspace = getWorkspaceOrThrow(workspaceId);
if (workspace.kind === "remote") {
const server = getRemoteServerOrThrow(workspace);
const target = resolveRemoteWorkspaceTarget(server, workspace);
return requestRemoteOpenwork<WorkspaceConfigSnapshot>({
body: patch,
method: "PATCH",
path: `/workspaces/${encodeURIComponent(target.remoteWorkspaceId)}/config`,
server,
timeoutMs: 15_000,
});
}
ensureWorkspaceLocal(workspace);
const current = ensureWorkspaceConfigState(workspace);
const nextOpenwork = patch.openwork ? mergeObjects(current.openwork, asObject(patch.openwork)) : current.openwork;
let nextOpencode = current.opencode;
if (patch.opencode) {
const merged = mergeObjects(current.opencode, asObject(patch.opencode));
const recognized = extractRecognizedOpencodeSections(merged);
upsertManagedRecords(workspace.id, "mcps", recognized.mcps);
upsertManagedRecords(workspace.id, "plugins", recognized.plugins);
upsertManagedRecords(workspace.id, "providerConfigs", recognized.providers);
nextOpencode = recognized.base;
}
const canonical = canonicalizeWorkspaceConfigState(workspace, {
openwork: nextOpenwork,
opencode: nextOpencode,
});
input.repositories.workspaceConfigState.upsert({
openwork: canonical.openwork,
opencode: canonical.opencode,
workspaceId: workspace.id,
});
return materializeWorkspaceSnapshot(workspaceId);
},
async readRawOpencodeConfig(workspaceId: string, scope: "global" | "project") {
const workspace = getWorkspaceOrThrow(workspaceId);
if (workspace.kind === "remote") {
const server = getRemoteServerOrThrow(workspace);
const target = resolveRemoteWorkspaceTarget(server, workspace);
const query = `?scope=${encodeURIComponent(scope)}`;
return requestRemoteOpenwork<{ content: string; exists: boolean; path: string | null; updatedAt: string }>({
path: `/workspaces/${encodeURIComponent(target.remoteWorkspaceId)}/config/opencode-raw${query}`,
server,
timeoutMs: 10_000,
});
}
return scope === "global" ? readRawGlobalOpencodeConfig() : readRawProjectOpencodeConfig(workspaceId);
},
reconcileAllWorkspaces() {
const workspaces = input.repositories.workspaces.list({ includeHidden: true }).filter((workspace) => workspace.kind !== "remote");
for (const workspace of workspaces) {
absorbWorkspaceConfigState(workspace);
materializeWorkspaceSnapshot(workspace.id);
}
return {
reconciledAt: nowIso(),
workspaceIds: workspaces.map((workspace) => workspace.id),
};
},
writeGlobalOpencodeConfig(content: string) {
const parsed = asObject(parseJsoncText(content));
const recognized = extractRecognizedOpencodeSections(parsed);
if (recognized.mcps.length || recognized.plugins.length || recognized.providers.length) {
throw new RouteError(
400,
"invalid_request",
"Global raw OpenCode config writes cannot include workspace-managed MCP, plugin, or provider sections during Phase 7.",
);
}
input.repositories.serverConfigState.upsert({
opencode: recognized.base,
serverId: input.serverId,
});
return readRawGlobalOpencodeConfig();
},
async writeWorkspaceRawOpencodeConfig(workspaceId: string, content: string) {
const workspace = getWorkspaceOrThrow(workspaceId);
if (workspace.kind === "remote") {
const server = getRemoteServerOrThrow(workspace);
const target = resolveRemoteWorkspaceTarget(server, workspace);
return requestRemoteOpenwork<{ content: string; exists: boolean; path: string | null; updatedAt: string }>({
body: { content, scope: "project" },
method: "POST",
path: `/workspaces/${encodeURIComponent(target.remoteWorkspaceId)}/config/opencode-raw`,
server,
timeoutMs: 15_000,
});
}
ensureWorkspaceLocal(workspace);
const parsed = asObject(parseJsoncText(content));
const recognized = extractRecognizedOpencodeSections(parsed);
upsertManagedRecords(workspace.id, "mcps", recognized.mcps);
upsertManagedRecords(workspace.id, "plugins", recognized.plugins);
upsertManagedRecords(workspace.id, "providerConfigs", recognized.providers);
const canonical = canonicalizeWorkspaceConfigState(workspace, {
openwork: ensureWorkspaceConfigState(workspace).openwork,
opencode: recognized.base,
});
input.repositories.workspaceConfigState.upsert({
openwork: canonical.openwork,
opencode: canonical.opencode,
workspaceId: workspace.id,
});
return readRawProjectOpencodeConfig(workspaceId);
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,283 @@
import path from "node:path";
import {
createInternalWorkspaceId,
createLocalWorkspaceId,
createRemoteWorkspaceId,
createServerId,
deriveWorkspaceSlugSource,
} from "../database/identifiers.js";
import type { ServerRepositories } from "../database/repositories.js";
import { ensureWorkspaceConfigDir, type ServerWorkingDirectory } from "../database/working-directory.js";
import type {
BackendKind,
HostingKind,
JsonObject,
ServerRecord,
WorkspaceKind,
WorkspaceRecord,
} from "../database/types.js";
export type LegacyRemoteWorkspaceInput = {
baseUrl: string;
displayName: string;
directory?: string | null;
legacyNotes: JsonObject;
remoteType: "openwork" | "opencode";
remoteWorkspaceId?: string | null;
serverAuth?: JsonObject | null;
serverBaseUrl: string;
serverHostingKind: HostingKind;
serverLabel: string;
workspaceStatus?: WorkspaceRecord["status"];
};
export type LegacyLocalWorkspaceInput = {
dataDir: string;
displayName: string;
kind?: Extract<WorkspaceKind, "control" | "help" | "local">;
legacyNotes?: JsonObject | null;
opencodeProjectId?: string | null;
status?: WorkspaceRecord["status"];
};
type EnsureLocalServerInput = {
baseUrl?: string | null;
capabilities?: JsonObject;
hostingKind: HostingKind;
label: string;
notes?: JsonObject | null;
};
function mergeJson(base: JsonObject | null | undefined, next: JsonObject | null | undefined) {
if (!base && !next) {
return null;
}
return {
...(base ?? {}),
...(next ?? {}),
};
}
function resolveSlug(repositories: ServerRepositories, workspaceId: string, baseSlug: string) {
let suffix = 1;
let candidate = baseSlug;
while (repositories.workspaces.findSlugConflict(candidate, workspaceId)) {
suffix += 1;
candidate = `${baseSlug}-${suffix}`;
}
return candidate;
}
export function createRegistryService(input: {
localServerCapabilities: JsonObject;
repositories: ServerRepositories;
workingDirectory: ServerWorkingDirectory;
}) {
const localServerId = createServerId("local", "primary");
const { repositories } = input;
function upsertWorkspace(inputWorkspace: Omit<WorkspaceRecord, "createdAt" | "updatedAt" | "slug">) {
const slugBase = deriveWorkspaceSlugSource({
dataDir: inputWorkspace.dataDir,
displayName: inputWorkspace.displayName,
fallback: inputWorkspace.kind,
});
const slug = resolveSlug(repositories, inputWorkspace.id, slugBase);
return repositories.workspaces.upsert({
...inputWorkspace,
slug,
});
}
return {
attachLocalServerBaseUrl(baseUrl: string) {
const existing = input.repositories.servers.getById(localServerId);
if (!existing) {
return this.ensureLocalServer({
baseUrl,
hostingKind: "self_hosted",
label: "Local OpenWork Server",
});
}
return input.repositories.servers.upsert({
...existing,
baseUrl,
capabilities: existing.capabilities,
});
},
ensureLocalServer(server: EnsureLocalServerInput): { created: boolean; server: ServerRecord } {
const existing = input.repositories.servers.getById(localServerId);
const next = input.repositories.servers.upsert({
auth: existing?.auth ?? null,
baseUrl: server.baseUrl ?? existing?.baseUrl ?? null,
capabilities: {
...input.localServerCapabilities,
...(existing?.capabilities ?? {}),
...(server.capabilities ?? {}),
},
hostingKind: server.hostingKind,
id: localServerId,
isEnabled: true,
isLocal: true,
kind: "local",
label: server.label,
lastSeenAt: new Date().toISOString(),
notes: mergeJson(existing?.notes, server.notes),
source: existing?.source ?? "seeded",
});
return {
created: !existing,
server: next,
};
},
ensureHiddenWorkspace(kind: "control" | "help") {
const workspaceId = createInternalWorkspaceId(kind);
const displayName = kind === "control" ? "Control Workspace" : "Help Workspace";
const workspace = upsertWorkspace({
configDir: ensureWorkspaceConfigDir(input.workingDirectory, workspaceId),
dataDir: null,
displayName,
id: workspaceId,
isHidden: true,
kind,
notes: {
internal: true,
seededBy: "server-v2-phase-2",
},
opencodeProjectId: null,
remoteWorkspaceId: null,
serverId: localServerId,
status: "ready",
});
const backendKind: BackendKind = "local_opencode";
input.repositories.workspaceRuntimeState.upsert({
backendKind,
health: {
hidden: true,
internalWorkspace: kind,
},
lastError: null,
lastSessionRefreshAt: null,
lastSyncAt: null,
workspaceId: workspace.id,
});
return workspace;
},
importLocalWorkspace(workspace: LegacyLocalWorkspaceInput) {
const workspaceKind = workspace.kind ?? "local";
const workspaceId = workspaceKind === "local" ? createLocalWorkspaceId(workspace.dataDir) : createInternalWorkspaceId(workspaceKind);
const configDir = ensureWorkspaceConfigDir(input.workingDirectory, workspaceId);
const record = upsertWorkspace({
configDir,
dataDir: workspace.dataDir,
displayName: workspace.displayName || path.basename(workspace.dataDir),
id: workspaceId,
isHidden: workspaceKind !== "local",
kind: workspaceKind,
notes: mergeJson(workspace.legacyNotes ?? null, {
importSource: "desktop_or_orchestrator",
workspaceKind,
}),
opencodeProjectId: workspace.opencodeProjectId ?? null,
remoteWorkspaceId: null,
serverId: localServerId,
status: workspace.status ?? "imported",
});
input.repositories.workspaceRuntimeState.upsert({
backendKind: "local_opencode",
health: {
configDir,
imported: true,
},
lastError: null,
lastSessionRefreshAt: null,
lastSyncAt: null,
workspaceId: record.id,
});
return record;
},
importRemoteWorkspace(workspace: LegacyRemoteWorkspaceInput) {
const serverId = createServerId("remote", workspace.serverBaseUrl);
const existingServer = input.repositories.servers.getById(serverId);
input.repositories.servers.upsert({
auth: workspace.serverAuth ?? existingServer?.auth ?? null,
baseUrl: workspace.serverBaseUrl,
capabilities: mergeJson(existingServer?.capabilities ?? {}, {
legacyRemoteType: workspace.remoteType,
phase: 2,
source: "desktop-import",
}) ?? {},
hostingKind: workspace.serverHostingKind,
id: serverId,
isEnabled: true,
isLocal: false,
kind: "remote",
label: workspace.serverLabel,
lastSeenAt: existingServer?.lastSeenAt ?? null,
notes: mergeJson(existingServer?.notes, workspace.legacyNotes),
source: existingServer?.source ?? "imported",
});
const workspaceId = createRemoteWorkspaceId({
baseUrl: workspace.serverBaseUrl,
directory: workspace.directory,
remoteType: workspace.remoteType,
remoteWorkspaceId: workspace.remoteWorkspaceId,
});
const record = upsertWorkspace({
configDir: null,
dataDir: null,
displayName: workspace.displayName,
id: workspaceId,
isHidden: false,
kind: "remote",
notes: mergeJson(workspace.legacyNotes, {
directory: workspace.directory ?? null,
remoteType: workspace.remoteType,
}),
opencodeProjectId: null,
remoteWorkspaceId: workspace.remoteWorkspaceId ?? null,
serverId,
status: workspace.workspaceStatus ?? "imported",
});
input.repositories.workspaceRuntimeState.upsert({
backendKind: "remote_openwork",
health: {
imported: true,
remoteServerId: serverId,
},
lastError: null,
lastSessionRefreshAt: null,
lastSyncAt: null,
workspaceId: record.id,
});
return record;
},
listServers() {
return input.repositories.servers.list();
},
listWorkspaces(includeHidden = false) {
return input.repositories.workspaces.list({ includeHidden });
},
localServerId,
};
}
type RegistryService = ReturnType<typeof createRegistryService>;
export type { RegistryService };

View File

@@ -0,0 +1,285 @@
import { HTTPException } from "hono/http-exception";
import { requestRemoteOpenwork } from "../adapters/remote-openwork.js";
import { createRemoteWorkspaceId, createServerId } from "../database/identifiers.js";
import type { ServerRepositories } from "../database/repositories.js";
import type { HostingKind, JsonObject, ServerRecord, WorkspaceRecord } from "../database/types.js";
type RemoteWorkspaceSnapshot = {
directory: string | null;
displayName: string;
remoteWorkspaceId: string;
};
function normalizeUrl(value: string) {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("baseUrl is required.");
}
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`;
const url = new URL(withProtocol);
return url.toString().replace(/\/+$/, "");
}
function stripWorkspaceMount(value: string) {
const url = new URL(normalizeUrl(value));
const segments = url.pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1] ?? "";
const prev = segments[segments.length - 2] ?? "";
if (prev === "w" && last) {
url.pathname = `/${segments.slice(0, -2).join("/")}`;
}
return url.toString().replace(/\/+$/, "");
}
function detectRemoteHostingKind(value: string): HostingKind {
const hostname = new URL(value).hostname.toLowerCase();
if (
hostname === "app.openworklabs.com"
|| hostname === "app.openwork.software"
|| hostname.endsWith(".openworklabs.com")
|| hostname.endsWith(".openwork.software")
) {
return "cloud";
}
return "self_hosted";
}
function asObject(value: unknown) {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function pickString(record: Record<string, unknown>, keys: string[]) {
for (const key of keys) {
const value = record[key];
if (typeof value === "string" && value.trim()) {
return value.trim();
}
}
return null;
}
function normalizeRemoteWorkspaceItems(payload: unknown): RemoteWorkspaceSnapshot[] {
const record = asObject(payload);
const items = Array.isArray(record.items) ? record.items : [];
return items.flatMap((entry) => {
const item = asObject(entry);
const backend = asObject(item.backend);
const local = asObject(backend.local);
const remote = asObject(backend.remote);
const remoteWorkspaceId = pickString(item, ["id"]);
if (!remoteWorkspaceId) {
return [];
}
const displayName = pickString(item, ["displayName", "name"]) ?? remoteWorkspaceId;
const directory = pickString(local, ["dataDir", "directory"])
?? pickString(remote, ["directory"])
?? pickString(item, ["path", "directory"]);
return [{
directory,
displayName,
remoteWorkspaceId,
} satisfies RemoteWorkspaceSnapshot];
});
}
export type RemoteServerService = ReturnType<typeof createRemoteServerService>;
export function createRemoteServerService(input: {
repositories: ServerRepositories;
}) {
function buildServerRecord(payload: {
baseUrl: string;
hostToken?: string | null;
label?: string | null;
token?: string | null;
}) {
const baseUrl = stripWorkspaceMount(payload.baseUrl);
const serverId = createServerId("remote", baseUrl);
const existing = input.repositories.servers.getById(serverId);
const auth: JsonObject = {
...(existing?.auth ?? {}),
...(payload.token?.trim() ? { openworkToken: payload.token.trim(), openworkClientToken: payload.token.trim() } : {}),
...(payload.hostToken?.trim() ? { openworkHostToken: payload.hostToken.trim() } : {}),
};
const label = payload.label?.trim() || existing?.label || new URL(baseUrl).host;
const server = input.repositories.servers.upsert({
auth: Object.keys(auth).length > 0 ? auth : existing?.auth ?? null,
baseUrl,
capabilities: {
...(existing?.capabilities ?? {}),
phase: 9,
remoteWorkspaceDiscovery: true,
remoteWorkspaceRouting: true,
},
hostingKind: existing?.hostingKind ?? detectRemoteHostingKind(baseUrl),
id: serverId,
isEnabled: true,
isLocal: false,
kind: "remote",
label,
lastSeenAt: new Date().toISOString(),
notes: {
...(existing?.notes ?? {}),
connectedVia: "server-v2-phase9",
},
source: existing?.source ?? "connected",
});
return server;
}
async function fetchRemoteWorkspaces(server: ServerRecord) {
const response = await requestRemoteOpenwork<unknown>({
path: "/workspaces",
server,
timeoutMs: 10_000,
});
return normalizeRemoteWorkspaceItems(response);
}
function updateWorkspaceRuntime(workspace: WorkspaceRecord, details: Record<string, unknown>) {
const current = input.repositories.workspaceRuntimeState.getByWorkspaceId(workspace.id);
input.repositories.workspaceRuntimeState.upsert({
backendKind: "remote_openwork",
health: {
...(current?.health ?? {}),
...details,
},
lastError: null,
lastSessionRefreshAt: current?.lastSessionRefreshAt ?? null,
lastSyncAt: new Date().toISOString(),
workspaceId: workspace.id,
});
}
function markMissingWorkspace(workspace: WorkspaceRecord) {
input.repositories.workspaces.upsert({
...workspace,
notes: {
...(workspace.notes ?? {}),
sync: {
missing: true,
recordedAt: new Date().toISOString(),
},
},
status: "attention",
});
const current = input.repositories.workspaceRuntimeState.getByWorkspaceId(workspace.id);
input.repositories.workspaceRuntimeState.upsert({
backendKind: "remote_openwork",
health: current?.health ?? null,
lastError: {
code: "not_found",
message: "Remote workspace was not returned during the latest sync.",
recordedAt: new Date().toISOString(),
},
lastSessionRefreshAt: current?.lastSessionRefreshAt ?? null,
lastSyncAt: new Date().toISOString(),
workspaceId: workspace.id,
});
}
function syncRemoteWorkspaceRecords(server: ServerRecord, discovered: RemoteWorkspaceSnapshot[], hints?: { directory?: string | null; workspaceId?: string | null }) {
const existing = input.repositories.workspaces.listByServerId(server.id, { includeHidden: true }).filter((workspace) => workspace.kind === "remote");
const seenWorkspaceIds = new Set<string>();
const synced: WorkspaceRecord[] = [];
for (const remoteWorkspace of discovered) {
const workspaceId = createRemoteWorkspaceId({
baseUrl: server.baseUrl ?? "",
remoteType: "openwork",
remoteWorkspaceId: remoteWorkspace.remoteWorkspaceId,
});
seenWorkspaceIds.add(workspaceId);
const previous = input.repositories.workspaces.getById(workspaceId);
const workspace = input.repositories.workspaces.upsert({
configDir: null,
dataDir: null,
displayName: remoteWorkspace.displayName,
id: workspaceId,
isHidden: false,
kind: "remote",
notes: {
...(previous?.notes ?? {}),
directory: remoteWorkspace.directory,
remoteType: "openwork",
sync: {
directoryHint: hints?.directory?.trim() || null,
syncedAt: new Date().toISOString(),
},
},
opencodeProjectId: null,
remoteWorkspaceId: remoteWorkspace.remoteWorkspaceId,
serverId: server.id,
slug: previous?.slug ?? workspaceId,
status: "ready",
});
synced.push(workspace);
updateWorkspaceRuntime(workspace, {
remoteServerId: server.id,
remoteWorkspaceId: remoteWorkspace.remoteWorkspaceId,
});
}
for (const workspace of existing) {
if (!seenWorkspaceIds.has(workspace.id)) {
markMissingWorkspace(workspace);
}
}
const requestedWorkspaceId = hints?.workspaceId?.trim();
const requestedDirectory = hints?.directory?.trim();
const selected = synced.find((workspace) => workspace.remoteWorkspaceId === requestedWorkspaceId)
?? synced.find((workspace) => typeof workspace.notes?.directory === "string" && requestedDirectory && workspace.notes.directory === requestedDirectory)
?? synced[0]
?? null;
return {
selectedWorkspaceId: selected?.id ?? null,
workspaces: synced,
};
}
return {
async connect(inputValue: {
baseUrl: string;
directory?: string | null;
hostToken?: string | null;
label?: string | null;
token?: string | null;
workspaceId?: string | null;
}) {
const server = buildServerRecord(inputValue);
const discovered = await fetchRemoteWorkspaces(server);
if (discovered.length === 0) {
throw new HTTPException(404, { message: "Remote OpenWork server did not return any visible workspaces." });
}
const result = syncRemoteWorkspaceRecords(server, discovered, {
directory: inputValue.directory,
workspaceId: inputValue.workspaceId,
});
return {
selectedWorkspaceId: result.selectedWorkspaceId,
server,
workspaces: result.workspaces,
};
},
async sync(serverId: string, hints?: { directory?: string | null; workspaceId?: string | null }) {
const server = input.repositories.servers.getById(serverId);
if (!server || server.kind !== "remote") {
throw new HTTPException(404, { message: `Remote server not found: ${serverId}` });
}
const discovered = await fetchRemoteWorkspaces(server);
const result = syncRemoteWorkspaceRecords(server, discovered, hints);
input.repositories.servers.upsert({
...server,
lastSeenAt: new Date().toISOString(),
});
return {
selectedWorkspaceId: result.selectedWorkspaceId,
server: input.repositories.servers.getById(server.id)!,
workspaces: result.workspaces,
};
},
};
}

View File

@@ -0,0 +1,408 @@
import { createHash, randomUUID } from "node:crypto";
import { HTTPException } from "hono/http-exception";
import type { ServerRepositories } from "../database/repositories.js";
import type { JsonObject, RouterBindingRecord, RouterIdentityRecord } from "../database/types.js";
import type { RuntimeService } from "./runtime-service.js";
import { RouteError } from "../http.js";
function asObject(value: unknown): JsonObject {
return value && typeof value === "object" && !Array.isArray(value) ? { ...(value as JsonObject) } : {};
}
function normalizeString(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function nowIso() {
return new Date().toISOString();
}
async function fetchTelegramBotInfo(token: string) {
const trimmed = token.trim();
if (!trimmed) {
return null as { id: number; name?: string; username?: string } | null;
}
try {
const response = await fetch(`https://api.telegram.org/bot${trimmed}/getMe`, {
headers: { Accept: "application/json" },
});
if (!response.ok) {
return null;
}
const json = await response.json().catch(() => null) as Record<string, unknown> | null;
const result = asObject(json?.result);
const id = Number(result.id);
if (!Number.isFinite(id)) {
return null;
}
return {
id,
name: typeof result.first_name === "string" ? result.first_name : undefined,
username: typeof result.username === "string" ? result.username : undefined,
};
} catch {
return null;
}
}
function createPairingCode() {
return Math.random().toString(36).slice(2, 8).toUpperCase();
}
function pairingCodeHash(value: string) {
return createHash("sha256").update(value).digest("hex");
}
export type RouterProductService = ReturnType<typeof createRouterProductService>;
export function createRouterProductService(input: {
repositories: ServerRepositories;
runtime: RuntimeService;
serverId: string;
}) {
function getIdentityOrThrow(identityId: string) {
const identity = input.repositories.routerIdentities.getById(identityId);
if (!identity || identity.serverId !== input.serverId) {
throw new HTTPException(404, { message: `Router identity not found: ${identityId}` });
}
return identity;
}
function getBindingOrThrow(bindingId: string) {
const binding = input.repositories.routerBindings.getById(bindingId);
if (!binding || binding.serverId !== input.serverId) {
throw new HTTPException(404, { message: `Router binding not found: ${bindingId}` });
}
return binding;
}
function listIdentities(kind?: "slack" | "telegram") {
return input.repositories.routerIdentities.listByServer(input.serverId).filter((identity) => !kind || identity.kind === kind);
}
function listBindings(filters?: { channel?: string; identityId?: string }) {
const identitiesById = new Map(listIdentities().map((identity) => [identity.id, identity] as const));
return input.repositories.routerBindings.listByServer(input.serverId)
.filter((binding) => {
const identity = identitiesById.get(binding.routerIdentityId);
if (!identity) {
return false;
}
if (filters?.identityId?.trim() && binding.routerIdentityId !== filters.identityId.trim()) {
return false;
}
if (filters?.channel?.trim() && identity.kind !== filters.channel.trim()) {
return false;
}
return true;
})
.map((binding) => ({
channel: identitiesById.get(binding.routerIdentityId)?.kind ?? "unknown",
directory: normalizeString(asObject(binding.config).directory) || normalizeString(asObject(binding.config).workspacePath),
identityId: binding.routerIdentityId,
peerId: binding.bindingKey,
updatedAt: Date.parse(binding.updatedAt) || undefined,
}));
}
async function apply() {
const health = await input.runtime.applyRouterConfig();
return {
applied: health.status === "running" || health.status === "disabled",
applyError: health.status === "error" ? health.lastError ?? "Router apply failed." : undefined,
applyStatus: health.status === "error" ? 502 : undefined,
health,
ok: true,
};
}
function buildHealthSnapshot() {
const runtimeHealth = input.runtime.getRouterHealth();
const telegram = listIdentities("telegram").filter((identity) => identity.isEnabled);
const slack = listIdentities("slack").filter((identity) => identity.isEnabled);
return {
config: {
groupsEnabled: false,
},
channels: {
slack: slack.length > 0,
telegram: telegram.length > 0,
whatsapp: false,
},
ok: runtimeHealth.status === "running" || runtimeHealth.status === "disabled",
opencode: {
healthy: runtimeHealth.status === "running",
url: runtimeHealth.baseUrl ?? runtimeHealth.healthUrl ?? "",
version: runtimeHealth.version ?? undefined,
},
};
}
function buildIdentityItem(identity: RouterIdentityRecord) {
const config = asObject(identity.config);
return {
access: typeof config.access === "string" && (config.access === "private" || config.access === "public") ? config.access : undefined,
enabled: identity.isEnabled,
id: identity.id,
pairingRequired: config.access === "private" || undefined,
running: input.runtime.getRouterHealth().status === "running",
};
}
function resolveIdentityForChannel(channel: "slack" | "telegram", identityId?: string) {
if (identityId?.trim()) {
const identity = getIdentityOrThrow(identityId.trim());
if (identity.kind !== channel) {
throw new RouteError(400, "invalid_request", `Identity ${identityId} is not a ${channel} identity.`);
}
return identity;
}
const fallback = listIdentities(channel).find((identity) => identity.isEnabled) ?? listIdentities(channel)[0] ?? null;
if (!fallback) {
throw new RouteError(400, "invalid_request", `No ${channel} identity is configured.`);
}
return fallback;
}
async function proxyRouter<T>(pathname: string, init?: { body?: unknown; method?: string }) {
const health = input.runtime.getRouterHealth();
if (!health.baseUrl || health.status !== "running") {
throw new RouteError(503, "service_unavailable", "Router is not running.");
}
const response = await fetch(`${health.baseUrl.replace(/\/+$/, "")}${pathname}`, {
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
headers: { Accept: "application/json", "Content-Type": "application/json" },
method: init?.method ?? "GET",
});
const text = await response.text();
const json = text ? JSON.parse(text) : null;
if (!response.ok) {
throw new RouteError(response.status, "bad_gateway", typeof json?.error === "string" ? json.error : `Router request failed (${response.status}).`);
}
return json as T;
}
return {
async apply() {
return apply();
},
async deleteSlackIdentity(identityId: string) {
const identity = getIdentityOrThrow(identityId);
if (identity.kind !== "slack") {
throw new RouteError(400, "invalid_request", "Router identity is not a Slack identity.");
}
input.repositories.routerIdentities.deleteById(identityId);
const applied = await apply();
return {
...applied,
slack: {
deleted: true,
id: identityId,
},
};
},
async deleteTelegramIdentity(identityId: string) {
const identity = getIdentityOrThrow(identityId);
if (identity.kind !== "telegram") {
throw new RouteError(400, "invalid_request", "Router identity is not a Telegram identity.");
}
input.repositories.routerIdentities.deleteById(identityId);
const applied = await apply();
return {
...applied,
telegram: {
deleted: true,
id: identityId,
},
};
},
getHealth() {
return buildHealthSnapshot();
},
async getTelegramInfo() {
const identity = listIdentities("telegram")[0] ?? null;
if (!identity) {
return { bot: null, configured: false, enabled: false, ok: true };
}
const token = normalizeString(asObject(identity.auth).token) || normalizeString(asObject(identity.config).token);
return {
bot: await fetchTelegramBotInfo(token),
configured: Boolean(token),
enabled: identity.isEnabled,
ok: true,
};
},
listBindings(filters?: { channel?: string; identityId?: string }) {
return { items: listBindings(filters), ok: true };
},
listRouterBindings() {
return input.repositories.routerBindings.listByServer(input.serverId);
},
listRouterIdentities() {
return input.repositories.routerIdentities.listByServer(input.serverId);
},
listSlackIdentities() {
return { items: listIdentities("slack").map(buildIdentityItem), ok: true };
},
listTelegramIdentities() {
return { items: listIdentities("telegram").map(buildIdentityItem), ok: true };
},
async sendMessage(inputValue: {
autoBind?: boolean;
channel: "slack" | "telegram";
directory?: string;
identityId?: string;
peerId?: string;
text: string;
}) {
const payload = {
...(inputValue.autoBind ? { autoBind: true } : {}),
channel: inputValue.channel,
...(normalizeString(inputValue.directory) ? { directory: normalizeString(inputValue.directory) } : {}),
...(normalizeString(inputValue.identityId) ? { identityId: normalizeString(inputValue.identityId) } : {}),
...(normalizeString(inputValue.peerId) ? { peerId: normalizeString(inputValue.peerId) } : {}),
text: inputValue.text,
};
return await proxyRouter<Record<string, unknown>>("/send", { body: payload, method: "POST" });
},
async setBinding(inputValue: { channel: "slack" | "telegram"; directory: string; identityId?: string; peerId: string }) {
const identity = resolveIdentityForChannel(inputValue.channel, inputValue.identityId);
const existing = input.repositories.routerBindings.listByServer(input.serverId)
.find((binding) => binding.routerIdentityId === identity.id && binding.bindingKey === inputValue.peerId) ?? null;
input.repositories.routerBindings.upsert({
config: { directory: inputValue.directory },
bindingKey: inputValue.peerId,
id: existing?.id ?? `binding_${randomUUID()}`,
isEnabled: true,
routerIdentityId: identity.id,
serverId: input.serverId,
});
await apply();
return { ok: true };
},
async setSlackTokens(botToken: string, appToken: string) {
return this.upsertSlackIdentity({ appToken, botToken, enabled: true, id: "default" });
},
async setTelegramEnabled(enabled: boolean, options?: { clearToken?: boolean }) {
const identity = listIdentities("telegram")[0] ?? null;
if (!identity) {
throw new RouteError(404, "not_found", "Telegram identity is not configured.");
}
input.repositories.routerIdentities.upsert({
...identity,
auth: options?.clearToken ? { ...identity.auth, token: null } : identity.auth,
isEnabled: enabled,
});
const applied = await apply();
return {
...applied,
enabled,
};
},
async setTelegramToken(token: string) {
return this.upsertTelegramIdentity({ access: "public", enabled: true, id: "default", token });
},
async upsertRouterBinding(payload: Omit<RouterBindingRecord, "createdAt" | "updatedAt"> & { createdAt?: string; updatedAt?: string }) {
const binding = input.repositories.routerBindings.upsert(payload);
await apply();
return binding;
},
async upsertRouterIdentity(payload: Omit<RouterIdentityRecord, "createdAt" | "updatedAt"> & { createdAt?: string; updatedAt?: string }) {
const identity = input.repositories.routerIdentities.upsert(payload);
await apply();
return identity;
},
async updateBinding(bindingId: string, payload: { config?: JsonObject; isEnabled?: boolean; routerIdentityId?: string }) {
const binding = getBindingOrThrow(bindingId);
return await this.upsertRouterBinding({
...binding,
config: payload.config ?? binding.config,
isEnabled: payload.isEnabled ?? binding.isEnabled,
routerIdentityId: payload.routerIdentityId ?? binding.routerIdentityId,
});
},
async updateIdentity(identityId: string, payload: { auth?: JsonObject | null; config?: JsonObject; displayName?: string; isEnabled?: boolean }) {
const identity = getIdentityOrThrow(identityId);
return await this.upsertRouterIdentity({
...identity,
auth: payload.auth ?? identity.auth,
config: payload.config ?? identity.config,
displayName: payload.displayName ?? identity.displayName,
isEnabled: payload.isEnabled ?? identity.isEnabled,
});
},
async upsertSlackIdentity(payload: { appToken: string; botToken: string; enabled?: boolean; id?: string }) {
const id = normalizeString(payload.id) || `router_slack_${randomUUID()}`;
input.repositories.routerIdentities.upsert({
auth: {
appToken: payload.appToken.trim(),
botToken: payload.botToken.trim(),
},
config: {},
displayName: id,
id,
isEnabled: payload.enabled !== false,
kind: "slack",
serverId: input.serverId,
});
const applied = await apply();
return {
...applied,
slack: {
enabled: payload.enabled !== false,
id,
},
};
},
async upsertTelegramIdentity(payload: { access?: "private" | "public"; enabled?: boolean; id?: string; token: string }) {
const id = normalizeString(payload.id) || `router_telegram_${randomUUID()}`;
const pairingCode = payload.access === "private" ? createPairingCode() : null;
input.repositories.routerIdentities.upsert({
auth: {
token: payload.token.trim(),
},
config: {
...(payload.access ? { access: payload.access } : {}),
...(pairingCode ? { pairingCodeHash: pairingCodeHash(pairingCode) } : {}),
},
displayName: id,
id,
isEnabled: payload.enabled !== false,
kind: "telegram",
serverId: input.serverId,
});
const applied = await apply();
const bot = await fetchTelegramBotInfo(payload.token);
return {
...applied,
telegram: {
access: payload.access,
...(bot ? { bot } : {}),
enabled: payload.enabled !== false,
id,
...(pairingCode ? { pairingCode, pairingRequired: true } : {}),
},
};
},
};
}

View File

@@ -0,0 +1,307 @@
import { afterEach, expect, test } from "bun:test";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createHash } from "node:crypto";
import { fileURLToPath } from "node:url";
import { createServerPersistence } from "../database/persistence.js";
import { resolveRuntimeTarget, type RuntimeManifest } from "../runtime/manifest.js";
import { createRuntimeService } from "./runtime-service.js";
const cleanupPaths: string[] = [];
afterEach(async () => {
while (cleanupPaths.length > 0) {
const target = cleanupPaths.pop();
if (!target) {
continue;
}
fs.rmSync(target, { force: true, recursive: true });
}
});
function makeTempDir(name: string) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), `${name}-`));
cleanupPaths.push(directory);
return directory;
}
async function sha256(filePath: string) {
const contents = await Bun.file(filePath).arrayBuffer();
return createHash("sha256").update(Buffer.from(contents)).digest("hex");
}
async function createFakeBinary(kind: "opencode" | "router", mode: string, exitAfterMs?: number) {
const wrapperDir = makeTempDir(`openwork-server-v2-${kind}`);
const binaryPath = path.join(wrapperDir, kind === "opencode" ? "opencode" : "opencode-router");
const fixturePath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "test-fixtures", "fake-runtime.ts");
const script = [
"#!/bin/sh",
`export FAKE_RUNTIME_KIND=${kind}`,
`export FAKE_RUNTIME_MODE=${mode}`,
...(exitAfterMs ? [`export FAKE_RUNTIME_EXIT_AFTER_MS=${exitAfterMs}`] : []),
`exec ${JSON.stringify(process.execPath)} ${JSON.stringify(fixturePath)} \"$@\"`,
"",
].join("\n");
fs.writeFileSync(binaryPath, script, "utf8");
fs.chmodSync(binaryPath, 0o755);
return binaryPath;
}
async function createFakeAssetService(opencodePath: string, routerPath: string) {
const target = resolveRuntimeTarget();
if (!target) {
throw new Error("Unsupported test target.");
}
const opencodeExists = fs.existsSync(opencodePath);
const opencodeStats = opencodeExists ? fs.statSync(opencodePath) : { size: 0 };
const routerStats = fs.statSync(routerPath);
const manifest: RuntimeManifest = {
files: {
opencode: {
path: path.basename(opencodePath),
sha256: opencodeExists ? await sha256(opencodePath) : "missing",
size: opencodeStats.size,
},
"opencode-router": {
path: path.basename(routerPath),
sha256: await sha256(routerPath),
size: routerStats.size,
},
},
generatedAt: new Date().toISOString(),
manifestVersion: 1,
opencodeVersion: "1.2.27",
rootDir: path.dirname(opencodePath),
routerVersion: "0.11.206",
serverVersion: "0.0.0-test",
source: "development",
target,
};
const opencodeBinary = {
absolutePath: opencodePath,
name: "opencode" as const,
sha256: manifest.files.opencode.sha256,
size: manifest.files.opencode.size,
source: "development" as const,
stagedRoot: path.dirname(opencodePath),
target,
version: "1.2.27",
};
const routerBinary = {
absolutePath: routerPath,
name: "opencode-router" as const,
sha256: manifest.files["opencode-router"].sha256,
size: manifest.files["opencode-router"].size,
source: "development" as const,
stagedRoot: path.dirname(routerPath),
target,
version: "0.11.206",
};
return {
ensureOpencodeBinary: async () => opencodeBinary,
ensureRouterBinary: async () => routerBinary,
getDevelopmentRoot: () => path.dirname(opencodePath),
getPinnedOpencodeVersion: async () => "1.2.27",
getReleaseRoot: () => path.dirname(opencodePath),
getRouterVersion: async () => "0.11.206",
getSource: () => "development" as const,
getTarget: () => target,
resolveRuntimeBundle: async () => ({
manifest,
opencode: opencodeBinary,
router: routerBinary,
}),
};
}
function createPersistence() {
const workingDirectory = makeTempDir("openwork-server-v2-runtime-service");
return createServerPersistence({
environment: "test",
localServer: {
baseUrl: null,
hostingKind: "self_hosted",
label: "Local OpenWork Server",
},
version: "0.0.0-test",
workingDirectory,
});
}
test("runtime bootstrap starts OpenCode successfully and persists health", async () => {
const persistence = createPersistence();
const opencodePath = await createFakeBinary("opencode", "success");
const routerPath = await createFakeBinary("router", "success");
const assetService = await createFakeAssetService(opencodePath, routerPath);
const runtime = createRuntimeService({
assetService,
bootstrapPolicy: "manual",
environment: "test",
repositories: persistence.repositories,
restartPolicy: { backoffMs: 25, maxAttempts: 0, windowMs: 1000 },
serverId: persistence.registry.localServerId,
serverVersion: "0.0.0-test",
workingDirectory: persistence.workingDirectory,
});
await runtime.bootstrap();
const opencode = runtime.getOpencodeHealth();
expect(opencode.running).toBe(true);
expect(opencode.status).toBe("running");
expect(opencode.baseUrl).toContain("http://127.0.0.1:");
expect(persistence.repositories.serverRuntimeState.getByServerId(persistence.registry.localServerId)?.opencodeStatus).toBe("running");
await runtime.dispose();
persistence.close();
});
test("runtime bootstrap surfaces missing OpenCode binaries clearly", async () => {
const persistence = createPersistence();
const routerPath = await createFakeBinary("router", "success");
const assetService = await createFakeAssetService(path.join(os.tmpdir(), `does-not-exist-opencode-${Date.now()}`), routerPath);
const runtime = createRuntimeService({
assetService,
bootstrapPolicy: "manual",
environment: "test",
repositories: persistence.repositories,
restartPolicy: { backoffMs: 25, maxAttempts: 0, windowMs: 1000 },
serverId: persistence.registry.localServerId,
serverVersion: "0.0.0-test",
workingDirectory: persistence.workingDirectory,
});
await expect(runtime.bootstrap()).rejects.toThrow("executable not found");
expect(runtime.getOpencodeHealth().status).toBe("error");
await runtime.dispose();
persistence.close();
});
test("runtime bootstrap surfaces OpenCode readiness timeouts", async () => {
const persistence = createPersistence();
const opencodePath = await createFakeBinary("opencode", "timeout");
const routerPath = await createFakeBinary("router", "success");
const assetService = await createFakeAssetService(opencodePath, routerPath);
process.env.OPENWORK_SERVER_V2_OPENCODE_START_TIMEOUT_MS = "300";
const runtime = createRuntimeService({
assetService,
bootstrapPolicy: "manual",
environment: "test",
repositories: persistence.repositories,
restartPolicy: { backoffMs: 25, maxAttempts: 0, windowMs: 1000 },
serverId: persistence.registry.localServerId,
serverVersion: "0.0.0-test",
workingDirectory: persistence.workingDirectory,
});
await expect(runtime.bootstrap()).rejects.toThrow("did not become ready");
expect(runtime.getOpencodeHealth().status).toBe("error");
delete process.env.OPENWORK_SERVER_V2_OPENCODE_START_TIMEOUT_MS;
await runtime.dispose();
persistence.close();
});
test("runtime supervisor records post-ready OpenCode crashes", async () => {
const persistence = createPersistence();
const opencodePath = await createFakeBinary("opencode", "success", 150);
const routerPath = await createFakeBinary("router", "success");
const assetService = await createFakeAssetService(opencodePath, routerPath);
const runtime = createRuntimeService({
assetService,
bootstrapPolicy: "manual",
environment: "test",
repositories: persistence.repositories,
restartPolicy: { backoffMs: 25, maxAttempts: 0, windowMs: 1000 },
serverId: persistence.registry.localServerId,
serverVersion: "0.0.0-test",
workingDirectory: persistence.workingDirectory,
});
await runtime.bootstrap();
await Bun.sleep(400);
expect(runtime.getOpencodeHealth().status).toBe("crashed");
expect(runtime.getOpencodeHealth().lastExit?.reason).toBe("unexpected_exit");
await runtime.dispose();
persistence.close();
});
test("runtime supervisor starts router when enabled and persists router state", async () => {
const persistence = createPersistence();
persistence.repositories.routerIdentities.upsert({
auth: { token: "telegram-token" },
config: {},
displayName: "Telegram Bot",
id: "router_identity_telegram",
isEnabled: true,
kind: "telegram",
serverId: persistence.registry.localServerId,
});
persistence.repositories.routerBindings.upsert({
bindingKey: "peer-1",
config: { directory: persistence.workingDirectory.rootDir },
id: "router_binding_one",
isEnabled: true,
routerIdentityId: "router_identity_telegram",
serverId: persistence.registry.localServerId,
});
const opencodePath = await createFakeBinary("opencode", "success");
const routerPath = await createFakeBinary("router", "success");
const assetService = await createFakeAssetService(opencodePath, routerPath);
const runtime = createRuntimeService({
assetService,
bootstrapPolicy: "manual",
environment: "test",
repositories: persistence.repositories,
restartPolicy: { backoffMs: 25, maxAttempts: 0, windowMs: 1000 },
serverId: persistence.registry.localServerId,
serverVersion: "0.0.0-test",
workingDirectory: persistence.workingDirectory,
});
await runtime.bootstrap();
const router = runtime.getRouterHealth();
expect(router.enablement.enabled).toBe(true);
expect(router.running).toBe(true);
expect(router.status).toBe("running");
expect(router.materialization?.bindingCount).toBe(1);
expect(persistence.repositories.serverRuntimeState.getByServerId(persistence.registry.localServerId)?.routerStatus).toBe("running");
await runtime.dispose();
persistence.close();
});
test("runtime upgrade restarts managed children and records upgrade state", async () => {
const persistence = createPersistence();
const opencodePath = await createFakeBinary("opencode", "success");
const routerPath = await createFakeBinary("router", "success");
const assetService = await createFakeAssetService(opencodePath, routerPath);
const runtime = createRuntimeService({
assetService,
bootstrapPolicy: "manual",
environment: "test",
repositories: persistence.repositories,
restartPolicy: { backoffMs: 25, maxAttempts: 0, windowMs: 1000 },
serverId: persistence.registry.localServerId,
serverVersion: "0.0.0-test",
workingDirectory: persistence.workingDirectory,
});
await runtime.bootstrap();
const upgraded = await runtime.upgradeRuntime();
expect(upgraded.state.status).toBe("completed");
expect(upgraded.summary.opencode.running).toBe(true);
expect(upgraded.summary.upgrade.status).toBe("completed");
await runtime.dispose();
persistence.close();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,322 @@
import { spawnSync } from "node:child_process";
import { existsSync } from "node:fs";
import { readdir, rm } from "node:fs/promises";
import { homedir } from "node:os";
import { join, resolve } from "node:path";
import { RouteError } from "../http.js";
import type { WorkspaceRegistryService } from "./workspace-registry-service.js";
export type ScheduledJobRun = {
prompt?: string;
command?: string;
arguments?: string;
files?: string[];
agent?: string;
model?: string;
variant?: string;
title?: string;
share?: boolean;
continue?: boolean;
session?: string;
runFormat?: string;
attachUrl?: string;
port?: number;
};
export type ScheduledJob = {
scopeId?: string;
timeoutSeconds?: number;
invocation?: { command: string; args: string[] };
slug: string;
name: string;
schedule: string;
prompt?: string;
attachUrl?: string;
run?: ScheduledJobRun;
source?: string;
workdir?: string;
createdAt: string;
updatedAt?: string;
lastRunAt?: string;
lastRunExitCode?: number;
lastRunError?: string;
lastRunSource?: string;
lastRunStatus?: string;
};
type JobEntry = {
job: ScheduledJob;
jobFile: string;
};
const SUPPORTED_PLATFORMS = new Set(["darwin", "linux"]);
function ensureSchedulerSupported() {
if (SUPPORTED_PLATFORMS.has(process.platform)) {
return;
}
throw new RouteError(501, "not_implemented", "Scheduler is supported only on macOS and Linux.");
}
function normalizePathForCompare(value: string) {
const trimmed = value.trim();
return trimmed ? resolve(trimmed) : "";
}
function slugify(name: string) {
let out = "";
let dash = false;
for (const char of name.trim().toLowerCase()) {
if (/[a-z0-9]/.test(char)) {
out += char;
dash = false;
continue;
}
if (!dash) {
out += "-";
dash = true;
}
}
return out.replace(/^-+|-+$/g, "");
}
function findJobEntryByName(entries: JobEntry[], name: string) {
const trimmed = name.trim();
if (!trimmed) {
return null;
}
const slug = slugify(trimmed);
const lower = trimmed.toLowerCase();
return entries.find((entry) =>
entry.job.slug === trimmed
|| entry.job.slug === slug
|| entry.job.slug.endsWith(`-${slug}`)
|| entry.job.name.toLowerCase() === lower
|| entry.job.name.toLowerCase().includes(lower),
) ?? null;
}
function schedulerSystemPaths(job: ScheduledJob, homeDir: string) {
const paths: string[] = [];
if (process.platform === "darwin") {
if (job.scopeId) {
paths.push(join(homeDir, "Library", "LaunchAgents", `com.opencode.job.${job.scopeId}.${job.slug}.plist`));
}
paths.push(join(homeDir, "Library", "LaunchAgents", `com.opencode.job.${job.slug}.plist`));
return paths;
}
if (process.platform === "linux") {
const base = join(homeDir, ".config", "systemd", "user");
if (job.scopeId) {
paths.push(join(base, `opencode-job-${job.scopeId}-${job.slug}.service`));
paths.push(join(base, `opencode-job-${job.scopeId}-${job.slug}.timer`));
}
paths.push(join(base, `opencode-job-${job.slug}.service`));
paths.push(join(base, `opencode-job-${job.slug}.timer`));
return paths;
}
return paths;
}
async function loadJobFile(path: string) {
const file = Bun.file(path);
if (!(await file.exists())) {
return null;
}
const parsed = await file.json().catch(() => null);
if (!parsed || typeof parsed !== "object") {
return null;
}
if (typeof (parsed as any).slug !== "string" || typeof (parsed as any).name !== "string" || typeof (parsed as any).schedule !== "string") {
return null;
}
return parsed as ScheduledJob;
}
export type SchedulerService = ReturnType<typeof createSchedulerService>;
export function createSchedulerService(input: {
workspaceRegistry: WorkspaceRegistryService;
homeDir?: string;
}) {
const resolvedHomeDir = (input.homeDir ?? process.env.HOME ?? homedir()).trim();
function requireHomeDir() {
if (!resolvedHomeDir) {
throw new RouteError(500, "internal_error", "Failed to resolve home directory.");
}
return resolvedHomeDir;
}
function legacyJobsDir() {
return join(requireHomeDir(), ".config", "opencode", "jobs");
}
function schedulerScopesDir() {
return join(requireHomeDir(), ".config", "opencode", "scheduler", "scopes");
}
function legacyJobFilePath(slug: string) {
return join(legacyJobsDir(), `${slug}.json`);
}
function scopedJobFilePath(scopeId: string, slug: string) {
return join(schedulerScopesDir(), scopeId, "jobs", `${slug}.json`);
}
async function loadLegacyJobEntries() {
const jobsDir = legacyJobsDir();
if (!existsSync(jobsDir)) {
return [] as JobEntry[];
}
const entries = await readdir(jobsDir, { withFileTypes: true });
const jobs: JobEntry[] = [];
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json")) {
continue;
}
const jobFile = join(jobsDir, entry.name);
const job = await loadJobFile(jobFile);
if (job) {
jobs.push({ job, jobFile });
}
}
return jobs;
}
async function loadScopedJobEntries() {
const scopesDir = schedulerScopesDir();
if (!existsSync(scopesDir)) {
return [] as JobEntry[];
}
const scopeEntries = await readdir(scopesDir, { withFileTypes: true });
const jobs: JobEntry[] = [];
for (const scopeEntry of scopeEntries) {
if (!scopeEntry.isDirectory()) {
continue;
}
const scopeId = scopeEntry.name;
const jobsDir = join(scopesDir, scopeId, "jobs");
if (!existsSync(jobsDir)) {
continue;
}
const entries = await readdir(jobsDir, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json")) {
continue;
}
const jobFile = join(jobsDir, entry.name);
const job = await loadJobFile(jobFile);
if (!job) {
continue;
}
jobs.push({ job: { ...job, scopeId: job.scopeId ?? scopeId }, jobFile });
}
}
return jobs;
}
async function loadAllJobEntries() {
const [scoped, legacy] = await Promise.all([loadScopedJobEntries(), loadLegacyJobEntries()]);
return [...scoped, ...legacy];
}
function requireLocalWorkspaceDataDir(workspaceId: string) {
const workspace = input.workspaceRegistry.getById(workspaceId, { includeHidden: true });
if (!workspace) {
throw new RouteError(404, "not_found", `Workspace not found: ${workspaceId}`);
}
if (workspace.backend.kind !== "local_opencode") {
throw new RouteError(501, "not_implemented", `Scheduler jobs are not supported for ${workspace.backend.kind} workspaces.`);
}
const dataDir = workspace.backend.local?.dataDir?.trim() ?? "";
if (!dataDir) {
throw new RouteError(400, "invalid_request", `Workspace ${workspace.id} does not have a local data directory.`);
}
return dataDir;
}
async function uninstallJob(job: ScheduledJob) {
const homeDir = requireHomeDir();
if (process.platform === "darwin") {
for (const plist of schedulerSystemPaths(job, homeDir)) {
if (!(await Bun.file(plist).exists())) {
continue;
}
spawnSync("launchctl", ["unload", plist]);
await rm(plist, { force: true });
}
return;
}
if (process.platform === "linux") {
const timerUnits = [
job.scopeId ? `opencode-job-${job.scopeId}-${job.slug}.timer` : null,
`opencode-job-${job.slug}.timer`,
].filter(Boolean) as string[];
for (const unit of timerUnits) {
spawnSync("systemctl", ["--user", "stop", unit]);
spawnSync("systemctl", ["--user", "disable", unit]);
}
for (const filePath of schedulerSystemPaths(job, homeDir)) {
if (await Bun.file(filePath).exists()) {
await rm(filePath, { force: true });
}
}
spawnSync("systemctl", ["--user", "daemon-reload"]);
return;
}
ensureSchedulerSupported();
}
return {
async listWorkspaceJobs(workspaceId: string) {
ensureSchedulerSupported();
const workdir = requireLocalWorkspaceDataDir(workspaceId);
const normalizedRoot = normalizePathForCompare(workdir);
const entries = await loadAllJobEntries();
const jobs = entries
.map((entry) => entry.job)
.filter((job) => {
const jobWorkdir = job.workdir?.trim() ?? "";
return jobWorkdir ? normalizePathForCompare(jobWorkdir) === normalizedRoot : false;
});
jobs.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
return { items: jobs };
},
async deleteWorkspaceJob(workspaceId: string, name: string) {
ensureSchedulerSupported();
const workdir = requireLocalWorkspaceDataDir(workspaceId);
const normalizedRoot = normalizePathForCompare(workdir);
const trimmed = name.trim();
if (!trimmed) {
throw new RouteError(400, "invalid_request", "name is required");
}
const entries = (await loadAllJobEntries()).filter((entry) => {
const jobWorkdir = entry.job.workdir?.trim() ?? "";
return jobWorkdir ? normalizePathForCompare(jobWorkdir) === normalizedRoot : false;
});
const found = findJobEntryByName(entries, trimmed);
if (!found) {
throw new RouteError(404, "not_found", `Job "${trimmed}" not found.`);
}
await uninstallJob(found.job);
await rm(found.jobFile, { force: true });
const legacyJobPath = legacyJobFilePath(found.job.slug);
if (legacyJobPath !== found.jobFile && await Bun.file(legacyJobPath).exists()) {
await rm(legacyJobPath, { force: true });
}
if (found.job.scopeId) {
const scopedJobPath = scopedJobFilePath(found.job.scopeId, found.job.slug);
if (scopedJobPath !== found.jobFile && await Bun.file(scopedJobPath).exists()) {
await rm(scopedJobPath, { force: true });
}
}
return { job: found.job };
},
};
}

View File

@@ -0,0 +1,90 @@
import type { ServerRepositories } from "../database/repositories.js";
import type { JsonObject, ServerRecord } from "../database/types.js";
export type ServerRegistrySummary = {
hiddenWorkspaceCount: number;
localServerId: string;
remoteServerCount: number;
totalServers: number;
visibleWorkspaceCount: number;
};
export type ServerInventoryItem = {
auth: {
configured: boolean;
scheme: "bearer" | "none";
};
baseUrl: string | null;
capabilities: JsonObject;
hostingKind: ServerRecord["hostingKind"];
id: string;
isEnabled: boolean;
isLocal: boolean;
kind: ServerRecord["kind"];
label: string;
lastSeenAt: string | null;
source: string;
updatedAt: string;
};
export type ServerRegistryService = ReturnType<typeof createServerRegistryService>;
function hasServerAuth(record: ServerRecord) {
if (!record.auth) {
return false;
}
return Object.values(record.auth).some((value) => typeof value === "string" && value.trim().length > 0);
}
export function createServerRegistryService(input: {
localServerId: string;
repositories: ServerRepositories;
}) {
const { repositories } = input;
function serialize(record: ServerRecord, options?: { includeBaseUrl?: boolean }) {
return {
auth: {
configured: hasServerAuth(record),
scheme: hasServerAuth(record) ? "bearer" : "none",
},
baseUrl: options?.includeBaseUrl === false ? null : record.baseUrl,
capabilities: record.capabilities,
hostingKind: record.hostingKind,
id: record.id,
isEnabled: record.isEnabled,
isLocal: record.isLocal,
kind: record.kind,
label: record.label,
lastSeenAt: record.lastSeenAt,
source: record.source,
updatedAt: record.updatedAt,
} satisfies ServerInventoryItem;
}
return {
getById(serverId: string) {
return repositories.servers.getById(serverId);
},
list(options?: { includeBaseUrl?: boolean }) {
return repositories.servers.list().map((record) => serialize(record, options));
},
serialize,
summarize(): ServerRegistrySummary {
const servers = repositories.servers.list();
const allWorkspaces = repositories.workspaces.list({ includeHidden: true });
const hiddenWorkspaceCount = allWorkspaces.filter((workspace) => workspace.isHidden).length;
return {
hiddenWorkspaceCount,
localServerId: input.localServerId,
remoteServerCount: servers.filter((server) => server.kind === "remote").length,
totalServers: servers.length,
visibleWorkspaceCount: allWorkspaces.length - hiddenWorkspaceCount,
};
},
};
}

View File

@@ -0,0 +1,148 @@
import type { ProcessInfoAdapter } from "../adapters/process-info.js";
import type { DatabaseStatusProvider } from "../database/status-provider.js";
import { routeNamespaces, workspaceResourcePattern } from "../routes/route-paths.js";
import type { AuthService, RequestActor } from "./auth-service.js";
import type { CapabilitiesService } from "./capabilities-service.js";
import type { RuntimeService } from "./runtime-service.js";
import type { ServerRegistryService } from "./server-registry-service.js";
import type { WorkspaceRegistryService } from "./workspace-registry-service.js";
export type SystemService = ReturnType<typeof createSystemService>;
export function createSystemService(input: {
auth: AuthService;
capabilities: CapabilitiesService;
environment: string;
processInfo: ProcessInfoAdapter;
database: DatabaseStatusProvider;
runtime: RuntimeService;
serverRegistry: ServerRegistryService;
startedAt: Date;
version: string;
workspaceRegistry: WorkspaceRegistryService;
}) {
const service = "openwork-server-v2" as const;
const packageName = "openwork-server-v2" as const;
return {
getRootInfo() {
return {
service,
packageName,
version: input.version,
environment: input.environment,
routes: {
...routeNamespaces,
workspaceResource: workspaceResourcePattern,
},
contract: {
source: "hono-openapi" as const,
openapiPath: routeNamespaces.openapi,
sdkPackage: "@openwork/server-sdk" as const,
},
};
},
getCapabilities(actor: RequestActor) {
return input.capabilities.getCapabilities(actor);
},
getHealth(now: Date = new Date()) {
return {
service,
status: "ok" as const,
startedAt: input.startedAt.toISOString(),
uptimeMs: Math.max(0, now.getTime() - input.startedAt.getTime()),
database: input.database.getStatus(),
};
},
getStatus(actor: RequestActor, now: Date = new Date()) {
const runtimeSummary = input.runtime.getRuntimeSummary();
const registry = input.serverRegistry.summarize();
return {
auth: input.auth.getSummary(actor),
capabilities: input.capabilities.getCapabilities(actor),
database: input.database.getStatus(),
environment: input.environment,
registry,
runtime: {
opencode: {
baseUrl: runtimeSummary.opencode.baseUrl,
running: runtimeSummary.opencode.running,
status: runtimeSummary.opencode.status,
version: runtimeSummary.opencode.version,
},
router: {
baseUrl: runtimeSummary.router.baseUrl,
running: runtimeSummary.router.running,
status: runtimeSummary.router.status,
version: runtimeSummary.router.version,
},
source: runtimeSummary.source,
target: runtimeSummary.target,
},
service,
startedAt: input.startedAt.toISOString(),
status: "ok" as const,
uptimeMs: Math.max(0, now.getTime() - input.startedAt.getTime()),
version: input.version,
};
},
getMetadata(actor: RequestActor) {
return {
foundation: {
phase: 10 as const,
middlewareOrder: [
"request-id",
"request-context",
"response-finalizer",
"request-logger",
"error-handler",
],
routeNamespaces: {
...routeNamespaces,
workspaceResource: workspaceResourcePattern,
},
database: input.database.getStatus(),
startup: input.database.getStartupDiagnostics(),
},
requestContext: {
actorKind: actor.kind,
requestIdHeader: "X-Request-Id" as const,
},
runtime: {
environment: input.processInfo.environment,
hostname: input.processInfo.hostname,
pid: input.processInfo.pid,
platform: input.processInfo.platform,
runtime: input.processInfo.runtime,
runtimeVersion: input.processInfo.runtimeVersion,
},
runtimeSupervisor: input.runtime.getRuntimeSummary(),
contract: {
source: "hono-openapi" as const,
openapiPath: routeNamespaces.openapi,
sdkPackage: "@openwork/server-sdk" as const,
},
};
},
listServers() {
return {
items: input.serverRegistry.list({ includeBaseUrl: true }),
};
},
listWorkspaces(options?: { includeHidden?: boolean }) {
return {
items: input.workspaceRegistry.list({ includeHidden: options?.includeHidden ?? false }),
};
},
getWorkspace(workspaceId: string, options?: { includeHidden?: boolean }) {
return input.workspaceRegistry.getById(workspaceId, { includeHidden: options?.includeHidden ?? false });
},
};
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
import type { ServerRegistryService } from "./server-registry-service.js";
import type { ServerRepositories } from "../database/repositories.js";
import type {
BackendKind,
JsonObject,
WorkspaceKind,
WorkspaceRecord,
WorkspaceRuntimeStateRecord,
} from "../database/types.js";
type WorkspacePreset = "minimal" | "remote" | "starter";
export type WorkspaceBackend = {
kind: BackendKind;
local: null | {
configDir: string | null;
dataDir: string | null;
opencodeProjectId: string | null;
};
remote: null | {
directory: string | null;
hostUrl: string | null;
remoteType: "openwork" | "opencode";
remoteWorkspaceId: string | null;
workspaceName: string | null;
};
serverId: string;
};
export type WorkspaceRuntimeSummary = {
backendKind: BackendKind;
health: JsonObject | null;
lastError: JsonObject | null;
lastSessionRefreshAt: string | null;
lastSyncAt: string | null;
updatedAt: string | null;
};
export type WorkspaceSummary = {
backend: WorkspaceBackend;
createdAt: string;
displayName: string;
hidden: boolean;
id: string;
kind: WorkspaceKind;
preset: WorkspacePreset;
runtime: WorkspaceRuntimeSummary;
server: ReturnType<ServerRegistryService["serialize"]>;
slug: string;
status: WorkspaceRecord["status"];
updatedAt: string;
};
export type WorkspaceDetail = WorkspaceSummary & {
notes: JsonObject | null;
};
export type WorkspaceRegistryService = ReturnType<typeof createWorkspaceRegistryService>;
function asJsonObject(value: unknown): JsonObject | null {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return value as JsonObject;
}
function readPreset(workspace: WorkspaceRecord): WorkspacePreset {
const legacyDesktop = asJsonObject(workspace.notes?.legacyDesktop);
const preset = typeof legacyDesktop?.preset === "string" ? legacyDesktop.preset.trim().toLowerCase() : "";
if (preset === "minimal" || preset === "starter") {
return preset;
}
return workspace.kind === "remote" ? "remote" : "starter";
}
function readRemoteDirectory(workspace: WorkspaceRecord) {
const value = workspace.notes?.directory;
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function readRemoteType(workspace: WorkspaceRecord): "openwork" | "opencode" {
const explicit = workspace.notes?.remoteType;
return explicit === "opencode" ? "opencode" : "openwork";
}
function readRemoteWorkspaceName(workspace: WorkspaceRecord) {
const legacyDesktop = asJsonObject(workspace.notes?.legacyDesktop);
const value = legacyDesktop?.openworkWorkspaceName;
return typeof value === "string" && value.trim() ? value.trim() : null;
}
function serializeRuntimeState(runtimeState: WorkspaceRuntimeStateRecord | null, backendKind: BackendKind): WorkspaceRuntimeSummary {
return {
backendKind,
health: runtimeState?.health ?? null,
lastError: runtimeState?.lastError ?? null,
lastSessionRefreshAt: runtimeState?.lastSessionRefreshAt ?? null,
lastSyncAt: runtimeState?.lastSyncAt ?? null,
updatedAt: runtimeState?.updatedAt ?? null,
};
}
export function createWorkspaceRegistryService(input: {
repositories: ServerRepositories;
servers: ServerRegistryService;
}) {
const { repositories } = input;
function resolveBackend(workspace: WorkspaceRecord): WorkspaceBackend {
const runtimeState = repositories.workspaceRuntimeState.getByWorkspaceId(workspace.id);
const backendKind = runtimeState?.backendKind ?? (workspace.kind === "remote" ? "remote_openwork" : "local_opencode");
if (backendKind === "remote_openwork") {
const server = input.servers.getById(workspace.serverId);
return {
kind: backendKind,
local: null,
remote: {
directory: readRemoteDirectory(workspace),
hostUrl: server?.baseUrl ?? null,
remoteType: readRemoteType(workspace),
remoteWorkspaceId: workspace.remoteWorkspaceId,
workspaceName: readRemoteWorkspaceName(workspace),
},
serverId: workspace.serverId,
};
}
return {
kind: "local_opencode",
local: {
configDir: workspace.configDir,
dataDir: workspace.dataDir,
opencodeProjectId: workspace.opencodeProjectId,
},
remote: null,
serverId: workspace.serverId,
};
}
function serializeWorkspace(workspace: WorkspaceRecord) {
const server = input.servers.getById(workspace.serverId);
if (!server) {
throw new Error(`Workspace ${workspace.id} points at missing server ${workspace.serverId}.`);
}
const backend = resolveBackend(workspace);
const runtimeState = repositories.workspaceRuntimeState.getByWorkspaceId(workspace.id);
return {
backend,
createdAt: workspace.createdAt,
displayName: workspace.displayName,
hidden: workspace.isHidden,
id: workspace.id,
kind: workspace.kind,
notes: workspace.notes,
preset: readPreset(workspace),
runtime: serializeRuntimeState(runtimeState, backend.kind),
server: input.servers.serialize(server, { includeBaseUrl: false }),
slug: workspace.slug,
status: workspace.status,
updatedAt: workspace.updatedAt,
} satisfies WorkspaceDetail;
}
function canReadWorkspace(workspace: WorkspaceRecord, options?: { includeHidden?: boolean }) {
return options?.includeHidden === true || !workspace.isHidden;
}
return {
getById(workspaceId: string, options?: { includeHidden?: boolean }) {
const workspace = repositories.workspaces.getById(workspaceId);
if (!workspace || !canReadWorkspace(workspace, options)) {
return null;
}
return serializeWorkspace(workspace);
},
list(options?: { includeHidden?: boolean }) {
return repositories.workspaces
.list({ includeHidden: options?.includeHidden ?? false })
.filter((workspace) => canReadWorkspace(workspace, options))
.map((workspace) => serializeWorkspace(workspace));
},
resolveBackend,
serializeWorkspace,
};
}

View File

@@ -0,0 +1,272 @@
import { HTTPException } from "hono/http-exception";
import type { ServerRepositories } from "../database/repositories.js";
import type { WorkspaceRecord } from "../database/types.js";
import { RouteError } from "../http.js";
import type {
SessionMessageRecord,
SessionRecord,
SessionSnapshotRecord,
SessionStatusRecord,
SessionTodoRecord,
WorkspaceEventRecord,
} from "../schemas/sessions.js";
import type { RuntimeService } from "./runtime-service.js";
import { createLocalOpencodeSessionAdapter } from "../adapters/sessions/local-opencode.js";
import { OpenCodeBackendError } from "../adapters/sessions/opencode-backend.js";
import { createRemoteOpenworkSessionAdapter } from "../adapters/sessions/remote-openwork.js";
type SessionBackend = ReturnType<typeof createLocalOpencodeSessionAdapter>;
function toBackendKind(workspace: WorkspaceRecord) {
return workspace.kind === "remote" ? "remote_openwork" : "local_opencode";
}
function readRuntimeState(repositories: ServerRepositories, workspace: WorkspaceRecord) {
return repositories.workspaceRuntimeState.getByWorkspaceId(workspace.id);
}
function recordSuccess(repositories: ServerRepositories, workspace: WorkspaceRecord, input: { refresh?: boolean; sync?: boolean }) {
const current = readRuntimeState(repositories, workspace);
const now = new Date().toISOString();
repositories.workspaceRuntimeState.upsert({
backendKind: current?.backendKind ?? toBackendKind(workspace),
health: current?.health ?? null,
lastError: null,
lastSessionRefreshAt: input.refresh ? now : current?.lastSessionRefreshAt ?? null,
lastSyncAt: input.sync ? now : current?.lastSyncAt ?? null,
workspaceId: workspace.id,
});
}
function recordError(repositories: ServerRepositories, workspace: WorkspaceRecord, error: RouteError | Error) {
const current = readRuntimeState(repositories, workspace);
repositories.workspaceRuntimeState.upsert({
backendKind: current?.backendKind ?? toBackendKind(workspace),
health: current?.health ?? null,
lastError: {
code: error instanceof RouteError ? error.code : "internal_error",
message: error.message,
recordedAt: new Date().toISOString(),
},
lastSessionRefreshAt: current?.lastSessionRefreshAt ?? null,
lastSyncAt: current?.lastSyncAt ?? null,
workspaceId: workspace.id,
});
}
function remapBackendError(error: unknown) {
if (error instanceof RouteError) {
throw error;
}
if (error instanceof OpenCodeBackendError) {
if (error.status === 400) {
throw new RouteError(400, "invalid_request", "Upstream session backend rejected the request.");
}
if (error.status === 404) {
throw new HTTPException(404, { message: "Requested session resource was not found." });
}
if (error.status === 501) {
throw new RouteError(501, "not_implemented", error.message || "Session operation is not supported by the resolved backend.");
}
throw new RouteError(502, "bad_gateway", error.message || "Resolved session backend request failed.");
}
if (error instanceof HTTPException) {
throw error;
}
throw new RouteError(500, "internal_error", error instanceof Error ? error.message : "Unexpected session service failure.");
}
export type WorkspaceSessionService = ReturnType<typeof createWorkspaceSessionService>;
export function createWorkspaceSessionService(input: {
repositories: ServerRepositories;
runtime: RuntimeService;
}) {
function getWorkspaceOrThrow(workspaceId: string) {
const workspace = input.repositories.workspaces.getById(workspaceId);
if (!workspace) {
throw new HTTPException(404, { message: `Workspace not found: ${workspaceId}` });
}
return workspace;
}
function resolveBackend(workspace: WorkspaceRecord): SessionBackend {
if (workspace.kind === "remote") {
const server = input.repositories.servers.getById(workspace.serverId);
if (!server) {
throw new RouteError(502, "bad_gateway", `Workspace ${workspace.id} points at missing server ${workspace.serverId}.`);
}
return createRemoteOpenworkSessionAdapter({ server, workspace });
}
return createLocalOpencodeSessionAdapter({ runtime: input.runtime, workspace });
}
async function runRead<T>(workspaceId: string, operation: (backend: SessionBackend) => Promise<T>) {
const workspace = getWorkspaceOrThrow(workspaceId);
try {
const result = await operation(resolveBackend(workspace));
recordSuccess(input.repositories, workspace, { refresh: true });
return result;
} catch (error) {
const remapped = (() => {
try {
remapBackendError(error);
} catch (next) {
return next;
}
return error;
})();
recordError(input.repositories, workspace, remapped as Error);
throw remapped;
}
}
async function runMutation<T>(workspaceId: string, operation: (backend: SessionBackend) => Promise<T>) {
const workspace = getWorkspaceOrThrow(workspaceId);
try {
const result = await operation(resolveBackend(workspace));
recordSuccess(input.repositories, workspace, { refresh: true, sync: true });
return result;
} catch (error) {
const remapped = (() => {
try {
remapBackendError(error);
} catch (next) {
return next;
}
return error;
})();
recordError(input.repositories, workspace, remapped as Error);
throw remapped;
}
}
return {
abortSession(workspaceId: string, sessionId: string) {
return runMutation(workspaceId, (backend) => backend.abortSession(sessionId));
},
command(workspaceId: string, sessionId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.command(sessionId, body));
},
createSession(workspaceId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.createSession(body));
},
deleteMessage(workspaceId: string, sessionId: string, messageId: string) {
return runMutation(workspaceId, (backend) => backend.deleteMessage(sessionId, messageId));
},
deleteMessagePart(workspaceId: string, sessionId: string, messageId: string, partId: string) {
return runMutation(workspaceId, (backend) => backend.deleteMessagePart(sessionId, messageId, partId));
},
deleteSession(workspaceId: string, sessionId: string) {
return runMutation(workspaceId, (backend) => backend.deleteSession(sessionId));
},
forkSession(workspaceId: string, sessionId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.forkSession(sessionId, body));
},
getMessage(workspaceId: string, sessionId: string, messageId: string): Promise<SessionMessageRecord> {
return runRead(workspaceId, (backend) => backend.getMessage(sessionId, messageId));
},
getSession(workspaceId: string, sessionId: string): Promise<SessionRecord> {
return runRead(workspaceId, (backend) => backend.getSession(sessionId));
},
getSessionSnapshot(workspaceId: string, sessionId: string, input?: { limit?: number }): Promise<SessionSnapshotRecord> {
return runRead(workspaceId, (backend) => backend.getSessionSnapshot(sessionId, input));
},
async getSessionStatus(workspaceId: string, sessionId: string): Promise<SessionStatusRecord> {
const statuses = await runRead(workspaceId, (backend) => backend.listStatuses());
return statuses[sessionId] ?? { type: "idle" };
},
initSession(workspaceId: string, sessionId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.initSession(sessionId, body));
},
listMessages(workspaceId: string, sessionId: string, input?: { limit?: number }): Promise<SessionMessageRecord[]> {
return runRead(workspaceId, (backend) => backend.listMessages(sessionId, input));
},
listSessions(workspaceId: string, input?: { limit?: number; roots?: boolean; search?: string; start?: number }): Promise<SessionRecord[]> {
return runRead(workspaceId, (backend) => backend.listSessions(input));
},
listSessionStatuses(workspaceId: string): Promise<Record<string, SessionStatusRecord>> {
return runRead(workspaceId, (backend) => backend.listStatuses());
},
listTodos(workspaceId: string, sessionId: string): Promise<SessionTodoRecord[]> {
return runRead(workspaceId, (backend) => backend.listTodos(sessionId));
},
promptAsync(workspaceId: string, sessionId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.promptAsync(sessionId, body));
},
revert(workspaceId: string, sessionId: string, body: { messageID: string }) {
return runMutation(workspaceId, (backend) => backend.revert(sessionId, body));
},
sendMessage(workspaceId: string, sessionId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.sendMessage(sessionId, body));
},
shareSession(workspaceId: string, sessionId: string) {
return runMutation(workspaceId, (backend) => backend.shareSession(sessionId));
},
shell(workspaceId: string, sessionId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.shell(sessionId, body));
},
async streamWorkspaceEvents(workspaceId: string, signal?: AbortSignal): Promise<AsyncIterable<WorkspaceEventRecord>> {
const workspace = getWorkspaceOrThrow(workspaceId);
try {
return await resolveBackend(workspace).streamEvents(signal);
} catch (error) {
const remapped = (() => {
try {
remapBackendError(error);
} catch (next) {
return next;
}
return error;
})();
recordError(input.repositories, workspace, remapped as Error);
throw remapped;
}
},
summarizeSession(workspaceId: string, sessionId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.summarizeSession(sessionId, body));
},
unshareSession(workspaceId: string, sessionId: string) {
return runMutation(workspaceId, (backend) => backend.unshareSession(sessionId));
},
unrevert(workspaceId: string, sessionId: string) {
return runMutation(workspaceId, (backend) => backend.unrevert(sessionId));
},
updateMessagePart(workspaceId: string, sessionId: string, messageId: string, partId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.updateMessagePart(sessionId, messageId, partId, body));
},
updateSession(workspaceId: string, sessionId: string, body: Record<string, unknown>) {
return runMutation(workspaceId, (backend) => backend.updateSession(sessionId, body));
},
};
}

View File

@@ -0,0 +1,308 @@
import { afterEach, describe, expect, test } from "bun:test";
import { createApp } from "./app.js";
import { createAppDependencies } from "./context/app-dependencies.js";
type Served = {
port: number;
stop: (closeActiveConnections?: boolean) => void | Promise<void>;
};
const stops: Array<() => void | Promise<void>> = [];
afterEach(async () => {
while (stops.length) {
await stops.pop()?.();
}
});
function createTestApp() {
const dependencies = createAppDependencies({
environment: "test",
inMemory: true,
legacy: {
desktopDataDir: `/tmp/openwork-server-v2-phase6-desktop-${Math.random().toString(16).slice(2)}`,
orchestratorDataDir: `/tmp/openwork-server-v2-phase6-orchestrator-${Math.random().toString(16).slice(2)}`,
},
runtime: {
bootstrapPolicy: "disabled",
},
startedAt: new Date("2026-04-14T00:00:00.000Z"),
version: "0.0.0-test",
});
return {
app: createApp({ dependencies }),
dependencies,
};
}
function withMockOpencodeBaseUrl(dependencies: ReturnType<typeof createAppDependencies>, baseUrl: string) {
dependencies.services.runtime.getOpencodeHealth = () => ({
baseUrl,
binaryPath: null,
diagnostics: { combined: [], stderr: [], stdout: [], totalLines: 0, truncated: false },
lastError: null,
lastExit: null,
lastReadyAt: null,
lastStartedAt: null,
manifest: null,
pid: 123,
running: true,
source: "development",
status: "running",
version: "1.2.3",
});
}
function startMockOpencode(options?: { expectBearer?: string; mountPrefix?: string }) {
const requests: Array<{ method: string; pathname: string; authorization: string | null; body: unknown }> = [];
const prefix = options?.mountPrefix?.replace(/\/+$/, "") ?? "";
const server = Bun.serve({
hostname: "127.0.0.1",
port: 0,
fetch(request) {
const url = new URL(request.url);
const pathname = prefix && url.pathname.startsWith(prefix) ? url.pathname.slice(prefix.length) || "/" : url.pathname;
const authorization = request.headers.get("authorization");
requests.push({ method: request.method, pathname, authorization, body: null });
if (options?.expectBearer) {
expect(authorization).toBe(`Bearer ${options.expectBearer}`);
}
if (pathname === "/event") {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(`data: ${JSON.stringify({ type: "session.status", properties: { sessionID: "ses_1", status: { type: "busy" } } })}\n\n`);
controller.enqueue(`data: ${JSON.stringify({ type: "session.idle", properties: { sessionID: "ses_1" } })}\n\n`);
controller.close();
},
});
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
},
});
}
if (pathname === "/session" && request.method === "GET") {
return Response.json([
{
id: "ses_1",
title: "Session One",
directory: "/tmp/workspace",
time: { created: 100, updated: 200 },
},
]);
}
if (pathname === "/session/status" && request.method === "GET") {
return Response.json({ ses_1: { type: "busy" } });
}
if (pathname === "/session" && request.method === "POST") {
return Response.json({
id: "ses_created",
title: "Created Session",
directory: "/tmp/workspace",
time: { created: 300, updated: 300 },
});
}
if (pathname === "/session/ses_1" && request.method === "GET") {
return Response.json({
id: "ses_1",
title: "Session One",
directory: "/tmp/workspace",
time: { created: 100, updated: 200 },
});
}
if (pathname === "/session/ses_1" && request.method === "PATCH") {
return Response.json({
id: "ses_1",
title: "Renamed Session",
directory: "/tmp/workspace",
time: { created: 100, updated: 250 },
});
}
if (pathname === "/session/ses_1" && request.method === "DELETE") {
return new Response(null, { status: 204 });
}
if (pathname === "/session/ses_1/message" && request.method === "GET") {
return Response.json([
{
info: {
id: "msg_1",
role: "assistant",
sessionID: "ses_1",
},
parts: [
{
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: "hello",
},
],
},
]);
}
if (pathname === "/session/ses_1/message/msg_1" && request.method === "GET") {
return Response.json({
info: {
id: "msg_1",
role: "assistant",
sessionID: "ses_1",
},
parts: [
{
id: "prt_1",
messageID: "msg_1",
sessionID: "ses_1",
type: "text",
text: "hello",
},
],
});
}
if (pathname === "/session/ses_1/todo" && request.method === "GET") {
return Response.json([
{ content: "Ship Phase 6", priority: "high", status: "completed" },
]);
}
if (pathname === "/session/ses_1/prompt_async" && request.method === "POST") {
return Response.json({ ok: true });
}
if (pathname === "/session/ses_1/command" && request.method === "POST") {
return Response.json({ ok: true });
}
if (pathname === "/session/ses_1/revert" && request.method === "POST") {
return Response.json({
id: "ses_1",
title: "Reverted Session",
directory: "/tmp/workspace",
time: { created: 100, updated: 260 },
});
}
if (pathname === "/session/ses_1/unrevert" && request.method === "POST") {
return Response.json({
id: "ses_1",
title: "Restored Session",
directory: "/tmp/workspace",
time: { created: 100, updated: 270 },
});
}
return Response.json({ code: "not_found", message: "Not found" }, { status: 404 });
},
}) as Served;
stops.push(() => server.stop(true));
return {
requests,
url: `http://127.0.0.1:${server.port}`,
};
}
describe("workspace session routes", () => {
test("serves local workspace session reads, writes, and streaming", async () => {
const mock = startMockOpencode();
const { app, dependencies } = createTestApp();
const workspace = dependencies.persistence.registry.importLocalWorkspace({
dataDir: "/tmp/workspace",
displayName: "Local Workspace",
status: "ready",
});
withMockOpencodeBaseUrl(dependencies, mock.url);
const listResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/sessions?roots=true&limit=1`);
expect(listResponse.status).toBe(200);
expect((await listResponse.json()).data.items[0].id).toBe("ses_1");
const snapshotResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/sessions/ses_1/snapshot?limit=5`);
expect(snapshotResponse.status).toBe(200);
const snapshot = await snapshotResponse.json();
expect(snapshot.data.session.id).toBe("ses_1");
expect(snapshot.data.status.type).toBe("busy");
expect(snapshot.data.todos[0].content).toBe("Ship Phase 6");
const createResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/sessions`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Create" }),
});
expect(createResponse.status).toBe(200);
expect((await createResponse.json()).data.id).toBe("ses_created");
const updateResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/sessions/ses_1`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "Rename" }),
});
expect(updateResponse.status).toBe(200);
expect((await updateResponse.json()).data.title).toBe("Renamed Session");
const promptResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/sessions/ses_1/prompt_async`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ parts: [{ type: "text", text: "Hello" }] }),
});
expect(promptResponse.status).toBe(200);
expect((await promptResponse.json()).data.accepted).toBe(true);
const revertResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/sessions/ses_1/revert`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messageID: "msg_1" }),
});
expect(revertResponse.status).toBe(200);
expect((await revertResponse.json()).data.title).toBe("Reverted Session");
const eventsResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/events`);
expect(eventsResponse.status).toBe(200);
const eventsBody = await eventsResponse.text();
expect(eventsBody).toContain("session.status");
expect(eventsBody).toContain("session.idle");
});
test("routes remote workspace sessions through the mounted remote backend", async () => {
const remote = startMockOpencode({ expectBearer: "secret", mountPrefix: "/w/alpha/opencode" });
const { app, dependencies } = createTestApp();
const workspace = dependencies.persistence.registry.importRemoteWorkspace({
baseUrl: `${remote.url}/w/alpha/opencode`,
directory: "/srv/remote-alpha",
displayName: "Remote Alpha",
legacyNotes: { source: "test" },
remoteType: "openwork",
remoteWorkspaceId: "alpha",
serverAuth: { openworkToken: "secret" },
serverBaseUrl: remote.url,
serverHostingKind: "self_hosted",
serverLabel: "remote.example.com",
workspaceStatus: "ready",
});
const listResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/sessions`);
expect(listResponse.status).toBe(200);
expect((await listResponse.json()).data.items[0].id).toBe("ses_1");
const commandResponse = await app.request(`http://openwork.local/workspaces/${workspace.id}/sessions/ses_1/command`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ command: "review" }),
});
expect(commandResponse.status).toBe(200);
expect((await commandResponse.json()).data.accepted).toBe(true);
});
});

View File

@@ -0,0 +1,93 @@
import { afterEach, expect, test } from "bun:test";
import net from "node:net";
import path from "node:path";
import { fileURLToPath } from "node:url";
const packageDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const spawnedChildren: Array<Bun.Subprocess> = [];
afterEach(async () => {
while (spawnedChildren.length > 0) {
const child = spawnedChildren.pop();
if (!child) {
continue;
}
child.kill();
await child.exited;
}
});
function getFreePort() {
return new Promise<number>((resolve, reject) => {
const server = net.createServer();
server.listen(0, "127.0.0.1", () => {
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Failed to resolve a test port."));
return;
}
const { port } = address;
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve(port);
});
});
server.once("error", reject);
});
}
async function waitForHealth(url: string) {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
try {
const response = await fetch(url);
if (response.ok) {
return response;
}
} catch {
// wait for server boot
}
await Bun.sleep(100);
}
throw new Error(`Timed out waiting for ${url}`);
}
test("cli boots as a standalone process and serves health plus runtime routes", async () => {
const port = await getFreePort();
const child = Bun.spawn(["bun", "src/cli.ts", "--port", String(port)], {
cwd: packageDir,
env: {
...process.env,
OPENWORK_SERVER_V2_IN_MEMORY: "1",
OPENWORK_SERVER_V2_RUNTIME_BOOTSTRAP: "disabled",
},
stderr: "pipe",
stdout: "pipe",
});
spawnedChildren.push(child);
const response = await waitForHealth(`http://127.0.0.1:${port}/system/health`);
const body = await response.json();
const runtimeSummaryResponse = await waitForHealth(`http://127.0.0.1:${port}/system/runtime/summary`);
const runtimeSummary = await runtimeSummaryResponse.json();
const runtimeVersionsResponse = await waitForHealth(`http://127.0.0.1:${port}/system/runtime/versions`);
const runtimeVersions = await runtimeVersionsResponse.json();
expect(body.ok).toBe(true);
expect(body.data.service).toBe("openwork-server-v2");
expect(runtimeSummary.ok).toBe(true);
expect(runtimeSummary.data.target).toBeTruthy();
expect(runtimeVersions.ok).toBe(true);
expect(runtimeVersions.data.pinned.serverVersion).toBeTruthy();
}, 15_000);

View File

@@ -0,0 +1,142 @@
#!/usr/bin/env bun
import http from "node:http";
function readOption(name: string, fallback = "") {
return process.env[name]?.trim() || fallback;
}
function parsePort(argv: string[]) {
const hostArg = argv.find((value) => value.startsWith("--hostname=")) ?? "--hostname=127.0.0.1";
const portArg = argv.find((value) => value.startsWith("--port=")) ?? "--port=0";
return {
host: hostArg.slice("--hostname=".length),
port: Number.parseInt(portArg.slice("--port=".length), 10) || 0,
};
}
async function startFakeOpencode(argv: string[]) {
const mode = readOption("FAKE_RUNTIME_MODE", "success");
const version = readOption("FAKE_RUNTIME_VERSION", "1.2.27");
if (mode === "early-exit") {
console.error("fake opencode exiting before readiness");
process.exit(7);
}
if (mode === "timeout") {
console.log("fake opencode booting slowly");
setInterval(() => {}, 1_000);
await new Promise(() => undefined);
}
const { host, port } = parsePort(argv);
const server = http.createServer((req, res) => {
const pathname = req.url ? new URL(req.url, "http://localhost").pathname : "";
if (pathname === "/health" || pathname === "/global/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ healthy: true, version }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(port, host, () => resolve());
});
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Failed to resolve fake opencode address.");
}
console.log(`opencode server listening on http://${host}:${address.port}`);
const exitAfterMs = Number.parseInt(readOption("FAKE_RUNTIME_EXIT_AFTER_MS", "0"), 10) || 0;
if (exitAfterMs > 0) {
setTimeout(() => {
server.close(() => {
process.exit(3);
});
}, exitAfterMs);
}
await new Promise<void>((resolve) => {
const shutdown = () => server.close(() => resolve());
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
});
}
async function startFakeRouter() {
const mode = readOption("FAKE_RUNTIME_MODE", "success");
const healthPort = Number.parseInt(readOption("OPENCODE_ROUTER_HEALTH_PORT", "0"), 10);
if (!healthPort) {
throw new Error("OPENCODE_ROUTER_HEALTH_PORT is required for the fake router.");
}
if (mode === "early-exit") {
console.error("fake router exiting before readiness");
process.exit(9);
}
if (mode === "timeout") {
console.log("fake router waiting forever");
setInterval(() => {}, 1_000);
await new Promise(() => undefined);
}
const server = http.createServer((req, res) => {
const pathname = req.url ? new URL(req.url, "http://localhost").pathname : "";
if (pathname === "/health" || pathname === "/") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ ok: true }));
return;
}
res.writeHead(404, { "Content-Type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
});
await new Promise<void>((resolve, reject) => {
server.once("error", reject);
server.listen(healthPort, "127.0.0.1", () => resolve());
});
const exitAfterMs = Number.parseInt(readOption("FAKE_RUNTIME_EXIT_AFTER_MS", "0"), 10) || 0;
if (exitAfterMs > 0) {
setTimeout(() => {
server.close(() => {
process.exit(4);
});
}, exitAfterMs);
}
await new Promise<void>((resolve) => {
const shutdown = () => server.close(() => resolve());
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
});
}
async function main() {
const kind = readOption("FAKE_RUNTIME_KIND", "opencode");
const [, , ...argv] = process.argv;
if (argv[0] !== "serve") {
console.log(readOption("FAKE_RUNTIME_VERSION", "1.2.27"));
return;
}
if (kind === "router") {
await startFakeRouter();
return;
}
await startFakeOpencode(argv);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : String(error));
process.exit(1);
});

View File

@@ -0,0 +1,21 @@
import packageJson from "../package.json" with { type: "json" };
declare const __OPENWORK_SERVER_V2_VERSION__: string | undefined;
function normalizeVersion(value: string | undefined | null) {
const trimmed = value?.trim() ?? "";
return trimmed || null;
}
export function resolveServerV2Version() {
return (
normalizeVersion(process.env.OPENWORK_SERVER_V2_VERSION) ??
normalizeVersion(
typeof __OPENWORK_SERVER_V2_VERSION__ === "string"
? __OPENWORK_SERVER_V2_VERSION__
: null,
) ??
normalizeVersion(packageJson.version) ??
"0.0.0"
);
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"types": ["bun-types", "node"]
},
"include": ["src", "scripts"]
}

View File

@@ -4,6 +4,8 @@
"version": "0.0.0",
"scripts": {
"dev": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork/desktop dev",
"dev:server-v2": "node scripts/dev-server-v2.mjs",
"dev:server-v2:server": "node scripts/dev-server-v2.mjs --no-app",
"dev:windows": ".\\scripts\\dev-windows.cmd",
"dev:windows:x64": ".\\scripts\\dev-windows.cmd x64",
"dev:ui": "OPENWORK_DEV_MODE=1 pnpm --filter @openwork/app dev",
@@ -18,7 +20,10 @@
"build:ui": "pnpm --filter @openwork/app build",
"build:web": "pnpm --filter @openwork-ee/den-web build",
"preview": "pnpm --filter @openwork/app preview",
"typecheck": "pnpm --filter @openwork/app typecheck",
"sdk:generate": "pnpm --filter openwork-server-v2 openapi:generate && pnpm --filter @openwork/server-sdk generate",
"sdk:watch": "pnpm --filter @openwork/server-sdk watch",
"contract:check": "node scripts/check-server-v2-contract.mjs",
"typecheck": "pnpm run sdk:generate && pnpm --filter @openwork/app typecheck",
"test:health": "pnpm --filter @openwork/app test:health",
"test:sessions": "pnpm --filter @openwork/app test:sessions",
"test:refactor": "pnpm --filter @openwork/app test:refactor",

View File

@@ -0,0 +1,10 @@
import { defineConfig } from "@hey-api/openapi-ts";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const configDir = dirname(fileURLToPath(import.meta.url));
export default defineConfig({
input: resolve(configDir, "../../apps/server-v2/openapi/openapi.json"),
output: resolve(configDir, "generated"),
});

View File

@@ -0,0 +1,30 @@
{
"name": "@openwork/server-sdk",
"private": true,
"type": "module",
"exports": {
".": {
"types": "./src/index.ts",
"development": "./src/index.ts",
"default": "./src/index.ts"
}
},
"files": [
"generated",
"src",
"README.md"
],
"scripts": {
"generate": "pnpm exec openapi-ts -f openapi-ts.config.ts",
"watch": "node ./scripts/watch.mjs",
"pretypecheck": "pnpm --dir ../.. run sdk:generate",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@hey-api/client-fetch": "0.13.1"
},
"devDependencies": {
"@hey-api/openapi-ts": "0.95.0",
"typescript": "^5.6.3"
}
}

View File

@@ -0,0 +1,72 @@
import { spawn } from "node:child_process";
import { watch } from "node:fs";
import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const packageDir = path.resolve(scriptDir, "..");
const specPath = path.resolve(packageDir, "../../apps/server-v2/openapi/openapi.json");
const specDir = path.dirname(specPath);
const specFilename = path.basename(specPath);
let activeChild = null;
let queued = false;
let timer = null;
function runGenerate() {
if (activeChild) {
queued = true;
return;
}
activeChild = spawn("pnpm", ["run", "generate"], {
cwd: packageDir,
stdio: "inherit",
env: process.env,
});
activeChild.once("exit", (code) => {
activeChild = null;
if (code && code !== 0) {
process.stderr.write(`[openwork-server-sdk] generation failed with exit code ${code}.\n`);
}
if (queued) {
queued = false;
scheduleGenerate();
}
});
}
function scheduleGenerate() {
if (timer) {
clearTimeout(timer);
}
timer = setTimeout(() => {
timer = null;
runGenerate();
}, 120);
}
try {
watch(specDir, (_eventType, filename) => {
if (!filename || path.basename(String(filename)) !== specFilename) {
return;
}
scheduleGenerate();
});
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exit(1);
}
runGenerate();
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => {
if (activeChild && activeChild.exitCode === null) {
activeChild.kill("SIGTERM");
}
process.exit(0);
});
}

View File

@@ -0,0 +1,17 @@
import { createClient } from "../generated/client/index";
import type { Client, Config, CreateClientConfig } from "../generated/client/index";
export type OpenWorkServerClientConfig = Config;
export type OpenWorkServerClient = Client;
export type OpenWorkServerClientFactory = CreateClientConfig;
export function normalizeServerBaseUrl(baseUrl: string) {
return baseUrl.replace(/\/+$/, "") || baseUrl;
}
export function createOpenWorkServerClient(config: OpenWorkServerClientConfig = {}): OpenWorkServerClient {
return createClient({
...config,
baseUrl: config.baseUrl ? normalizeServerBaseUrl(config.baseUrl) : config.baseUrl,
});
}

View File

@@ -0,0 +1,18 @@
export * from "../generated/index";
export { createClient } from "../generated/client/index";
export type {
Client,
ClientOptions,
Config,
CreateClientConfig,
RequestOptions,
RequestResult,
} from "../generated/client/index";
export {
createOpenWorkServerClient,
normalizeServerBaseUrl,
type OpenWorkServerClient,
type OpenWorkServerClientConfig,
type OpenWorkServerClientFactory,
} from "./client.js";
export * from "./streams/index.js";

View File

@@ -0,0 +1,12 @@
export {
createOpenWorkServerEventStream,
type OpenWorkServerEventStreamOptions,
type OpenWorkServerEventStreamResult,
type OpenWorkServerStreamEvent,
} from "./sse.js";
export {
createOpenWorkServerWorkspaceEventStream,
type OpenWorkServerWorkspaceEvent,
type OpenWorkServerWorkspaceEventStreamOptions,
type OpenWorkServerWorkspaceEventStreamResult,
} from "./workspace-events.js";

View File

@@ -0,0 +1,10 @@
import { createSseClient } from "../../generated/core/serverSentEvents.gen";
import type { ServerSentEventsOptions, ServerSentEventsResult, StreamEvent } from "../../generated/core/serverSentEvents.gen";
export type OpenWorkServerEventStreamOptions<TData = unknown> = ServerSentEventsOptions<TData>;
export type OpenWorkServerEventStreamResult<TData = unknown> = ServerSentEventsResult<TData>;
export type OpenWorkServerStreamEvent<TData = unknown> = StreamEvent<TData>;
export function createOpenWorkServerEventStream<TData = unknown>(options: OpenWorkServerEventStreamOptions<TData>) {
return createSseClient<TData>(options as ServerSentEventsOptions<unknown>) as OpenWorkServerEventStreamResult<TData>;
}

View File

@@ -0,0 +1,30 @@
import { normalizeServerBaseUrl } from "../client.js";
import type { OpenWorkServerV2WorkspaceEvent } from "../../generated/types.gen";
import {
createOpenWorkServerEventStream,
type OpenWorkServerEventStreamOptions,
type OpenWorkServerEventStreamResult,
} from "./sse.js";
export type OpenWorkServerWorkspaceEvent = OpenWorkServerV2WorkspaceEvent;
export type OpenWorkServerWorkspaceEventStreamOptions = Omit<
OpenWorkServerEventStreamOptions<OpenWorkServerWorkspaceEvent>,
"url"
> & {
baseUrl: string;
workspaceId: string;
};
export type OpenWorkServerWorkspaceEventStreamResult = OpenWorkServerEventStreamResult<OpenWorkServerWorkspaceEvent>;
export function createOpenWorkServerWorkspaceEventStream(
options: OpenWorkServerWorkspaceEventStreamOptions,
): OpenWorkServerWorkspaceEventStreamResult {
const baseUrl = normalizeServerBaseUrl(options.baseUrl);
const url = `${baseUrl}/workspaces/${encodeURIComponent(options.workspaceId)}/events`;
return createOpenWorkServerEventStream<OpenWorkServerWorkspaceEvent>({
...options,
url,
});
}

View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "generated"]
}

462
pnpm-lock.yaml generated
View File

@@ -267,6 +267,37 @@ importers:
specifier: ^5.6.3
version: 5.9.3
apps/server-v2:
dependencies:
'@opencode-ai/sdk':
specifier: 1.2.27
version: 1.2.27
hono:
specifier: 4.12.12
version: 4.12.12
hono-openapi:
specifier: 1.3.0
version: 1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.12))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.12.12)(openapi-types@12.1.3)
jsonc-parser:
specifier: ^3.3.1
version: 3.3.1
yaml:
specifier: ^2.8.1
version: 2.8.2
zod:
specifier: ^4.3.6
version: 4.3.6
devDependencies:
'@types/node':
specifier: ^22.10.2
version: 22.19.7
bun-types:
specifier: ^1.3.6
version: 1.3.6
typescript:
specifier: ^5.6.3
version: 5.9.3
apps/share:
dependencies:
'@vercel/blob':
@@ -681,6 +712,19 @@ importers:
specifier: ^5.5.4
version: 5.9.3
packages/openwork-server-sdk:
dependencies:
'@hey-api/client-fetch':
specifier: 0.13.1
version: 0.13.1(@hey-api/openapi-ts@0.95.0(typescript@5.9.3))
devDependencies:
'@hey-api/openapi-ts':
specifier: 0.95.0
version: 0.95.0(typescript@5.9.3)
typescript:
specifier: ^5.6.3
version: 5.9.3
packages/ui:
dependencies:
'@paper-design/shaders':
@@ -1721,6 +1765,37 @@ packages:
engines: {node: '>=6'}
hasBin: true
'@hey-api/client-fetch@0.13.1':
resolution: {integrity: sha512-29jBRYNdxVGlx5oewFgOrkulZckpIpBIRHth3uHFn1PrL2ucMy52FvWOY3U3dVx2go1Z3kUmMi6lr07iOpUqqA==}
deprecated: Starting with v0.73.0, this package is bundled directly inside @hey-api/openapi-ts.
peerDependencies:
'@hey-api/openapi-ts': < 2
'@hey-api/codegen-core@0.7.4':
resolution: {integrity: sha512-DGd9yeSQzflOWO3Y5mt1GRXkXH9O/yIMgbxPjwLI3jwu/3nAjoXXD26lEeFb6tclYlg0JAqTIs5d930G/qxHeA==}
engines: {node: '>=20.19.0'}
'@hey-api/json-schema-ref-parser@1.3.1':
resolution: {integrity: sha512-7atnpUkT8TyUPHYPLk91j/GyaqMuwTEHanLOe50Dlx0EEvNuQqFD52Yjg8x4KU0UFL1mWlyhE+sUE/wAtQ1N2A==}
engines: {node: '>=20.19.0'}
'@hey-api/openapi-ts@0.95.0':
resolution: {integrity: sha512-lk5C+WKl5yqEmliQihEyhX/jNcWlAykTSEqkDeKa9xSq5YDAzOFvx7oos8YTqiIzdc4TemtlEaB8Rns7+8A0qg==}
engines: {node: '>=20.19.0'}
hasBin: true
peerDependencies:
typescript: '>=5.5.3 || >=6.0.0 || 6.0.1-rc'
'@hey-api/shared@0.3.0':
resolution: {integrity: sha512-G+4GPojdLEh9bUwRG88teMPM1HdqMm/IsJ38cbnNxhyDu1FkFGwilkA1EqnULCzfTam/ZoZkaLdmAd8xEh4Xsw==}
engines: {node: '>=20.19.0'}
'@hey-api/spec-types@0.1.0':
resolution: {integrity: sha512-StS4RrAO5pyJCBwe6uF9MAuPflkztriW+FPnVb7oEjzDYv1sxPwP+f7fL6u6D+UVrKpZ/9bPNx/xXVdkeWPU6A==}
'@hey-api/types@0.1.4':
resolution: {integrity: sha512-thWfawrDIP7wSI9ioT13I5soaaqB5vAPIiZmgD8PbeEVKNrkonc0N/Sjj97ezl7oQgusZmaNphGdMKipPO6IBg==}
'@hono/node-server@1.19.11':
resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==}
engines: {node: '>=18.14.1'}
@@ -2027,6 +2102,9 @@ packages:
'@js-sdsl/ordered-map@4.4.2':
resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==}
'@jsdevtools/ono@7.1.3':
resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==}
'@lexical/clipboard@0.35.0':
resolution: {integrity: sha512-ko7xSIIiayvDiqjNDX6fgH9RlcM6r9vrrvJYTcfGVBor5httx16lhIi0QJZ4+RNPvGtTjyFv4bwRmsixRRwImg==}
@@ -2310,6 +2388,9 @@ packages:
'@opencode-ai/sdk@1.1.39':
resolution: {integrity: sha512-EUYBZAci0bzG9+a7JVINmqAqis71ipG2/D3juvmvvKFyu0YBIT/6b+g3+p82Eb5CU2dujxpPdJJCaexZ1389eQ==}
'@opencode-ai/sdk@1.2.27':
resolution: {integrity: sha512-Wk0o/I+Fo+wE3zgvlJDs8Fb67KlKqX0PrV8dK5adSDkANq6r4Z25zXJg2iOir+a8ntg3rAcpel1OY4FV/TwRUA==}
'@opentelemetry/api-logs@0.207.0':
resolution: {integrity: sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==}
engines: {node: '>=8.0.0'}
@@ -3631,6 +3712,10 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
ansi-colors@4.1.3:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
@@ -3652,6 +3737,9 @@ packages:
arg@5.0.2:
resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==}
argparse@2.0.1:
resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==}
async-retry@1.3.3:
resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==}
@@ -3858,6 +3946,10 @@ packages:
bun-webgpu@0.1.4:
resolution: {integrity: sha512-Kw+HoXl1PMWJTh9wvh63SSRofTA8vYBFCw0XEP1V1fFdQEDhI8Sgf73sdndE/oDpN/7CMx0Yv/q8FCvO39ROMQ==}
bundle-name@4.1.0:
resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==}
engines: {node: '>=18'}
bundle-require@5.1.0:
resolution: {integrity: sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
@@ -3868,6 +3960,14 @@ packages:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
c12@3.3.3:
resolution: {integrity: sha512-750hTRvgBy5kcMNPdh95Qo+XUBeGo8C7nsKSmedDmaQI+E0r82DwHeM6vBewDe4rGFbnxoa4V9pw+sPh5+Iz8Q==}
peerDependencies:
magicast: '*'
peerDependenciesMeta:
magicast:
optional: true
cac@6.7.14:
resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==}
engines: {node: '>=8'}
@@ -3914,10 +4014,20 @@ packages:
resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
engines: {node: '>= 14.16.0'}
chokidar@5.0.0:
resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==}
engines: {node: '>= 20.19.0'}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
citty@0.1.6:
resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==}
citty@0.2.2:
resolution: {integrity: sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==}
cjs-module-lexer@2.2.0:
resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==}
@@ -3939,6 +4049,10 @@ packages:
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-support@1.1.3:
resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==}
hasBin: true
combined-stream@1.0.8:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
@@ -3950,6 +4064,10 @@ packages:
resolution: {integrity: sha512-Vw8qHK3bZM9y/P10u3Vib8o/DdkvA2OtPtZvD871QKjy74Wj1WSKFILMPRPSdUSx5RFK1arlJzEtA4PkFgnbuA==}
engines: {node: '>=18'}
commander@14.0.3:
resolution: {integrity: sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==}
engines: {node: '>=20'}
commander@4.1.1:
resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==}
engines: {node: '>= 6'}
@@ -3965,6 +4083,9 @@ packages:
confbox@0.1.8:
resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==}
confbox@0.2.4:
resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==}
consola@3.4.2:
resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==}
engines: {node: ^14.18.0 || >=16.10.0}
@@ -3981,6 +4102,10 @@ packages:
crelt@1.0.6:
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'}
cssesc@3.0.0:
resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
engines: {node: '>=4'}
@@ -4160,6 +4285,18 @@ packages:
decode-named-character-reference@1.3.0:
resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==}
default-browser-id@5.0.1:
resolution: {integrity: sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==}
engines: {node: '>=18'}
default-browser@5.5.0:
resolution: {integrity: sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==}
engines: {node: '>=18'}
define-lazy-prop@3.0.0:
resolution: {integrity: sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==}
engines: {node: '>=12'}
defu@6.1.4:
resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==}
@@ -4178,6 +4315,9 @@ packages:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
destr@2.0.5:
resolution: {integrity: sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
@@ -4392,6 +4532,9 @@ packages:
resolution: {integrity: sha512-A5EmesHW6rfnZ9ysHQjPdJRni0SRar0tjtG5MNtm9n5TUvsYU8oozprtRD4AqHxcZWWlVuAmQo2nWKfN9oyjTw==}
engines: {node: '>=0.10.0'}
exsolve@1.0.8:
resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==}
extend@3.0.2:
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
@@ -4510,9 +4653,16 @@ packages:
get-tsconfig@4.13.1:
resolution: {integrity: sha512-EoY1N2xCn44xU6750Sx7OjOIT59FkmstNc3X6y5xpz7D5cBtZRe/3pSlTkDJgqsOk3WwZPkWfonhhUJfttQo3w==}
get-tsconfig@4.13.6:
resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==}
gifwrap@0.10.1:
resolution: {integrity: sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw==}
giget@2.0.0:
resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==}
hasBin: true
glob-parent@5.1.2:
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
engines: {node: '>= 6'}
@@ -4595,6 +4745,10 @@ packages:
hono:
optional: true
hono@4.12.12:
resolution: {integrity: sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q==}
engines: {node: '>=16.9.0'}
hono@4.12.8:
resolution: {integrity: sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A==}
engines: {node: '>=16.9.0'}
@@ -4659,6 +4813,11 @@ packages:
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
is-docker@3.0.0:
resolution: {integrity: sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
hasBin: true
is-electron@2.2.2:
resolution: {integrity: sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg==}
@@ -4677,6 +4836,15 @@ packages:
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
is-in-ssh@1.0.0:
resolution: {integrity: sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==}
engines: {node: '>=20'}
is-inside-container@1.0.0:
resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==}
engines: {node: '>=14.16'}
hasBin: true
is-node-process@1.2.0:
resolution: {integrity: sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==}
@@ -4699,6 +4867,13 @@ packages:
resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==}
engines: {node: '>=12.13'}
is-wsl@3.1.1:
resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==}
engines: {node: '>=16'}
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isomorphic-ws@5.0.0:
resolution: {integrity: sha512-muId7Zzn9ywDsyXgTIafTry2sV3nySZeUDe6YedVd1Hvuuep5AsIlqK+XefWpYTyJG5e503F2xIuT2lcU6rCSw==}
peerDependencies:
@@ -4732,6 +4907,10 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
js-yaml@4.1.1:
resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==}
hasBin: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
@@ -5201,6 +5380,9 @@ packages:
sass:
optional: true
node-fetch-native@1.6.7:
resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==}
node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -5221,6 +5403,11 @@ packages:
resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
engines: {node: '>=0.10.0'}
nypm@0.6.5:
resolution: {integrity: sha512-K6AJy1GMVyfyMXRVB88700BJqNUkByijGJM8kEHpLdcAt+vSQAVfkWWHYzuRXHSY6xA2sNc5RjTj0p9rE2izVQ==}
engines: {node: '>=18'}
hasBin: true
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -5229,6 +5416,9 @@ packages:
resolution: {integrity: sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==}
engines: {node: '>= 6'}
ohash@2.0.11:
resolution: {integrity: sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==}
omggif@1.0.10:
resolution: {integrity: sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw==}
@@ -5236,6 +5426,10 @@ packages:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
open@11.0.0:
resolution: {integrity: sha512-smsWv2LzFjP03xmvFoJ331ss6h+jixfA4UUV/Bsiyuu4YJPfN+FIQGOIiv4w9/+MoHkfkJ22UIaQWRVFRfH6Vw==}
engines: {node: '>=20'}
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
@@ -5303,6 +5497,10 @@ packages:
resolution: {integrity: sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==}
engines: {node: '>=14.0.0'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
path-parse@1.0.7:
resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==}
@@ -5317,6 +5515,9 @@ packages:
resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==}
engines: {node: '>=8'}
perfect-debounce@2.1.0:
resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==}
picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -5353,6 +5554,9 @@ packages:
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
pkg-types@2.3.0:
resolution: {integrity: sha512-SIqCzDRg0s9npO5XQ3tNZioRY1uK06lA41ynBC1YmFTmnY6FjUjVt6s4LoADmwoig1qqD0oK8h1p/8mlMx8Oig==}
pkg-up@3.1.0:
resolution: {integrity: sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA==}
engines: {node: '>=8'}
@@ -5454,6 +5658,10 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
powershell-utils@0.1.0:
resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==}
engines: {node: '>=20'}
prismjs@1.30.0:
resolution: {integrity: sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==}
engines: {node: '>=6'}
@@ -5484,6 +5692,9 @@ packages:
quick-format-unescaped@4.0.4:
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
rc9@2.1.2:
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
react-dom@18.2.0:
resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==}
peerDependencies:
@@ -5541,6 +5752,10 @@ packages:
resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==}
engines: {node: '>= 14.18.0'}
readdirp@5.0.0:
resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==}
engines: {node: '>= 20.19.0'}
real-require@0.2.0:
resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==}
engines: {node: '>= 12.13.0'}
@@ -5614,6 +5829,10 @@ packages:
roughjs@4.6.6:
resolution: {integrity: sha512-ZUz/69+SYpFN/g/lUlo2FXcIjRkSu3nDarreVdGGndHEBJ6cXPdKguS8JGxwj5HA5xIbVKSmLgr5b3AWxtRfvQ==}
run-applescript@7.1.0:
resolution: {integrity: sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==}
engines: {node: '>=18'}
run-parallel@1.2.0:
resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==}
@@ -5647,6 +5866,11 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
semver@7.7.4:
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
engines: {node: '>=10'}
@@ -5669,6 +5893,14 @@ packages:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
shell-quote@1.8.3:
resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==}
engines: {node: '>= 0.4'}
@@ -6151,6 +6383,11 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -6167,6 +6404,10 @@ packages:
utf-8-validate:
optional: true
wsl-utils@0.3.1:
resolution: {integrity: sha512-g/eziiSUNBSsdDJtCLB8bdYEUMj4jR7AGeUo96p/3dTafgjHhpF4RiCFPiRILwjQoDXx5MqkBr4fwWtR3Ky4Wg==}
engines: {node: '>=20'}
xml-parse-from-string@1.0.1:
resolution: {integrity: sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g==}
@@ -7504,10 +7745,69 @@ snapshots:
protobufjs: 7.5.4
yargs: 17.7.2
'@hey-api/client-fetch@0.13.1(@hey-api/openapi-ts@0.95.0(typescript@5.9.3))':
dependencies:
'@hey-api/openapi-ts': 0.95.0(typescript@5.9.3)
'@hey-api/codegen-core@0.7.4':
dependencies:
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
c12: 3.3.3
color-support: 1.1.3
transitivePeerDependencies:
- magicast
'@hey-api/json-schema-ref-parser@1.3.1':
dependencies:
'@jsdevtools/ono': 7.1.3
'@types/json-schema': 7.0.15
js-yaml: 4.1.1
'@hey-api/openapi-ts@0.95.0(typescript@5.9.3)':
dependencies:
'@hey-api/codegen-core': 0.7.4
'@hey-api/json-schema-ref-parser': 1.3.1
'@hey-api/shared': 0.3.0
'@hey-api/spec-types': 0.1.0
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
color-support: 1.1.3
commander: 14.0.3
get-tsconfig: 4.13.6
typescript: 5.9.3
transitivePeerDependencies:
- magicast
'@hey-api/shared@0.3.0':
dependencies:
'@hey-api/codegen-core': 0.7.4
'@hey-api/json-schema-ref-parser': 1.3.1
'@hey-api/spec-types': 0.1.0
'@hey-api/types': 0.1.4
ansi-colors: 4.1.3
cross-spawn: 7.0.6
open: 11.0.0
semver: 7.7.3
transitivePeerDependencies:
- magicast
'@hey-api/spec-types@0.1.0':
dependencies:
'@hey-api/types': 0.1.4
'@hey-api/types@0.1.4': {}
'@hono/node-server@1.19.11(hono@4.12.8)':
dependencies:
hono: 4.12.8
'@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.12)':
dependencies:
'@standard-schema/spec': 1.1.0
hono: 4.12.12
optional: true
'@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.8)':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -7843,6 +8143,8 @@ snapshots:
'@js-sdsl/ordered-map@4.4.2': {}
'@jsdevtools/ono@7.1.3': {}
'@lexical/clipboard@0.35.0':
dependencies:
'@lexical/html': 0.35.0
@@ -8129,6 +8431,8 @@ snapshots:
'@opencode-ai/sdk@1.1.39': {}
'@opencode-ai/sdk@1.2.27': {}
'@opentelemetry/api-logs@0.207.0':
dependencies:
'@opentelemetry/api': 1.9.0
@@ -9607,6 +9911,8 @@ snapshots:
'@opentelemetry/api': 1.9.0
zod: 4.3.6
ansi-colors@4.1.3: {}
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
@@ -9624,6 +9930,8 @@ snapshots:
arg@5.0.2: {}
argparse@2.0.1: {}
async-retry@1.3.3:
dependencies:
retry: 0.13.1
@@ -9818,6 +10126,10 @@ snapshots:
bun-webgpu-win32-x64: 0.1.4
optional: true
bundle-name@4.1.0:
dependencies:
run-applescript: 7.1.0
bundle-require@5.1.0(esbuild@0.27.2):
dependencies:
esbuild: 0.27.2
@@ -9827,6 +10139,21 @@ snapshots:
dependencies:
streamsearch: 1.1.0
c12@3.3.3:
dependencies:
chokidar: 5.0.0
confbox: 0.2.4
defu: 6.1.4
dotenv: 17.3.1
exsolve: 1.0.8
giget: 2.0.0
jiti: 2.6.1
ohash: 2.0.11
pathe: 2.0.3
perfect-debounce: 2.1.0
pkg-types: 2.3.0
rc9: 2.1.2
cac@6.7.14: {}
call-bind-apply-helpers@1.0.2:
@@ -9878,8 +10205,18 @@ snapshots:
dependencies:
readdirp: 4.1.2
chokidar@5.0.0:
dependencies:
readdirp: 5.0.0
chownr@3.0.0: {}
citty@0.1.6:
dependencies:
consola: 3.4.2
citty@0.2.2: {}
cjs-module-lexer@2.2.0: {}
client-only@0.0.1: {}
@@ -9898,6 +10235,8 @@ snapshots:
color-name@1.1.4: {}
color-support@1.1.3: {}
combined-stream@1.0.8:
dependencies:
delayed-stream: 1.0.0
@@ -9906,6 +10245,8 @@ snapshots:
commander@12.1.0: {}
commander@14.0.3: {}
commander@4.1.1: {}
commander@7.2.0: {}
@@ -9914,6 +10255,8 @@ snapshots:
confbox@0.1.8: {}
confbox@0.2.4: {}
consola@3.4.2: {}
convert-source-map@2.0.0: {}
@@ -9928,6 +10271,12 @@ snapshots:
crelt@1.0.6: {}
cross-spawn@7.0.6:
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
cssesc@3.0.0: {}
csstype@3.2.3: {}
@@ -10126,6 +10475,15 @@ snapshots:
dependencies:
character-entities: 2.0.2
default-browser-id@5.0.1: {}
default-browser@5.5.0:
dependencies:
bundle-name: 4.1.0
default-browser-id: 5.0.1
define-lazy-prop@3.0.0: {}
defu@6.1.4: {}
delaunator@5.1.0:
@@ -10138,6 +10496,8 @@ snapshots:
dequal@2.0.3: {}
destr@2.0.5: {}
detect-libc@2.1.2: {}
devlop@1.1.0:
@@ -10319,6 +10679,8 @@ snapshots:
dependencies:
homedir-polyfill: 1.0.3
exsolve@1.0.8: {}
extend@3.0.2: {}
fast-glob@3.3.3:
@@ -10435,11 +10797,24 @@ snapshots:
dependencies:
resolve-pkg-maps: 1.0.0
get-tsconfig@4.13.6:
dependencies:
resolve-pkg-maps: 1.0.0
gifwrap@0.10.1:
dependencies:
image-q: 4.0.0
omggif: 1.0.10
giget@2.0.0:
dependencies:
citty: 0.1.6
consola: 3.4.2
defu: 6.1.4
node-fetch-native: 1.6.7
nypm: 0.6.5
pathe: 2.0.3
glob-parent@5.1.2:
dependencies:
is-glob: 4.0.3
@@ -10564,6 +10939,16 @@ snapshots:
dependencies:
parse-passwd: 1.0.0
hono-openapi@1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.12))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.12.12)(openapi-types@12.1.3):
dependencies:
'@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6)
'@standard-community/standard-openapi': 0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6)
'@types/json-schema': 7.0.15
openapi-types: 12.1.3
optionalDependencies:
'@hono/standard-validator': 0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.12)
hono: 4.12.12
hono-openapi@1.3.0(@hono/standard-validator@0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.8))(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6))(@standard-community/standard-openapi@0.2.9(@standard-community/standard-json@0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6))(@standard-schema/spec@1.1.0)(openapi-types@12.1.3)(zod@4.3.6))(@types/json-schema@7.0.15)(hono@4.12.8)(openapi-types@12.1.3):
dependencies:
'@standard-community/standard-json': 0.3.5(@standard-schema/spec@1.1.0)(@types/json-schema@7.0.15)(quansync@0.2.11)(zod@4.3.6)
@@ -10574,6 +10959,8 @@ snapshots:
'@hono/standard-validator': 0.2.2(@standard-schema/spec@1.1.0)(hono@4.12.8)
hono: 4.12.8
hono@4.12.12: {}
hono@4.12.8: {}
html-entities@2.3.3: {}
@@ -10630,6 +11017,8 @@ snapshots:
is-decimal@2.0.1: {}
is-docker@3.0.0: {}
is-electron@2.2.2: {}
is-extglob@2.1.1: {}
@@ -10642,6 +11031,12 @@ snapshots:
is-hexadecimal@2.0.1: {}
is-in-ssh@1.0.0: {}
is-inside-container@1.0.0:
dependencies:
is-docker: 3.0.0
is-node-process@1.2.0: {}
is-number@7.0.0: {}
@@ -10654,6 +11049,12 @@ snapshots:
is-what@4.1.16: {}
is-wsl@3.1.1:
dependencies:
is-inside-container: 1.0.0
isexe@2.0.0: {}
isomorphic-ws@5.0.0(ws@8.19.0):
dependencies:
ws: 8.19.0
@@ -10702,6 +11103,10 @@ snapshots:
js-tokens@4.0.0: {}
js-yaml@4.1.1:
dependencies:
argparse: 2.0.1
jsesc@3.1.0: {}
json-schema@0.4.0: {}
@@ -11370,6 +11775,8 @@ snapshots:
- '@babel/core'
- babel-plugin-macros
node-fetch-native@1.6.7: {}
node-fetch@2.7.0:
dependencies:
whatwg-url: 5.0.0
@@ -11380,14 +11787,31 @@ snapshots:
normalize-range@0.1.2: {}
nypm@0.6.5:
dependencies:
citty: 0.2.2
pathe: 2.0.3
tinyexec: 1.0.4
object-assign@4.1.1: {}
object-hash@3.0.0: {}
ohash@2.0.11: {}
omggif@1.0.10: {}
on-exit-leak-free@2.1.2: {}
open@11.0.0:
dependencies:
default-browser: 5.5.0
define-lazy-prop: 3.0.0
is-in-ssh: 1.0.0
is-inside-container: 1.0.0
powershell-utils: 0.1.0
wsl-utils: 0.3.1
openapi-types@12.1.3: {}
p-finally@1.0.0: {}
@@ -11451,6 +11875,8 @@ snapshots:
path-expression-matcher@1.1.3: {}
path-key@3.1.1: {}
path-parse@1.0.7: {}
path-scurry@1.11.1:
@@ -11462,6 +11888,8 @@ snapshots:
peek-readable@4.1.0: {}
perfect-debounce@2.1.0: {}
picocolors@1.1.1: {}
picomatch@2.3.1: {}
@@ -11502,6 +11930,12 @@ snapshots:
mlly: 1.8.2
pathe: 2.0.3
pkg-types@2.3.0:
dependencies:
confbox: 0.2.4
exsolve: 1.0.8
pathe: 2.0.3
pkg-up@3.1.0:
dependencies:
find-up: 3.0.0
@@ -11588,6 +12022,8 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
powershell-utils@0.1.0: {}
prismjs@1.30.0: {}
process-warning@5.0.0: {}
@@ -11619,6 +12055,11 @@ snapshots:
quick-format-unescaped@4.0.4: {}
rc9@2.1.2:
dependencies:
defu: 6.1.4
destr: 2.0.5
react-dom@18.2.0(react@18.2.0):
dependencies:
loose-envify: 1.4.0
@@ -11689,6 +12130,8 @@ snapshots:
readdirp@4.1.2: {}
readdirp@5.0.0: {}
real-require@0.2.0: {}
rehype-harden@1.1.8:
@@ -11809,6 +12252,8 @@ snapshots:
points-on-curve: 0.2.0
points-on-path: 0.2.1
run-applescript@7.1.0: {}
run-parallel@1.2.0:
dependencies:
queue-microtask: 1.2.3
@@ -11833,6 +12278,8 @@ snapshots:
semver@6.3.1: {}
semver@7.7.3: {}
semver@7.7.4: {}
seroval-plugins@1.3.3(seroval@1.3.2):
@@ -11874,6 +12321,12 @@ snapshots:
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
shebang-command@2.0.0:
dependencies:
shebang-regex: 3.0.0
shebang-regex@3.0.0: {}
shell-quote@1.8.3: {}
simple-xml-to-json@1.2.3: {}
@@ -12340,6 +12793,10 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
which@2.0.2:
dependencies:
isexe: 2.0.0
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -12348,6 +12805,11 @@ snapshots:
ws@8.19.0: {}
wsl-utils@0.3.1:
dependencies:
is-wsl: 3.1.1
powershell-utils: 0.1.0
xml-parse-from-string@1.0.1: {}
xml2js@0.5.0:

View File

@@ -0,0 +1,350 @@
# App Audit
## Scope
This audit covers `apps/app/**` only.
## Alignment Note
This audit documents the current client-side footprint, not the final target-state ownership boundary.
To fully match `prds/server-v2-plan/ideal-flow.md`, cloud settings, workspace/server relationship state, config mutation, and session/runtime behavior should become server-owned, while app-local storage shrinks to transient UI state and minimal reconnect data.
The goal is to document:
- every meaningful feature that does not explicitly contact the OpenWork server
- every feature that does substantial local/client-side work before it eventually sends data to the server
This document now assumes the target architecture is a single main server API surface, not a permanent split between app, orchestrator control plane, and server control plane.
The focus is on the client-owned lifecycle: local state, browser APIs, local persistence, parsing, transformations, clipboard, dialogs, routing, rendering, Tauri-bridged local actions, and mixed local-then-server flows.
## Disposition Labels
- `Stay`: should remain in the app because it is transient UI state, presentation logic, or other true client behavior.
- `Move`: should move behind the server because it is real workspace behavior.
- `Split`: some UI orchestration or local preprocessing should stay, but the underlying capability should move behind the server.
## High-Level Lifecycle
1. The frontend boots and restores local shell state.
2. Theme, zoom, window preferences, and workspace/session preferences are restored locally.
3. Deep links and startup state are parsed locally before deciding whether to connect anywhere.
4. Workspace creation, connection, sharing, session composition, and settings flows do a lot of local shaping before contacting server surfaces.
5. Large parts of the UI remain purely local: layout, drafts, rendering, search, diagnostics, and clipboard/open-file helpers.
## Shell And Persistent UI State
Disposition guidance:
- all items in this section -> `Stay`
Reasoning: theme, zoom, layout, local preferences, and shell restoration are legitimate client-owned concerns.
### `theme`
- What it does: manages light/dark/system theme and applies it to the document.
- Called from and when: initialized during app boot and updated when the user changes theme settings.
- Ends up calling: `localStorage`, `matchMedia`, `document.documentElement.dataset.theme`, and CSS `color-scheme`; no server contact.
### `LocalProvider` and `persisted`
- What they do: persist app-level UI preferences and shell state such as tabs, thinking visibility, model defaults, and other local settings.
- Called from and when: mounted at app startup and used throughout the app lifecycle.
- Ends up calling: browser storage or platform storage abstractions; no server contact by themselves.
### `useSessionDisplayPreferences`
- What it does: stores per-user display preferences such as whether “thinking” is shown.
- Called from and when: used while rendering session pages and on settings resets.
- Ends up calling: local preference persistence and render state updates; no server contact.
### app startup/session preference restoration in `app.tsx`
- What it does: restores startup state such as last-selected session, selected base URL, engine source/runtime preferences, and update-related UI settings.
- Called from and when: runs during app boot.
- Ends up calling: `localStorage`, navigation setup, startup session restoration, and connection preference state; no direct server contact, though restored values may later influence server calls.
### font zoom and window chrome helpers
- What they do: handle zoom shortcuts, persist zoom state, apply CSS fallback zoom, and toggle Tauri window decorations.
- Called from and when: initialized during app boot and triggered on keyboard shortcuts or preference changes.
- Ends up calling: `localStorage`, CSS updates, Tauri webview zoom APIs, and Tauri window APIs; no server contact.
### workspace shell layout persistence
- What it does: stores sidebar widths, expansion state, and other shell layout preferences.
- Called from and when: used while the session shell is open and while the user resizes or toggles layout areas.
- Ends up calling: local storage and render/layout updates only; no server contact.
## Deep Links And Cloud Session State
Disposition guidance:
- deep-link bridge -> `Stay`
- deep-link parsing and controller logic -> `Split`
- OpenWork Cloud settings persistence -> `Split`
- manual cloud sign-in flow -> `Split`
- OpenWork Cloud template cache -> `Stay`
Reasoning: parsing, routing, and lightweight cached cloud session state stay in the UI, but durable cloud settings and auth/session state should move behind the server.
### deep-link bridge
- What it does: queues native/browser deep links before the app is fully ready and replays them into the running UI.
- Called from and when: used at app boot and when desktop/native deep-link events arrive.
- Ends up calling: `window.__OPENWORK__` state and custom browser events; no server contact by itself.
### deep-link parsing and controller logic
- What it does: parses OpenWork remote-connect links, Den auth links, debug links, and cleans URL state after consuming them.
- Called from and when: runs on app boot and when deep-link events arrive.
- Ends up calling: local routing, modal state, query-param cleanup, and cloud/session settings updates; some branches eventually contact OpenWork or Den after local parsing is complete.
### OpenWork Cloud settings persistence
- What it does: stores cloud base URL, auth token, and active org for Den/OpenWork cloud features.
- Called from and when: used by cloud settings, workspace creation, and sharing flows.
- Ends up calling: `localStorage` and local cloud-session state; in the ideal model durable cloud auth/settings move to the server DB and this becomes transient reconnect/UI state.
### manual cloud sign-in flow
- What it does: accepts a pasted deep link or handoff code, parses it locally, validates it, and exchanges it for a cloud token.
- Called from and when: called from the Cloud settings panel when the user signs in manually.
- Ends up calling: local parsing and status state first, then cloud auth endpoints.
### OpenWork Cloud template cache
- What it does: memoizes cloud template lists by cloud identity and org.
- Called from and when: used when template-driven workspace creation or cloud settings panels open.
- Ends up calling: in-memory cache and signals first; initial loads eventually fetch from cloud/server surfaces.
## Workspace Creation And Connection
Disposition guidance:
- `CreateWorkspaceModal` -> `Stay`
- `createWorkspaceFlow` -> `Split`
- sandbox creation flow -> `Split`
- `createRemoteWorkspaceFlow` -> `Split`
- onboarding/bootstrap branching -> `Stay`
Reasoning: modal state and startup branching stay in the UI, but actual workspace creation/connection/runtime behavior should move behind the server.
### `CreateWorkspaceModal`
- What it does: owns the local UI state for local workspace creation, remote worker connection, cloud template browsing, worker filtering, and folder selection.
- Called from and when: opened from onboarding and create/connect workspace flows.
- Ends up calling: local modal state, folder pickers, cloud template cache, browser/Tauri link opening, and then optionally remote or cloud connection flows.
### `createWorkspaceFlow`
- What it does: orchestrates local workspace creation, derives default names, queues a starter session, updates selection state, and routes into first-session setup.
- Called from and when: called from onboarding, the create-workspace modal, and template/bundle import flows.
- Ends up calling: local busy state, selected workspace state, navigation, Tauri local workspace creation, and starter session setup; some branches eventually use local server surfaces.
### sandbox creation flow
- What it does: manages local progress state, Docker preflight state, debug logs, and Tauri event subscriptions for sandbox startup.
- Called from and when: called from sandbox/new worker creation UI.
- Ends up calling: local progress UI, Tauri event listeners, and debug state first, then remote/server registration once the sandbox is ready.
### `createRemoteWorkspaceFlow`
- What it does: normalizes the remote host URL/token, resolves remote workspace identity, updates local server settings, and persists a remote workspace record.
- Called from and when: called from deep links, onboarding connect flows, worker open actions, and remote workspace modals.
- Ends up calling: local validation, local settings persistence, routing, selected-workspace state, and then remote server requests; in the ideal model the durable remote workspace record belongs in the local server DB, not the app.
### onboarding/bootstrap branching
- What it does: decides whether startup should create a welcome workspace, reconnect a local runtime, reconnect a remote worker, or stay on welcome/onboarding UI.
- Called from and when: runs during app startup.
- Ends up calling: local startup-phase state, navigation, and workspace-selection logic first; some branches eventually connect to server/runtime surfaces.
## Bundle Import, Share, And Publish
Disposition guidance:
- bundle URL parsing and fetch fallback -> `Stay`
- bundle schema parsing -> `Stay`
- bundle workflow store -> `Split`
- workspace share/export state -> `Split`
- bundle publishing helpers -> `Split`
Reasoning: parsing and UI state stay client-side, but import/export/share of real workspace capabilities should ultimately be server-owned.
### bundle URL parsing and fetch fallback
- What it does: parses bundle deep links, cleans bundle-specific query params, rewrites bundle URLs, and chooses fetch strategies.
- Called from and when: runs on app boot, bundle deep-link open, and debug-open flows.
- Ends up calling: local URL cleanup and fetch strategy selection first, then bundle fetches.
### bundle schema parsing
- What it does: validates imported bundle shape and normalizes names, presets, and files into app-friendly structures.
- Called from and when: used whenever a bundle is opened, previewed, or imported.
- Ends up calling: local parsing/validation only; no server contact by itself.
### bundle workflow store
- What it does: owns modal state and the import decision tree, including trust warnings, worker-target resolution, and import routing.
- Called from and when: entered from bundle deep links, team templates, and debug-open flows.
- Ends up calling: local modal state, navigation, worker selection, and import state first, then workspace import or worker-creation flows.
### workspace share/export state
- What it does: derives shareable metadata for local and remote workspaces, resolves missing workspace IDs, and tracks share modal state.
- Called from and when: used by the session share flow.
- Ends up calling: local modal state, clipboard, browser/Tauri opener, and share metadata derivation first; publish/export actions later contact OpenWork or Den.
### bundle publishing helpers
- What they do: shape bundle payloads locally and reconcile org identity before publish.
- Called from and when: called from sharing and skills-publish flows.
- Ends up calling: local payload construction first, then OpenWork/Den publish APIs.
## Session Composer And Session View
Disposition guidance:
- session draft persistence -> `Stay`
- attachment preprocessing in the composer -> `Stay`
- prompt and command file-part shaping -> `Split`
- optimistic session creation and navigation -> `Split`
- undo, redo, and compact helpers -> `Split`
- local message search and command palette -> `Stay`
- message-windowing and render throttling -> `Stay`
- local file and folder affordances -> `Stay`
Reasoning: drafts, rendering, local search, and client-side attachment prep stay in the UI; real session operations and workspace-aware file semantics should move behind the server.
### session draft persistence
- What it does: stores and restores per-workspace/per-session draft text and mode.
- Called from and when: active while the user edits prompts and switches sessions.
- Ends up calling: `localStorage`, custom flush events, and local composer state only; no server contact until send.
### attachment preprocessing in the composer
- What it does: filters incoming files, compresses images, estimates payload size, creates preview URLs, and handles drag/paste attachment intake.
- Called from and when: called while the user edits a prompt or drops/pastes attachments.
- Ends up calling: `FileReader`, `OffscreenCanvas` or canvas APIs, object URLs, clipboard/paste handling, and local warning state; attachments may later be sent to the server when the user submits.
### prompt and command file-part shaping in the session actions store
- What it does: resolves file mentions, converts attachments into data URLs, builds prompt or command payload parts, and clears drafts at the right time.
- Called from and when: called when the user sends a prompt, retries, or creates a new session from local input.
- Ends up calling: local prompt/draft state, file/data transformation, and then final prompt/session server calls.
### optimistic session creation and navigation
- What it does: creates a new session flow in the UI, preserves the initial prompt locally, selects the session, refreshes sidebar state, and navigates to it.
- Called from and when: called when the user sends without an active session or explicitly creates one.
- Ends up calling: local navigation and session-list state first, then session creation on the server.
### undo, redo, and compact helpers
- What they do: perform local prompt/session-state coordination around history operations.
- Called from and when: called from session history controls.
- Ends up calling: local prompt/session state first, then server-backed revert or compact operations.
### local message search and session command palette
- What it does: builds client-side searchable message text, debounces queries, tracks hits, and scrolls to matches; also manages local session command-palette behavior.
- Called from and when: active while a session is open and the user searches or switches via palette.
- Ends up calling: local render/search state, scroll positioning, and navigation only; no server contact.
### message-windowing and render throttling
- What it does: windows long histories, batches streaming render commits, and tracks render/perf details locally.
- Called from and when: active during session rendering and message streaming.
- Ends up calling: render state and performance bookkeeping only; no server contact.
### local file and folder affordances in the session UI
- What they do: reveal workspace directories, open local files, and reveal artifact paths.
- Called from and when: called from message parts and session sidebars.
- Ends up calling: Tauri opener APIs and local toast state; no server contact.
## Skills, Plugins, MCP, And Local Config Editing
Disposition guidance:
- cloud import metadata normalization -> `Split`
- skills and cloud-sync local prep -> `Split`
- plugin config editing -> `Move`
- MCP connection and config flow -> `Split`
- cloud provider list memoization -> `Stay`
Reasoning: UI-owned shaping and memoization stay local, but config mutation, skills/plugins/MCP capability changes, and other workspace mutations should move behind the server.
### cloud import metadata normalization
- What it does: parses and rewrites `cloudImports` metadata in workspace config.
- Called from and when: used when syncing skills, providers, and hubs with cloud-backed metadata.
- Ends up calling: local config-shaping logic first; final writes should route through the server, with any Tauri-backed config path treated as temporary fallback-only debt.
### skills and cloud-sync local prep
- What it does: slugifies names, extracts markdown bodies, builds frontmatter, tracks imported cloud-skill maps, and stores hub preferences locally.
- Called from and when: used in skills import/edit/remove flows.
- Ends up calling: local markdown/config shaping, local state, `localStorage`, and in some cases direct local skill-file mutation; those direct local mutation paths are temporary fallback-only debt, and successful flows should ultimately go through the server or cloud APIs.
### plugin config editing
- What it does: parses and rewrites plugin arrays inside `opencode.json` using local JSONC edits.
- Called from and when: called from plugin/settings UI.
- Ends up calling: local config parsing and file-update shaping first; writes should move behind server-backed config APIs, and local writes should be treated as temporary fallback-only debt.
### MCP connection and config flow
- What it does: builds MCP config objects locally, infers names, injects special Chrome DevTools env values, and edits/removes config.
- Called from and when: called from MCP connect, remove, and auth/logout UI.
- Ends up calling: local config editing, local MCP modal state, and then server-backed MCP writes/logout or external auth flows; direct local config edits here should be treated as temporary fallback-only debt.
### cloud provider list memoization
- What it does: caches and merges provider lists using local cloud session/org state.
- Called from and when: used while provider settings or provider-auth UI is open.
- Ends up calling: in-memory cache and local state first; refreshes eventually contact Den or the main server, and any direct OpenCode access should be treated as migration debt.
## Diagnostics, Reset, And Desktop Utilities
Disposition guidance:
- OpenWork server settings persistence -> `Split`
- reset and reload state management -> `Stay`
- settings diagnostics and export helpers -> `Split`
- incidental clipboard/open-link helpers -> `Stay`
Reasoning: client preferences, reload state, clipboard, and pure diagnostics remain UI concerns, while durable connection/auth/product state should move behind the server.
### OpenWork server settings persistence
- What it does: normalizes and persists host URL, token, remote-access preference, and derived base URL/client settings.
- Called from and when: used on app boot and when connection settings change.
- Ends up calling: `localStorage` and local connection state; in the ideal model this shrinks to minimal reconnect/bootstrap hints while durable connection registry and cloud auth/session metadata move behind the server.
### reset and reload state management
- What it does: tracks reload-required reasons, supports local resets, and relaunch/reload behavior.
- Called from and when: used by settings and reload-warning UI.
- Ends up calling: local storage cleanup, local state resets, and app relaunch/reload APIs; no direct server contact.
### settings diagnostics and export helpers
- What they do: build local debug/export payloads, copy them to clipboard, download them as files, or reveal paths in Finder.
- Called from and when: called from settings diagnostics UI.
- Ends up calling: clipboard APIs, Blob download APIs, Tauri opener APIs, and local reset helpers; most paths do not contact the server.
### incidental clipboard/open-link helpers
- What they do: copy links, share codes, messages, and open auth/share URLs.
- Called from and when: called from share, auth, and message actions across the UI.
- Ends up calling: clipboard and opener/browser APIs; they often follow a server action, but the helper itself is local.
## Coverage Limits
- This audit stays focused on `apps/app`.
- It intentionally excludes simple server fetch wrappers unless the client does meaningful local work first.
- It describes mixed flows where local parsing/state/setup happens before a server request, because those are important ownership boundaries.

View File

@@ -0,0 +1,593 @@
# Server V2 Architecture
## Status: Draft
## Date: 2026-04-09
## Purpose
This document expands `prds/server-v2-plan/plan.md` with a more concrete technical design for Server V2.
The goal is to define a whole new Hono-based server package, expose a typed contract, and support incremental client migration onto that server.
## Core Model
Server V2 starts as a separate new server package and process.
```text
apps/server-v2/
├── server process
├── OpenAPI contract
└── server-owned runtime/workspace behavior
```
This means:
- a clean replacement server boundary
- a separate deployable/process during the transition
- new logic isolated in new files
- no need to preserve legacy server structure while designing the new architecture
## Target End State
The long-term target is a single main server API surface.
Desired shape:
```text
desktop app or CLI
-> starts or connects to one OpenWork server process
-> OpenWork server owns workspace/runtime/product behavior
-> OpenWork server supervises the local runtime pieces it needs
```
This means:
- the orchestrator should stop being a separate product control plane
- orchestrator runtime/workspace APIs should be folded into the main server
- bootstrap and supervision behavior should move into the main server wherever practical
## Design Principles
- Server V2 code lives in new files only.
- The new server API contract is explicit and typed.
- Clients depend on generated contracts and a small app-side SDK adapter, not server internals.
- Multi-server routing is explicit at the client boundary.
- The desktop app is a thin interface layer, not a second workspace runtime.
- Workspace behavior belongs to the server, even when the server is hosted locally by the desktop app.
- Migration happens by vertical slice, not by broad framework churn.
- Legacy code should be deleted as soon as each migrated slice is complete.
## Ownership Boundary
The architecture should enforce a simple rule:
- the app presents and collects intent
- the server performs workspace work
### Desktop app owns
- local UI state
- navigation and presentation state
- drafts, filters, and transient client-side interaction state
- cached/derived visible server and workspace state returned by the server
- starting or connecting to server processes
### Server owns
- workspace reads
- workspace writes
- AI/session/task behavior
- project/runtime inspection
- skill, plugin, MCP, and config mutation
- OpenCode integration and sidecar/runtime coordination
- any other workspace-scoped capability that is more than transient UI state
This boundary applies even in desktop-hosted mode. Running on the same machine does not make the UI the right owner of workspace behavior.
The same principle applies to the orchestrator boundary:
- product/runtime control surfaces should move into the server
- bootstrap and supervision should also move into the server wherever practical
- the desktop shell should ideally launch one server process, not a separate runtime manager
## Server Layout
Proposed package layout inside `apps/server-v2`:
```text
apps/server-v2/
├── src/
│ ├── app.ts
│ ├── cli.ts
│ ├── bootstrap/
│ ├── database/
│ ├── context/
│ ├── middleware/
│ ├── routes/
│ ├── services/
│ ├── schemas/
│ └── adapters/
├── openapi/
└── scripts/
```
### Ownership
- `app.ts` builds the Hono app and mounts route groups.
- `bootstrap/` owns server startup plus any runtime supervision that gets folded into the server.
- `database/` owns sqlite state, migrations, and persistence boundaries.
- `routes/` owns HTTP concerns: method, path, validation, response shape.
- `services/` owns domain workflows.
- `schemas/` owns request/response definitions.
- `adapters/` owns integration with OpenCode, storage, and runtime pieces.
- `middleware/` owns cross-cutting HTTP concerns.
- `context/` owns per-request wiring and shared typed context.
## Runtime Supervision Inside The New Server
The new server should not just proxy product logic. It should also supervise the local runtime pieces it depends on.
That includes:
- OpenCode
- `opencode-router`
- any other local child runtime needed for the product surface
### Router supervision model
Current baseline being replaced:
- orchestrator decides whether router is enabled
- orchestrator resolves the router binary
- orchestrator spawns and supervises the router
Target model:
- server bootstrap decides whether router is enabled
- server bootstrap resolves the router binary
- server bootstrap spawns and supervises the router
- server API exposes router status/control behavior to the UI
Recommended shape:
- one `opencode-router` child per local OpenWork server
- server-owned router config materialization from sqlite or server-managed config state
- server-owned health checks, restart behavior, and status reporting
This keeps router lifecycle under the same ownership boundary as the rest of the runtime.
### Why one router per server
- identities and bindings are naturally server-level
- supervision is simpler
- workspace scoping can still be enforced by server logic
- the UI does not need to understand a second runtime graph
## Startup Strategy
The desktop app should eventually launch the new server directly.
Target shape:
```text
desktop app
-> launches apps/server-v2
-> talks only to the new server process
```
Rules:
- the new server should not be designed as a mounted sub-application of the old server
- startup/bootstrap should move into the new server package over time
- orchestrator control-plane routes should be replaced by main-server routes rather than preserved as a second API model
## Typed Contract Flow
The new server is the source of truth for its contract.
```text
Hono route + schema definitions
-> generated OpenAPI spec
-> generated TypeScript SDK
-> app-side createSdk(serverId) adapter
-> app features
```
### Why this flow
- The server owns the contract.
- The SDK stays in sync through generation.
- App code gets strong typing without importing server implementation.
- A tiny app-side adapter remains free to handle runtime-specific decisions without replacing the generated SDK.
- The app can stay thin because the contract surface represents real workspace capabilities, not just transport helpers.
## OpenAPI and SDK Generation
Detailed generator and script choices live in `prds/server-v2-plan/sdk-generation.md`.
Proposed structure:
```text
apps/server-v2/openapi/openapi.json
packages/openwork-server-sdk/generated/**
packages/openwork-server-sdk/src/index.ts
apps/app/.../createSdk({ serverId }) adapter
```
### Contract rules
- The OpenAPI spec is generated, not handwritten.
- `hono-openapi` is the leading candidate for generating the new server OpenAPI spec because it is Hono-native and fits the route-first model we want.
- The generated SDK is TypeScript-first.
- The SDK should expose stable exports from `src/index.ts`.
- The app should avoid importing raw generated files directly.
- The generated SDK package should stay server-agnostic and reusable.
- The app-facing entrypoint should look like `createSdk({ serverId })`.
- `createSdk({ serverId })` should live in app code, resolve `serverId` into base URL, token, and capabilities locally, then prepare the generated client.
- `createSdk({ serverId })` should stay lightweight enough that it can be called per use without meaningful overhead.
- The SDK surface should grow until app-owned workspace behavior shrinks to near zero.
`hono-openapi` should be treated as the spec-generation layer only:
- it generates the OpenAPI contract from Hono routes and schemas
- a separate SDK generator still produces the TypeScript client package
- SSE ergonomics still likely require small handwritten helpers
### App-facing SDK shape
Preferred usage for standard endpoints:
```ts
await createSdk({ serverId }).sessions.listMessages({ workspaceId, sessionId })
```
This keeps:
- server selection explicit through `serverId`
- resource hierarchy explicit through params like `workspaceId` and `sessionId`
- the client surface mostly generated rather than manually re-modeled
### SSE contract note
OpenAPI can document SSE endpoints, but most generated SDKs do not produce an ergonomic typed streaming API automatically.
Because of that:
- normal JSON endpoints should come directly from the generated SDK
- the likely one or two SSE endpoints may need small handwritten stream helpers
- those helpers should still be exported from the same SDK package
- event payload types should come from generated or shared contract output, not from server source files
### CI rules
CI should regenerate both the OpenAPI spec and the SDK and fail if a diff appears.
That gives us:
- no silent contract drift
- reproducible SDK output
- reliable local and CI behavior
## Local Development Loop
The local developer experience should make contract changes visible immediately.
Detailed local watch and rebuild behavior lives in `prds/server-v2-plan/local-dev.md`.
Desired loop:
```text
edit new-server route or schema
-> regenerate openapi/openapi.json
-> regenerate TypeScript SDK
-> app sees updated types and methods
-> continue coding without manual sync work
```
Recommended watch pipeline:
- `apps/server-v2`: watch `src/**`, regenerate `openapi/openapi.json` through `hono-openapi`
- `packages/openwork-server-sdk`: watch `openapi/openapi.json`, regenerate the reusable generated client package
- `apps/app`: watch the app-side `createSdk({ serverId })` adapter alongside normal app code
- `packages/openwork-server-sdk`: optional watch build if the package publishes built output
- `apps/app`: consumes the workspace package directly
This should keep endpoint changes and client types effectively live in monorepo development.
The server runtime watcher should ignore generated OpenAPI and SDK files so contract regeneration does not cause unnecessary backend restart loops.
## Client Architecture
The client side should use a thin adapter over the generated SDK rather than a large custom wrapper hierarchy.
```text
generated SDK
-> createSdk({ serverId }) adapter
-> app features
```
### Generated SDK responsibilities
- typed request and response shapes
- typed route methods
- low-level transport helpers
- representing server-owned workspace capabilities in a reusable client surface
### Thin adapter responsibilities
- resolve `serverId` into current server config
- inject auth/token headers
- during migration, route features to the current or new server when needed
- prepare a lightweight client instance
- add capability checks when needed
The adapter should not rebuild a second large API model on top of the generated SDK unless there is a strong reason.
It also should not become a place where workspace behavior is reimplemented in the app.
## Multi-Server Target Model
The system may know about different server destinations at the same time, so target selection must be explicit.
The important distinction is:
- a server target identifies which server to talk to
- a workspace ID identifies which workspace on that server to operate on
Those are related, but they are not the same thing.
The local OpenWork server should maintain the durable registry of servers and workspaces. The app should render or cache what the server returns.
That model is intentionally minimal. The app only needs enough local state to know:
- which servers exist
- which workspaces belong to which server
- which workspace is selected in the UI
It should not need to locally own the underlying workspace behavior itself.
That allows:
- multiple workspaces on one server
- multiple configured servers in one app session
- one SDK creation point per server target, with workspace IDs passed into individual operations when direct server targeting is needed
Examples:
- local desktop-hosted OpenWork server
- remote worker-backed OpenWork server
- hosted OpenWork Cloud server
Proposed shared shape:
```ts
export type ServerTargetKind = "local" | "remote"
export type ServerHostingKind = "desktop" | "self_hosted" | "cloud"
export type ServerTarget = {
kind: ServerTargetKind
hostingKind: ServerHostingKind
baseUrl: string
token?: string
capabilities?: {
v2?: boolean
}
}
```
Preferred app-facing creation during migration or server-management flows:
```ts
const sdk = createSdk({ serverId })
```
Then operations should take the workspace ID explicitly:
```ts
await sdk.sessions.list({ workspaceId })
await sdk.sessions.get({ workspaceId, sessionId })
await sdk.sessions.listMessages({ workspaceId, sessionId })
```
Illustrative app-side model:
```ts
type WorkspaceRecord = {
id: string
serverTargetId: string
}
```
In that model:
- `serverTargetId` tells the app which server configuration to use
- `id` is the stable OpenWork workspace identifier the UI uses
This avoids hidden globals and makes mixed-target flows possible while keeping server selection separate from workspace identity.
In the ideal steady state, normal app traffic should still flow through the local OpenWork server using stable OpenWork workspace IDs, with remote OpenWork workspace IDs and OpenCode project IDs remaining server-owned mappings.
## Migration Routing Model
During migration, the adapter may choose between the current and new server per operation.
Example decision inputs:
- does the target advertise new-server capability?
- is the feature enabled for the new server?
- has this specific endpoint been ported?
- do we need a temporary fallback?
Illustrative flow:
```text
feature resolves workspace -> server target
-> feature calls createSdk({ serverId }).sessions.list({ workspaceId })
-> adapter inspects target + capability + rollout settings
-> adapter calls the current or new server implementation
-> feature receives typed result
```
This keeps migration logic out of the UI.
The more of the product surface we move behind the server, the less special-case behavior the app needs to keep locally.
## Streaming Strategy
The app should consume OpenCode-related streaming only through the OpenWork server.
That means:
- the desktop app never connects directly to underlying OpenCode SSE endpoints
- the new server exposes its own SSE endpoints where needed
- the new server can proxy, translate, or normalize underlying OpenCode stream events
Because there will likely be only one or two SSE endpoints, we do not need a large custom streaming framework.
Recommended shape:
- document the SSE routes in the new server contract
- keep event payloads typed from generated or shared contract types
- expose small handwritten streaming helpers from `packages/openwork-server-sdk`
- keep those helpers under the same `createSdk({ serverId })` entrypoint
Illustrative usage:
```ts
const stream = await createSdk({ serverId }).sessions.streamMessages({
workspaceId,
sessionId,
})
for await (const event of stream) {
// typed SSE event
}
```
This gives us one unified client surface while accepting that OpenAPI generation alone is usually not enough for ergonomic typed SSE consumption.
## Domain Slice Migration
The preferred migration unit is a vertical slice.
Example order:
1. health and diagnostics
2. low-risk read endpoints
3. session reads
4. workspace reads
5. mutations
6. higher-risk workflow endpoints
Rules:
- migrate one slice fully enough to validate the pattern
- switch that slice's adapter routing to the new server
- remove app-owned workspace logic for that slice when the new server version is ready
- remove old-server code when the slice no longer needs it
Example categories to move behind the server over time:
1. workspace file reads and writes
2. workspace config mutation
3. skill/plugin/MCP mutation
4. project/runtime inspection
5. session/task execution behavior
6. orchestrator workspace/runtime control APIs
7. orchestrator-managed tool/config mutation behavior
## Orchestrator Integration Path
The recommended path is to collapse orchestrator responsibilities inward rather than preserve a separate orchestrator control plane forever.
### What should move into the server
- workspace activation and disposal semantics
- runtime control/status/upgrade product APIs
- daemon-style workspace/runtime control surfaces
- config/skill/plugin/MCP mutation product capabilities
- managed OpenCode integration behavior that clients should consume through one API
- child process launch and supervision where practical
- sidecar and binary resolution where practical
- local env/port/bootstrap setup where practical
- sandbox/container startup orchestration where practical
### Recommended migration shape
```text
today:
desktop -> orchestrator API -> server API
target:
desktop -> server API
desktop -> launches one server process
server -> starts and supervises local children when needed
```
This removes the separate orchestrator boundary rather than preserving it as a second permanent host layer.
## Error and Compatibility Model
The new server should improve consistency instead of repeating legacy inconsistencies.
Targets:
- consistent error envelopes
- predictable auth failures
- stable response schemas
- request IDs for tracing
- typed success and error bodies where practical
During migration, the adapter may need to normalize old-server and new-server responses into one app-facing shape.
## Testing Strategy
We need confidence at three levels.
### 1. Contract tests
- route validation works
- response schemas match expectations
- generated SDK matches current spec
### 2. Server integration tests
- new-server routes hit real service/adapters
- auth and runtime context behave correctly
- the new server works correctly as its own process and API surface
### 3. App integration tests
- the SDK adapter calls the correct target
- adapter-based old-server/new-server switching works during migration
- desktop flows continue to work while slices are migrated
## Exit Criteria for the Old Server
We can remove the old server when:
- all app consumers use new-server-backed SDK calls
- no routes still require the old server
- compatibility shims are no longer needed
- desktop startup launches only the new server
At that point, Server V2 stops being a migration concept and becomes the server.
The same spirit applies to the client boundary:
- the app still owns local UI state
- but workspace capabilities should no longer be split between app and server
- the server should be the clear owner of workspace behavior
The same spirit also applies to the orchestrator boundary:
- runtime/workspace product capability should no longer be split between orchestrator and server
- bootstrap and supervision should also collapse into the server wherever possible
- the main server should be the canonical and primary runtime control surface
## Open Decisions
- whether capability detection is static, dynamic, or both
- which endpoint group becomes the first proof-of-path migration
- whether the working name `openwork-server-v2` survives to ship time or is renamed before release

View File

@@ -0,0 +1,474 @@
# Current Server Audit
## Scope
This audit covers the current server under `apps/server/**`.
The goal is to document the current server in the same framework as the other audits:
- what the major function/module is
- what it does in human-readable language
- where it is called from and when
- what it ultimately calls or affects
This is meant to help break down the current server into clear migration targets for the new server.
## Overall Shape
- The current server is still a Bun-first, custom-router server centered in `apps/server/src/server.ts`.
- Most meaningful behavior is implemented through one large route-registration function, `createRoutes`, plus focused modules for config mutation, OpenCode integration, auth/tokens, reload/watch behavior, portable export/import, and OpenCode Router bridging.
- The earlier in-place `/v2` scaffold under `apps/server/src/v2` has been removed. The real replacement server now lives separately under `apps/server-v2/**`.
## 1. Startup, CLI, Config, And Process Boot
### `src/cli.ts` main entrypoint
- What it is: the packaged/server CLI entrypoint.
- What it does: parses startup args, resolves runtime config, starts the server, and prints startup information.
- Called from and when: called when the `openwork-server` binary or `bun src/cli.ts` is launched.
- What it calls: `parseCliArgs`, `resolveServerConfig`, `createServerLogger`, and `startServer`.
### `parseCliArgs`
- What it is: CLI argument parser.
- What it does: turns command-line flags into normalized runtime options.
- Called from and when: called immediately at process startup.
- What it calls: feeds `resolveServerConfig` with host/port/token/workspace/OpenCode/logging overrides.
### `resolveServerConfig`
- What it is: config resolution pipeline.
- What it does: merges CLI args, env vars, and config file state into the final runtime config.
- Called from and when: called once during boot before the server starts.
- What it calls: `buildWorkspaceInfos`, token defaults, approval/cors/logging/read-only/authorized-roots setup.
### `buildWorkspaceInfos`
- What it is: workspace config normalizer.
- What it does: turns configured workspace records into normalized `WorkspaceInfo` objects with stable IDs.
- Called from and when: called while building the final server config.
- What it calls: produces the workspace metadata used by routing, auth, proxying, export/import, and runtime flows.
### `createServerLogger`
- What it is: server logging factory.
- What it does: creates either plain text or OTEL-style JSON logging with a run ID.
- Called from and when: called during startup and reused for request logging.
- What it calls: all startup, request, and reload-watcher logs.
### `startServer`
- What it is: main server boot function.
- What it does: initializes approvals, reload events, tokens, watchers, route registration, and starts Bun HTTP serving.
- Called from and when: called once after config resolution.
- What it calls: Bun `serve`, `ApprovalService`, `TokenService`, `ReloadEventStore`, `startReloadWatchers`, proxy behavior, and all legacy routes.
## 2. HTTP Routing And Request Dispatch
### `startServer(...).fetch`
- What it is: the top-level Bun request handler.
- What it does: handles every incoming request, applies CORS and request logging, routes mounted workspace paths, proxies OpenCode/OpenCode Router requests, and finally dispatches to legacy routes.
- Called from and when: called by Bun for every HTTP request.
- What it calls: `parseWorkspaceMount`, OpenCode proxy helpers, OpenCode Router proxy helpers, and `createRoutes` matches.
### `parseWorkspaceMount`
- What it is: mounted-workspace path parser.
- What it does: detects workspace-mounted URLs like `/w/:id/...`.
- Called from and when: called early in request dispatch.
- What it calls: enables single-workspace mounted base URL behavior.
### `createRoutes`
- What it is: the current legacy route registration map.
- What it does: defines the bulk of the server API surface: status, tokens, workspaces, config, sessions, router, files, skills, plugins, MCP, export/import, approvals, and more.
- Called from and when: called once at startup.
- What it calls: nearly every major subsystem in the current server.
### `withCors`
- What it is: response header helper.
- What it does: adds CORS headers based on configured allowlist.
- Called from and when: applied to every response in the dispatcher finalization path.
- What it calls: browser access policy for the server surface.
### `logRequest`
- What it is: per-request log helper.
- What it does: emits structured request logs with auth/proxy metadata.
- Called from and when: called after each request resolves or fails.
- What it calls: operational visibility into status/auth/proxy usage.
## 3. Auth, Tokens, And Approvals
### `TokenService`
- What it is: persisted scoped-token manager.
- What it does: manages bearer tokens with scopes like owner, collaborator, and viewer.
- Called from and when: instantiated at startup and used by auth and token-management routes.
- What it calls: reads and writes `tokens.json`, resolves token scope, issues and revokes tokens.
### `requireClient`
- What it is: client-auth guard.
- What it does: authenticates normal client bearer tokens.
- Called from and when: called by client-protected routes and proxy paths.
- What it calls: token resolution and `Actor` creation.
### `requireHost`
- What it is: host/admin auth guard.
- What it does: authenticates host token or owner bearer token.
- Called from and when: called by host-only routes like token management and approvals.
- What it calls: elevated owner-level auth flows.
### `requireClientScope`
- What it is: scope enforcement helper.
- What it does: enforces minimum client token scope for mutations.
- Called from and when: called inside many write routes.
- What it calls: permission failures for viewers or lower-scope actors.
### `ApprovalService`
- What it is: in-memory approval queue and responder.
- What it does: stores pending approvals and resolves allow/deny/timeout outcomes.
- Called from and when: instantiated at startup and used by approval-gated routes.
- What it calls: mutation blocking until host/admin response.
### `requireApproval`
- What it is: approval wrapper.
- What it does: enforces approval on sensitive writes.
- Called from and when: called by config, file, plugin, skill, MCP, command, scheduler, and router identity writes.
- What it calls: `ApprovalService`; throws `write_denied` on deny/timeout.
### `/tokens` and `/approvals` routes
- What they are: auth/approval control endpoints.
- What they do: expose token inventory/issuance/revocation and pending approval inventory/response actions.
- Called from and when: called by host/admin control UI or operator flows.
- What they call: `TokenService` and `ApprovalService`.
## 4. Workspace Lifecycle, Status, And Capabilities
### `resolveWorkspace`
- What it is: workspace lookup and validation helper.
- What it does: resolves a workspace by ID, validates authorized-root membership, and repairs legacy commands if writable.
- Called from and when: called by almost every workspace-scoped route.
- What it calls: normalized `WorkspaceInfo` for all downstream file/config/OpenCode actions.
### `/status` and `/workspaces`
- What they are: core discovery/status routes.
- What they do: expose server health, config summary, capabilities, and workspace inventory.
- Called from and when: called by clients during connect, status refresh, and initial UI load.
- What they call: workspace serialization, `buildCapabilities`, bind/auth/read-only summary state.
### `buildCapabilities`
- What it is: capability summarizer.
- What it does: advertises what this server instance can do.
- Called from and when: called by `/capabilities` routes.
- What it calls: read-only mode, approvals, sandbox, browser provider, OpenCode/OpenCode Router availability.
### `/workspaces/local`
- What it is: local workspace creation route.
- What it does: creates a new local workspace folder and seeds starter files.
- Called from and when: called by host/admin workspace creation flows.
- What it calls: `ensureWorkspaceFiles`, workspace config persistence, audit logging.
### workspace rename / activate / delete routes
- What they are: workspace management endpoints.
- What they do: rename, activate, or remove a workspace from the server.
- Called from and when: called by host/admin workspace-management UI.
- What they call: in-memory config mutation, `server.json` persistence, reload watcher restart, audit logging.
## 5. Workspace Bootstrapping And Local Config Files
### `ensureWorkspaceFiles`
- What it is: workspace seeding helper.
- What it does: creates starter `.opencode` state, commands, skills, agent, `opencode.json`, and `openwork.json`.
- Called from and when: called when creating a local workspace.
- What it calls: OpenWork/OpenCode starter file generation.
### `ensureOpencodeConfig`
- What it is: OpenCode config seeder.
- What it does: seeds `opencode.json` defaults, default agent, scheduler plugin, and starter MCP.
- Called from and when: called during `ensureWorkspaceFiles`.
- What it calls: first-run OpenCode behavior for the workspace.
### `ensureWorkspaceOpenworkConfig`
- What it is: OpenWork config seeder.
- What it does: seeds `openwork.json` with authorized roots, blueprint sessions, and workspace metadata.
- Called from and when: called during `ensureWorkspaceFiles`.
- What it calls: OpenWork-specific workspace behavior and starter session metadata.
### workspace config routes
- What they are: config read/patch/raw text endpoints.
- What they do: read or patch workspace `opencode` and `openwork` config, including raw OpenCode config editor flows.
- Called from and when: called by settings/config UI.
- What they call: JSONC mutation helpers, raw config file writes, reload events, and audit entries.
## 6. OpenCode Integration And Session Read Model
### `resolveWorkspaceOpencodeConnection`
- What it is: OpenCode connection resolver.
- What it does: resolves OpenCode base URL and optional Basic auth for a workspace.
- Called from and when: called by OpenCode proxy and reload flows.
- What it calls: upstream OpenCode connection parameters.
### `proxyOpencodeRequest`
- What it is: OpenCode reverse proxy.
- What it does: forwards `/opencode` traffic to upstream OpenCode while injecting workspace directory and upstream auth.
- Called from and when: called by the main dispatcher for `/opencode` and mounted equivalents.
- What it calls: upstream OpenCode HTTP endpoints.
### `reloadOpencodeEngine`
- What it is: engine reload helper.
- What it does: calls OpenCode `/instance/dispose` to force an engine reload.
- Called from and when: called by `/workspace/:id/engine/reload`.
- What it calls: upstream OpenCode instance reset.
### session routes and `session-read-model.ts`
- What they are: session list/detail/messages/snapshot routes plus normalization helpers.
- What they do: fetch session data from OpenCode and validate/normalize the payloads.
- Called from and when: called by session UI/history surfaces.
- What they call: `fetchOpencodeJson` and `buildSessionList`, `buildSession`, `buildSessionMessages`, `buildSessionSnapshot`.
### `seedOpencodeSessionMessages`
- What it is: direct OpenCode DB seeding helper.
- What it does: inserts starter messages directly into the OpenCode SQLite DB for blueprint sessions.
- Called from and when: used during starter-session materialization.
- What it calls: direct OpenCode DB mutation.
## 7. Reload/Watch Behavior
### `startReloadWatchers`
- What it is: workspace reload-watcher setup.
- What it does: starts per-workspace watchers over root config files and `.opencode` trees.
- Called from and when: called during `startServer`, restarted when workspaces change.
- What it calls: `ReloadEventStore` with debounced reload signals.
### `ReloadEventStore`
- What it is: reload event queue.
- What it does: stores debounced workspace-scoped reload events with cursors.
- Called from and when: instantiated at startup, used by watchers and write routes.
- What it calls: `/workspace/:id/events` polling responses.
### `emitReloadEvent`
- What it is: manual reload signal helper.
- What it does: records reload signals after server-side mutations.
- Called from and when: called after config/plugin/skill/MCP/command/import writes.
- What it calls: client/runtime synchronization for server-caused file changes.
### `/workspace/:id/events` and `/engine/reload`
- What they are: reload polling and explicit engine-reload endpoints.
- What they do: return reload events since a cursor, and explicitly reload the upstream OpenCode engine.
- Called from and when: called by clients that need hot-reload awareness or manual engine reload.
- What they call: `ReloadEventStore` and `reloadOpencodeEngine`.
## 8. File Access, Inbox/Outbox, And Session-Scoped File Editing
### `FileSessionStore`
- What it is: ephemeral file-session manager.
- What it does: tracks scoped file editing sessions and workspace file-event cursors.
- Called from and when: instantiated inside `createRoutes`.
- What it calls: write eligibility, TTL, ownership, and incremental file-event streams.
### file session routes
- What they are: scoped file catalog/read/write/ops endpoints.
- What they do: create a file session, return a catalog snapshot, read files, write files with conflict detection, and apply mkdir/delete/rename ops.
- Called from and when: called by editors and remote file-management tooling.
- What they call: actual workspace filesystem reads/writes, file event logs, approvals, and audit logging.
### simple content routes
- What they are: markdown-oriented read/write routes.
- What they do: provide simpler file content APIs for lighter document flows.
- Called from and when: called by markdown/file editors.
- What they call: actual workspace file reads/writes plus audit and file-event signaling.
### inbox/outbox routes
- What they are: file ingest and artifact download endpoints.
- What they do: manage uploadable inbox files and downloadable artifact files under `.opencode/openwork`.
- Called from and when: called by file injection/download flows.
- What they call: workspace file writes, file listings, and binary download responses.
## 9. Plugins, Skills, MCP, Commands, And Scheduler
### plugin functions
- What they are: `listPlugins`, `addPlugin`, `removePlugin`.
- What they do: expose and mutate OpenCode plugin config and plugin directories.
- Called from and when: called by `/workspace/:id/plugins` routes.
- What they call: `opencode.json` mutation, plugin discovery, reload events.
### skill functions
- What they are: `listSkills`, `upsertSkill`, `deleteSkill`, plus Skill Hub helpers.
- What they do: discover and manage local/global skills, and install remote GitHub-backed skills.
- Called from and when: called by `/workspace/:id/skills*` routes and workspace bootstrap flows.
- What they call: `.opencode/skills` reads/writes, GitHub fetches, reload events.
### MCP functions
- What they are: `listMcp`, `addMcp`, `removeMcp`.
- What they do: manage MCP server config in `opencode.json`.
- Called from and when: called by `/workspace/:id/mcp*` routes.
- What they call: MCP config mutation and tool availability changes.
### command functions
- What they are: `listCommands`, `upsertCommand`, `deleteCommand`, `repairCommands`.
- What they do: manage project/global command markdown files and repair legacy frontmatter.
- Called from and when: called by `/workspace/:id/commands*` and implicitly by `resolveWorkspace`.
- What they call: `.opencode/commands` writes, frontmatter repair, reload events.
### scheduler functions
- What they are: scheduler job inspection/removal helpers.
- What they do: inspect and delete scheduled jobs backed by launchd/systemd and JSON job files.
- Called from and when: called by `/workspace/:id/scheduler/jobs*` routes.
- What they call: job file deletion and OS scheduler unload/remove behavior.
## 10. OpenCode Router / Messaging Integration
### `resolveOpenCodeRouterProxyPolicy`
- What it is: OpenCode Router auth policy resolver.
- What it does: decides what auth and scope is required for router proxy paths.
- Called from and when: called by the main dispatcher for `/opencode-router` paths.
- What it calls: access control for bindings, identities, health, and other router APIs.
### `proxyOpenCodeRouterRequest`
- What it is: OpenCode Router reverse proxy.
- What it does: forwards raw OpenCode Router requests to the local router service.
- Called from and when: called for `/opencode-router` and mounted equivalents.
- What it calls: localhost OpenCode Router health/config/send endpoints.
### router identity persistence helpers
- What they are: Telegram/Slack identity config writers.
- What they do: persist messaging identity config into `opencode-router.json`.
- Called from and when: called by workspace router-management routes.
- What they call: local router config mutation while preserving legacy fallback fields.
### `tryPostOpenCodeRouterHealth` / `tryFetchOpenCodeRouterHealth`
- What they are: best-effort router apply/fetch helpers.
- What they do: apply or fetch router health/config state without requiring a restart.
- Called from and when: called after router config changes and health/bind/send flows.
- What they call: live router process control/status behavior.
### workspace router routes
- What they are: `/workspace/:id/opencode-router/*` routes.
- What they do: manage health, Telegram/Slack setup, identities, bindings, and outbound sends.
- Called from and when: called by messaging/connectors UI.
- What they call: router config files, live router process state, identity pairing state, and outbound routing behavior.
## 11. Portable Export/Import And Sharing
### `exportWorkspace`
- What it is: portable workspace export builder.
- What it does: builds a portable workspace bundle including config, skills, commands, and allowed portable files.
- Called from and when: called by `/workspace/:id/export`.
- What it calls: workspace reads, config sanitization, portable file planning, and sensitive-data warnings.
### `importWorkspace`
- What it is: portable workspace import applier.
- What it does: applies imported bundle data into a workspace in replace or merge mode.
- Called from and when: called by `/workspace/:id/import`.
- What it calls: config writes, skills/commands writes, portable file writes, reload events.
### portable config and file helpers
- What they are: `sanitizePortableOpencodeConfig`, `planPortableFiles`, `listPortableFiles`, `writePortableFiles`, export-safety helpers.
- What they do: restrict export/import to portable config/files and detect or strip sensitive data.
- Called from and when: called by export/import flows.
- What they call: safe config/file selection and secret-aware export behavior.
### shared bundle publishing/fetching
- What they are: `publishSharedBundle`, `fetchSharedBundle`.
- What they do: publish and fetch named bundle payloads via a trusted OpenWork publisher.
- Called from and when: called by `/share/bundles/publish` and `/share/bundles/fetch`.
- What they call: remote publisher services and trusted-origin bundle fetch behavior.
## 12. Audit Trail And Blueprint Session Materialization
### audit functions
- What they are: `recordAudit`, `readAuditEntries`, `readLastAudit`.
- What they do: append and read per-workspace JSONL audit logs.
- Called from and when: called after most mutation flows and by `/workspace/:id/audit`.
- What they call: audit persistence under OpenWork data directories.
### blueprint session helpers
- What they are: blueprint template normalization/materialization helpers.
- What they do: parse starter-session templates from `openwork.json`, track what was already materialized, create OpenCode sessions, and seed starter messages.
- Called from and when: called by blueprint/session materialization routes and workspace bootstrap flows.
- What they call: upstream OpenCode session creation, direct OpenCode DB seeding, and `openwork.json` updates.
## 13. Runtime Control And Operational Endpoints
### `/health` and related status routes
- What they are: health and operational summary endpoints.
- What they do: report reachability, uptime, actor identity, runtime status, and toy UI/debug support.
- Called from and when: called by probes, status pages, and manual operator/debug flows.
- What they call: server uptime/version state, auth resolution, runtime control service, and toy UI assets.
### `/runtime/versions` and `/runtime/upgrade`
- What they are: runtime-control proxy endpoints.
- What they do: proxy runtime version and upgrade behavior. These are the legacy current-server route names; the Server V2 plan normalizes equivalent server-wide runtime endpoints under `/system/runtime/*`.
- Called from and when: called by upgrade/admin flows.
- What they call: `fetchRuntimeControl` and the configured runtime control base URL.
### `fetchRuntimeControl`
- What it is: runtime control HTTP client.
- What it does: calls the configured runtime control base URL with bearer auth.
- Called from and when: called by runtime version/upgrade routes.
- What it calls: external runtime control plane.
## Key Takeaways
- The current server is dominated by one large orchestration file, `apps/server/src/server.ts`, with many meaningful domains hanging off it.
- The best decomposition candidates for the new server are:
- startup/config/runtime wiring
- auth/tokens/approvals
- workspace lifecycle/config
- OpenCode proxy + session read model
- file session API
- OpenCode Router integrations
- portable export/import + sharing
- plugins/skills/MCP/commands/scheduler
- The strongest existing seams are service-style modules such as `TokenService`, `ApprovalService`, `ReloadEventStore`, `FileSessionStore`, `session-read-model.ts`, `portable-files.ts`, `workspace-export-safety.ts`, and `skill-hub.ts`.
- The weakest area is route ownership: many domains still terminate directly inside `createRoutes` instead of domain routers/controllers.

View File

@@ -0,0 +1,614 @@
# Server V2 Distribution
## Status: Draft
## Date: 2026-04-13
## Purpose
This document defines the preferred distribution model for the new OpenWork server.
It covers:
- how the new server should be built
- how `opencode` and `opencode-router` should be packaged
- how the desktop app should bundle the server
- how standalone server users should install and run it
## Core Distribution Goal
We want one canonical server runtime per platform.
That server runtime should:
- be the same thing the desktop app bundles
- also be shippable as a standalone server download
- include the matching OpenCode and OpenCode Router sidecars
## Recommended Build Model
Recommended implementation/runtime choice:
- implement `apps/server-v2` in TypeScript
- run it with Bun in development
- compile it with Bun for distribution
Recommended packaging choice:
- one compiled server executable per target platform
- embed `opencode` and `opencode-router` into that executable
- extract those sidecars into a managed runtime directory on first run
- launch them from there
This gives us a single-file-per-platform distribution model without needing a second wrapper executable, unless Bun packaging proves insufficient in practice.
## Why Bun Changes The Packaging Story
Bun's `--compile` support gives us a much stronger path than a normal JS runtime build.
Important capabilities:
- compile TypeScript into a standalone executable
- cross-compile for other platforms
- embed arbitrary files with `with { type: "file" }`
- embed build-time constants
- produce minified and bytecode-compiled binaries for faster startup
That means the new server can likely:
- be built as a Bun-compiled executable
- carry embedded sidecar payloads
- self-extract those payloads on startup
## Target Distribution Shape
Per platform, the canonical runtime should be:
- `openwork-server-v2`
- compiled Bun executable
- embedded `opencode`
- embedded `opencode-router`
- embedded release/runtime manifest
One artifact per platform, for example:
- `openwork-server-v2-darwin-arm64`
- `openwork-server-v2-darwin-x64`
- `openwork-server-v2-linux-x64`
- `openwork-server-v2-linux-arm64`
- `openwork-server-v2-windows-x64.exe`
## Desktop Distribution
Desktop users download the desktop app.
The desktop app should:
- ship with the matching `openwork-server-v2` runtime embedded or bundled as an app resource
- launch only that server
- never directly launch `opencode` or `opencode-router`
At runtime:
1. Desktop app launches `openwork-server-v2`.
2. `openwork-server-v2` checks its managed runtime directory.
3. If needed, it extracts embedded `opencode` and `opencode-router`.
4. It starts and supervises those sidecars itself.
5. Desktop app talks only to the server over port + token.
## Standalone Server Distribution
Some users will want only the server.
For them, we should publish the same canonical runtime as a standalone download.
That means:
- standalone users download `openwork-server-v2` for their platform
- they run it directly
- it performs the same sidecar extraction and supervision the desktop-bundled copy would do
This keeps the runtime identical between:
- desktop-hosted use
- standalone server use
## Runtime Extraction Model
The server executable should embed sidecar payloads and extract them to a persistent versioned runtime directory.
Current implementation note:
- Phase 10 now treats the managed app-data runtime directory as the canonical release runtime location.
- The release runtime is populated on first run from a bundled runtime source directory (for example the desktop resource sidecar directory or an executable-adjacent bundle with `manifest.json`) and is then reused across later runs.
- `apps/server-v2/script/build.ts` now also supports `--embed-runtime`, which generates a temporary build entrypoint that embeds `opencode`, `opencode-router`, and `manifest.json` directly into the compiled Server V2 binary via Bun `with { type: "file" }` imports.
- Extraction now uses a lock, temp directory, atomic replace, lease file, and conservative cleanup of stale runtime directories.
- The standalone embedded artifact can now boot without an adjacent sidecar bundle: when no filesystem bundle is present, Server V2 falls back to the embedded runtime payload and extracts from there.
Recommended behavior:
1. On startup, the server determines its runtime version.
2. It computes a runtime directory under app-data.
3. It checks whether the sidecars already exist and match the expected manifest/checksums.
4. If not, it extracts them atomically.
5. It marks executable bits where needed.
6. It launches sidecars from that runtime directory.
Recommended runtime path shape:
```text
<app-data>/runtime/server-v2/<server-version>/
```
Example contents:
```text
<app-data>/runtime/server-v2/0.1.0/
manifest.json
opencode
opencode-router
```
## Why Persistent Runtime Dir Instead Of Temp
We should prefer a persistent runtime directory instead of temp.
Reasons:
- avoids repeated extraction on every run
- avoids temp cleanup breaking the runtime
- improves debuggability
- makes versioned runtime upgrades simpler
- makes locking and atomic replacement easier
## Build Pipeline
Recommended build flow:
1. Build or collect the platform-specific `opencode` binary.
2. Build or collect the platform-specific `opencode-router` binary.
3. Generate a runtime manifest containing:
- server version
- OpenCode version
- router version
- target platform
- checksums
4. Compile `apps/server-v2/src/cli.ts` with Bun.
5. Embed the sidecars and manifest into the compiled executable.
Illustrative Bun compile command:
```bash
bun build --compile --minify --bytecode --target=bun-darwin-arm64 ./src/cli.ts --outfile dist/openwork-server-v2
```
The exact build script will likely be JS-driven rather than a one-liner so it can:
- prepare sidecar assets
- generate the manifest
- inject build-time constants
- compile per target
Current implementation note:
- `pnpm --filter openwork-server-v2 build:bin` builds the plain compiled executable.
- `pnpm --filter openwork-server-v2 build:bin:embedded --bundle-dir <runtime-bundle-dir>` builds the compiled executable with embedded runtime assets from a prepared bundle directory.
- `pnpm --filter openwork-server-v2 build:bin:embedded:all` drives the same embedding flow across the supported Bun targets when target-specific runtime bundle files are staged.
- The build script resolves target-specific asset filenames like `opencode-<triple>` and `manifest.json-<triple>` when cross-target bundles are staged.
## Bun Embedding Model
The preferred Bun packaging approach is:
- embed sidecar files with `with { type: "file" }`
- access them via Bun's embedded file support
- copy them into the persistent runtime directory on first run
This means we do not need a separate wrapper binary unless Bun's real-world behavior proves insufficient.
## Cross-Platform Targets
The server should be built in a matrix across supported targets.
Initial likely targets:
- `bun-darwin-arm64`
- `bun-darwin-x64`
- `bun-linux-x64`
- `bun-linux-arm64`
- `bun-windows-x64`
Possible later targets:
- `bun-windows-arm64`
- musl variants for portable Linux distribution
For Linux x64, baseline builds may be safer if broad CPU compatibility matters.
## Version Pinning
Each server release should pin:
- server version
- OpenCode version
- router version
Recommended runtime manifest shape:
```json
{
"serverVersion": "0.1.0",
"opencodeVersion": "1.2.27",
"routerVersion": "0.1.0",
"target": "bun-darwin-arm64",
"files": {
"opencode": {
"sha256": "..."
},
"opencode-router": {
"sha256": "..."
}
}
}
```
## Desktop Vs Standalone Release Model
### Desktop release
- desktop app contains the matching `openwork-server-v2` runtime
- user launches app
- app launches server
### Standalone server release
- user downloads `openwork-server-v2` for their platform
- user launches server directly
- server self-extracts sidecars and runs normally
This gives us one runtime with two install channels.
## Local Dev Asset Model
Local development should preserve the same ownership model as production without requiring the final compiled single-file bundle on every edit.
Recommended dev behavior:
- run `apps/server-v2` directly with Bun in watch mode
- keep `opencode-router` as a locally built workspace binary from `apps/opencode-router`
- acquire `opencode` as a pinned release artifact rather than committing the binary into git
- stage both binaries into a gitignored local runtime-assets directory
- have Server V2 launch those staged binaries by absolute path
The important rule is that development should still be deterministic:
- no reliance on `PATH`
- no silent use of whichever `opencode` binary happens to be installed globally
- no checked-in release binaries under source control
### Why `opencode` should not be committed into the repo
We do not need the `opencode` binary checked into git.
What we need is a reproducible acquisition path:
- read the pinned version from `constants.json`
- download the matching OpenCode release artifact for the current platform
- store it in a gitignored local runtime-assets/cache location
- use that exact file for local dev and for release embedding
This keeps local dev aligned with the pinned product version while avoiding binary churn in the repo.
### Source of truth for the pinned version
The OpenCode version should come from the existing root `constants.json` file.
For Server V2 planning, that means:
- `constants.json` remains the version pin source of truth for `opencode`
- local dev setup should read `opencodeVersion` from `constants.json`
- release packaging should read the same value when embedding the final binary
### Recommended local path shape
Illustrative shape:
```text
<repo>/.local/runtime-assets/
opencode/
darwin-arm64/
v1.2.27/
opencode
opencode-router/
darwin-arm64/
dev/
opencode-router
```
Notes:
- this directory should be gitignored
- exact path names can change, but the shape should be versioned and platform-specific
- `opencode-router` can use a `dev` slot because it is built from the local workspace during development
- `opencode` should use the pinned version from `constants.json`
### Recommended dev acquisition flow
1. Read `opencodeVersion` from `constants.json`.
2. Resolve the current platform/arch target.
3. Check whether the pinned `opencode` binary already exists in the local runtime-assets cache.
4. If not, download the matching OpenCode release artifact.
5. Verify checksum if the release metadata supports it.
6. Mark executable bits where needed.
7. Build `apps/opencode-router` locally and place its binary in the staged dev runtime location.
8. Start Server V2 and pass those absolute binary paths into runtime startup.
### Dev vs release relationship
The difference between dev and release should be only where the sidecar payloads come from:
- release: sidecars are embedded into `openwork-server-v2` and extracted on first run
- local dev: sidecars are staged into a gitignored local runtime-assets directory first
The runtime ownership model should stay the same in both cases:
- Server V2 resolves the binaries
- Server V2 launches them
- Server V2 supervises them
## How This Differs From The Current System
Today, runtime distribution is more fragmented.
Current behavior is closer to:
- desktop app bundles or prepares multiple sidecars
- desktop/Tauri still owns more startup logic
- orchestrator is a separate hosting/control layer
- `openwork-server`, `opencode`, and `opencode-router` are not yet one canonical server runtime bundle
Target behavior becomes:
- desktop app starts one thing: `openwork-server-v2`
- standalone users start that same `openwork-server-v2`
- `openwork-server-v2` starts and supervises its own runtime dependencies
So the key shift is:
- from component distribution
- to runtime-bundle distribution
## Current Workflow Reality
Based on the current repo workflows in this branch:
- macOS notarization is explicitly configured
- Windows signing now has an explicit repo workflow path in `.github/workflows/windows-signed-artifacts.yml`, but it still requires a real signing certificate and Windows validation run before broad rollout
That means:
- we should not assume we already have a working Windows signing pipeline
- the new server distribution plan will need an explicit Windows signing step for both desktop and standalone runtime artifacts
## Important Caveats
This Bun-based single-file approach looks promising, but it still needs validation.
### 1. Embedded binary extraction and execution
We need to confirm that embedded sidecar binaries can be:
- copied out reliably
- marked executable reliably
- launched reliably on all supported platforms
### 2. macOS code signing and notarization
This is especially important.
We need to validate:
- the compiled server's codesigning story
- Bun JIT entitlements if needed
- behavior of extracted sidecars under Gatekeeper/notarization
Important practical note:
- the main `openwork-server-v2` executable will need a clean signing and notarization path
- extracted sidecars may also need to be signed appropriately if macOS quarantine or Gatekeeper treats them as separate executables
- we should assume that "signed main binary" does not automatically make extracted child binaries a non-issue
Questions to validate on macOS:
- can the signed/notarized main server extract and launch sidecars without triggering new trust prompts?
- do extracted sidecars need to preserve signatures from the embedded payloads?
- do we need to strip quarantine attributes or will that create trust problems?
- does Bun's compiled executable require specific JIT-related entitlements in our real deployment model?
This means macOS is not just a packaging detail. It is one of the first things we should prototype before fully committing to the single-file distribution format.
### 3. Windows AV / SmartScreen behavior
Extracted executables may have more friction on Windows.
We need to test:
- first-run extraction
- launch reliability
- user-facing warnings
Important practical note:
- Windows Defender or third-party AV may treat a self-extracting executable plus child-process extraction as suspicious behavior
- SmartScreen reputation may apply to the main executable separately from the extracted sidecars
- repeated extraction into temp-like locations is more likely to look suspicious than extraction into a stable app-data runtime directory
Questions to validate on Windows:
- does first-run extraction trigger Defender or SmartScreen warnings?
- are extracted sidecars quarantined, delayed, or scanned in ways that materially hurt startup time?
- do signed extracted sidecars behave better than unsigned ones?
- do we need to prefer a stable per-version runtime directory to avoid repeated AV scans and trust churn?
This means Windows should also get an early prototype, especially for first-run startup latency and user-facing trust prompts.
## Windows Signing Plan
We should plan to sign Windows artifacts explicitly.
That includes:
- desktop app executable/installer
- standalone `openwork-server-v2.exe`
- extracted Windows sidecars when they are shipped as separate signed executables inside the embedded runtime bundle
Recommended signing model:
- use Authenticode signing at minimum
- consider EV signing if SmartScreen reputation becomes a serious UX issue
- timestamp signatures so they remain valid after certificate rotation or expiry
Rule of thumb:
- every Windows executable we intentionally ship to users should be signed
That includes:
- the desktop app executable and/or installer
- `openwork-server-v2.exe`
- `opencode.exe`
- `opencode-router.exe`
Important practical point:
- signing only the main desktop executable is not enough for the server runtime model we want
- if `openwork-server-v2.exe` extracts `opencode.exe` and `opencode-router.exe`, those sidecars should ideally also be signed before embedding
## Suggested Windows Release Flow
### Desktop release flow
1. Build the Windows desktop artifact.
2. Sign the desktop executable or installer.
3. Verify the signature.
4. Publish the signed asset.
### Standalone server release flow
1. Build `openwork-server-v2.exe` for Windows.
2. Build or collect signed Windows `opencode.exe` and `opencode-router.exe` payloads.
3. Embed those signed sidecar payloads into the server executable.
4. Sign the final `openwork-server-v2.exe`.
5. Verify the signature.
6. Publish the signed asset.
This means Windows signing happens at two layers:
- sidecar payload signing
- final runtime signing
## GitHub Actions Sketch
The repo does not currently show an explicit Windows signing step, so we should plan one.
Illustrative shape:
```yaml
jobs:
build-windows-server-v2:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Build server runtime
run: pnpm --filter openwork-server-v2 build:bin:windows
- name: Build or fetch Windows sidecars
run: pnpm --filter openwork-server-v2 build:sidecars:windows
- name: Import signing certificate
shell: pwsh
run: |
$bytes = [Convert]::FromBase64String("${{ secrets.WINDOWS_CERT_PFX_BASE64 }}")
[IO.File]::WriteAllBytes("codesign.pfx", $bytes)
- name: Sign sidecars
shell: pwsh
run: |
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f codesign.pfx /p "${{ secrets.WINDOWS_CERT_PASSWORD }}" dist\\sidecars\\opencode.exe
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f codesign.pfx /p "${{ secrets.WINDOWS_CERT_PASSWORD }}" dist\\sidecars\\opencode-router.exe
- name: Build embedded runtime
run: pnpm --filter openwork-server-v2 package:windows
- name: Sign final server runtime
shell: pwsh
run: |
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f codesign.pfx /p "${{ secrets.WINDOWS_CERT_PASSWORD }}" dist\\openwork-server-v2.exe
- name: Verify signature
shell: pwsh
run: |
signtool verify /pa /v dist\\openwork-server-v2.exe
```
The desktop Windows job would follow the same pattern, but sign the desktop executable/installer artifact.
## Secrets And Infrastructure Needed
To support Windows signing in CI, we will likely need:
- a Windows code signing certificate in PFX form
- a password for that PFX
- a timestamp server URL
- possibly a separate EV signing process if we choose that route later
Likely GitHub secrets:
- `WINDOWS_CERT_PFX_BASE64`
- `WINDOWS_CERT_PASSWORD`
- `WINDOWS_TIMESTAMP_URL`
## Recommendation
For now, the important planning takeaway is:
- Windows signing is not already clearly implemented in the repo workflows
- we should treat it as a required new release capability for Server V2 distribution
- both desktop and standalone server runtimes will need explicit Windows signing support
### 4. Upgrade and extraction locking
We need to design:
- atomic extraction
- concurrent launch locking
- old runtime cleanup
- rollback behavior if extraction is interrupted
## Recommended Path
Recommended sequence:
1. Build the new server in Bun/TypeScript.
2. Make the server own runtime supervision logically first.
3. Prototype a multi-file per-platform build first if needed for speed.
4. Then implement the Bun single-file embedded-sidecar distribution path.
5. Use that same runtime artifact in both:
- desktop releases
- standalone server releases
The long-term preferred model is still the Bun-based self-extracting single executable per platform.
## Open Questions
1. Should the extracted runtime directory be versioned only by server version, or by server+OpenCode+router tuple?
2. What exact app-data path should we standardize on for desktop-hosted and standalone modes?
3. How should old extracted runtimes be garbage-collected safely?
4. Do we want to keep a multi-file fallback distribution format even after the single-file format works?
5. What exact release pipeline should produce the platform sidecars before the Bun compile step?

View File

@@ -0,0 +1,169 @@
# Server V2 Final Cutover Checklist
## Status
In progress. This checklist is the honest Phase 10 cutover and release ledger for the current worktree state.
## Default Path Checks
- Desktop startup default: `Legacy`
- App rollout default: legacy path unless `OPENWORK_UI_USE_SERVER_V2=1`
- Runtime ownership default: Server V2 supervises OpenCode and router
- Route mount: root-mounted (`/system/*`, `/workspaces/*`), no legacy `/v2`
## Completed In Current Worktree
- Composite local dev graph exists and is documented:
- `pnpm dev:server-v2`
- `pnpm dev:server-v2:server`
- OpenAPI and SDK watch loops are hardened against replace-style writes and generator side effects.
- Server V2 resolves a real package version instead of reporting `0.0.0`.
- Bun compile build exists for Server V2.
- Embedded runtime packaging exists via `apps/server-v2/script/build.ts --embed-runtime`.
- Release runtime extraction uses a persistent versioned runtime directory with lock, atomic replace, lease tracking, and cleanup.
- Desktop can bundle and start Server V2 when `OPENWORK_UI_USE_SERVER_V2=1` is enabled.
- App can route through Server V2 when `OPENWORK_UI_USE_SERVER_V2=1` is enabled.
- Windows signing workflow exists at `.github/workflows/windows-signed-artifacts.yml` for the standalone Server V2 binary, signed sidecars, and desktop Windows artifacts.
## Remaining Release Gates
- Delete legacy `apps/server` codepaths once no active caller needs them.
- Delete or archive obsolete orchestrator control-plane code once no active caller needs it.
- Commit or otherwise land the regenerated Server V2 contract so `pnpm contract:check` passes on a clean tree.
- Validate macOS signing + notarization with real signed artifacts.
- Validate Windows SmartScreen / Defender / AV behavior with real Windows artifacts.
- Capture Chrome MCP success evidence once the Docker stack session flow is fixed.
## Current Validation Status In This Environment
- `pnpm sdk:generate` runs successfully.
- `pnpm contract:check` still fails in this in-progress worktree because generated Phase 10 contract changes are not committed yet; it is acting as a drift detector against `HEAD`, not as a no-op generator check.
- Server V2 package tests and typecheck pass.
- App Server V2 boundary tests and app typecheck pass.
- Desktop Rust tests pass.
- Plain and embedded Server V2 Bun builds both pass.
- Embedded standalone runtime smoke passed: the compiled Server V2 binary launched from outside the bundle directory extracted and started OpenCode from the managed runtime directory using `source: release`.
- Docker dev stack now starts on the Server V2 path after moving the stack off orchestrator startup and serializing shared `pnpm install` work across containers.
- Docker API-level smoke succeeded for `GET /system/health`, `GET /system/opencode/health`, and `GET /workspaces` against the running dev stack.
- Docker product-flow API smoke now succeeds for `POST /workspaces/:id/sessions` after fixing Server V2 compatibility config materialization to emit the OpenCode-compatible `permission.external_directory` object-map format.
- Chrome MCP validation is not runnable from this environment because the current tool session does not expose Chrome DevTools MCP actions.
- macOS signing/notarization was not completed here because no signing identity or notary credentials are available in this session.
- Windows signing workflow was implemented, but end-to-end signing and SmartScreen / AV validation were not completed here because no Windows runner or Windows signing certificate is available in this session.
## Automated Validation Commands
Run from repo root unless noted.
```bash
pnpm sdk:generate
pnpm contract:check
pnpm --filter openwork-server-v2 test
pnpm --filter openwork-server-v2 typecheck
pnpm --filter @openwork/app test:server-v2-boundary
pnpm --filter @openwork/app typecheck
cargo test --manifest-path apps/desktop/src-tauri/Cargo.toml --locked
pnpm --filter openwork-server-v2 build:bin
pnpm --filter openwork-server-v2 build:bin:embedded --bundle-dir ../desktop/src-tauri/sidecars
```
## macOS Manual Validation
Prerequisites:
- Apple signing identity available in keychain
- Notary API key and issuer configured
- Real release-style sidecar bundle prepared
Suggested validation flow:
```bash
pnpm -C apps/desktop prepare:sidecar
pnpm --filter openwork-server-v2 build:bin:embedded --bundle-dir ../desktop/src-tauri/sidecars
codesign --deep --force -vvvv --sign "<APPLE_IDENTITY>" --entitlements apps/desktop/src-tauri/entitlements.plist apps/server-v2/dist/bin/openwork-server-v2
codesign -vvv --verify apps/server-v2/dist/bin/openwork-server-v2
xcrun notarytool submit apps/server-v2/dist/bin/openwork-server-v2 --key <KEY_ID> --key-id <KEY_ID> --issuer <ISSUER_ID> --wait
spctl --assess --type execute --verbose apps/server-v2/dist/bin/openwork-server-v2
OPENWORK_SERVER_V2_WORKDIR="$(mktemp -d)" apps/server-v2/dist/bin/openwork-server-v2 --port 32123
```
Confirm:
- the binary verifies after signing
- notarization succeeds
- first-run extraction succeeds from the embedded payload
- extracted sidecars launch without trust prompts
## Windows Manual Validation
Prerequisites:
- Windows runner or workstation
- Authenticode certificate in PFX form
- `signtool.exe`
Suggested validation flow:
```powershell
pnpm install --frozen-lockfile
pnpm -C apps/desktop prepare:sidecar
pnpm --filter openwork-server-v2 build:bin:embedded --bundle-dir ../desktop/src-tauri/sidecars --target bun-windows-x64
signtool sign /fd SHA256 /tr http://timestamp.digicert.com /td SHA256 /f codesign.pfx /p <PASSWORD> apps\server-v2\dist\bin\openwork-server-v2-bun-windows-x64.exe
signtool verify /pa /v apps\server-v2\dist\bin\openwork-server-v2-bun-windows-x64.exe
```
SmartScreen / AV validation:
```powershell
$env:OPENWORK_SERVER_V2_WORKDIR = Join-Path $env:TEMP "openwork-server-v2-smoke"
New-Item -ItemType Directory -Force -Path $env:OPENWORK_SERVER_V2_WORKDIR | Out-Null
apps\server-v2\dist\bin\openwork-server-v2-bun-windows-x64.exe --port 32123
Invoke-WebRequest http://127.0.0.1:32123/system/opencode/health
Invoke-WebRequest http://127.0.0.1:32123/system/runtime/summary
```
Record:
- whether SmartScreen warns on first launch
- whether Defender delays or quarantines extracted sidecars
- first-run vs second-run startup latency
- whether signed extracted sidecars materially reduce warnings
## End-to-End Product Validation
Preferred flow:
```bash
packaging/docker/dev-up.sh
```
Then validate a real UI flow with Chrome MCP:
- open the printed web URL
- navigate to the session surface
- send a message
- confirm the response renders
- save screenshot evidence
If Chrome MCP is unavailable in the current environment, record that explicitly and include the exact command above plus the expected manual reviewer steps.
Current state from this worktree:
- `packaging/docker/dev-up.sh` now reaches healthy `server`, `web`, and `share` containers on the Server V2 path.
- Docker API smoke including session creation now succeeds:
```bash
source tmp/.dev-env-<id>
curl -H "Authorization: Bearer $OPENWORK_TOKEN" http://127.0.0.1:<OPENWORK_PORT>/workspaces
curl -X POST -H "Authorization: Bearer $OPENWORK_TOKEN" -H "Content-Type: application/json" --data '{"title":"Docker E2E"}' http://127.0.0.1:<OPENWORK_PORT>/workspaces/<WORKSPACE_ID>/sessions
```
- Remaining manual reviewer work:
```bash
packaging/docker/dev-up.sh
source tmp/.dev-env-<id>
curl -H "Authorization: Bearer $OPENWORK_TOKEN" http://127.0.0.1:<OPENWORK_PORT>/workspaces
curl -X POST -H "Authorization: Bearer $OPENWORK_TOKEN" -H "Content-Type: application/json" --data '{"title":"Docker E2E"}' http://127.0.0.1:<OPENWORK_PORT>/workspaces/<WORKSPACE_ID>/sessions
```
and then complete the Chrome MCP UI flow in the running stack.

Some files were not shown because too many files have changed in this diff Show More