diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 00000000..7d7ad04a --- /dev/null +++ b/RELEASE.md @@ -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. diff --git a/package.json b/package.json index ca79a62d..bc1f1593 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index b5ce32d6..02390e6a 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -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(null); const [openworkServerWorkspaceId, setOpenworkServerWorkspaceId] = createSignal(null); const [openworkServerHostInfo, setOpenworkServerHostInfo] = createSignal(null); + const [openworkServerDiagnostics, setOpenworkServerDiagnostics] = createSignal(null); const [owpenbotInfoState, setOwpenbotInfoState] = createSignal(null); const [openwrkStatusState, setOpenwrkStatusState] = createSignal(null); const [openworkAuditEntries, setOpenworkAuditEntries] = createSignal([]); @@ -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(), diff --git a/packages/app/src/app/lib/openwork-server.ts b/packages/app/src/app/lib/openwork-server.ts index 405389e2..1f1d34dc 100644 --- a/packages/app/src/app/lib/openwork-server.ts +++ b/packages/app/src/app/lib/openwork-server.ts @@ -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(baseUrl, "/status", { token, hostToken }), capabilities: () => requestJson(baseUrl, "/capabilities", { token, hostToken }), listWorkspaces: () => requestJson(baseUrl, "/workspaces", { token, hostToken }), activateWorkspace: (workspaceId: string) => diff --git a/packages/app/src/app/lib/tauri.ts b/packages/app/src/app/lib/tauri.ts index ec5495d3..8094cb28 100644 --- a/packages/app/src/app/lib/tauri.ts +++ b/packages/app/src/app/lib/tauri.ts @@ -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; diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index c313cec8..1d4cda95 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -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} diff --git a/packages/app/src/app/pages/settings.tsx b/packages/app/src/app/pages/settings.tsx index c811aea3..c519ad96 100644 --- a/packages/app/src/app/pages/settings.tsx +++ b/packages/app/src/app/pages/settings.tsx @@ -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) {
+
+
+
Versions
+
Sidecar + desktop build info.
+
+
+
Desktop app: {appVersionLabel()}
+
+ Openwrk: {props.openwrkStatus?.cliVersion ?? "—"} +
+
OpenCode: {opencodeVersionLabel()}
+
+ OpenWork server: {openworkServerVersionLabel()} +
+
Owpenbot: {owpenbotVersionLabel()}
+
+
+
@@ -1807,6 +1863,15 @@ export default function SettingsView(props: SettingsViewProps) {
OpenCode: {props.openwrkStatus?.opencode?.baseUrl ?? "—"}
+
+ Openwrk version: {props.openwrkStatus?.cliVersion ?? "—"} +
+
+ Sidecar: {openwrkSidecarSummary()} +
+
+ Opencode binary: {formatOpenwrkBinary(props.openwrkStatus?.binaries?.opencode ?? null)} +
Active workspace: {props.openwrkStatus?.activeId ?? "—"}
@@ -1923,6 +1988,34 @@ export default function SettingsView(props: SettingsViewProps) {
+
+
+
OpenWork server diagnostics
+
+ {props.openworkServerDiagnostics?.version ?? "—"} +
+
+ Diagnostics unavailable.
} + > + {(diag) => ( +
+
Started: {formatUptime(diag().uptimeMs)}
+
Read-only: {diag().readOnly ? "true" : "false"}
+
+ Approval: {diag().approval.mode} ({diag().approval.timeoutMs}ms) +
+
Workspaces: {diag().workspaceCount}
+
Active workspace: {diag().activeWorkspaceId ?? "—"}
+
Config path: {diag().server.configPath ?? "default"}
+
Token source: {diag().tokenSource.client}
+
Host token source: {diag().tokenSource.host}
+
+ )} + +
+
OpenWork server capabilities
diff --git a/packages/desktop/src-tauri/src/commands/owpenbot.rs b/packages/desktop/src-tauri/src/commands/owpenbot.rs index 31510073..700b12dd 100644 --- a/packages/desktop/src-tauri/src/commands/owpenbot.rs +++ b/packages/desktop/src-tauri/src/commands/owpenbot.rs @@ -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 { + 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; diff --git a/packages/desktop/src-tauri/src/openwrk/mod.rs b/packages/desktop/src-tauri/src/openwrk/mod.rs index f7efa60f..cf3b6521 100644 --- a/packages/desktop/src-tauri/src/openwrk/mod.rs +++ b/packages/desktop/src-tauri/src/openwrk/mod.rs @@ -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, pub daemon: Option, pub opencode: Option, + pub cli_version: Option, + pub sidecar: Option, + pub binaries: Option, pub active_id: Option, #[serde(default)] pub workspaces: Vec, @@ -31,6 +41,9 @@ pub struct OpenwrkHealth { pub ok: bool, pub daemon: Option, pub opencode: Option, + pub cli_version: Option, + pub sidecar: Option, + pub binaries: Option, pub active_id: Option, pub workspace_count: Option, } @@ -199,6 +212,9 @@ pub fn openwrk_status_from_state(data_dir: &str, last_error: Option) -> 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) -> 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, diff --git a/packages/desktop/src-tauri/src/owpenbot/manager.rs b/packages/desktop/src-tauri/src/owpenbot/manager.rs index d627f645..16d80aef 100644 --- a/packages/desktop/src-tauri/src/owpenbot/manager.rs +++ b/packages/desktop/src-tauri/src/owpenbot/manager.rs @@ -13,6 +13,7 @@ pub struct OwpenbotManager { pub struct OwpenbotState { pub child: Option, pub child_exited: bool, + pub version: Option, pub workspace_path: Option, pub opencode_url: Option, pub qr_data: Option, @@ -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; diff --git a/packages/desktop/src-tauri/src/types.rs b/packages/desktop/src-tauri/src/types.rs index d7ff90f6..ee68ba10 100644 --- a/packages/desktop/src-tauri/src/types.rs +++ b/packages/desktop/src-tauri/src/types.rs @@ -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, + pub actual_version: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OpenwrkBinaryState { + pub opencode: Option, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct OpenwrkSidecarInfo { + pub dir: Option, + pub base_url: Option, + pub manifest_url: Option, + pub target: Option, + pub source: Option, + pub opencode_source: Option, + pub allow_external: Option, +} + #[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, pub opencode: Option, + pub cli_version: Option, + pub sidecar: Option, + pub binaries: Option, pub active_id: Option, pub workspace_count: usize, pub workspaces: Vec, @@ -144,6 +174,7 @@ pub struct OpenwrkStatus { #[serde(rename_all = "camelCase")] pub struct OwpenbotInfo { pub running: bool, + pub version: Option, pub workspace_path: Option, pub opencode_url: Option, pub qr_data: Option, diff --git a/packages/headless/README.md b/packages/headless/README.md index 0e74d3b1..3cb8e3bd 100644 --- a/packages/headless/README.md +++ b/packages/headless/README.md @@ -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-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 diff --git a/packages/headless/scripts/build-sidecars.mjs b/packages/headless/scripts/build-sidecars.mjs index bc2d98e6..2d53f55b 100644 --- a/packages/headless/scripts/build-sidecars.mjs +++ b/packages/headless/scripts/build-sidecars.mjs @@ -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, }; diff --git a/packages/headless/src/cli.ts b/packages/headless/src/cli.ts index 773ff89a..3ee1450e 100644 --- a/packages/headless/src/cli.ts +++ b/packages/headless/src/cli.ts @@ -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, + 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 { try { await stat(path); @@ -636,8 +694,8 @@ function resolveOpencodeAsset(target: SidecarTarget): string | null { async function runCommand(command: string, args: string[], cwd?: string): Promise { 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 { 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 => { + 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 { 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 => { + 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 { 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 => { + 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 { @@ -1059,6 +1223,9 @@ async function loadRouterState(path: string): Promise { version: 1, daemon: undefined, opencode: undefined, + cliVersion: undefined, + sidecar: undefined, + binaries: undefined, activeId: "", workspaces: [], }; @@ -1196,9 +1363,12 @@ function printHelp(): void { " --sidecar-dir Cache directory for downloaded sidecars", " --sidecar-base-url Base URL for sidecar downloads", " --sidecar-manifest Override sidecar manifest URL", + " --sidecar-source auto | bundled | downloaded | external", + " --opencode-source 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 { 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 | 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, + }, }, }; diff --git a/packages/server/README.md b/packages/server/README.md index 6cfe8e8b..27a61fa5 100644 --- a/packages/server/README.md +++ b/packages/server/README.md @@ -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` diff --git a/packages/server/src/cli.ts b/packages/server/src/cli.ts index 94328456..a839ee94 100644 --- a/packages/server/src/cli.ts +++ b/packages/server/src/cli.ts @@ -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}`); +} diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index 0fd45f9a..e418e1eb 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -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 Workspace root (repeatable)", " --cors 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 { port: Number.isNaN(port) ? DEFAULT_PORT : port, token, hostToken, + configPath, approval, corsOrigins, workspaces, diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 3780abf5..f9d79acb 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -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; + } = { 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)); }); diff --git a/packages/server/src/types.ts b/packages/server/src/types.ts index 948c4ad8..df791d29 100644 --- a/packages/server/src/types.ts +++ b/packages/server/src/types.ts @@ -39,6 +39,7 @@ export interface ServerConfig { port: number; token: string; hostToken: string; + configPath?: string; approval: ApprovalConfig; corsOrigins: string[]; workspaces: WorkspaceInfo[]; diff --git a/scripts/release/review.mjs b/scripts/release/review.mjs new file mode 100644 index 00000000..9bb70093 --- /dev/null +++ b/scripts/release/review.mjs @@ -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); +}