feat: surface sidecar diagnostics for troubleshooting and releases

This commit is contained in:
Benjamin Shafii
2026-02-01 16:05:16 -08:00
parent dd644d3b0b
commit 95246d5270
20 changed files with 900 additions and 73 deletions

44
RELEASE.md Normal file
View File

@@ -0,0 +1,44 @@
# Release checklist
OpenWork releases should be deterministic, easy to reproduce, and fully verifiable with CLI tooling.
## Preflight
- Sync the default branch (currently `dev`).
- Run `pnpm release:review` and fix any mismatches.
- If you are building sidecar assets, set `SOURCE_DATE_EPOCH` to the tag timestamp for deterministic manifests.
## App release (desktop)
1. Bump versions (app + desktop + Tauri + Cargo):
- `pnpm bump:patch` or `pnpm bump:minor` or `pnpm bump:major`
2. Re-run `pnpm release:review`.
3. Build sidecars for the desktop bundle:
- `pnpm --filter @different-ai/openwork prepare:sidecar`
4. Commit the version bump.
5. Tag and push:
- `git tag vX.Y.Z`
- `git push origin vX.Y.Z`
## openwrk (npm + sidecars)
1. Bump `packages/headless/package.json`.
2. Build sidecar assets and manifest:
- `pnpm --filter openwrk build:sidecars`
3. Create the GitHub release for sidecars:
- `gh release create openwrk-vX.Y.Z packages/headless/dist/sidecars/* --repo different-ai/openwork`
4. Publish the package:
- `pnpm --filter openwrk publish --access public`
## openwork-server + owpenbot (if version changed)
- `pnpm --filter openwork-server publish --access public`
- `pnpm --filter owpenwork publish --access public`
## Verification
- `openwrk start --workspace /path/to/workspace --check --check-events`
- `gh run list --repo different-ai/openwork --workflow "Release App" --limit 5`
- `gh release view vX.Y.Z --repo different-ai/openwork`
Use `pnpm release:review --json` when automating these checks in scripts or agents.

View File

