diff --git a/.opencode/skills/openwork-docker-chrome-mcp/SKILL.md b/.opencode/skills/openwork-docker-chrome-mcp/SKILL.md index 754e34ab9..e8d4cb78d 100644 --- a/.opencode/skills/openwork-docker-chrome-mcp/SKILL.md +++ b/.opencode/skills/openwork-docker-chrome-mcp/SKILL.md @@ -47,6 +47,21 @@ Evidence: - Take a Chrome MCP screenshot after the response appears. - If something fails, capture console logs and (optionally) Docker logs. +### Verification checklist (copy into PR) + +- [ ] Started stack with `packaging/docker/dev-up.sh` from repo root. +- [ ] Used the printed Web UI URL (not a guessed port). +- [ ] Completed one full user flow in the UI (input -> action -> visible result). +- [ ] Captured at least one screenshot for the success state. +- [ ] Captured failure evidence when relevant (console and/or Docker logs). +- [ ] Stopped stack with the exact printed `docker compose -p ... down` command. + +Suggested screenshot set for user-facing changes: +- Before action state. +- During action/progress state. +- Success state. +- Failure or recovery state (if applicable). + ### 3) Stop the stack Use the exact `docker compose -p ... down` command printed by `dev-up.sh`. diff --git a/AGENTS.md b/AGENTS.md index 741370507..97be9be19 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -37,6 +37,18 @@ Read INFRASTRUCTURE.md * **Slick and fluid**: 60fps animations, micro-interactions, premium feel. * **Mobile-native**: touch targets, gestures, and layouts optimized for small screens. +## Task Intake (Required) + +Before making changes, explicitly confirm the target repository in your first task update. + +Required format: + +1. `Target repo: ` (for example: `_repos/openwork`) +2. `Out of scope repos: ` (for example: `_repos/opencode`) +3. `Planned output: ` + +If the user request references multiple repos and the intended edit location is ambiguous, stop after discovery and ask for a single repo target before editing files. + ## New Feature Workflow (Required) When the user asks to create a new feature, follow this exact procedure: diff --git a/packages/app/src/app/app.tsx b/packages/app/src/app/app.tsx index 665574a70..ecc733590 100644 --- a/packages/app/src/app/app.tsx +++ b/packages/app/src/app/app.tsx @@ -4600,6 +4600,9 @@ export default function App() { engineDoctorCheckedAt: engineDoctorCheckedAt(), engineInstallLogs: engineInstallLogs(), error: error(), + canRepairMigration: workspaceStore.canRepairOpencodeMigration(), + migrationRepairBusy: workspaceStore.migrationRepairBusy(), + migrationRepairResult: workspaceStore.migrationRepairResult(), isWindows: isWindowsPlatform(), onClientDirectoryChange: setClientDirectory, onOpenworkHostUrlChange: (value: string) => @@ -4615,6 +4618,7 @@ export default function App() { onSelectStartup: workspaceStore.onSelectStartup, onRememberStartupToggle: workspaceStore.onRememberStartupToggle, onStartHost: workspaceStore.onStartHost, + onRepairMigration: workspaceStore.onRepairOpencodeMigration, onCreateWorkspace: workspaceStore.createWorkspaceFlow, onPickWorkspaceFolder: workspaceStore.pickWorkspaceFolder, onImportWorkspaceConfig: workspaceStore.importWorkspaceConfig, @@ -4859,6 +4863,10 @@ export default function App() { workspaceDebugEvents: workspaceStore.workspaceDebugEvents(), clearWorkspaceDebugEvents: workspaceStore.clearWorkspaceDebugEvents, safeStringify, + repairOpencodeMigration: workspaceStore.repairOpencodeMigration, + migrationRepairBusy: workspaceStore.migrationRepairBusy(), + migrationRepairResult: workspaceStore.migrationRepairResult(), + migrationRepairAvailable: workspaceStore.canRepairOpencodeMigration(), repairOpencodeCache, cacheRepairBusy: cacheRepairBusy(), cacheRepairResult: cacheRepairResult(), diff --git a/packages/app/src/app/context/workspace.ts b/packages/app/src/app/context/workspace.ts index 999a153fb..abc0201bb 100644 --- a/packages/app/src/app/context/workspace.ts +++ b/packages/app/src/app/context/workspace.ts @@ -34,6 +34,7 @@ import { downloadDir, homeDir } from "@tauri-apps/api/path"; import { engineDoctor, engineInfo, + opencodeDbMigrate, engineInstall, engineStart, engineStop, @@ -94,6 +95,11 @@ export type SandboxCreateProgressState = { export type SandboxCreatePhase = "idle" | "preflight" | "provisioning" | "finalizing"; +export type MigrationRepairResult = { + ok: boolean; + message: string; +}; + export function createWorkspaceStore(options: { startupPreference: () => StartupPreference | null; setStartupPreference: (value: StartupPreference | null) => void; @@ -177,6 +183,15 @@ export function createWorkspaceStore(options: { const connectInFlightByKey = new Map>(); let createRemoteInFlight: Promise | null = null; + const DEFAULT_CONNECT_HEALTH_TIMEOUT_MS = 12_000; + const LOCAL_BOOT_CONNECT_HEALTH_TIMEOUT_MS = 180_000; + const LONG_BOOT_CONNECT_REASONS = new Set(["host-start", "bootstrap-local"]); + const DB_MIGRATE_UNSUPPORTED_PATTERNS = [ + /unknown(?:\s+sub)?command\s+['"`]?db['"`]?/i, + /unrecognized(?:\s+sub)?command\s+['"`]?db['"`]?/i, + /no such command[:\s]+db/i, + /found argument ['"`]db['"`] which wasn't expected/i, + ] as const; const connectRequestKey = ( nextBaseUrl: string, @@ -202,6 +217,26 @@ export function createWorkspaceStore(options: { String(connectOptions?.navigate ?? true), ].join("::"); + const resolveConnectHealthTimeoutMs = (reason?: string) => { + const normalizedReason = reason?.trim() ?? ""; + if (LONG_BOOT_CONNECT_REASONS.has(normalizedReason)) { + return LOCAL_BOOT_CONNECT_HEALTH_TIMEOUT_MS; + } + return DEFAULT_CONNECT_HEALTH_TIMEOUT_MS; + }; + + const formatExecOutput = (result: { stdout: string; stderr: string }) => { + const stderr = result.stderr.trim(); + const stdout = result.stdout.trim(); + return [stderr, stdout].filter(Boolean).join("\n\n"); + }; + + const isDbMigrateUnsupported = (output: string) => { + const normalized = output.trim(); + if (!normalized) return false; + return DB_MIGRATE_UNSUPPORTED_PATTERNS.some((pattern) => pattern.test(normalized)); + }; + const [engine, setEngine] = createSignal(null); const [engineAuth, setEngineAuth] = createSignal(null); const [engineDoctorResult, setEngineDoctorResult] = createSignal(null); @@ -280,6 +315,8 @@ export function createWorkspaceStore(options: { >({}); const [exportingWorkspaceConfig, setExportingWorkspaceConfig] = createSignal(false); const [importingWorkspaceConfig, setImportingWorkspaceConfig] = createSignal(false); + const [migrationRepairBusy, setMigrationRepairBusy] = createSignal(false); + const [migrationRepairResult, setMigrationRepairResult] = createSignal(null); const activeWorkspaceInfo = createMemo(() => workspaces().find((w) => w.id === activeWorkspaceId()) ?? null); const activeWorkspaceDisplay = createMemo(() => { @@ -1153,6 +1190,7 @@ export function createWorkspaceStore(options: { reason: context?.reason ?? null, workspaceType: context?.workspaceType ?? null, targetRoot: context?.targetRoot ?? null, + healthTimeoutMs: resolveConnectHealthTimeoutMs(context?.reason), quiet: connectOptions?.quiet ?? false, navigate: connectOptions?.navigate ?? true, authMode: auth && "mode" in auth ? (auth as any).mode : auth ? "basic" : "none", @@ -1182,9 +1220,14 @@ export function createWorkspaceStore(options: { try { let resolvedDirectory = directory?.trim() ?? ""; let nextClient = createClient(nextBaseUrl, resolvedDirectory || undefined, auth); - const health = await waitForHealthy(nextClient, { timeoutMs: 12_000 }); + const healthTimeoutMs = resolveConnectHealthTimeoutMs(context?.reason); + const health = await waitForHealthy(nextClient, { timeoutMs: healthTimeoutMs }); connectMetrics.healthyMs = Date.now() - connectStart; - wsDebug("connect:healthy", { ms: Date.now() - connectStart, version: health.version }); + wsDebug("connect:healthy", { + ms: Date.now() - connectStart, + version: health.version, + timeoutMs: healthTimeoutMs, + }); if (context?.workspaceType === "remote" && !resolvedDirectory) { try { @@ -2208,6 +2251,109 @@ export function createWorkspaceStore(options: { } } + function canRepairOpencodeMigration() { + if (!isTauriRuntime()) return false; + const workspace = activeWorkspaceInfo(); + if (!workspace || workspace.workspaceType !== "local") return false; + return Boolean(activeWorkspacePath().trim()); + } + + async function repairOpencodeMigration(optionsOverride?: { navigate?: boolean }) { + if (!isTauriRuntime()) { + const message = t("app.migration.desktop_required", currentLocale()); + setMigrationRepairResult({ ok: false, message }); + options.setError(message); + return false; + } + + if (migrationRepairBusy()) return false; + + const workspace = activeWorkspaceInfo(); + if (!workspace || workspace.workspaceType !== "local") { + const message = t("app.migration.local_only", currentLocale()); + setMigrationRepairResult({ ok: false, message }); + options.setError(message); + return false; + } + + const root = activeWorkspacePath().trim(); + if (!root) { + const message = t("app.migration.workspace_required", currentLocale()); + setMigrationRepairResult({ ok: false, message }); + options.setError(message); + return false; + } + + setMigrationRepairBusy(true); + setMigrationRepairResult(null); + options.setError(null); + options.setBusy(true); + options.setBusyLabel("status.repairing_migration"); + options.setBusyStartedAt(Date.now()); + + try { + if (engine()?.running) { + const info = await engineStop(); + setEngine(info); + } + + const source = options.engineSource(); + const result = await opencodeDbMigrate({ + projectDir: root, + preferSidecar: source === "sidecar", + opencodeBinPath: source === "custom" ? options.engineCustomBinPath?.().trim() || null : null, + }); + + if (!result.ok) { + const output = formatExecOutput(result); + if (isDbMigrateUnsupported(output)) { + const message = t("app.migration.unsupported", currentLocale()); + setMigrationRepairResult({ ok: false, message }); + options.setError(message); + return false; + } + + const fallback = t("app.migration.failed", currentLocale()); + const message = output ? `${fallback}\n\n${output}` : fallback; + setMigrationRepairResult({ ok: false, message }); + options.setError(addOpencodeCacheHint(message)); + return false; + } + + const started = await startHost({ + workspacePath: root, + navigate: optionsOverride?.navigate ?? false, + }); + if (!started) { + const message = t("app.migration.restart_failed", currentLocale()); + setMigrationRepairResult({ ok: false, message }); + return false; + } + + setMigrationRepairResult({ ok: true, message: t("app.migration.success", currentLocale()) }); + return true; + } catch (error) { + const message = addOpencodeCacheHint(error instanceof Error ? error.message : safeStringify(error)); + setMigrationRepairResult({ ok: false, message }); + options.setError(message); + return false; + } finally { + setMigrationRepairBusy(false); + options.setBusy(false); + options.setBusyLabel(null); + options.setBusyStartedAt(null); + } + } + + async function onRepairOpencodeMigration() { + options.setStartupPreference("local"); + options.setOnboardingStep("connecting"); + const ok = await repairOpencodeMigration({ navigate: true }); + if (!ok) { + options.setOnboardingStep("local"); + } + } + async function startHost(optionsOverride?: { workspacePath?: string; navigate?: boolean }) { if (!isTauriRuntime()) { options.setError(t("app.error.tauri_required", currentLocale())); @@ -2258,6 +2404,7 @@ export function createWorkspaceStore(options: { } options.setError(null); + setMigrationRepairResult(null); options.setBusy(true); options.setBusyLabel("status.starting_engine"); options.setBusyStartedAt(Date.now()); @@ -2856,6 +3003,8 @@ export function createWorkspaceStore(options: { workspaceConnectionStateById, exportingWorkspaceConfig, importingWorkspaceConfig, + migrationRepairBusy, + migrationRepairResult, activeWorkspaceDisplay, activeWorkspacePath, activeWorkspaceRoot, @@ -2883,6 +3032,8 @@ export function createWorkspaceStore(options: { pickWorkspaceFolder, exportWorkspaceConfig, importWorkspaceConfig, + canRepairOpencodeMigration, + repairOpencodeMigration, startHost, stopHost, reloadWorkspaceEngine, @@ -2890,6 +3041,7 @@ export function createWorkspaceStore(options: { onSelectStartup, onBackToWelcome, onStartHost, + onRepairOpencodeMigration, onAttachHost, onConnectClient, onRememberStartupToggle, diff --git a/packages/app/src/app/lib/tauri.ts b/packages/app/src/app/lib/tauri.ts index 6864bfcec..afb06f983 100644 --- a/packages/app/src/app/lib/tauri.ts +++ b/packages/app/src/app/lib/tauri.ts @@ -768,6 +768,23 @@ export async function setOpenCodeRouterGroupsEnabled(enabled: boolean): Promise< } } +export async function opencodeDbMigrate(input: { + projectDir: string; + preferSidecar?: boolean; + opencodeBinPath?: string | null; +}): Promise { + const safeProjectDir = input.projectDir.trim(); + if (!safeProjectDir) { + throw new Error("project_dir is required"); + } + + return invoke("opencode_db_migrate", { + projectDir: safeProjectDir, + preferSidecar: input.preferSidecar ?? false, + opencodeBinPath: input.opencodeBinPath ?? null, + }); +} + export async function opencodeMcpAuth( projectDir: string, serverName: string, diff --git a/packages/app/src/app/pages/dashboard.tsx b/packages/app/src/app/pages/dashboard.tsx index 3cdaad44f..a33a41912 100644 --- a/packages/app/src/app/pages/dashboard.tsx +++ b/packages/app/src/app/pages/dashboard.tsx @@ -265,6 +265,10 @@ export type DashboardViewProps = { workspaceDebugEvents: unknown; clearWorkspaceDebugEvents: () => void; safeStringify: (value: unknown) => string; + repairOpencodeMigration: () => void; + migrationRepairBusy: boolean; + migrationRepairResult: { ok: boolean; message: string } | null; + migrationRepairAvailable: boolean; repairOpencodeCache: () => void; cacheRepairBusy: boolean; cacheRepairResult: string | null; @@ -1428,6 +1432,10 @@ export default function DashboardView(props: DashboardViewProps) { workspaceDebugEvents={props.workspaceDebugEvents} clearWorkspaceDebugEvents={props.clearWorkspaceDebugEvents} safeStringify={props.safeStringify} + repairOpencodeMigration={props.repairOpencodeMigration} + migrationRepairBusy={props.migrationRepairBusy} + migrationRepairResult={props.migrationRepairResult} + migrationRepairAvailable={props.migrationRepairAvailable} repairOpencodeCache={props.repairOpencodeCache} cacheRepairBusy={props.cacheRepairBusy} cacheRepairResult={props.cacheRepairResult} diff --git a/packages/app/src/app/pages/onboarding.tsx b/packages/app/src/app/pages/onboarding.tsx index 9c3fe9ac9..0be08702f 100644 --- a/packages/app/src/app/pages/onboarding.tsx +++ b/packages/app/src/app/pages/onboarding.tsx @@ -35,6 +35,9 @@ export type OnboardingViewProps = { engineDoctorCheckedAt: number | null; engineInstallLogs: string | null; error: string | null; + canRepairMigration: boolean; + migrationRepairBusy: boolean; + migrationRepairResult: { ok: boolean; message: string } | null; developerMode: boolean; isWindows: boolean; onClientDirectoryChange: (value: string) => void; @@ -43,6 +46,7 @@ export type OnboardingViewProps = { onSelectStartup: (mode: StartupPreference) => void; onRememberStartupToggle: () => void; onStartHost: () => void; + onRepairMigration: () => void; onCreateWorkspace: (preset: "starter" | "automation" | "minimal", folder: string | null) => void; onPickWorkspaceFolder: () => Promise; onImportWorkspaceConfig: () => void; @@ -444,9 +448,39 @@ export default function OnboardingView(props: OnboardingViewProps) { - -
- {props.error} + +
+ +
{props.error}
+
+ + {(result) => ( +
+ {result().message} +
+ )} +
+ +
+ + {translate("onboarding.fix_migration_hint")} +
+
diff --git a/packages/app/src/app/pages/settings.tsx b/packages/app/src/app/pages/settings.tsx index 98a9278b9..740b9d328 100644 --- a/packages/app/src/app/pages/settings.tsx +++ b/packages/app/src/app/pages/settings.tsx @@ -26,6 +26,7 @@ import { opencodeRouterStop, pickFile, } from "../lib/tauri"; +import { currentLocale, t } from "../../i18n"; export type SettingsViewProps = { startupPreference: StartupPreference | null; @@ -102,6 +103,10 @@ export type SettingsViewProps = { workspaceDebugEvents: unknown; clearWorkspaceDebugEvents: () => void; safeStringify: (value: unknown) => string; + repairOpencodeMigration: () => void; + migrationRepairBusy: boolean; + migrationRepairResult: { ok: boolean; message: string } | null; + migrationRepairAvailable: boolean; repairOpencodeCache: () => void; cacheRepairBusy: boolean; cacheRepairResult: string | null; @@ -143,6 +148,7 @@ export function OpenCodeRouterSettings(_props: { export default function SettingsView(props: SettingsViewProps) { + const translate = (key: string) => t(key, currentLocale()); const engineCustomBinPathLabel = () => props.engineCustomBinPath.trim() || "No binary selected."; const handlePickEngineBinary = async () => { @@ -884,6 +890,41 @@ export default function SettingsView(props: SettingsViewProps) {
+ +
+
+
{translate("settings.migration_recovery_label")}
+
{translate("settings.migration_recovery_hint")}
+
+
+ +
+ + {(result) => ( +
+ {result().message} +
+ )} +
+
+
+
diff --git a/packages/app/src/i18n/locales/en.ts b/packages/app/src/i18n/locales/en.ts index c619a01d2..445ec5675 100644 --- a/packages/app/src/i18n/locales/en.ts +++ b/packages/app/src/i18n/locales/en.ts @@ -636,6 +636,11 @@ export default { "settings.developer_title": "Developer", "settings.opencode_cache_label": "OpenCode cache", "settings.opencode_cache_hint": "Repairs cached data used to start the engine. Safe to run.", + "settings.migration_recovery_label": "Migration recovery", + "settings.migration_recovery_hint": "Use this if local startup fails while moving from legacy JSON data.", + "settings.fix_migration": "Fix migration", + "settings.fixing_migration": "Fixing migration...", + "settings.migration_repair_requires_desktop": "Migration repair requires the desktop app", "settings.pending_permissions_label": "Pending permissions", "settings.recent_events_label": "Recent events", "settings.stop_active_runs_hint": "Stop active runs to update", @@ -709,6 +714,9 @@ export default { "onboarding.cli_install_commands": "Install OpenCode with one of the commands below, then restart OpenWork.", "onboarding.show_search_notes": "Show search notes", "onboarding.last_checked": "Last checked {time}", + "onboarding.fix_migration": "Fix migration", + "onboarding.fixing_migration": "Fixing migration...", + "onboarding.fix_migration_hint": "Stops local engine, runs opencode db migrate, then retries startup.", "onboarding.server_url_placeholder": "http://localhost:8088", "onboarding.directory_placeholder": "my-project", "onboarding.connect_host": "Connect to server", @@ -778,6 +786,7 @@ export default { "status.reloading_engine": "Reloading engine", "status.restarting_engine": "Restarting engine", "status.installing_opencode": "Installing OpenCode", + "status.repairing_migration": "Repairing migration", "status.disconnecting": "Disconnecting", // ==================== Workspace Switching ==================== @@ -798,6 +807,13 @@ export default { "app.error.host_requires_local": "Select a local worker to start the engine.", "app.error.sidecar_unsupported_windows": "Sidecar OpenCode is bundled on Windows when available. Falling back to PATH.", "app.error.install_failed": "OpenCode install failed. See logs above.", + "app.migration.desktop_required": "Migration repair requires the desktop app.", + "app.migration.local_only": "Migration repair is only available for local workers.", + "app.migration.workspace_required": "Pick a local worker folder before repairing migration.", + "app.migration.unsupported": "This OpenCode binary does not support `opencode db migrate`. Update OpenCode to >=1.2.6 or switch to bundled engine.", + "app.migration.failed": "OpenCode migration failed.", + "app.migration.restart_failed": "Migration completed, but OpenWork could not restart the local engine.", + "app.migration.success": "Migration repaired. Local startup was retried.", "app.error.command_name_template_required": "Command name and instructions are required.", "app.error.workspace_commands_desktop": "Commands require the desktop app.", "app.error.command_scope_unknown": "This command can't be managed in this mode.", diff --git a/packages/app/src/i18n/locales/zh.ts b/packages/app/src/i18n/locales/zh.ts index 85db04989..dcd72e8cf 100644 --- a/packages/app/src/i18n/locales/zh.ts +++ b/packages/app/src/i18n/locales/zh.ts @@ -596,6 +596,11 @@ export default { "settings.developer_title": "开发者", "settings.opencode_cache_label": "OpenCode 缓存", "settings.opencode_cache_hint": "修复用于启动引擎的缓存数据。安全运行。", + "settings.migration_recovery_label": "迁移修复", + "settings.migration_recovery_hint": "如果本地启动在从旧版 JSON 数据迁移时失败,请使用此操作。", + "settings.fix_migration": "修复迁移", + "settings.fixing_migration": "正在修复迁移...", + "settings.migration_repair_requires_desktop": "迁移修复需要桌面应用", "settings.pending_permissions_label": "待处理的权限", "settings.recent_events_label": "最近的事件", "settings.stop_active_runs_hint": "停止活动运行以更新", @@ -668,6 +673,9 @@ export default { "onboarding.cli_install_commands": "使用以下命令之一安装 OpenCode,然后重启 OpenWork。", "onboarding.show_search_notes": "显示搜索说明", "onboarding.last_checked": "上次检查时间 {time}", + "onboarding.fix_migration": "修复迁移", + "onboarding.fixing_migration": "正在修复迁移...", + "onboarding.fix_migration_hint": "会停止本地引擎,执行 opencode db migrate,然后重试启动。", "onboarding.server_url_placeholder": "http://localhost:8088", "onboarding.directory_placeholder": "my-project", "onboarding.connect_host": "连接服务器", @@ -737,6 +745,7 @@ export default { "status.reloading_engine": "正在重新加载引擎", "status.restarting_engine": "正在重启引擎", "status.installing_opencode": "正在安装 OpenCode", + "status.repairing_migration": "正在修复迁移", "status.disconnecting": "正在断开连接", // ==================== Workspace Switching ==================== @@ -757,6 +766,13 @@ export default { "app.error.host_requires_local": "请先选择本地工作区以启动引擎。", "app.error.sidecar_unsupported_windows": "Windows 上的 Sidecar OpenCode 可用时会内置。将回退到 PATH。", "app.error.install_failed": "OpenCode 安装失败。请查看上方日志。", + "app.migration.desktop_required": "迁移修复需要桌面应用。", + "app.migration.local_only": "迁移修复仅适用于本地工作区。", + "app.migration.workspace_required": "请先选择本地工作区文件夹再修复迁移。", + "app.migration.unsupported": "当前 OpenCode 二进制不支持 `opencode db migrate`。请将 OpenCode 更新到 >=1.2.6,或切换为内置引擎。", + "app.migration.failed": "OpenCode 迁移失败。", + "app.migration.restart_failed": "迁移已完成,但 OpenWork 无法重启本地引擎。", + "app.migration.success": "迁移已修复,已重试本地启动。", "app.error.command_name_template_required": "命令名称和指令为必填项。", "app.error.workspace_commands_desktop": "命令需要桌面应用。", "app.error.command_scope_unknown": "此命令无法在当前模式下管理。", diff --git a/packages/desktop/src-tauri/src/commands/engine.rs b/packages/desktop/src-tauri/src/commands/engine.rs index 2b6fb284e..2ab61a914 100644 --- a/packages/desktop/src-tauri/src/commands/engine.rs +++ b/packages/desktop/src-tauri/src/commands/engine.rs @@ -1,17 +1,19 @@ use tauri::{AppHandle, Manager, State}; +use crate::commands::opencode_router::opencodeRouter_start; use crate::config::{read_opencode_config, write_opencode_config}; use crate::engine::doctor::{ opencode_serve_help, opencode_version, resolve_engine_path, resolve_sidecar_candidate, }; use crate::engine::manager::EngineManager; use crate::engine::spawn::{find_free_port, spawn_engine}; -use crate::commands::opencode_router::opencodeRouter_start; -use crate::orchestrator::{self, OrchestratorSpawnOptions}; -use crate::orchestrator::manager::OrchestratorManager; -use crate::openwork_server::{manager::OpenworkServerManager, resolve_connect_url, start_openwork_server}; use crate::opencode_router::manager::OpenCodeRouterManager; use crate::opencode_router::spawn::resolve_opencode_router_health_port; +use crate::openwork_server::{ + manager::OpenworkServerManager, resolve_connect_url, start_openwork_server, +}; +use crate::orchestrator::manager::OrchestratorManager; +use crate::orchestrator::{self, OrchestratorSpawnOptions}; use crate::types::{EngineDoctorResult, EngineInfo, EngineRuntime, ExecResult}; use crate::utils::truncate_output; use serde_json::json; @@ -56,7 +58,10 @@ struct OutputState { } #[tauri::command] -pub fn engine_info(manager: State, orchestrator_manager: State) -> EngineInfo { +pub fn engine_info( + manager: State, + orchestrator_manager: State, +) -> EngineInfo { let mut state = manager.inner.lock().expect("engine mutex poisoned"); if state.runtime == EngineRuntime::Orchestrator { let data_dir = orchestrator_manager @@ -91,14 +96,16 @@ pub fn engine_info(manager: State, orchestrator_manager: State { let line = String::from_utf8_lossy(&line_bytes).to_string(); if let Ok(mut state) = orchestrator_state_handle.try_lock() { - let next = state - .last_stdout - .as_deref() - .unwrap_or_default() - .to_string() + let next = state.last_stdout.as_deref().unwrap_or_default().to_string() + &line; state.last_stdout = Some(truncate_output(&next, 8000)); } @@ -376,11 +385,7 @@ pub fn engine_start( CommandEvent::Stderr(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes).to_string(); if let Ok(mut state) = orchestrator_state_handle.try_lock() { - let next = state - .last_stderr - .as_deref() - .unwrap_or_default() - .to_string() + let next = state.last_stderr.as_deref().unwrap_or_default().to_string() + &line; state.last_stderr = Some(truncate_output(&next, 8000)); } @@ -393,11 +398,7 @@ pub fn engine_start( CommandEvent::Error(message) => { if let Ok(mut state) = orchestrator_state_handle.try_lock() { state.child_exited = true; - let next = state - .last_stderr - .as_deref() - .unwrap_or_default() - .to_string() + let next = state.last_stderr.as_deref().unwrap_or_default().to_string() + &message; state.last_stderr = Some(truncate_output(&next, 8000)); } @@ -423,9 +424,7 @@ pub fn engine_start( let health = orchestrator::wait_for_orchestrator(&daemon_base_url, health_timeout_ms) .map_err(|e| { - format!( - "Failed to start orchestrator (waited {health_timeout_ms}ms): {e}" - ) + format!("Failed to start orchestrator (waited {health_timeout_ms}ms): {e}") })?; let opencode = health .opencode @@ -453,7 +452,10 @@ pub fn engine_start( Ok(port) => Some(port), Err(error) => { if let Ok(mut state) = manager.inner.lock() { - state.last_stderr = Some(truncate_output(&format!("OpenCodeRouter health port: {error}"), 8000)); + state.last_stderr = Some(truncate_output( + &format!("OpenCodeRouter health port: {error}"), + 8000, + )); } None } @@ -469,7 +471,8 @@ pub fn engine_start( opencode_router_health_port, ) { if let Ok(mut state) = manager.inner.lock() { - state.last_stderr = Some(truncate_output(&format!("OpenWork server: {error}"), 8000)); + state.last_stderr = + Some(truncate_output(&format!("OpenWork server: {error}"), 8000)); } } @@ -483,7 +486,8 @@ pub fn engine_start( opencode_router_health_port, ) { if let Ok(mut state) = manager.inner.lock() { - state.last_stderr = Some(truncate_output(&format!("OpenCodeRouter: {error}"), 8000)); + state.last_stderr = + Some(truncate_output(&format!("OpenCodeRouter: {error}"), 8000)); } } @@ -530,12 +534,8 @@ pub fn engine_start( output.stdout.push_str(&line); } if let Ok(mut state) = state_handle.try_lock() { - let next = state - .last_stdout - .as_deref() - .unwrap_or_default() - .to_string() - + &line; + let next = + state.last_stdout.as_deref().unwrap_or_default().to_string() + &line; state.last_stdout = Some(truncate_output(&next, 8000)); } } @@ -545,12 +545,8 @@ pub fn engine_start( output.stderr.push_str(&line); } if let Ok(mut state) = state_handle.try_lock() { - let next = state - .last_stderr - .as_deref() - .unwrap_or_default() - .to_string() - + &line; + let next = + state.last_stderr.as_deref().unwrap_or_default().to_string() + &line; state.last_stderr = Some(truncate_output(&next, 8000)); } } @@ -633,11 +629,15 @@ pub fn engine_start( state.opencode_username = opencode_username.clone(); state.opencode_password = opencode_password.clone(); - let opencode_connect_url = resolve_connect_url(port).unwrap_or_else(|| format!("http://{client_host}:{port}")); + let opencode_connect_url = + resolve_connect_url(port).unwrap_or_else(|| format!("http://{client_host}:{port}")); let opencode_router_health_port = match resolve_opencode_router_health_port() { Ok(port) => Some(port), Err(error) => { - state.last_stderr = Some(truncate_output(&format!("OpenCodeRouter health port: {error}"), 8000)); + state.last_stderr = Some(truncate_output( + &format!("OpenCodeRouter health port: {error}"), + 8000, + )); None } }; diff --git a/packages/desktop/src-tauri/src/commands/misc.rs b/packages/desktop/src-tauri/src/commands/misc.rs index 0b09a89d5..bc7891d3a 100644 --- a/packages/desktop/src-tauri/src/commands/misc.rs +++ b/packages/desktop/src-tauri/src/commands/misc.rs @@ -170,6 +170,37 @@ fn validate_project_dir(app: &AppHandle, project_dir: &str) -> Result, +) -> Result { + if let Some(custom) = opencode_bin_path { + let trimmed = custom.trim(); + if !trimmed.is_empty() { + return Ok(PathBuf::from(trimmed)); + } + } + + let resource_dir = app.path().resource_dir().ok(); + let current_bin_dir = tauri::process::current_binary(&app.env()) + .ok() + .and_then(|path| path.parent().map(|parent| parent.to_path_buf())); + + let (program, _in_path, notes) = resolve_engine_path( + prefer_sidecar, + resource_dir.as_deref(), + current_bin_dir.as_deref(), + ); + + program.ok_or_else(|| { + let notes_text = notes.join("\n"); + format!( + "OpenCode CLI not found.\n\nInstall with:\n- brew install anomalyco/tap/opencode\n- curl -fsSL https://opencode.ai/install | bash\n\nNotes:\n{notes_text}" + ) + }) +} + #[tauri::command] pub fn reset_opencode_cache() -> Result { let candidates = opencode_cache_candidates(); @@ -240,6 +271,38 @@ pub fn app_build_info(app: AppHandle) -> AppBuildInfo { } } +#[tauri::command] +pub fn opencode_db_migrate( + app: AppHandle, + project_dir: String, + prefer_sidecar: Option, + opencode_bin_path: Option, +) -> Result { + let project_dir = validate_project_dir(&app, &project_dir)?; + let program = + resolve_opencode_program(&app, prefer_sidecar.unwrap_or(false), opencode_bin_path)?; + + let mut command = command_for_program(&program); + for (key, value) in crate::bun_env::bun_env_overrides() { + command.env(key, value); + } + + let output = command + .arg("db") + .arg("migrate") + .current_dir(&project_dir) + .output() + .map_err(|e| format!("Failed to run opencode db migrate: {e}"))?; + + let status = output.status.code().unwrap_or(-1); + Ok(ExecResult { + ok: output.status.success(), + status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) +} + /// Run `opencode mcp auth ` in the given project directory. /// This spawns the process detached so the OAuth flow can open a browser. #[tauri::command] @@ -251,18 +314,7 @@ pub fn opencode_mcp_auth( let project_dir = validate_project_dir(&app, &project_dir)?; let server_name = validate_server_name(&server_name)?; - let resource_dir = app.path().resource_dir().ok(); - let current_bin_dir = tauri::process::current_binary(&app.env()) - .ok() - .and_then(|path| path.parent().map(|parent| parent.to_path_buf())); - let (program, _in_path, notes) = - resolve_engine_path(true, resource_dir.as_deref(), current_bin_dir.as_deref()); - let Some(program) = program else { - let notes_text = notes.join("\n"); - return Err(format!( - "OpenCode CLI not found.\n\nInstall with:\n- brew install anomalyco/tap/opencode\n- curl -fsSL https://opencode.ai/install | bash\n\nNotes:\n{notes_text}" - )); - }; + let program = resolve_opencode_program(&app, true, None)?; let mut command = command_for_program(&program); for (key, value) in crate::bun_env::bun_env_overrides() { diff --git a/packages/desktop/src-tauri/src/commands/mod.rs b/packages/desktop/src-tauri/src/commands/mod.rs index 0024aac09..2278faef7 100644 --- a/packages/desktop/src-tauri/src/commands/mod.rs +++ b/packages/desktop/src-tauri/src/commands/mod.rs @@ -2,10 +2,10 @@ pub mod command_files; pub mod config; pub mod engine; pub mod misc; -pub mod orchestrator; +pub mod opencode_router; pub mod openwork_server; pub mod opkg; -pub mod opencode_router; +pub mod orchestrator; pub mod scheduler; pub mod skills; pub mod updater; diff --git a/packages/desktop/src-tauri/src/commands/opencode_router.rs b/packages/desktop/src-tauri/src/commands/opencode_router.rs index 7323de0ed..158ebce4d 100644 --- a/packages/desktop/src-tauri/src/commands/opencode_router.rs +++ b/packages/desktop/src-tauri/src/commands/opencode_router.rs @@ -40,10 +40,9 @@ pub async fn opencodeRouter_info( // If manager doesn't think opencodeRouter is running, check health endpoint as fallback // This handles cases where opencodeRouter was started externally or by a previous app instance if !info.running { - let health_port = { - manager.inner.lock().ok().and_then(|s| s.health_port) - }.unwrap_or(DEFAULT_OPENCODE_ROUTER_HEALTH_PORT); - + let health_port = { manager.inner.lock().ok().and_then(|s| s.health_port) } + .unwrap_or(DEFAULT_OPENCODE_ROUTER_HEALTH_PORT); + if let Some(health) = check_health_endpoint(health_port) { info.running = true; if let Some(opencode) = health.get("opencode") { @@ -76,7 +75,9 @@ pub async fn opencodeRouter_info( } } if info.workspace_path.is_none() { - if let Some(directory) = opencode.get("directory").and_then(|value| value.as_str()) { + if let Some(directory) = + opencode.get("directory").and_then(|value| value.as_str()) + { let trimmed = directory.trim(); if !trimmed.is_empty() { info.workspace_path = Some(trimmed.to_string()); @@ -135,24 +136,16 @@ pub fn opencodeRouter_start( CommandEvent::Stdout(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes).to_string(); if let Ok(mut state) = state_handle.try_lock() { - let next = state - .last_stdout - .as_deref() - .unwrap_or_default() - .to_string() - + &line; + let next = + state.last_stdout.as_deref().unwrap_or_default().to_string() + &line; state.last_stdout = Some(truncate_output(&next, 8000)); } } CommandEvent::Stderr(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes).to_string(); if let Ok(mut state) = state_handle.try_lock() { - let next = state - .last_stderr - .as_deref() - .unwrap_or_default() - .to_string() - + &line; + let next = + state.last_stderr.as_deref().unwrap_or_default().to_string() + &line; state.last_stderr = Some(truncate_output(&next, 8000)); } } @@ -168,12 +161,8 @@ pub fn opencodeRouter_start( CommandEvent::Error(message) => { if let Ok(mut state) = state_handle.try_lock() { state.child_exited = true; - let next = state - .last_stderr - .as_deref() - .unwrap_or_default() - .to_string() - + &message; + let next = + state.last_stderr.as_deref().unwrap_or_default().to_string() + &message; state.last_stderr = Some(truncate_output(&next, 8000)); } } @@ -186,7 +175,9 @@ pub fn opencodeRouter_start( } #[tauri::command] -pub fn opencodeRouter_stop(manager: State) -> Result { +pub fn opencodeRouter_stop( + manager: State, +) -> Result { let mut state = manager .inner .lock() @@ -211,10 +202,8 @@ pub async fn opencodeRouter_status( }; if !running { - let check_port = { - manager.inner.lock().ok().and_then(|s| s.health_port) - } - .unwrap_or(DEFAULT_OPENCODE_ROUTER_HEALTH_PORT); + let check_port = { manager.inner.lock().ok().and_then(|s| s.health_port) } + .unwrap_or(DEFAULT_OPENCODE_ROUTER_HEALTH_PORT); if check_health_endpoint(check_port).is_some() { running = true; @@ -265,7 +254,10 @@ pub async fn opencodeRouter_status( .filter(|value| !value.is_empty()); let mut opencode = serde_json::Map::new(); - opencode.insert("url".to_string(), serde_json::Value::String(opencode_url.to_string())); + opencode.insert( + "url".to_string(), + serde_json::Value::String(opencode_url.to_string()), + ); if let Some(directory) = opencode_directory { opencode.insert( "directory".to_string(), diff --git a/packages/desktop/src-tauri/src/commands/openwork_server.rs b/packages/desktop/src-tauri/src/commands/openwork_server.rs index 49e5a3ea6..697c03819 100644 --- a/packages/desktop/src-tauri/src/commands/openwork_server.rs +++ b/packages/desktop/src-tauri/src/commands/openwork_server.rs @@ -5,7 +5,10 @@ use crate::types::OpenworkServerInfo; #[tauri::command] pub fn openwork_server_info(manager: State) -> OpenworkServerInfo { - let mut state = manager.inner.lock().expect("openwork server mutex poisoned"); + let mut state = manager + .inner + .lock() + .expect("openwork server mutex poisoned"); OpenworkServerManager::snapshot_locked(&mut state) } diff --git a/packages/desktop/src-tauri/src/engine/paths.rs b/packages/desktop/src-tauri/src/engine/paths.rs index a3b6c4bba..5fe19767d 100644 --- a/packages/desktop/src-tauri/src/engine/paths.rs +++ b/packages/desktop/src-tauri/src/engine/paths.rs @@ -81,7 +81,9 @@ pub(crate) fn resolve_opencode_env_override() -> (Option, Vec) (None, notes) } -fn resolve_opencode_executable_impl(mut notes: Vec) -> (Option, bool, Vec) { +fn resolve_opencode_executable_impl( + mut notes: Vec, +) -> (Option, bool, Vec) { if let Some(path) = resolve_in_path(OPENCODE_EXECUTABLE) { notes.push(format!("Found in PATH: {}", path.display())); return (Some(path), true, notes); @@ -118,6 +120,7 @@ pub fn resolve_opencode_executable() -> (Option, bool, Vec) { resolve_opencode_executable_impl(notes) } -pub(crate) fn resolve_opencode_executable_without_override() -> (Option, bool, Vec) { +pub(crate) fn resolve_opencode_executable_without_override() -> (Option, bool, Vec) +{ resolve_opencode_executable_impl(Vec::new()) } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index be9455c17..14e1e29b7 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -22,7 +22,8 @@ use commands::command_files::{ use commands::config::{read_opencode_config, write_opencode_config}; use commands::engine::{engine_doctor, engine_info, engine_install, engine_start, engine_stop}; use commands::misc::{ - app_build_info, opencode_mcp_auth, reset_opencode_cache, reset_openwork_state, + app_build_info, opencode_db_migrate, opencode_mcp_auth, reset_opencode_cache, + reset_openwork_state, }; use commands::opencode_router::{ opencodeRouter_config_set, opencodeRouter_info, opencodeRouter_start, opencodeRouter_status, @@ -119,6 +120,7 @@ pub fn run() { app_build_info, reset_openwork_state, reset_opencode_cache, + opencode_db_migrate, opencode_mcp_auth, scheduler_list_jobs, scheduler_delete_job, diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 59e446a24..ba2723577 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -1,5 +1,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - openwork::run(); + openwork::run(); } diff --git a/packages/desktop/src-tauri/src/openwork_server/mod.rs b/packages/desktop/src-tauri/src/openwork_server/mod.rs index 22ecf7c75..b981fa452 100644 --- a/packages/desktop/src-tauri/src/openwork_server/mod.rs +++ b/packages/desktop/src-tauri/src/openwork_server/mod.rs @@ -26,9 +26,7 @@ fn build_urls(port: u16) -> (Option, Option, Option) { Some(format!("http://{trimmed}.local:{port}")) }; - let lan_url = local_ip() - .ok() - .map(|ip| format!("http://{ip}:{port}")); + let lan_url = local_ip().ok().map(|ip| format!("http://{ip}:{port}")); let connect_url = lan_url.clone().or(mdns_url.clone()); @@ -49,7 +47,10 @@ pub fn start_openwork_server( opencode_password: Option<&str>, opencode_router_health_port: Option, ) -> Result { - let mut state = manager.inner.lock().map_err(|_| "openwork server mutex poisoned".to_string())?; + let mut state = manager + .inner + .lock() + .map_err(|_| "openwork server mutex poisoned".to_string())?; OpenworkServerManager::stop_locked(&mut state); let host = "0.0.0.0".to_string(); @@ -101,14 +102,16 @@ pub fn start_openwork_server( CommandEvent::Stdout(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes).to_string(); if let Ok(mut state) = state_handle.try_lock() { - let next = state.last_stdout.as_deref().unwrap_or_default().to_string() + &line; + let next = + state.last_stdout.as_deref().unwrap_or_default().to_string() + &line; state.last_stdout = Some(truncate_output(&next, 8000)); } } CommandEvent::Stderr(line_bytes) => { let line = String::from_utf8_lossy(&line_bytes).to_string(); if let Ok(mut state) = state_handle.try_lock() { - let next = state.last_stderr.as_deref().unwrap_or_default().to_string() + &line; + let next = + state.last_stderr.as_deref().unwrap_or_default().to_string() + &line; state.last_stderr = Some(truncate_output(&next, 8000)); } } @@ -124,7 +127,8 @@ pub fn start_openwork_server( CommandEvent::Error(message) => { if let Ok(mut state) = state_handle.try_lock() { state.child_exited = true; - let next = state.last_stderr.as_deref().unwrap_or_default().to_string() + &message; + let next = + state.last_stderr.as_deref().unwrap_or_default().to_string() + &message; state.last_stderr = Some(truncate_output(&next, 8000)); } } diff --git a/pr/opencode-db-migration-recovery/session-smoke-response.png b/pr/opencode-db-migration-recovery/session-smoke-response.png new file mode 100644 index 000000000..9b145b3c3 Binary files /dev/null and b/pr/opencode-db-migration-recovery/session-smoke-response.png differ diff --git a/pr/opencode-db-migration-recovery/session-smoke.png b/pr/opencode-db-migration-recovery/session-smoke.png new file mode 100644 index 000000000..98d847577 Binary files /dev/null and b/pr/opencode-db-migration-recovery/session-smoke.png differ diff --git a/pr/opencode-db-migration-recovery/settings-advanced.png b/pr/opencode-db-migration-recovery/settings-advanced.png new file mode 100644 index 000000000..98d847577 Binary files /dev/null and b/pr/opencode-db-migration-recovery/settings-advanced.png differ diff --git a/pr/opencode-db-migration-recovery/settings-general.png b/pr/opencode-db-migration-recovery/settings-general.png new file mode 100644 index 000000000..df28042d4 Binary files /dev/null and b/pr/opencode-db-migration-recovery/settings-general.png differ diff --git a/pr/opencode-db-migration-recovery/testing.md b/pr/opencode-db-migration-recovery/testing.md new file mode 100644 index 000000000..15ab7b8bf --- /dev/null +++ b/pr/opencode-db-migration-recovery/testing.md @@ -0,0 +1,51 @@ +## Opencode DB Migration Recovery - verification guide + +### What changed + +- Added a desktop command to run `opencode db migrate` from OpenWork. +- Added workspace recovery flow to stop engine, migrate, and restart once. +- Added onboarding and settings UI entry points for "Fix migration". +- Increased first local boot health timeout for `host-start` and `bootstrap-local`. + +### Screenshots in this folder + +- `session-smoke-response.png` +- `settings-general.png` +- `settings-advanced.png` + +Note: this environment was connected to a remote workspace surface, so desktop-only migration controls were not rendered in Chrome MCP screenshots. + +### Third-party test steps + +1. Install dependencies and validate builds. + +```bash +pnpm install +pnpm --filter @different-ai/openwork-ui typecheck +pnpm --filter @different-ai/openwork-ui build +pnpm --filter @different-ai/openwork-desktop tauri build --debug +``` + +2. Run desktop app and verify recovery action appears. + +```bash +pnpm --filter @different-ai/openwork-desktop tauri dev +``` + +3. In the desktop app, create or open a local workspace and force local startup preference. + +4. Trigger a migration failure scenario (example: use an older `opencode` binary or stale `.opencode/opencode.db` schema), then confirm: + - onboarding error panel shows `Fix migration` + - settings -> Advanced shows `Migration recovery` + - clicking `Fix migration` runs migrate, then retries local start once + +5. Confirm fallback copy for older CLIs without `db migrate`: + - expected message points user to upgrading OpenCode. + +6. Optional Docker + Chrome MCP gate for UI sanity: + +```bash +packaging/docker/dev-up.sh +``` + +Use the printed Web UI URL, run one smoke prompt/response flow, then stop with the printed `docker compose -p ... down` command.