mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
feat: surface sidecar diagnostics for troubleshooting and releases
This commit is contained in:
44
RELEASE.md
Normal file
44
RELEASE.md
Normal 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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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
146
scripts/release/review.mjs
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user