diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a97fd8165..e4686e2b1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,8 +39,19 @@ jobs: with: targets: ${{ matrix.target }} - - name: Install protoc - run: sudo apt-get update && sudo apt-get install -y protobuf-compiler + - name: Install system dependencies + run: | + sudo apt-get update && sudo apt-get install -y protobuf-compiler + curl -fsSL https://deb.nodesource.com/setup_22.x | sudo bash - + sudo apt-get install -y nodejs + + - name: Install bun + uses: oven-sh/setup-bun@v2 + + - name: Build OpenCode embed + run: | + cd interface && bun install --frozen-lockfile && cd .. + ./scripts/build-opencode-embed.sh - name: Determine version tag id: version diff --git a/.gitignore b/.gitignore index 03ebf30f5..d76b4bc46 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,8 @@ # Interface interface/node_modules/ interface/dist/ +interface/public/opencode-embed/ +.opencode-build-cache/ .idea list/ diff --git a/Dockerfile b/Dockerfile index c90848f92..079a8a01e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,6 +16,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN curl -fsSL https://bun.sh/install | bash ENV PATH="/root/.bun/bin:${PATH}" +# Node 22+ is required for the OpenCode embed Vite build. +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y --no-install-recommends nodejs \ + && rm -rf /var/lib/apt/lists/* + WORKDIR /build # 1. Fetch and cache Rust dependencies. @@ -26,14 +31,24 @@ RUN mkdir src && echo "fn main() {}" > src/main.rs && touch src/lib.rs \ && cargo build --release \ && rm -rf src -# 2. Build the frontend. +# 2. Install frontend dependencies. COPY interface/package.json interface/ RUN cd interface && bun install + +# 3. Build the OpenCode embed bundle (live coding UI in Workers tab). +# Must run before the frontend build so the embed assets in +# interface/public/opencode-embed/ are included in the Vite output. +COPY scripts/build-opencode-embed.sh scripts/ +COPY interface/opencode-embed-src/ interface/opencode-embed-src/ +RUN ./scripts/build-opencode-embed.sh + +# 4. Build the frontend (includes OpenCode embed assets from step 3). COPY interface/ interface/ RUN cd interface && bun run build -# 3. Copy source and compile the real binary. -# build.rs runs the frontend build (already done above, node_modules present). +# 5. Copy source and compile the real binary. +# build.rs is skipped (SPACEBOT_SKIP_FRONTEND_BUILD=1) since the +# frontend is already built above with the OpenCode embed included. # prompts/ is needed for include_str! in src/prompts/text.rs. # migrations/ is needed for sqlx::migrate! in src/db.rs. # docs/ is needed for rust-embed in src/self_awareness.rs. diff --git a/README.md b/README.md index eb7087898..0c17bb3ac 100644 --- a/README.md +++ b/README.md @@ -424,6 +424,11 @@ Read the full vision in the [roadmap](docs/content/docs/(deployment)/roadmap.mdx ```bash git clone https://github.com/spacedriveapp/spacebot cd spacebot + +# Optional: build the OpenCode embedded UI (requires Node 22+ and bun) +# Without this, OpenCode workers still work — the Workers tab shows a transcript view instead. +# ./scripts/build-opencode-embed.sh + cargo build --release ``` diff --git a/docs/content/docs/(features)/opencode.mdx b/docs/content/docs/(features)/opencode.mdx index 00f9403e9..1e9d042b3 100644 --- a/docs/content/docs/(features)/opencode.mdx +++ b/docs/content/docs/(features)/opencode.mdx @@ -176,3 +176,39 @@ webfetch = "allow" ``` The OpenCode server is a child process managed by Spacebot. It persists across worker invocations for the same directory. Multiple workers targeting the same directory share the same server (different sessions). + +## Embedded Web UI + +When you view an OpenCode worker in the Workers tab, Spacebot can show the full interactive OpenCode interface inline — the same editor, terminal, and conversation view you'd get from OpenCode's standalone app. + +The embedded UI uses a Shadow DOM for CSS isolation and a memory router to avoid conflicts with Spacebot's own routing. All API and SSE traffic is proxied through Spacebot's reverse proxy at `/api/opencode/{port}/`, keeping everything same-origin. + +### Building the embed + +The embed bundle is not included in the repository. Build it with: + +```bash +# Requires Node 22+ and bun +./scripts/build-opencode-embed.sh +# or: just build-opencode-embed +``` + +This clones OpenCode at a pinned upstream commit, copies the embed entry points from `interface/opencode-embed-src/`, builds with Vite, and outputs to `interface/public/opencode-embed/`. The output is ~46MB (mostly shiki grammar chunks for syntax highlighting) and is gitignored. + +If you use [fnm](https://github.com/Schniz/fnm) for Node version management: + +```bash +eval "$(fnm env)" && fnm use v24.14.0 && ./scripts/build-opencode-embed.sh +``` + +### Without the embed + +Everything works without building the embed. OpenCode workers still run normally — the Workers tab falls back to the transcript view, which shows tool calls, status updates, and results. The only difference is you don't get the live interactive OpenCode UI. + +### Updating the pinned commit + +The OpenCode commit is pinned in `scripts/build-opencode-embed.sh` as `OPENCODE_COMMIT`. To update: + +1. Change the commit hash in the script +2. Re-run `./scripts/build-opencode-embed.sh` +3. Test the embedded UI in the Workers tab diff --git a/docs/content/docs/(getting-started)/quickstart.mdx b/docs/content/docs/(getting-started)/quickstart.mdx index 3c7c55319..daa4718a2 100644 --- a/docs/content/docs/(getting-started)/quickstart.mdx +++ b/docs/content/docs/(getting-started)/quickstart.mdx @@ -58,12 +58,18 @@ cd spacebot # Optional: build the web UI (React + Vite, embedded into the binary) cd interface && bun install && cd .. +# Optional: build the OpenCode embed (live coding UI in the Workers tab) +# Requires Node 22+ (use fnm: fnm install v24.14.0 && fnm use v24.14.0) +./scripts/build-opencode-embed.sh + # Install the binary cargo install --path . ``` The `build.rs` script automatically runs `bun run build` during compilation if `interface/node_modules` exists. Without it, the binary still works — you just get an empty UI on the web dashboard. +The OpenCode embed step (`build-opencode-embed.sh`) clones OpenCode at a pinned commit, builds the embeddable SPA, and places it in `interface/public/opencode-embed/`. This is optional — without it, OpenCode workers still function normally, but the Workers tab will show a transcript view instead of the live interactive OpenCode UI. + ## Configure Spacebot needs at least one LLM provider key. You can either set an environment variable or create a config file. diff --git a/interface/opencode-embed-src/embed-entry.tsx b/interface/opencode-embed-src/embed-entry.tsx new file mode 100644 index 000000000..39eb80755 --- /dev/null +++ b/interface/opencode-embed-src/embed-entry.tsx @@ -0,0 +1,16 @@ +/** + * SPA entry point for the embeddable OpenCode build. + * + * Unlike entry.tsx, this does NOT auto-render. Instead it attaches + * `mountOpenCode` to the window object so the host app can call it + * after loading this script. + * + * This file is used as the entry in index-embed.html for a normal + * Vite SPA build (not library mode), so we get full code splitting. + */ + +import { mountOpenCode } from "./embed" +export type { MountOpenCodeConfig, MountOpenCodeHandle } from "./embed" + +// Attach to window so the host app can call it after script load. +;(window as any).__opencode_embed__ = { mountOpenCode } diff --git a/interface/opencode-embed-src/embed.tsx b/interface/opencode-embed-src/embed.tsx new file mode 100644 index 000000000..73413ccfe --- /dev/null +++ b/interface/opencode-embed-src/embed.tsx @@ -0,0 +1,282 @@ +/** + * Embeddable entry point for the OpenCode app. + * + * Exports a `mountOpenCode` function that renders the full OpenCode SPA + * into an arbitrary DOM element using a MemoryRouter (no window.history + * interference). Designed to be consumed by host apps (e.g. Spacebot) + * that already have their own router. + * + * Unlike entry.tsx, this module has NO top-level side effects — it only + * executes when `mountOpenCode` is called. + */ + +import "@/index.css" +import { File } from "@opencode-ai/ui/file" +import { I18nProvider } from "@opencode-ai/ui/context" +import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { FileComponentProvider } from "@opencode-ai/ui/context/file" +import { MarkedProvider } from "@opencode-ai/ui/context/marked" +import { Font } from "@opencode-ai/ui/font" +import { ThemeProvider, useTheme, type DesktopTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { MetaProvider } from "@solidjs/meta" +import { MemoryRouter, Route, createMemoryHistory } from "@solidjs/router" +import { ErrorBoundary, lazy, onMount, type ParentProps, Show, Suspense } from "solid-js" +import { render } from "solid-js/web" +import spacebotTheme from "./spacebot-theme.json" +import { CommandProvider } from "@/context/command" +import { CommentsProvider } from "@/context/comments" +import { FileProvider } from "@/context/file" +import { GlobalSDKProvider } from "@/context/global-sdk" +import { GlobalSyncProvider } from "@/context/global-sync" +import { HighlightsProvider } from "@/context/highlights" +import { LanguageProvider, useLanguage } from "@/context/language" +import { LayoutProvider } from "@/context/layout" +import { ModelsProvider } from "@/context/models" +import { NotificationProvider } from "@/context/notification" +import { PermissionProvider } from "@/context/permission" +import { type Platform, PlatformProvider, usePlatform } from "@/context/platform" +import { PromptProvider } from "@/context/prompt" +import { type ServerConnection, ServerProvider, useServer } from "@/context/server" +import { SettingsProvider } from "@/context/settings" +import { TerminalProvider } from "@/context/terminal" +import DirectoryLayout from "@/pages/directory-layout" +import Layout from "@/pages/layout" +import { ErrorPage } from "./pages/error" + +const Home = lazy(() => import("@/pages/home")) +const Session = lazy(() => import("@/pages/session")) +const Loading = () =>
+ +const HomeRoute = () => ( + }> + + +) + +const SessionRoute = () => ( + + }> + + + +) + +function UiI18nBridge(props: ParentProps) { + const language = useLanguage() + return {props.children} +} + +function MarkedProviderWithNativeParser(props: ParentProps) { + const platform = usePlatform() + return {props.children} +} + +function AppShellProviders(props: ParentProps) { + return ( + + + + + + + + {props.children} + + + + + + + + ) +} + +function SessionProviders(props: ParentProps) { + return ( + + + + {props.children} + + + + ) +} + +function RouterRoot(props: ParentProps) { + return {props.children} +} + +function ServerKey(props: ParentProps) { + const server = useServer() + return ( + + {props.children} + + ) +} + +/** + * Registers and activates a custom theme + color scheme inside the + * ThemeProvider. Runs once on mount — theme changes propagate + * reactively through OpenCode's own effect in the ThemeProvider. + */ +function ThemeInjector(props: ParentProps & { theme?: DesktopTheme; colorScheme?: ColorScheme }) { + const ctx = useTheme() + onMount(() => { + const theme = props.theme + if (theme) { + ctx.registerTheme(theme) + ctx.setTheme(theme.id) + } + if (props.colorScheme) { + ctx.setColorScheme(props.colorScheme) + } + }) + return <>{props.children} +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +export type MountOpenCodeConfig = { + /** URL of the OpenCode server, e.g. "http://127.0.0.1:12345" */ + serverUrl: string + + /** + * Initial route to navigate to inside the embedded app. + * e.g. "//session/" + * Defaults to "/" (home / project picker). + */ + initialRoute?: string + + /** + * Custom theme to register and activate. If omitted, the built-in + * Spacebot theme is used. Pass `null` to skip theme injection + * entirely and use OpenCode's default theme. + */ + theme?: DesktopTheme | null + + /** + * Force a color scheme. Defaults to "dark" to match Spacebot's UI. + * Pass "system" to respect the user's OS preference. + */ + colorScheme?: ColorScheme +} + +export type MountOpenCodeHandle = { + /** Tear down the SolidJS app and remove all DOM nodes. */ + dispose: () => void + + /** + * Navigate the embedded app to a new route. + * e.g. handle.navigate("//session/") + */ + navigate: (route: string) => void +} + +/** + * Mount the OpenCode SPA into a DOM element. + * + * - Uses MemoryRouter so it never touches window.history / window.location. + * - The caller is responsible for providing a container element (can be inside + * a Shadow DOM for CSS isolation). + * - Returns a handle with `dispose()` for cleanup and `navigate()` for + * programmatic route changes. + */ +export function mountOpenCode( + container: HTMLElement, + config: MountOpenCodeConfig, +): MountOpenCodeHandle { + const { serverUrl, initialRoute = "/", colorScheme = "dark" } = config + // Resolve theme: undefined → default Spacebot theme, null → no injection + const theme = config.theme === undefined + ? (spacebotTheme as DesktopTheme) + : config.theme ?? undefined + + // Create an in-memory history that never touches the real URL bar. + const memory = createMemoryHistory() + // Set the initial route before render so the router starts there. + memory.set({ value: initialRoute }) + + const platform: Platform = { + platform: "web", + version: "embed", + openLink: (url) => window.open(url, "_blank", "noopener,noreferrer"), + back: () => memory.go(-1), + forward: () => memory.go(1), + restart: async () => { + // No-op in embedded mode — the host app controls lifecycle. + }, + notify: async () => { + // Notifications don't make sense in embedded mode. + }, + // Don't let the embedded app read/write the host's localStorage + // for defaultServerUrl — we control the server URL via config. + getDefaultServerUrl: async () => serverUrl, + setDefaultServerUrl: () => {}, + } + + const server: ServerConnection.Http = { + type: "http", + http: { url: serverUrl }, + } + // Inline ServerConnection.key() to avoid namespace bundling issues. + // ServerConnection.Key.make is just a branded string cast. + const serverKey = serverUrl as ServerConnection.Key + + const dispose = render( + () => ( + + + + + + + + }> + + + + + + + + ( + {routerProps.children} + )} + > + + + + + + + + + + + + + + + + + + + + + ), + container, + ) + + return { + dispose, + navigate: (route: string) => { + memory.set({ value: route }) + }, + } +} diff --git a/interface/opencode-embed-src/index-embed.html b/interface/opencode-embed-src/index-embed.html new file mode 100644 index 000000000..2519bb32d --- /dev/null +++ b/interface/opencode-embed-src/index-embed.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/interface/opencode-embed-src/spacebot-theme.json b/interface/opencode-embed-src/spacebot-theme.json new file mode 100644 index 000000000..6dc5be2d2 --- /dev/null +++ b/interface/opencode-embed-src/spacebot-theme.json @@ -0,0 +1,147 @@ +{ + "$schema": "https://opencode.ai/desktop-theme.json", + "name": "Spacebot", + "id": "spacebot", + "light": { + "seeds": { + "neutral": "#f5f3f7", + "primary": "#a54de8", + "success": "#3dba72", + "warning": "#d4993f", + "error": "#d44848", + "info": "#5096d4", + "interactive": "#a54de8", + "diffAdd": "#a8e0c0", + "diffDelete": "#f0b0b0" + }, + "overrides": { + "background-base": "#f5f3f7", + "background-weak": "#eeedf2", + "background-strong": "#faf9fc", + "background-stronger": "#fdfcfe", + "text-base": "#1a1a20", + "text-weak": "#4a4a56", + "text-strong": "#0c0c0e", + "surface-base": "#faf9fc", + "surface-raised-base": "#ffffff", + "surface-weak": "#eeedf2", + "input-base": "#ffffff", + "border-weak-base": "#dddbe6", + "border-base": "#b5b3c2", + "border-strong-base": "#7a788a", + "syntax-string": "#3dba72", + "syntax-primitive": "#d44848", + "syntax-property": "#a54de8", + "syntax-type": "#d4993f", + "syntax-constant": "#5096d4", + "markdown-heading": "#a54de8", + "markdown-text": "#1a1a20", + "markdown-code": "#3dba72", + "markdown-strong": "#a54de8", + "markdown-link": "#a54de8", + "markdown-link-text": "#a54de8" + } + }, + "dark": { + "seeds": { + "neutral": "#0c0c0e", + "primary": "#a54de8", + "success": "#5cf0b0", + "warning": "#f0c070", + "error": "#f06060", + "info": "#70d0f0", + "interactive": "#a54de8", + "diffAdd": "#5cf0b0", + "diffDelete": "#f06060" + }, + "overrides": { + "background-base": "#0c0c0e", + "background-weak": "#0e0e11", + "background-strong": "#0a0a0c", + "background-stronger": "#080809", + "surface-base": "#131317", + "base": "#131317", + "surface-base-hover": "#18181d", + "surface-raised-base": "#0e0e11", + "surface-raised-base-hover": "#131317", + "surface-raised-strong": "#1a1a1f", + "surface-raised-stronger": "#222228", + "surface-weak": "#111114", + "surface-weaker": "#0e0e11", + "surface-float-base": "#0a0a0c", + "surface-inset-base": "#0a0a0c", + "input-base": "#111114", + "input-hover": "#131317", + "input-active": "#0e0e11", + "input-selected": "#2a1a4a", + "text-base": "#eeeff3", + "text-weak": "#6d6e74", + "text-weaker": "#55565c", + "text-strong": "#ffffff", + "text-interactive-base": "#c080f0", + "icon-base": "#6d6e74", + "icon-weak-base": "#3a3a42", + "icon-strong-base": "#eeeff3", + "icon-interactive-base": "#a54de8", + "border-weak-base": "#222228", + "border-weak-hover": "#2a2a32", + "border-weak-active": "#32323c", + "border-weak-selected": "#3a3a46", + "border-weak-disabled": "#141418", + "border-weak-focus": "#2e2e38", + "border-base": "#3a3a46", + "border-hover": "#444450", + "border-active": "#4e4e5a", + "border-selected": "#585864", + "border-disabled": "#1a1a1f", + "border-focus": "#484854", + "border-strong-base": "#585864", + "border-strong-hover": "#62626e", + "border-strong-active": "#6c6c78", + "border-strong-selected": "#767682", + "border-strong-disabled": "#28282e", + "border-strong-focus": "#666672", + "border-weaker-base": "#141418", + "border-weaker-hover": "#1a1a1f", + "border-weaker-active": "#222228", + "surface-interactive-base": "#1e0e3a", + "surface-interactive-hover": "#2a1a4a", + "surface-interactive-weak": "#140a28", + "surface-brand-base": "#a54de8", + "surface-brand-hover": "#9340d4", + "surface-diff-add-base": "#0e2018", + "surface-diff-delete-base": "#200e10", + "surface-diff-hidden-base": "#18181f", + "button-secondary-base": "#1a1a1f", + "button-secondary-hover": "#222228", + "button-ghost-hover": "#131317", + "button-ghost-hover2": "#18181d", + "syntax-comment": "var(--text-weak)", + "syntax-string": "#5cf0b0", + "syntax-primitive": "#f06060", + "syntax-property": "#c080f0", + "syntax-type": "#f0c070", + "syntax-constant": "#70d0f0", + "syntax-keyword": "var(--text-weak)", + "syntax-operator": "var(--text-weak)", + "syntax-variable": "var(--text-strong)", + "syntax-object": "var(--text-strong)", + "syntax-punctuation": "var(--text-weak)", + "syntax-info": "#70d0f0", + "markdown-heading": "#c080f0", + "markdown-text": "#eeeff3", + "markdown-link": "#c080f0", + "markdown-link-text": "#c080f0", + "markdown-code": "#5cf0b0", + "markdown-block-quote": "#6d6e74", + "markdown-emph": "#f0c070", + "markdown-strong": "#c080f0", + "markdown-horizontal-rule": "#222228", + "markdown-list-item": "#c080f0", + "markdown-list-enumeration": "#c080f0", + "markdown-image": "#c080f0", + "markdown-image-text": "#c080f0", + "markdown-code-block": "#eeeff3" + } + } +} diff --git a/interface/opencode-embed-src/vite.config.embed.ts b/interface/opencode-embed-src/vite.config.embed.ts new file mode 100644 index 000000000..aefe7e1bb --- /dev/null +++ b/interface/opencode-embed-src/vite.config.embed.ts @@ -0,0 +1,59 @@ +/** + * Vite config for building the OpenCode embed bundle. + * + * Builds as a normal SPA (not library mode) so we get Vite's full + * automatic code splitting for shiki grammars, lazy routes, etc. + * The entry script attaches `mountOpenCode` to `window.__opencode_embed__` + * instead of auto-rendering. + * + * Output: + * - dist-embed/index.html (minimal HTML, loads entry JS) + * - dist-embed/assets/index-*.js (main entry chunk, ~2-3MB) + * - dist-embed/assets/index-*.css (all CSS) + * - dist-embed/assets/*.js (lazy chunks: shiki grammars, etc.) + * + * Build with: + * ./node_modules/.bin/vite build --config vite.config.embed.ts + * + * Usage in host app: + * 1. Load the entry JS as a " - ); - - let rewritten = if let Some(head_pos) = html.find("") { - let insert_at = head_pos + "".len(); - format!("{}{injection}{}", &html[..insert_at], &html[insert_at..]) - } else if let Some(head_pos) = html.find("") { - let insert_at = head_pos + "".len(); - format!("{}{injection}{}", &html[..insert_at], &html[insert_at..]) - } else { - // No tag found — prepend the injection - format!("{injection}{html}") - }; - - match response_builder.body(Body::from(rewritten)) { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "failed to build rewritten HTML response"); - (StatusCode::INTERNAL_SERVER_ERROR, "proxy response error").into_response() - } - } - } else { - // Non-HTML: stream the response body as-is (supports SSE) - let body_stream = upstream_response - .bytes_stream() - .map_err(std::io::Error::other); - - match response_builder.body(Body::from_stream(body_stream)) { - Ok(response) => response, - Err(error) => { - tracing::warn!(%error, "failed to build proxy response"); - (StatusCode::INTERNAL_SERVER_ERROR, "proxy response error").into_response() - } + let body_stream = upstream_response + .bytes_stream() + .map_err(std::io::Error::other); + + match response_builder.body(Body::from_stream(body_stream)) { + Ok(response) => response, + Err(error) => { + tracing::warn!(%error, "failed to build proxy response"); + (StatusCode::INTERNAL_SERVER_ERROR, "proxy response error").into_response() } } } diff --git a/src/api/server.rs b/src/api/server.rs index 1fe29dc87..2826f5fb3 100644 --- a/src/api/server.rs +++ b/src/api/server.rs @@ -96,6 +96,8 @@ pub async fn start_http_server( "/opencode/{port}/{*path}", any(opencode_proxy::opencode_proxy), ) + .route("/opencode/{port}", any(opencode_proxy::opencode_proxy)) + .route("/opencode/{port}/", any(opencode_proxy::opencode_proxy)) .route("/agents/memories", get(memories::list_memories)) .route("/agents/memories/search", get(memories::search_memories)) .route("/agents/memories/graph", get(memories::memory_graph)) diff --git a/src/api/workers.rs b/src/api/workers.rs index 85a01539f..dff7a4f09 100644 --- a/src/api/workers.rs +++ b/src/api/workers.rs @@ -77,6 +77,8 @@ pub(super) struct WorkerDetailResponse { opencode_port: Option, /// Whether this worker accepts follow-up input via route. interactive: bool, + /// Working directory for OpenCode workers. + directory: Option, } /// List worker runs for an agent, with live status merged from StatusBlocks. @@ -191,5 +193,6 @@ pub(super) async fn worker_detail( opencode_session_id: detail.opencode_session_id, opencode_port: detail.opencode_port, interactive: detail.interactive, + directory: detail.directory, })) } diff --git a/src/conversation/history.rs b/src/conversation/history.rs index f29728a39..41d8ad5aa 100644 --- a/src/conversation/history.rs +++ b/src/conversation/history.rs @@ -372,26 +372,6 @@ impl ProcessRunLogger { }); } - /// Persist the working directory for a worker. Fire-and-forget. - /// - /// Called from `spawn_opencode_worker_from_state` after the worker row is - /// created, so the directory survives for idle-worker resume. - pub fn log_worker_directory(&self, worker_id: WorkerId, directory: &std::path::Path) { - let pool = self.pool.clone(); - let id = worker_id.to_string(); - let dir = directory.to_string_lossy().to_string(); - tokio::spawn(async move { - if let Err(error) = sqlx::query("UPDATE worker_runs SET directory = ? WHERE id = ?") - .bind(&dir) - .bind(&id) - .execute(&pool) - .await - { - tracing::warn!(%error, worker_id = %id, "failed to persist worker directory"); - } - }); - } - /// Update a worker's status. Fire-and-forget. /// Most status text updates are transient — they're available via the /// in-memory StatusBlock for live workers and don't need to be persisted. @@ -835,7 +815,7 @@ impl ProcessRunLogger { let row = sqlx::query( "SELECT w.id, w.task, w.result, w.status, w.worker_type, w.channel_id, \ w.started_at, w.completed_at, w.transcript, w.tool_calls, \ - w.opencode_session_id, w.opencode_port, w.interactive, \ + w.opencode_session_id, w.opencode_port, w.interactive, w.directory, \ c.display_name as channel_name \ FROM worker_runs w \ LEFT JOIN channels c ON w.channel_id = c.id \ @@ -870,6 +850,9 @@ impl ProcessRunLogger { opencode_session_id: row.try_get("opencode_session_id").ok(), opencode_port: row.try_get::("opencode_port").ok(), interactive: row.try_get::("interactive").unwrap_or(false), + directory: row + .try_get::, _>("directory") + .unwrap_or(None), })) } } @@ -922,6 +905,7 @@ pub struct WorkerDetailRow { pub opencode_session_id: Option, pub opencode_port: Option, pub interactive: bool, + pub directory: Option, } #[cfg(test)] diff --git a/src/lib.rs b/src/lib.rs index 3f20573f8..87cfa126f 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -165,6 +165,9 @@ pub enum ProcessEvent { task: String, worker_type: String, interactive: bool, + /// Working directory for the worker (used by OpenCode workers to + /// persist the directory for idle-worker resume). + directory: Option, }, WorkerStatus { agent_id: AgentId,