@@ -25,6 +25,7 @@
"bump:minor": "pnpm --filter @different-ai/openwork-ui bump:minor",
"bump:major": "pnpm --filter @different-ai/openwork-ui bump:major",
"bump:set": "pnpm --filter @different-ai/openwork-ui bump:set",
"release:review": "node scripts/release/review.mjs",
"tauri": "pnpm --filter @different-ai/openwork exec tauri"
},
"pnpm": {

View File

@@ -144,6 +144,7 @@ import {
clearOpenworkServerSettings,
type OpenworkAuditEntry,
type OpenworkServerCapabilities,
type OpenworkServerDiagnostics,
type OpenworkServerStatus,
type OpenworkServerSettings,
OpenworkServerError,
@@ -243,6 +244,7 @@ export default function App() {
const [openworkServerCheckedAt, setOpenworkServerCheckedAt] = createSignal<number | null>(null);
const [openworkServerWorkspaceId, setOpenworkServerWorkspaceId] = createSignal<string | null>(null);
const [openworkServerHostInfo, setOpenworkServerHostInfo] = createSignal<OpenworkServerInfo | null>(null);
const [openworkServerDiagnostics, setOpenworkServerDiagnostics] = createSignal<OpenworkServerDiagnostics | null>(null);
const [owpenbotInfoState, setOwpenbotInfoState] = createSignal<OwpenbotInfo | null>(null);
const [openwrkStatusState, setOpenwrkStatusState] = createSignal<OpenwrkStatus | null>(null);
const [openworkAuditEntries, setOpenworkAuditEntries] = createSignal<OpenworkAuditEntry[]>([]);
@@ -436,6 +438,43 @@ export default function App() {
});
});
createEffect(() => {
if (typeof window === "undefined") return;
if (!developerMode()) {
setOpenworkServerDiagnostics(null);
return;
}
const client = openworkServerClient();
if (!client || openworkServerStatus() === "disconnected") {
setOpenworkServerDiagnostics(null);
return;
}
let active = true;
let busy = false;
const run = async () => {
if (busy) return;
busy = true;
try {
const status = await client.status();
if (active) setOpenworkServerDiagnostics(status);
} catch {
if (active) setOpenworkServerDiagnostics(null);
} finally {
busy = false;
}
};
run();
const interval = window.setInterval(run, 10_000);
onCleanup(() => {
active = false;
window.clearInterval(interval);
});
});
createEffect(() => {
if (!isTauriRuntime()) return;
if (!developerMode()) return;
@@ -3901,6 +3940,7 @@ export default function App() {
openworkServerSettings: openworkServerSettings(),
openworkServerHostInfo: openworkServerHostInfo(),
openworkServerCapabilities: devtoolsCapabilities(),
openworkServerDiagnostics: openworkServerDiagnostics(),
openworkServerWorkspaceId: resolvedDevtoolsWorkspaceId(),
openworkAuditEntries: openworkAuditEntries(),
openworkAuditStatus: openworkAuditStatus(),

View File

@@ -12,6 +12,21 @@ export type OpenworkServerCapabilities = {
export type OpenworkServerStatus = "connected" | "disconnected" | "limited";
export type OpenworkServerDiagnostics = {
ok: boolean;
version: string;
uptimeMs: number;
readOnly: boolean;
approval: { mode: "manual" | "auto"; timeoutMs: number };
corsOrigins: string[];
workspaceCount: number;
activeWorkspaceId: string | null;
workspace: OpenworkWorkspaceInfo | null;
authorizedRoots: string[];
server: { host: string; port: number; configPath?: string | null };
tokenSource: { client: string; host: string };
};
export type OpenworkServerSettings = {
urlOverride?: string;
portOverride?: number;
@@ -277,6 +292,7 @@ export function createOpenworkServerClient(options: { baseUrl: string; token?: s
token,
health: () =>
requestJson<{ ok: boolean; version: string; uptimeMs: number }>(baseUrl, "/health", { token, hostToken }),
status: () => requestJson<OpenworkServerDiagnostics>(baseUrl, "/status", { token, hostToken }),
capabilities: () => requestJson<OpenworkServerCapabilities>(baseUrl, "/capabilities", { token, hostToken }),
listWorkspaces: () => requestJson<OpenworkWorkspaceList>(baseUrl, "/workspaces", { token, hostToken }),
activateWorkspace: (workspaceId: string) =>

View File

@@ -46,6 +46,27 @@ export type OpenwrkOpencodeState = {
startedAt: number;
};
export type OpenwrkBinaryInfo = {
path: string;
source: string;
expectedVersion?: string | null;
actualVersion?: string | null;
};
export type OpenwrkBinaryState = {
opencode?: OpenwrkBinaryInfo | null;
};
export type OpenwrkSidecarInfo = {
dir?: string | null;
baseUrl?: string | null;
manifestUrl?: string | null;
target?: string | null;
source?: string | null;
opencodeSource?: string | null;
allowExternal?: boolean | null;
};
export type OpenwrkWorkspace = {
id: string;
name: string;
@@ -62,6 +83,9 @@ export type OpenwrkStatus = {
dataDir: string;
daemon: OpenwrkDaemonState | null;
opencode: OpenwrkOpencodeState | null;
cliVersion?: string | null;
sidecar?: OpenwrkSidecarInfo | null;
binaries?: OpenwrkBinaryState | null;
activeId: string | null;
workspaceCount: number;
workspaces: OpenwrkWorkspace[];
@@ -544,6 +568,7 @@ export type OwpenbotStatusResult =
export type OwpenbotInfo = {
running: boolean;
version: string | null;
workspacePath: string | null;
opencodeUrl: string | null;
qrData: string | null;

View File

@@ -14,7 +14,13 @@ import type {
} from "../types";
import type { McpDirectoryInfo } from "../constants";
import { formatRelativeTime } from "../utils";
import type { OpenworkAuditEntry, OpenworkServerCapabilities, OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
import type {
OpenworkAuditEntry,
OpenworkServerCapabilities,
OpenworkServerDiagnostics,
OpenworkServerSettings,
OpenworkServerStatus,
} from "../lib/openwork-server";
import type { EngineInfo, OpenwrkStatus, OpenworkServerInfo, OwpenbotInfo, WorkspaceInfo } from "../lib/tauri";
import Button from "../components/button";
@@ -71,6 +77,7 @@ export type DashboardViewProps = {
openworkServerSettings: OpenworkServerSettings;
openworkServerHostInfo: OpenworkServerInfo | null;
openworkServerCapabilities: OpenworkServerCapabilities | null;
openworkServerDiagnostics: OpenworkServerDiagnostics | null;
openworkServerWorkspaceId: string | null;
openworkAuditEntries: OpenworkAuditEntry[];
openworkAuditStatus: "idle" | "loading" | "error";
@@ -909,6 +916,7 @@ export default function DashboardView(props: DashboardViewProps) {
openworkServerSettings={props.openworkServerSettings}
openworkServerHostInfo={props.openworkServerHostInfo}
openworkServerCapabilities={props.openworkServerCapabilities}
openworkServerDiagnostics={props.openworkServerDiagnostics}
openworkServerWorkspaceId={props.openworkServerWorkspaceId}
openworkAuditEntries={props.openworkAuditEntries}
openworkAuditStatus={props.openworkAuditStatus}

View File

@@ -8,9 +8,16 @@ import SettingsKeybinds, { type KeybindSetting } from "../components/settings-ke
import { ChevronDown, HardDrive, MessageCircle, PlugZap, RefreshCcw, Shield, Smartphone, X } from "lucide-solid";
import type { OpencodeConnectStatus, ProviderListItem, SettingsTab } from "../types";
import { createOpenworkServerClient } from "../lib/openwork-server";
import type { OpenworkAuditEntry, OpenworkServerCapabilities, OpenworkServerSettings, OpenworkServerStatus } from "../lib/openwork-server";
import type {
OpenworkAuditEntry,
OpenworkServerCapabilities,
OpenworkServerDiagnostics,
OpenworkServerSettings,
OpenworkServerStatus,
} from "../lib/openwork-server";
import type {
EngineInfo,
OpenwrkBinaryInfo,
OpenwrkStatus,
OpenworkServerInfo,
OwpenbotInfo,
@@ -45,6 +52,7 @@ export type SettingsViewProps = {
openworkServerSettings: OpenworkServerSettings;
openworkServerHostInfo: OpenworkServerInfo | null;
openworkServerCapabilities: OpenworkServerCapabilities | null;
openworkServerDiagnostics: OpenworkServerDiagnostics | null;
openworkServerWorkspaceId: string | null;
openworkAuditEntries: OpenworkAuditEntry[];
openworkAuditStatus: "idle" | "loading" | "error";
@@ -929,6 +937,36 @@ export default function SettingsView(props: SettingsViewProps) {
return props.owpenbotInfo?.lastStderr?.trim() || "No stderr captured yet.";
};
const formatOpenwrkBinary = (binary?: OpenwrkBinaryInfo | null) => {
if (!binary) return "Binary unavailable";
const version = binary.actualVersion || binary.expectedVersion || "unknown";
return `${binary.source} · ${version}`;
};
const formatOpenwrkBinaryVersion = (binary?: OpenwrkBinaryInfo | null) => {
if (!binary) return "—";
return binary.actualVersion || binary.expectedVersion || "—";
};
const openwrkBinaryPath = () => props.openwrkStatus?.binaries?.opencode?.path ?? "—";
const openwrkSidecarSummary = () => {
const info = props.openwrkStatus?.sidecar;
if (!info) return "Sidecar config unavailable";
const source = info.source ?? "auto";
const target = info.target ?? "unknown";
return `${source} · ${target}`;
};
const appVersionLabel = () => (props.appVersion ? `v${props.appVersion}` : "—");
const opencodeVersionLabel = () => formatOpenwrkBinaryVersion(props.openwrkStatus?.binaries?.opencode ?? null);
const openworkServerVersionLabel = () => props.openworkServerDiagnostics?.version ?? "—";
const owpenbotVersionLabel = () => props.owpenbotInfo?.version ?? "—";
const formatUptime = (uptimeMs?: number | null) => {
if (!uptimeMs) return "—";
return formatRelativeTime(Date.now() - uptimeMs);
};
const buildOpenworkSettings = () => ({
...props.openworkServerSettings,
urlOverride: openworkUrl().trim() || undefined,
@@ -1752,6 +1790,24 @@ export default function SettingsView(props: SettingsViewProps) {
</div>
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
<div class="bg-gray-1 p-4 rounded-xl border border-gray-6 space-y-3">
<div>
<div class="text-sm font-medium text-gray-12">Versions</div>
<div class="text-xs text-gray-10">Sidecar + desktop build info.</div>
</div>
<div class="space-y-1">
<div class="text-[11px] text-gray-7 font-mono truncate">Desktop app: {appVersionLabel()}</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
Openwrk: {props.openwrkStatus?.cliVersion ?? "—"}
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">OpenCode: {opencodeVersionLabel()}</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
OpenWork server: {openworkServerVersionLabel()}
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">Owpenbot: {owpenbotVersionLabel()}</div>
</div>
</div>
<div class="bg-gray-1 p-4 rounded-xl border border-gray-6 space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
@@ -1807,6 +1863,15 @@ export default function SettingsView(props: SettingsViewProps) {
<div class="text-[11px] text-gray-7 font-mono truncate">
OpenCode: {props.openwrkStatus?.opencode?.baseUrl ?? "—"}
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
Openwrk version: {props.openwrkStatus?.cliVersion ?? "—"}
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
Sidecar: {openwrkSidecarSummary()}
</div>
<div class="text-[11px] text-gray-7 font-mono truncate" title={openwrkBinaryPath()}>
Opencode binary: {formatOpenwrkBinary(props.openwrkStatus?.binaries?.opencode ?? null)}
</div>
<div class="text-[11px] text-gray-7 font-mono truncate">
Active workspace: {props.openwrkStatus?.activeId ?? "—"}
</div>
@@ -1923,6 +1988,34 @@ export default function SettingsView(props: SettingsViewProps) {
</div>
</div>
<div class="bg-gray-1 p-4 rounded-xl border border-gray-6 space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-12">OpenWork server diagnostics</div>
<div class="text-[11px] text-gray-8 font-mono truncate">
{props.openworkServerDiagnostics?.version ?? "—"}
</div>
</div>
<Show
when={props.openworkServerDiagnostics}
fallback={<div class="text-xs text-gray-9">Diagnostics unavailable.</div>}
>
{(diag) => (
<div class="grid md:grid-cols-2 gap-2 text-xs text-gray-11">
<div>Started: {formatUptime(diag().uptimeMs)}</div>
<div>Read-only: {diag().readOnly ? "true" : "false"}</div>
<div>
Approval: {diag().approval.mode} ({diag().approval.timeoutMs}ms)
</div>
<div>Workspaces: {diag().workspaceCount}</div>
<div>Active workspace: {diag().activeWorkspaceId ?? "—"}</div>
<div>Config path: {diag().server.configPath ?? "default"}</div>
<div>Token source: {diag().tokenSource.client}</div>
<div>Host token source: {diag().tokenSource.host}</div>
</div>
)}
</Show>
</div>
<div class="bg-gray-1 p-4 rounded-xl border border-gray-6 space-y-3">
<div class="flex items-center justify-between gap-3">
<div class="text-sm font-medium text-gray-12">OpenWork server capabilities</div>

View File

@@ -19,6 +19,15 @@ pub async fn owpenbot_info(
OwpenbotManager::snapshot_locked(&mut state)
};
if info.version.is_none() {
if let Some(version) = owpenbot_version(&app).await {
info.version = Some(version.clone());
if let Ok(mut state) = manager.inner.lock() {
state.version = Some(version);
}
}
}
// Only fetch from CLI status if manager doesn't have values (fallback for when sidecar isn't started)
if info.opencode_url.is_none() || info.workspace_path.is_none() {
if let Ok(status) = owpenbot_json(&app, &["status", "--json"], "get status").await {
@@ -346,6 +355,28 @@ async fn owpenbot_json(
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse {context}: {e}"))
}
async fn owpenbot_version(app: &AppHandle) -> Option<String> {
use tauri_plugin_shell::ShellExt;
let command = match app.shell().sidecar("owpenbot") {
Ok(command) => command,
Err(_) => app.shell().command("owpenbot"),
};
let output = command.args(["--version"]).output().await.ok()?;
if !output.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim();
if trimmed.is_empty() {
return None;
}
Some(trimmed.to_string())
}
#[tauri::command]
pub async fn owpenbot_pairing_approve(app: AppHandle, code: String) -> Result<(), String> {
use tauri_plugin_shell::ShellExt;

View File

@@ -9,7 +9,14 @@ use tauri_plugin_shell::process::{CommandChild, CommandEvent};
use tauri_plugin_shell::ShellExt;
use crate::paths::home_dir;
use crate::types::{OpenwrkDaemonState, OpenwrkOpencodeState, OpenwrkStatus, OpenwrkWorkspace};
use crate::types::{
OpenwrkBinaryState,
OpenwrkDaemonState,
OpenwrkOpencodeState,
OpenwrkSidecarInfo,
OpenwrkStatus,
OpenwrkWorkspace,
};
pub mod manager;
@@ -20,6 +27,9 @@ pub struct OpenwrkStateFile {
pub version: Option<u32>,
pub daemon: Option<OpenwrkDaemonState>,
pub opencode: Option<OpenwrkOpencodeState>,
pub cli_version: Option<String>,
pub sidecar: Option<OpenwrkSidecarInfo>,
pub binaries: Option<OpenwrkBinaryState>,
pub active_id: Option<String>,
#[serde(default)]
pub workspaces: Vec<OpenwrkWorkspace>,
@@ -31,6 +41,9 @@ pub struct OpenwrkHealth {
pub ok: bool,
pub daemon: Option<OpenwrkDaemonState>,
pub opencode: Option<OpenwrkOpencodeState>,
pub cli_version: Option<String>,
pub sidecar: Option<OpenwrkSidecarInfo>,
pub binaries: Option<OpenwrkBinaryState>,
pub active_id: Option<String>,
pub workspace_count: Option<usize>,
}
@@ -199,6 +212,9 @@ pub fn openwrk_status_from_state(data_dir: &str, last_error: Option<String>) ->
data_dir: data_dir.to_string(),
daemon: state.as_ref().and_then(|state| state.daemon.clone()),
opencode: state.as_ref().and_then(|state| state.opencode.clone()),
cli_version: state.as_ref().and_then(|state| state.cli_version.clone()),
sidecar: state.as_ref().and_then(|state| state.sidecar.clone()),
binaries: state.as_ref().and_then(|state| state.binaries.clone()),
active_id,
workspace_count,
workspaces,
@@ -238,6 +254,9 @@ pub fn resolve_openwrk_status(data_dir: &str, last_error: Option<String>) -> Ope
data_dir: data_dir.to_string(),
daemon: health.daemon,
opencode: health.opencode,
cli_version: health.cli_version,
sidecar: health.sidecar,
binaries: health.binaries,
active_id,
workspace_count,
workspaces,

View File

@@ -13,6 +13,7 @@ pub struct OwpenbotManager {
pub struct OwpenbotState {
pub child: Option<CommandChild>,
pub child_exited: bool,
pub version: Option<String>,
pub workspace_path: Option<String>,
pub opencode_url: Option<String>,
pub qr_data: Option<String>,
@@ -35,6 +36,7 @@ impl OwpenbotManager {
OwpenbotInfo {
running,
version: state.version.clone(),
workspace_path: state.workspace_path.clone(),
opencode_url: state.opencode_url.clone(),
qr_data: state.qr_data.clone(),
@@ -51,6 +53,7 @@ impl OwpenbotManager {
let _ = child.kill();
}
state.child_exited = true;
state.version = None;
state.workspace_path = None;
state.opencode_url = None;
state.qr_data = None;

View File

@@ -114,6 +114,33 @@ pub struct OpenwrkOpencodeState {
pub started_at: u64,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OpenwrkBinaryInfo {
pub path: String,
pub source: String,
pub expected_version: Option<String>,
pub actual_version: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OpenwrkBinaryState {
pub opencode: Option<OpenwrkBinaryInfo>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OpenwrkSidecarInfo {
pub dir: Option<String>,
pub base_url: Option<String>,
pub manifest_url: Option<String>,
pub target: Option<String>,
pub source: Option<String>,
pub opencode_source: Option<String>,
pub allow_external: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct OpenwrkWorkspace {
@@ -134,6 +161,9 @@ pub struct OpenwrkStatus {
pub data_dir: String,
pub daemon: Option<OpenwrkDaemonState>,
pub opencode: Option<OpenwrkOpencodeState>,
pub cli_version: Option<String>,
pub sidecar: Option<OpenwrkSidecarInfo>,
pub binaries: Option<OpenwrkBinaryState>,
pub active_id: Option<String>,
pub workspace_count: usize,
pub workspaces: Vec<OpenwrkWorkspace>,
@@ -144,6 +174,7 @@ pub struct OpenwrkStatus {
#[serde(rename_all = "camelCase")]
pub struct OwpenbotInfo {
pub running: bool,
pub version: Option<String>,
pub workspace_path: Option<String>,
pub opencode_url: Option<String>,
pub qr_data: Option<String>,

View File

@@ -15,6 +15,11 @@ openwrk start --workspace /path/to/workspace --approval auto
first run using a SHA-256 manifest. Use `--sidecar-dir` or `OPENWRK_SIDECAR_DIR` to control the
cache location, and `--sidecar-base-url` / `--sidecar-manifest` to point at a custom host.
Use `--sidecar-source` to control where `openwork-server` and `owpenbot` are resolved
(`auto` | `bundled` | `downloaded` | `external`), and `--opencode-source` to control
`opencode` resolution. Set `OPENWRK_SIDECAR_SOURCE` / `OPENWRK_OPENCODE_SOURCE` to
apply the same policies via env vars.
By default the manifest is fetched from
`https://github.com/different-ai/openwork/releases/download/openwrk-v<openwrk-version>/openwrk-sidecars.json`.
@@ -24,6 +29,8 @@ Owpenbot is optional. If it exits, `openwrk` continues running unless you pass
For development overrides only, set `OPENWRK_ALLOW_EXTERNAL=1` or pass `--allow-external` to use
locally installed `openwork-server` or `owpenbot` binaries.
Add `--verbose` (or `OPENWRK_VERBOSE=1`) to print extra diagnostics about resolved binaries.
Or from source:
```bash

View File

@@ -14,6 +14,13 @@ if (!openwrkVersion) {
throw new Error("openwrk version missing in packages/headless/package.json");
}
const sourceDateEpoch = process.env.SOURCE_DATE_EPOCH
? Number(process.env.SOURCE_DATE_EPOCH)
: null;
const generatedAt = Number.isFinite(sourceDateEpoch)
? new Date(sourceDateEpoch * 1000).toISOString()
: new Date().toISOString();
const serverPkg = JSON.parse(readFileSync(resolve(repoRoot, "packages", "server", "package.json"), "utf8"));
const serverVersion = String(serverPkg.version ?? "").trim();
if (!serverVersion) {
@@ -89,7 +96,7 @@ for (const target of targets) {
const manifest = {
version: openwrkVersion,
generatedAt: new Date().toISOString(),
generatedAt,
entries,
};

View File

@@ -77,12 +77,31 @@ type SidecarConfig = {
type BinarySource = "bundled" | "external" | "downloaded";
type BinarySourcePreference = "auto" | "bundled" | "downloaded" | "external";
type ResolvedBinary = {
bin: string;
source: BinarySource;
expectedVersion?: string;
};
type BinaryDiagnostics = {
path: string;
source: BinarySource;
expectedVersion?: string;
actualVersion?: string;
};
type SidecarDiagnostics = {
dir: string;
baseUrl: string;
manifestUrl: string;
target: SidecarTarget | null;
source: BinarySourcePreference;
opencodeSource: BinarySourcePreference;
allowExternal: boolean;
};
type RouterWorkspaceType = "local" | "remote";
type RouterWorkspace = {
@@ -110,10 +129,34 @@ type RouterOpencodeState = {
startedAt: number;
};
type RouterBinaryInfo = {
path: string;
source: BinarySource;
expectedVersion?: string;
actualVersion?: string;
};
type RouterBinaryState = {
opencode?: RouterBinaryInfo;
};
type RouterSidecarState = {
dir: string;
baseUrl: string;
manifestUrl: string;
target: SidecarTarget | null;
source: BinarySourcePreference;
opencodeSource: BinarySourcePreference;
allowExternal: boolean;
};
type RouterState = {
version: number;
daemon?: RouterDaemonState;
opencode?: RouterOpencodeState;
cliVersion?: string;
sidecar?: RouterSidecarState;
binaries?: RouterBinaryState;
activeId: string;
workspaces: RouterWorkspace[];
};
@@ -241,6 +284,21 @@ function readNumber(
return fallback;
}
function readBinarySource(
flags: Map<string, string | boolean>,
key: string,
fallback: BinarySourcePreference,
envKey?: string,
): BinarySourcePreference {
const raw = readFlag(flags, key) ?? (envKey ? process.env[envKey] : undefined);
if (!raw) return fallback;
const normalized = String(raw).trim().toLowerCase();
if (normalized === "auto" || normalized === "bundled" || normalized === "downloaded" || normalized === "external") {
return normalized as BinarySourcePreference;
}
throw new Error(`Invalid ${key} value: ${raw}. Use auto|bundled|downloaded|external.`);
}
async function fileExists(path: string): Promise<boolean> {
try {
await stat(path);
@@ -636,8 +694,8 @@ function resolveOpencodeAsset(target: SidecarTarget): string | null {
async function runCommand(command: string, args: string[], cwd?: string): Promise<void> {
const child = spawn(command, args, { cwd, stdio: "inherit" });
const result = await Promise.race([
once(child, "exit").then(([code]) => ({ type: "exit", code })),
once(child, "error").then(([error]) => ({ type: "error", error })),
once(child, "exit").then(([code]) => ({ type: "exit" as const, code })),
once(child, "error").then(([error]) => ({ type: "error" as const, error })),
]);
if (result.type === "error") {
throw new Error(`Command failed: ${command} ${args.join(" ")}: ${String(result.error)}`);
@@ -894,23 +952,74 @@ async function resolveOpenworkServerBin(options: {
manifest: VersionManifest | null;
allowExternal: boolean;
sidecar: SidecarConfig;
source: BinarySourcePreference;
}): Promise<ResolvedBinary> {
if (options.explicit && !options.allowExternal) {
throw new Error("openwork-server-bin requires --allow-external");
}
if (options.explicit && options.source !== "auto" && options.source !== "external") {
throw new Error("openwork-server-bin requires --sidecar-source external or auto");
}
const expectedVersion = await resolveExpectedVersion(options.manifest, "openwork-server");
const resolveExternal = async (): Promise<ResolvedBinary> => {
if (!options.allowExternal) {
throw new Error("External openwork-server requires --allow-external");
}
if (options.explicit) {
const resolved = resolveBinPath(options.explicit);
if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) {
throw new Error(`openwork-server-bin not found: ${resolved}`);
}
return { bin: resolved, source: "external", expectedVersion };
}
const require = createRequire(import.meta.url);
try {
const pkgPath = require.resolve("openwork-server/package.json");
const pkgDir = dirname(pkgPath);
const binaryPath = join(pkgDir, "dist", "bin", "openwork-server");
if (await isExecutable(binaryPath)) {
return { bin: binaryPath, source: "external", expectedVersion };
}
const cliPath = join(pkgDir, "dist", "cli.js");
if (await isExecutable(cliPath)) {
return { bin: cliPath, source: "external", expectedVersion };
}
} catch {
// ignore
}
return { bin: "openwork-server", source: "external", expectedVersion };
};
if (options.source === "bundled") {
const bundled = await resolveBundledBinary(options.manifest, "openwork-server");
if (!bundled) {
throw new Error("Bundled openwork-server binary missing. Build with pnpm --filter openwrk build:bin:bundled.");
}
return { bin: bundled, source: "bundled", expectedVersion };
}
if (options.source === "downloaded") {
const downloaded = await downloadSidecarBinary({ name: "openwork-server", sidecar: options.sidecar });
if (!downloaded) {
throw new Error("openwork-server download failed. Check sidecar manifest or base URL.");
}
return downloaded;
}
if (options.source === "external") {
return resolveExternal();
}
const bundled = await resolveBundledBinary(options.manifest, "openwork-server");
if (bundled && !(options.allowExternal && options.explicit)) {
return { bin: bundled, source: "bundled", expectedVersion };
}
if (options.explicit) {
const resolved = resolveBinPath(options.explicit);
if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) {
throw new Error(`openwork-server-bin not found: ${resolved}`);
}
return { bin: resolved, source: "external", expectedVersion };
return resolveExternal();
}
const downloaded = await downloadSidecarBinary({ name: "openwork-server", sidecar: options.sidecar });
@@ -918,27 +1027,11 @@ async function resolveOpenworkServerBin(options: {
if (!options.allowExternal) {
throw new Error(
"Bundled openwork-server binary missing and download failed. Use --allow-external or set OPENWRK_SIDECAR_MANIFEST_URL.",
"Bundled openwork-server binary missing and download failed. Use --allow-external or --sidecar-source external.",
);
}
const require = createRequire(import.meta.url);
try {
const pkgPath = require.resolve("openwork-server/package.json");
const pkgDir = dirname(pkgPath);
const binaryPath = join(pkgDir, "dist", "bin", "openwork-server");
if (await isExecutable(binaryPath)) {
return { bin: binaryPath, source: "external", expectedVersion };
}
const cliPath = join(pkgDir, "dist", "cli.js");
if (await isExecutable(cliPath)) {
return { bin: cliPath, source: "external", expectedVersion };
}
} catch {
// ignore
}
return { bin: "openwork-server", source: "external", expectedVersion };
return resolveExternal();
}
async function resolveOpencodeBin(options: {
@@ -946,23 +1039,59 @@ async function resolveOpencodeBin(options: {
manifest: VersionManifest | null;
allowExternal: boolean;
sidecar: SidecarConfig;
source: BinarySourcePreference;
}): Promise<ResolvedBinary> {
if (options.explicit && !options.allowExternal) {
throw new Error("opencode-bin requires --allow-external");
}
if (options.explicit && options.source !== "auto" && options.source !== "external") {
throw new Error("opencode-bin requires --opencode-source external or auto");
}
const expectedVersion = await resolveExpectedVersion(options.manifest, "opencode");
const resolveExternal = async (): Promise<ResolvedBinary> => {
if (!options.allowExternal) {
throw new Error("External opencode requires --allow-external");
}
if (options.explicit) {
const resolved = resolveBinPath(options.explicit);
if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) {
throw new Error(`opencode-bin not found: ${resolved}`);
}
return { bin: resolved, source: "external", expectedVersion };
}
return { bin: "opencode", source: "external", expectedVersion };
};
if (options.source === "bundled") {
const bundled = await resolveBundledBinary(options.manifest, "opencode");
if (!bundled) {
throw new Error("Bundled opencode binary missing. Build with pnpm --filter openwrk build:bin:bundled.");
}
return { bin: bundled, source: "bundled", expectedVersion };
}
if (options.source === "downloaded") {
const downloaded = await downloadSidecarBinary({ name: "opencode", sidecar: options.sidecar });
if (downloaded) return downloaded;
const opencodeDownloaded = await resolveOpencodeDownload(options.sidecar, expectedVersion);
if (opencodeDownloaded) {
return { bin: opencodeDownloaded, source: "downloaded", expectedVersion };
}
throw new Error("opencode download failed. Check sidecar manifest or OPENCODE_VERSION.");
}
if (options.source === "external") {
return resolveExternal();
}
const bundled = await resolveBundledBinary(options.manifest, "opencode");
if (bundled && !(options.allowExternal && options.explicit)) {
return { bin: bundled, source: "bundled", expectedVersion };
}
if (options.explicit) {
const resolved = resolveBinPath(options.explicit);
if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) {
throw new Error(`opencode-bin not found: ${resolved}`);
}
return { bin: resolved, source: "external", expectedVersion };
return resolveExternal();
}
const downloaded = await downloadSidecarBinary({ name: "opencode", sidecar: options.sidecar });
@@ -975,11 +1104,11 @@ async function resolveOpencodeBin(options: {
if (!options.allowExternal) {
throw new Error(
"Bundled opencode binary missing and download failed. Use --allow-external or set OPENWRK_SIDECAR_MANIFEST_URL.",
"Bundled opencode binary missing and download failed. Use --allow-external or --opencode-source external.",
);
}
return { bin: "opencode", source: "external", expectedVersion };
return resolveExternal();
}
async function resolveOwpenbotBin(options: {
@@ -987,23 +1116,70 @@ async function resolveOwpenbotBin(options: {
manifest: VersionManifest | null;
allowExternal: boolean;
sidecar: SidecarConfig;
source: BinarySourcePreference;
}): Promise<ResolvedBinary> {
if (options.explicit && !options.allowExternal) {
throw new Error("owpenbot-bin requires --allow-external");
}
if (options.explicit && options.source !== "auto" && options.source !== "external") {
throw new Error("owpenbot-bin requires --sidecar-source external or auto");
}
const expectedVersion = await resolveExpectedVersion(options.manifest, "owpenbot");
const resolveExternal = async (): Promise<ResolvedBinary> => {
if (!options.allowExternal) {
throw new Error("External owpenbot requires --allow-external");
}
if (options.explicit) {
const resolved = resolveBinPath(options.explicit);
if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) {
throw new Error(`owpenbot-bin not found: ${resolved}`);
}
return { bin: resolved, source: "external", expectedVersion };
}
const repoDir = await resolveOwpenbotRepoDir();
if (repoDir) {
const binPath = join(repoDir, "dist", "bin", "owpenbot");
if (await isExecutable(binPath)) {
return { bin: binPath, source: "external", expectedVersion };
}
const cliPath = join(repoDir, "dist", "cli.js");
if (await fileExists(cliPath)) {
return { bin: cliPath, source: "external", expectedVersion };
}
}
return { bin: "owpenbot", source: "external", expectedVersion };
};
if (options.source === "bundled") {
const bundled = await resolveBundledBinary(options.manifest, "owpenbot");
if (!bundled) {
throw new Error("Bundled owpenbot binary missing. Build with pnpm --filter openwrk build:bin:bundled.");
}
return { bin: bundled, source: "bundled", expectedVersion };
}
if (options.source === "downloaded") {
const downloaded = await downloadSidecarBinary({ name: "owpenbot", sidecar: options.sidecar });
if (!downloaded) {
throw new Error("owpenbot download failed. Check sidecar manifest or base URL.");
}
return downloaded;
}
if (options.source === "external") {
return resolveExternal();
}
const bundled = await resolveBundledBinary(options.manifest, "owpenbot");
if (bundled && !(options.allowExternal && options.explicit)) {
return { bin: bundled, source: "bundled", expectedVersion };
}
if (options.explicit) {
const resolved = resolveBinPath(options.explicit);
if ((resolved.includes("/") || resolved.startsWith(".")) && !(await fileExists(resolved))) {
throw new Error(`owpenbot-bin not found: ${resolved}`);
}
return { bin: resolved, source: "external", expectedVersion };
return resolveExternal();
}
const downloaded = await downloadSidecarBinary({ name: "owpenbot", sidecar: options.sidecar });
@@ -1011,23 +1187,11 @@ async function resolveOwpenbotBin(options: {
if (!options.allowExternal) {
throw new Error(
"Bundled owpenbot binary missing and download failed. Use --allow-external or set OPENWRK_SIDECAR_MANIFEST_URL.",
"Bundled owpenbot binary missing and download failed. Use --allow-external or --sidecar-source external.",
);
}
const repoDir = await resolveOwpenbotRepoDir();
if (repoDir) {
const binPath = join(repoDir, "dist", "bin", "owpenbot");
if (await isExecutable(binPath)) {
return { bin: binPath, source: "external", expectedVersion };
}
const cliPath = join(repoDir, "dist", "cli.js");
if (await fileExists(cliPath)) {
return { bin: cliPath, source: "external", expectedVersion };
}
}
return { bin: "owpenbot", source: "external", expectedVersion };
return resolveExternal();
}
function resolveRouterDataDir(flags: Map<string, string | boolean>): string {
@@ -1059,6 +1223,9 @@ async function loadRouterState(path: string): Promise<RouterState> {
version: 1,
daemon: undefined,
opencode: undefined,
cliVersion: undefined,
sidecar: undefined,
binaries: undefined,
activeId: "",
workspaces: [],
};
@@ -1196,9 +1363,12 @@ function printHelp(): void {
" --sidecar-dir <path> Cache directory for downloaded sidecars",
" --sidecar-base-url <url> Base URL for sidecar downloads",
" --sidecar-manifest <url> Override sidecar manifest URL",
" --sidecar-source <mode> auto | bundled | downloaded | external",
" --opencode-source <mode> auto | bundled | downloaded | external",
" --check Run health checks then exit",
" --check-events Verify SSE events during check",
" --json Output JSON when applicable",
" --verbose Print additional diagnostics",
" --help Show help",
" --version Show version",
].join("\n");
@@ -1425,7 +1595,7 @@ async function verifyOpenworkServer(input: {
expectedOpencodeDirectory?: string;
expectedOpencodeUsername?: string;
expectedOpencodePassword?: string;
}) {
}): Promise<string | undefined> {
const health = await fetchJson(`${input.baseUrl}/health`);
const actualVersion = typeof health?.version === "string" ? health.version : undefined;
assertVersionMatch("openwork-server", input.expectedVersion, actualVersion, `${input.baseUrl}/health`);
@@ -1474,6 +1644,8 @@ async function verifyOpenworkServer(input: {
const hostHeaders = { "X-OpenWork-Host-Token": input.hostToken };
await fetchJson(`${input.baseUrl}/approvals`, { headers: hostHeaders });
return actualVersion;
}
async function runChecks(input: {
@@ -1585,6 +1757,13 @@ function outputError(error: unknown, json: boolean): void {
console.error(message);
}
function createVerboseLogger(enabled: boolean) {
return (message: string) => {
if (!enabled) return;
console.log(`[openwrk] ${message}`);
};
}
async function spawnRouterDaemon(args: ParsedArgs, dataDir: string, host: string, port: number) {
const self = resolveSelfCommand();
const commandArgs = [
@@ -1606,6 +1785,10 @@ async function spawnRouterDaemon(args: ParsedArgs, dataDir: string, host: string
const opencodeUsername = readFlag(args.flags, "opencode-username") ?? process.env.OPENWORK_OPENCODE_USERNAME;
const opencodePassword = readFlag(args.flags, "opencode-password") ?? process.env.OPENWORK_OPENCODE_PASSWORD;
const corsValue = readFlag(args.flags, "cors") ?? process.env.OPENWRK_OPENCODE_CORS;
const allowExternal = readBool(args.flags, "allow-external", false, "OPENWRK_ALLOW_EXTERNAL");
const sidecarSource = readFlag(args.flags, "sidecar-source") ?? process.env.OPENWRK_SIDECAR_SOURCE;
const opencodeSource = readFlag(args.flags, "opencode-source") ?? process.env.OPENWRK_OPENCODE_SOURCE;
const verbose = readBool(args.flags, "verbose", false, "OPENWRK_VERBOSE");
if (opencodeBin) commandArgs.push("--opencode-bin", opencodeBin);
if (opencodeHost) commandArgs.push("--opencode-host", opencodeHost);
@@ -1614,6 +1797,10 @@ async function spawnRouterDaemon(args: ParsedArgs, dataDir: string, host: string
if (opencodeUsername) commandArgs.push("--opencode-username", opencodeUsername);
if (opencodePassword) commandArgs.push("--opencode-password", opencodePassword);
if (corsValue) commandArgs.push("--cors", corsValue);
if (allowExternal) commandArgs.push("--allow-external");
if (sidecarSource) commandArgs.push("--sidecar-source", sidecarSource);
if (opencodeSource) commandArgs.push("--opencode-source", opencodeSource);
if (verbose) commandArgs.push("--verbose");
const child = spawn(self.command, commandArgs, {
detached: true,
@@ -1783,6 +1970,10 @@ async function runInstanceCommand(args: ParsedArgs) {
async function runRouterDaemon(args: ParsedArgs) {
const outputJson = readBool(args.flags, "json", false);
const verbose = readBool(args.flags, "verbose", false, "OPENWRK_VERBOSE");
const logVerbose = createVerboseLogger(verbose && !outputJson);
const sidecarSource = readBinarySource(args.flags, "sidecar-source", "auto", "OPENWRK_SIDECAR_SOURCE");
const opencodeSource = readBinarySource(args.flags, "opencode-source", "auto", "OPENWRK_OPENCODE_SOURCE");
const dataDir = resolveRouterDataDir(args.flags);
const statePath = routerStatePath(dataDir);
let state = await loadRouterState(statePath);
@@ -1823,15 +2014,46 @@ async function runRouterDaemon(args: ParsedArgs) {
const sidecar = resolveSidecarConfig(args.flags, cliVersion);
const allowExternal = readBool(args.flags, "allow-external", false, "OPENWRK_ALLOW_EXTERNAL");
const manifest = await readVersionManifest();
logVerbose(`cli version: ${cliVersion}`);
logVerbose(`sidecar target: ${sidecar.target ?? "unknown"}`);
logVerbose(`sidecar dir: ${sidecar.dir}`);
logVerbose(`sidecar base URL: ${sidecar.baseUrl}`);
logVerbose(`sidecar manifest: ${sidecar.manifestUrl}`);
logVerbose(`sidecar source: ${sidecarSource}`);
logVerbose(`opencode source: ${opencodeSource}`);
logVerbose(`allow external: ${allowExternal ? "true" : "false"}`);
const opencodeBinary = await resolveOpencodeBin({
explicit: opencodeBin,
manifest,
allowExternal,
sidecar,
source: opencodeSource,
});
logVerbose(`opencode bin: ${opencodeBinary.bin} (${opencodeBinary.source})`);
let opencodeChild: ReturnType<typeof spawn> | null = null;
const updateDiagnostics = (actualVersion?: string) => {
state.cliVersion = cliVersion;
state.sidecar = {
dir: sidecar.dir,
baseUrl: sidecar.baseUrl,
manifestUrl: sidecar.manifestUrl,
target: sidecar.target,
source: sidecarSource,
opencodeSource,
allowExternal,
};
state.binaries = {
opencode: {
path: opencodeBinary.bin,
source: opencodeBinary.source,
expectedVersion: opencodeBinary.expectedVersion,
actualVersion,
},
};
};
const ensureOpencode = async () => {
const existing = state.opencode;
if (existing && isProcessAlive(existing.pid)) {
@@ -1842,6 +2064,10 @@ async function runRouterDaemon(args: ParsedArgs) {
});
try {
await waitForOpencodeHealthy(client, 2000, 200);
if (!state.sidecar || !state.cliVersion || !state.binaries?.opencode) {
updateDiagnostics(state.binaries?.opencode?.actualVersion);
await saveRouterState(statePath, state);
}
return { baseUrl: existing.baseUrl, client };
} catch {
// restart
@@ -1852,7 +2078,8 @@ async function runRouterDaemon(args: ParsedArgs) {
await stopChild(opencodeChild);
}
await verifyOpencodeVersion(opencodeBinary);
const opencodeActualVersion = await verifyOpencodeVersion(opencodeBinary);
logVerbose(`opencode version: ${opencodeActualVersion ?? "unknown"}`);
const child = await startOpencode({
bin: opencodeBinary.bin,
workspace: resolvedWorkdir,
@@ -1876,6 +2103,7 @@ async function runRouterDaemon(args: ParsedArgs) {
baseUrl,
startedAt: nowMs(),
};
updateDiagnostics(opencodeActualVersion);
await saveRouterState(statePath, state);
return { baseUrl, client };
};
@@ -1914,16 +2142,19 @@ async function runRouterDaemon(args: ParsedArgs) {
};
try {
if (req.method === "GET" && url.pathname === "/health") {
send(200, {
ok: true,
daemon: state.daemon ?? null,
opencode: state.opencode ?? null,
activeId: state.activeId,
workspaceCount: state.workspaces.length,
});
return;
}
if (req.method === "GET" && url.pathname === "/health") {
send(200, {
ok: true,
daemon: state.daemon ?? null,
opencode: state.opencode ?? null,
activeId: state.activeId,
workspaceCount: state.workspaces.length,
cliVersion: state.cliVersion ?? null,
sidecar: state.sidecar ?? null,
binaries: state.binaries ?? null,
});
return;
}
if (req.method === "GET" && url.pathname === "/workspaces") {
send(200, { activeId: state.activeId, workspaces: state.workspaces });
@@ -2223,6 +2454,10 @@ async function runStart(args: ParsedArgs) {
const outputJson = readBool(args.flags, "json", false);
const checkOnly = readBool(args.flags, "check", false);
const checkEvents = readBool(args.flags, "check-events", false);
const verbose = readBool(args.flags, "verbose", false, "OPENWRK_VERBOSE");
const logVerbose = createVerboseLogger(verbose && !outputJson);
const sidecarSource = readBinarySource(args.flags, "sidecar-source", "auto", "OPENWRK_SIDECAR_SOURCE");
const opencodeSource = readBinarySource(args.flags, "opencode-source", "auto", "OPENWRK_OPENCODE_SOURCE");
const workspace = readFlag(args.flags, "workspace") ?? process.env.OPENWORK_WORKSPACE ?? process.cwd();
const resolvedWorkspace = await ensureWorkspace(workspace);
@@ -2268,11 +2503,20 @@ async function runStart(args: ParsedArgs) {
const sidecar = resolveSidecarConfig(args.flags, cliVersion);
const manifest = await readVersionManifest();
const allowExternal = readBool(args.flags, "allow-external", false, "OPENWRK_ALLOW_EXTERNAL");
logVerbose(`cli version: ${cliVersion}`);
logVerbose(`sidecar target: ${sidecar.target ?? "unknown"}`);
logVerbose(`sidecar dir: ${sidecar.dir}`);
logVerbose(`sidecar base URL: ${sidecar.baseUrl}`);
logVerbose(`sidecar manifest: ${sidecar.manifestUrl}`);
logVerbose(`sidecar source: ${sidecarSource}`);
logVerbose(`opencode source: ${opencodeSource}`);
logVerbose(`allow external: ${allowExternal ? "true" : "false"}`);
const opencodeBinary = await resolveOpencodeBin({
explicit: explicitOpencodeBin,
manifest,
allowExternal,
sidecar,
source: opencodeSource,
});
const explicitOpenworkServerBin = readFlag(args.flags, "openwork-server-bin") ?? process.env.OPENWORK_SERVER_BIN;
const explicitOwpenbotBin = readFlag(args.flags, "owpenbot-bin") ?? process.env.OWPENBOT_BIN;
@@ -2283,6 +2527,7 @@ async function runStart(args: ParsedArgs) {
manifest,
allowExternal,
sidecar,
source: sidecarSource,
});
const owpenbotBinary = owpenbotEnabled
? await resolveOwpenbotBin({
@@ -2290,8 +2535,15 @@ async function runStart(args: ParsedArgs) {
manifest,
allowExternal,
sidecar,
source: sidecarSource,
})
: null;
let owpenbotActualVersion: string | undefined;
logVerbose(`opencode bin: ${opencodeBinary.bin} (${opencodeBinary.source})`);
logVerbose(`openwork-server bin: ${openworkServerBinary.bin} (${openworkServerBinary.source})`);
if (owpenbotBinary) {
logVerbose(`owpenbot bin: ${owpenbotBinary.bin} (${owpenbotBinary.source})`);
}
const opencodeBaseUrl = `http://127.0.0.1:${opencodePort}`;
const opencodeConnect = resolveConnectUrl(opencodePort, connectHost);
@@ -2323,7 +2575,7 @@ async function runStart(args: ParsedArgs) {
};
try {
await verifyOpencodeVersion(opencodeBinary);
const opencodeActualVersion = await verifyOpencodeVersion(opencodeBinary);
const opencodeChild = await startOpencode({
bin: opencodeBinary.bin,
workspace: resolvedWorkspace,
@@ -2371,7 +2623,7 @@ async function runStart(args: ParsedArgs) {
await waitForHealthy(openworkBaseUrl);
await verifyOpenworkServer({
const openworkActualVersion = await verifyOpenworkServer({
baseUrl: openworkBaseUrl,
token: openworkToken,
hostToken: openworkHostToken,
@@ -2382,12 +2634,14 @@ async function runStart(args: ParsedArgs) {
expectedOpencodeUsername: opencodeUsername,
expectedOpencodePassword: opencodePassword,
});
logVerbose(`openwork-server version: ${openworkActualVersion ?? "unknown"}`);
if (owpenbotEnabled) {
if (!owpenbotBinary) {
throw new Error("Owpenbot binary missing.");
}
await verifyOwpenbotVersion(owpenbotBinary);
owpenbotActualVersion = await verifyOwpenbotVersion(owpenbotBinary);
logVerbose(`owpenbot version: ${owpenbotActualVersion ?? "unknown"}`);
const owpenbotChild = await startOwpenbot({
bin: owpenbotBinary.bin,
workspace: resolvedWorkspace,
@@ -2421,6 +2675,7 @@ async function runStart(args: ParsedArgs) {
password: opencodePassword,
bindHost: opencodeBindHost,
port: opencodePort,
version: opencodeActualVersion,
},
openwork: {
baseUrl: openworkBaseUrl,
@@ -2429,9 +2684,45 @@ async function runStart(args: ParsedArgs) {
port: openworkPort,
token: openworkToken,
hostToken: openworkHostToken,
version: openworkActualVersion,
},
owpenbot: {
enabled: owpenbotEnabled,
version: owpenbotEnabled ? owpenbotActualVersion : undefined,
},
diagnostics: {
cliVersion,
sidecar: {
dir: sidecar.dir,
baseUrl: sidecar.baseUrl,
manifestUrl: sidecar.manifestUrl,
target: sidecar.target,
source: sidecarSource,
opencodeSource,
allowExternal,
} as SidecarDiagnostics,
binaries: {
opencode: {
path: opencodeBinary.bin,
source: opencodeBinary.source,
expectedVersion: opencodeBinary.expectedVersion,
actualVersion: opencodeActualVersion,
} as BinaryDiagnostics,
openworkServer: {
path: openworkServerBinary.bin,
source: openworkServerBinary.source,
expectedVersion: openworkServerBinary.expectedVersion,
actualVersion: openworkActualVersion,
} as BinaryDiagnostics,
owpenbot: owpenbotBinary
? ({
path: owpenbotBinary.bin,
source: owpenbotBinary.source,
expectedVersion: owpenbotBinary.expectedVersion,
actualVersion: owpenbotActualVersion,
} as BinaryDiagnostics)
: null,
},
},
};

View File

@@ -21,6 +21,8 @@ pnpm --filter openwork-server dev -- \
The server logs the client token and host token on boot when they are auto-generated.
Add `--verbose` to print resolved config details on startup. Use `--version` to print the server version and exit.
## Config file
Defaults to `~/.config/openwork/server.json` (override with `OPENWORK_SERVER_CONFIG` or `--config`).
@@ -61,6 +63,7 @@ Defaults to `~/.config/openwork/server.json` (override with `OPENWORK_SERVER_CON
## Endpoints (initial)
- `GET /health`
- `GET /status`
- `GET /capabilities`
- `GET /workspaces`
- `GET /workspace/:id/config`

View File

@@ -2,6 +2,7 @@
import { parseCliArgs, printHelp, resolveServerConfig } from "./config.js";
import { startServer } from "./server.js";
import pkg from "../package.json" with { type: "json" };
const args = parseCliArgs(process.argv.slice(2));
@@ -10,6 +11,11 @@ if (args.help) {
process.exit(0);
}
if (args.version) {
console.log(pkg.version);
process.exit(0);
}
const config = await resolveServerConfig(args);
const server = startServer(config);
@@ -29,3 +35,13 @@ if (config.workspaces.length === 0) {
} else {
console.log(`Workspaces: ${config.workspaces.length}`);
}
if (args.verbose) {
console.log(`Config path: ${config.configPath ?? "unknown"}`);
console.log(`Read-only: ${config.readOnly ? "true" : "false"}`);
console.log(`Approval: ${config.approval.mode} (${config.approval.timeoutMs}ms)`);
console.log(`CORS origins: ${config.corsOrigins.join(", ")}`);
console.log(`Authorized roots: ${config.authorizedRoots.join(", ")}`);
console.log(`Token source: ${config.tokenSource}`);
console.log(`Host token source: ${config.hostTokenSource}`);
}

View File

@@ -19,6 +19,8 @@ interface CliArgs {
workspaces: string[];
corsOrigins?: string[];
readOnly?: boolean;
verbose?: boolean;
version?: boolean;
help?: boolean;
}
@@ -49,6 +51,14 @@ export function parseCliArgs(argv: string[]): CliArgs {
args.help = true;
continue;
}
if (value === "--version") {
args.version = true;
continue;
}
if (value === "--verbose") {
args.verbose = true;
continue;
}
if (value === "--config") {
args.configPath = argv[index + 1];
index += 1;
@@ -145,6 +155,8 @@ export function printHelp(): void {
" --workspace <path> Workspace root (repeatable)",
" --cors <origins> Comma-separated origins or *",
" --read-only Disable writes",
" --verbose Print resolved config",
" --version Show version",
].join("\n");
console.log(message);
}
@@ -256,6 +268,7 @@ export async function resolveServerConfig(cli: CliArgs): Promise<ServerConfig> {
port: Number.isNaN(port) ? DEFAULT_PORT : port,
token,
hostToken,
configPath,
approval,
corsOrigins,
workspaces,

View File

@@ -45,10 +45,13 @@ export function startServer(config: ServerConfig) {
const reloadEvents = new ReloadEventStore();
const routes = createRoutes(config, approvals);
const server = Bun.serve({
const serverOptions: {
hostname: string;
port: number;
fetch: (request: Request) => Response | Promise<Response>;
} = {
hostname: config.host,
port: config.port,
idleTimeout: 120, // Allow long-running operations like engine reload
fetch: async (request: Request) => {
const url = new URL(request.url);
if (request.method === "OPTIONS") {
@@ -79,7 +82,11 @@ export function startServer(config: ServerConfig) {
return withCors(jsonResponse(formatError(apiError), apiError.status), request, config);
}
},
});
};
(serverOptions as { idleTimeout?: number }).idleTimeout = 120;
const server = Bun.serve(serverOptions);
return server;
}
@@ -222,6 +229,31 @@ function createRoutes(config: ServerConfig, approvals: ApprovalService): Route[]
return jsonResponse({ ok: true, version: SERVER_VERSION, uptimeMs: Date.now() - config.startedAt });
});
addRoute(routes, "GET", "/status", "client", async () => {
const active = config.workspaces[0];
return jsonResponse({
ok: true,
version: SERVER_VERSION,
uptimeMs: Date.now() - config.startedAt,
readOnly: config.readOnly,
approval: config.approval,
corsOrigins: config.corsOrigins,
workspaceCount: config.workspaces.length,
activeWorkspaceId: active?.id ?? null,
workspace: active ? serializeWorkspace(active) : null,
authorizedRoots: config.authorizedRoots,
server: {
host: config.host,
port: config.port,
configPath: config.configPath ?? null,
},
tokenSource: {
client: config.tokenSource,
host: config.hostTokenSource,
},
});
});
addRoute(routes, "GET", "/capabilities", "client", async () => {
return jsonResponse(buildCapabilities(config));
});

View File

@@ -39,6 +39,7 @@ export interface ServerConfig {
port: number;
token: string;
hostToken: string;
configPath?: string;
approval: ApprovalConfig;
corsOrigins: string[];
workspaces: WorkspaceInfo[];

146
scripts/release/review.mjs Normal file
View File

@@ -0,0 +1,146 @@
import { existsSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(fileURLToPath(new URL("../..", import.meta.url)));
const args = process.argv.slice(2);
const outputJson = args.includes("--json");
const strict = args.includes("--strict");
const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
const readText = (path) => readFileSync(path, "utf8");
const readCargoVersion = (path) => {
const content = readText(path);
const match = content.match(/^version\s*=\s*"([^"]+)"/m);
return match ? match[1] : null;
};
const appPkg = readJson(resolve(root, "packages", "app", "package.json"));
const desktopPkg = readJson(resolve(root, "packages", "desktop", "package.json"));
const headlessPkg = readJson(resolve(root, "packages", "headless", "package.json"));
const serverPkg = readJson(resolve(root, "packages", "server", "package.json"));
const owpenbotPkg = readJson(resolve(root, "packages", "owpenbot", "package.json"));
const tauriConfig = readJson(resolve(root, "packages", "desktop", "src-tauri", "tauri.conf.json"));
const cargoVersion = readCargoVersion(resolve(root, "packages", "desktop", "src-tauri", "Cargo.toml"));
const versions = {
app: appPkg.version ?? null,
desktop: desktopPkg.version ?? null,
tauri: tauriConfig.version ?? null,
cargo: cargoVersion ?? null,
server: serverPkg.version ?? null,
headless: headlessPkg.version ?? null,
owpenbot: owpenbotPkg.version ?? null,
opencode: {
desktop: desktopPkg.opencodeVersion ?? null,
headless: headlessPkg.opencodeVersion ?? null,
},
owpenbotVersionPinned: desktopPkg.owpenbotVersion ?? null,
headlessOpenworkServerRange: headlessPkg.dependencies?.["openwork-server"] ?? null,
};
const checks = [];
const warnings = [];
let ok = true;
const addCheck = (label, pass, details) => {
checks.push({ label, ok: pass, details });
if (!pass) ok = false;
};
const addWarning = (message) => warnings.push(message);
addCheck(
"App/desktop versions match",
versions.app && versions.desktop && versions.app === versions.desktop,
`${versions.app ?? "?"} vs ${versions.desktop ?? "?"}`,
);
addCheck(
"Desktop/Tauri versions match",
versions.desktop && versions.tauri && versions.desktop === versions.tauri,
`${versions.desktop ?? "?"} vs ${versions.tauri ?? "?"}`,
);
addCheck(
"Desktop/Cargo versions match",
versions.desktop && versions.cargo && versions.desktop === versions.cargo,
`${versions.desktop ?? "?"} vs ${versions.cargo ?? "?"}`,
);
addCheck(
"Owpenbot version pinned in desktop",
versions.owpenbot && versions.owpenbotVersionPinned && versions.owpenbot === versions.owpenbotVersionPinned,
`${versions.owpenbotVersionPinned ?? "?"} vs ${versions.owpenbot ?? "?"}`,
);
addCheck(
"OpenCode version matches (desktop/headless)",
versions.opencode.desktop && versions.opencode.headless && versions.opencode.desktop === versions.opencode.headless,
`${versions.opencode.desktop ?? "?"} vs ${versions.opencode.headless ?? "?"}`,
);
const openworkServerRange = versions.headlessOpenworkServerRange ?? "";
const openworkServerPinned = /^\d+\.\d+\.\d+/.test(openworkServerRange);
if (!openworkServerRange) {
addWarning("openwrk is missing an openwork-server dependency.");
} else if (!openworkServerPinned) {
addWarning(`openwrk openwork-server dependency is not pinned (${openworkServerRange}).`);
} else {
addCheck(
"Openwork-server dependency matches server version",
versions.server && openworkServerRange === versions.server,
`${openworkServerRange} vs ${versions.server ?? "?"}`,
);
}
const sidecarManifestPath = resolve(root, "packages", "headless", "dist", "sidecars", "openwrk-sidecars.json");
if (existsSync(sidecarManifestPath)) {
const manifest = readJson(sidecarManifestPath);
addCheck(
"Sidecar manifest version matches openwrk",
versions.headless && manifest.version === versions.headless,
`${manifest.version ?? "?"} vs ${versions.headless ?? "?"}`,
);
const serverEntry = manifest.entries?.["openwork-server"]?.version;
const owpenbotEntry = manifest.entries?.owpenbot?.version;
if (serverEntry) {
addCheck(
"Sidecar manifest openwork-server version matches",
versions.server && serverEntry === versions.server,
`${serverEntry ?? "?"} vs ${versions.server ?? "?"}`,
);
}
if (owpenbotEntry) {
addCheck(
"Sidecar manifest owpenbot version matches",
versions.owpenbot && owpenbotEntry === versions.owpenbot,
`${owpenbotEntry ?? "?"} vs ${versions.owpenbot ?? "?"}`,
);
}
} else {
addWarning("Sidecar manifest missing (run pnpm --filter openwrk build:sidecars).");
}
if (!process.env.SOURCE_DATE_EPOCH) {
addWarning("SOURCE_DATE_EPOCH is not set (sidecar manifests will include current time).");
}
const report = { ok, versions, checks, warnings };
if (outputJson) {
console.log(JSON.stringify(report, null, 2));
} else {
console.log("Release review");
for (const check of checks) {
const status = check.ok ? "ok" : "fail";
console.log(`- ${status}: ${check.label} (${check.details})`);
}
if (warnings.length) {
console.log("Warnings:");
for (const warning of warnings) {
console.log(`- ${warning}`);
}
}
}
if (strict && !ok) {
process.exit(1);
}