diff --git a/.agents/skills/agent-browser/SKILL.md b/.agents/skills/agent-browser/SKILL.md index 680828d2..ab3ea3c6 100644 --- a/.agents/skills/agent-browser/SKILL.md +++ b/.agents/skills/agent-browser/SKILL.md @@ -1,530 +1,339 @@ --- name: agent-browser -description: Browser automation CLI for AI agents. Use when the user needs to interact with websites, including navigating pages, filling forms, clicking buttons, taking screenshots, extracting data, testing web apps, or automating any browser task. Triggers include requests to "open a website", "fill out a form", "click a button", "take a screenshot", "scrape data from a page", "test this web app", "login to a site", "automate browser actions", or any task requiring programmatic web interaction. -allowed-tools: Bash(npx agent-browser:*), Bash(agent-browser:*) +description: Automates browser interactions for web testing, form filling, screenshots, and data extraction. Use when the user needs to navigate websites, interact with web pages, fill forms, take screenshots, test web applications, or extract information from web pages. +allowed-tools: Bash(agent-browser:*) --- # Browser Automation with agent-browser -## Core Workflow - -Every browser automation follows this pattern: - -1. **Navigate**: `agent-browser open ` -2. **Snapshot**: `agent-browser snapshot -i` (get element refs like `@e1`, `@e2`) -3. **Interact**: Use refs to click, fill, select -4. **Re-snapshot**: After navigation or DOM changes, get fresh refs +## Quick start ```bash -agent-browser open https://example.com/form -agent-browser snapshot -i -# Output: @e1 [input type="email"], @e2 [input type="password"], @e3 [button] "Submit" - -agent-browser fill @e1 "user@example.com" -agent-browser fill @e2 "password123" -agent-browser click @e3 -agent-browser wait --load networkidle -agent-browser snapshot -i # Check result +agent-browser open # Navigate to page +agent-browser snapshot -i # Get interactive elements with refs +agent-browser click @e1 # Click element by ref +agent-browser fill @e2 "text" # Fill input by ref +agent-browser close # Close browser ``` -## Command Chaining - -Commands can be chained with `&&` in a single shell invocation. The browser persists between commands via a background daemon, so chaining is safe and more efficient than separate calls. - -```bash -# Chain open + wait + snapshot in one call -agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser snapshot -i - -# Chain multiple interactions -agent-browser fill @e1 "user@example.com" && agent-browser fill @e2 "password123" && agent-browser click @e3 +## Core workflow -# Navigate and capture -agent-browser open https://example.com && agent-browser wait --load networkidle && agent-browser screenshot page.png -``` +1. Navigate: `agent-browser open ` +2. Snapshot: `agent-browser snapshot -i` (returns elements with refs like `@e1`, `@e2`) +3. Interact using refs from the snapshot +4. Re-snapshot after navigation or significant DOM changes -**When to chain:** Use `&&` when you don't need to read the output of an intermediate command before proceeding (e.g., open + wait + screenshot). Run commands separately when you need to parse the output first (e.g., snapshot to discover refs, then interact using those refs). +## Commands -## Essential Commands +### Navigation ```bash -# Navigation -agent-browser open # Navigate (aliases: goto, navigate) -agent-browser close # Close browser - -# Snapshot -agent-browser snapshot -i # Interactive elements with refs (recommended) -agent-browser snapshot -i -C # Include cursor-interactive elements (divs with onclick, cursor:pointer) -agent-browser snapshot -s "#selector" # Scope to CSS selector - -# Interaction (use @refs from snapshot) -agent-browser click @e1 # Click element -agent-browser click @e1 --new-tab # Click and open in new tab -agent-browser fill @e2 "text" # Clear and type text -agent-browser type @e2 "text" # Type without clearing -agent-browser select @e1 "option" # Select dropdown option -agent-browser check @e1 # Check checkbox -agent-browser press Enter # Press key -agent-browser keyboard type "text" # Type at current focus (no selector) -agent-browser keyboard inserttext "text" # Insert without key events -agent-browser scroll down 500 # Scroll page -agent-browser scroll down 500 --selector "div.content" # Scroll within a specific container - -# Get information -agent-browser get text @e1 # Get element text -agent-browser get url # Get current URL -agent-browser get title # Get page title - -# Wait -agent-browser wait @e1 # Wait for element -agent-browser wait --load networkidle # Wait for network idle -agent-browser wait --url "**/page" # Wait for URL pattern -agent-browser wait 2000 # Wait milliseconds - -# Downloads -agent-browser download @e1 ./file.pdf # Click element to trigger download -agent-browser wait --download ./output.zip # Wait for any download to complete -agent-browser --download-path ./downloads open # Set default download directory - -# Capture -agent-browser screenshot # Screenshot to temp dir -agent-browser screenshot --full # Full page screenshot -agent-browser screenshot --annotate # Annotated screenshot with numbered element labels -agent-browser pdf output.pdf # Save as PDF - -# Diff (compare page states) -agent-browser diff snapshot # Compare current vs last snapshot -agent-browser diff snapshot --baseline before.txt # Compare current vs saved file -agent-browser diff screenshot --baseline before.png # Visual pixel diff -agent-browser diff url # Compare two pages -agent-browser diff url --wait-until networkidle # Custom wait strategy -agent-browser diff url --selector "#main" # Scope to element +agent-browser open # Navigate to URL (aliases: goto, navigate) + # Supports: https://, http://, file://, about:, data:// + # Auto-prepends https:// if no protocol given +agent-browser back # Go back +agent-browser forward # Go forward +agent-browser reload # Reload page +agent-browser close # Close browser (aliases: quit, exit) +agent-browser connect 9222 # Connect to browser via CDP port ``` -## Common Patterns - -### Form Submission +### Snapshot (page analysis) ```bash -agent-browser open https://example.com/signup -agent-browser snapshot -i -agent-browser fill @e1 "Jane Doe" -agent-browser fill @e2 "jane@example.com" -agent-browser select @e3 "California" -agent-browser check @e4 -agent-browser click @e5 -agent-browser wait --load networkidle +agent-browser snapshot # Full accessibility tree +agent-browser snapshot -i # Interactive elements only (recommended) +agent-browser snapshot -c # Compact output +agent-browser snapshot -d 3 # Limit depth to 3 +agent-browser snapshot -s "#main" # Scope to CSS selector ``` -### Authentication with Auth Vault (Recommended) +### Interactions (use @refs from snapshot) ```bash -# Save credentials once (encrypted with AGENT_BROWSER_ENCRYPTION_KEY) -# Recommended: pipe password via stdin to avoid shell history exposure -echo "pass" | agent-browser auth save github --url https://github.com/login --username user --password-stdin - -# Login using saved profile (LLM never sees password) -agent-browser auth login github - -# List/show/delete profiles -agent-browser auth list -agent-browser auth show github -agent-browser auth delete github -``` - -### Authentication with State Persistence +agent-browser click @e1 # Click +agent-browser dblclick @e1 # Double-click +agent-browser focus @e1 # Focus element +agent-browser fill @e2 "text" # Clear and type +agent-browser type @e2 "text" # Type without clearing +agent-browser press Enter # Press key (alias: key) +agent-browser press Control+a # Key combination +agent-browser keydown Shift # Hold key down +agent-browser keyup Shift # Release key +agent-browser hover @e1 # Hover +agent-browser check @e1 # Check checkbox +agent-browser uncheck @e1 # Uncheck checkbox +agent-browser select @e1 "value" # Select dropdown option +agent-browser select @e1 "a" "b" # Select multiple options +agent-browser scroll down 500 # Scroll page (default: down 300px) +agent-browser scrollintoview @e1 # Scroll element into view (alias: scrollinto) +agent-browser drag @e1 @e2 # Drag and drop +agent-browser upload @e1 file.pdf # Upload files +``` + +### Get information ```bash -# Login once and save state -agent-browser open https://app.example.com/login -agent-browser snapshot -i -agent-browser fill @e1 "$USERNAME" -agent-browser fill @e2 "$PASSWORD" -agent-browser click @e3 -agent-browser wait --url "**/dashboard" -agent-browser state save auth.json - -# Reuse in future sessions -agent-browser state load auth.json -agent-browser open https://app.example.com/dashboard +agent-browser get text @e1 # Get element text +agent-browser get html @e1 # Get innerHTML +agent-browser get value @e1 # Get input value +agent-browser get attr @e1 href # Get attribute +agent-browser get title # Get page title +agent-browser get url # Get current URL +agent-browser get count ".item" # Count matching elements +agent-browser get box @e1 # Get bounding box +agent-browser get styles @e1 # Get computed styles (font, color, bg, etc.) ``` -### Session Persistence +### Check state ```bash -# Auto-save/restore cookies and localStorage across browser restarts -agent-browser --session-name myapp open https://app.example.com/login -# ... login flow ... -agent-browser close # State auto-saved to ~/.agent-browser/sessions/ - -# Next time, state is auto-loaded -agent-browser --session-name myapp open https://app.example.com/dashboard - -# Encrypt state at rest -export AGENT_BROWSER_ENCRYPTION_KEY=$(openssl rand -hex 32) -agent-browser --session-name secure open https://app.example.com - -# Manage saved states -agent-browser state list -agent-browser state show myapp-default.json -agent-browser state clear myapp -agent-browser state clean --older-than 7 +agent-browser is visible @e1 # Check if visible +agent-browser is enabled @e1 # Check if enabled +agent-browser is checked @e1 # Check if checked ``` -### Data Extraction +### Screenshots & PDF ```bash -agent-browser open https://example.com/products -agent-browser snapshot -i -agent-browser get text @e5 # Get specific element text -agent-browser get text body > page.txt # Get all page text - -# JSON output for parsing -agent-browser snapshot -i --json -agent-browser get text @e1 --json +agent-browser screenshot # Save to a temporary directory +agent-browser screenshot path.png # Save to a specific path +agent-browser screenshot --full # Full page +agent-browser pdf output.pdf # Save as PDF ``` -### Parallel Sessions +### Video recording ```bash -agent-browser --session site1 open https://site-a.com -agent-browser --session site2 open https://site-b.com - -agent-browser --session site1 snapshot -i -agent-browser --session site2 snapshot -i - -agent-browser session list +agent-browser record start ./demo.webm # Start recording (uses current URL + state) +agent-browser click @e1 # Perform actions +agent-browser record stop # Stop and save video +agent-browser record restart ./take2.webm # Stop current + start new recording ``` -### Connect to Existing Chrome +Recording creates a fresh context but preserves cookies/storage from your session. If no URL is provided, it +automatically returns to your current page. For smooth demos, explore first, then start recording. -```bash -# Auto-discover running Chrome with remote debugging enabled -agent-browser --auto-connect open https://example.com -agent-browser --auto-connect snapshot +### Wait -# Or with explicit CDP port -agent-browser --cdp 9222 snapshot +```bash +agent-browser wait @e1 # Wait for element +agent-browser wait 2000 # Wait milliseconds +agent-browser wait --text "Success" # Wait for text (or -t) +agent-browser wait --url "**/dashboard" # Wait for URL pattern (or -u) +agent-browser wait --load networkidle # Wait for network idle (or -l) +agent-browser wait --fn "window.ready" # Wait for JS condition (or -f) ``` -### Color Scheme (Dark Mode) +### Mouse control ```bash -# Persistent dark mode via flag (applies to all pages and new tabs) -agent-browser --color-scheme dark open https://example.com - -# Or via environment variable -AGENT_BROWSER_COLOR_SCHEME=dark agent-browser open https://example.com - -# Or set during session (persists for subsequent commands) -agent-browser set media dark +agent-browser mouse move 100 200 # Move mouse +agent-browser mouse down left # Press button +agent-browser mouse up left # Release button +agent-browser mouse wheel 100 # Scroll wheel ``` -### Visual Browser (Debugging) +### Semantic locators (alternative to refs) ```bash -agent-browser --headed open https://example.com -agent-browser highlight @e1 # Highlight element -agent-browser record start demo.webm # Record session -agent-browser profiler start # Start Chrome DevTools profiling -agent-browser profiler stop trace.json # Stop and save profile (path optional) +agent-browser find role button click --name "Submit" +agent-browser find text "Sign In" click +agent-browser find text "Sign In" click --exact # Exact match only +agent-browser find label "Email" fill "user@test.com" +agent-browser find placeholder "Search" type "query" +agent-browser find alt "Logo" click +agent-browser find title "Close" click +agent-browser find testid "submit-btn" click +agent-browser find first ".item" click +agent-browser find last ".item" click +agent-browser find nth 2 "a" hover ``` -Use `AGENT_BROWSER_HEADED=1` to enable headed mode via environment variable. Browser extensions work in both headed and headless mode. - -### Local Files (PDFs, HTML) +### Browser settings ```bash -# Open local files with file:// URLs -agent-browser --allow-file-access open file:///path/to/document.pdf -agent-browser --allow-file-access open file:///path/to/page.html -agent-browser screenshot output.png +agent-browser set viewport 1920 1080 # Set viewport size +agent-browser set device "iPhone 14" # Emulate device +agent-browser set geo 37.7749 -122.4194 # Set geolocation (alias: geolocation) +agent-browser set offline on # Toggle offline mode +agent-browser set headers '{"X-Key":"v"}' # Extra HTTP headers +agent-browser set credentials user pass # HTTP basic auth (alias: auth) +agent-browser set media dark # Emulate color scheme +agent-browser set media light reduced-motion # Light mode + reduced motion ``` -### iOS Simulator (Mobile Safari) +### Cookies & Storage ```bash -# List available iOS simulators -agent-browser device list - -# Launch Safari on a specific device -agent-browser -p ios --device "iPhone 16 Pro" open https://example.com - -# Same workflow as desktop - snapshot, interact, re-snapshot -agent-browser -p ios snapshot -i -agent-browser -p ios tap @e1 # Tap (alias for click) -agent-browser -p ios fill @e2 "text" -agent-browser -p ios swipe up # Mobile-specific gesture - -# Take screenshot -agent-browser -p ios screenshot mobile.png - -# Close session (shuts down simulator) -agent-browser -p ios close +agent-browser cookies # Get all cookies +agent-browser cookies set name value # Set cookie +agent-browser cookies clear # Clear cookies +agent-browser storage local # Get all localStorage +agent-browser storage local key # Get specific key +agent-browser storage local set k v # Set value +agent-browser storage local clear # Clear all ``` -**Requirements:** macOS with Xcode, Appium (`npm install -g appium && appium driver install xcuitest`) - -**Real devices:** Works with physical iOS devices if pre-configured. Use `--device ""` where UDID is from `xcrun xctrace list devices`. - -## Security - -All security features are opt-in. By default, agent-browser imposes no restrictions on navigation, actions, or output. - -### Content Boundaries (Recommended for AI Agents) - -Enable `--content-boundaries` to wrap page-sourced output in markers that help LLMs distinguish tool output from untrusted page content: +### Network ```bash -export AGENT_BROWSER_CONTENT_BOUNDARIES=1 -agent-browser snapshot -# Output: -# --- AGENT_BROWSER_PAGE_CONTENT nonce= origin=https://example.com --- -# [accessibility tree] -# --- END_AGENT_BROWSER_PAGE_CONTENT nonce= --- +agent-browser network route # Intercept requests +agent-browser network route --abort # Block requests +agent-browser network route --body '{}' # Mock response +agent-browser network unroute [url] # Remove routes +agent-browser network requests # View tracked requests +agent-browser network requests --filter api # Filter requests ``` -### Domain Allowlist - -Restrict navigation to trusted domains. Wildcards like `*.example.com` also match the bare domain `example.com`. Sub-resource requests, WebSocket, and EventSource connections to non-allowed domains are also blocked. Include CDN domains your target pages depend on: +### Tabs & Windows ```bash -export AGENT_BROWSER_ALLOWED_DOMAINS="example.com,*.example.com" -agent-browser open https://example.com # OK -agent-browser open https://malicious.com # Blocked +agent-browser tab # List tabs +agent-browser tab new [url] # New tab +agent-browser tab 2 # Switch to tab by index +agent-browser tab close # Close current tab +agent-browser tab close 2 # Close tab by index +agent-browser window new # New window ``` -### Action Policy - -Use a policy file to gate destructive actions: +### Frames ```bash -export AGENT_BROWSER_ACTION_POLICY=./policy.json +agent-browser frame "#iframe" # Switch to iframe +agent-browser frame main # Back to main frame ``` -Example `policy.json`: -```json -{"default": "deny", "allow": ["navigate", "snapshot", "click", "scroll", "wait", "get"]} -``` - -Auth vault operations (`auth login`, etc.) bypass action policy but domain allowlist still applies. - -### Output Limits - -Prevent context flooding from large pages: +### Dialogs ```bash -export AGENT_BROWSER_MAX_OUTPUT=50000 +agent-browser dialog accept [text] # Accept dialog +agent-browser dialog dismiss # Dismiss dialog ``` -## Diffing (Verifying Changes) - -Use `diff snapshot` after performing an action to verify it had the intended effect. This compares the current accessibility tree against the last snapshot taken in the session. +### JavaScript ```bash -# Typical workflow: snapshot -> action -> diff -agent-browser snapshot -i # Take baseline snapshot -agent-browser click @e2 # Perform action -agent-browser diff snapshot # See what changed (auto-compares to last snapshot) +agent-browser eval "document.title" # Run JavaScript ``` -For visual regression testing or monitoring: +## Global options ```bash -# Save a baseline screenshot, then compare later -agent-browser screenshot baseline.png -# ... time passes or changes are made ... -agent-browser diff screenshot --baseline baseline.png +agent-browser --session ... # Isolated browser session +agent-browser --json ... # JSON output for parsing +agent-browser --headed ... # Show browser window (not headless) +agent-browser --full ... # Full page screenshot (-f) +agent-browser --cdp ... # Connect via Chrome DevTools Protocol +agent-browser -p ... # Cloud browser provider (--provider) +agent-browser --proxy ... # Use proxy server +agent-browser --headers ... # HTTP headers scoped to URL's origin +agent-browser --executable-path

# Custom browser executable +agent-browser --extension ... # Load browser extension (repeatable) +agent-browser --help # Show help (-h) +agent-browser --version # Show version (-V) +agent-browser --help # Show detailed help for a command +``` + +### Proxy support -# Compare staging vs production -agent-browser diff url https://staging.example.com https://prod.example.com --screenshot +```bash +agent-browser --proxy http://proxy.com:8080 open example.com +agent-browser --proxy http://user:pass@proxy.com:8080 open example.com +agent-browser --proxy socks5://proxy.com:1080 open example.com ``` -`diff snapshot` output uses `+` for additions and `-` for removals, similar to git diff. `diff screenshot` produces a diff image with changed pixels highlighted in red, plus a mismatch percentage. - -## Timeouts and Slow Pages - -The default Playwright timeout is 25 seconds for local browsers. This can be overridden with the `AGENT_BROWSER_DEFAULT_TIMEOUT` environment variable (value in milliseconds). For slow websites or large pages, use explicit waits instead of relying on the default timeout: +## Environment variables ```bash -# Wait for network activity to settle (best for slow pages) -agent-browser wait --load networkidle - -# Wait for a specific element to appear -agent-browser wait "#content" -agent-browser wait @e1 - -# Wait for a specific URL pattern (useful after redirects) -agent-browser wait --url "**/dashboard" - -# Wait for a JavaScript condition -agent-browser wait --fn "document.readyState === 'complete'" - -# Wait a fixed duration (milliseconds) as a last resort -agent-browser wait 5000 +AGENT_BROWSER_SESSION="mysession" # Default session name +AGENT_BROWSER_EXECUTABLE_PATH="/path/chrome" # Custom browser path +AGENT_BROWSER_EXTENSIONS="/ext1,/ext2" # Comma-separated extension paths +AGENT_BROWSER_PROVIDER="your-cloud-browser-provider" # Cloud browser provider (select browseruse or browserbase) +AGENT_BROWSER_STREAM_PORT="9223" # WebSocket streaming port +AGENT_BROWSER_HOME="/path/to/agent-browser" # Custom install location (for daemon.js) ``` -When dealing with consistently slow websites, use `wait --load networkidle` after `open` to ensure the page is fully loaded before taking a snapshot. If a specific element is slow to render, wait for it directly with `wait ` or `wait @ref`. - -## Session Management and Cleanup - -When running multiple agents or automations concurrently, always use named sessions to avoid conflicts: +## Example: Form submission ```bash -# Each agent gets its own isolated session -agent-browser --session agent1 open site-a.com -agent-browser --session agent2 open site-b.com +agent-browser open https://example.com/form +agent-browser snapshot -i +# Output shows: textbox "Email" [ref=e1], textbox "Password" [ref=e2], button "Submit" [ref=e3] -# Check active sessions -agent-browser session list +agent-browser fill @e1 "user@example.com" +agent-browser fill @e2 "password123" +agent-browser click @e3 +agent-browser wait --load networkidle +agent-browser snapshot -i # Check result ``` -Always close your browser session when done to avoid leaked processes: +## Example: Authentication with saved state ```bash -agent-browser close # Close default session -agent-browser --session agent1 close # Close specific session -``` - -If a previous session was not closed properly, the daemon may still be running. Use `agent-browser close` to clean it up before starting new work. - -## Ref Lifecycle (Important) - -Refs (`@e1`, `@e2`, etc.) are invalidated when the page changes. Always re-snapshot after: - -- Clicking links or buttons that navigate -- Form submissions -- Dynamic content loading (dropdowns, modals) +# Login once +agent-browser open https://app.example.com/login +agent-browser snapshot -i +agent-browser fill @e1 "username" +agent-browser fill @e2 "password" +agent-browser click @e3 +agent-browser wait --url "**/dashboard" +agent-browser state save auth.json -```bash -agent-browser click @e5 # Navigates to new page -agent-browser snapshot -i # MUST re-snapshot -agent-browser click @e1 # Use new refs +# Later sessions: load saved state +agent-browser state load auth.json +agent-browser open https://app.example.com/dashboard ``` -## Annotated Screenshots (Vision Mode) - -Use `--annotate` to take a screenshot with numbered labels overlaid on interactive elements. Each label `[N]` maps to ref `@eN`. This also caches refs, so you can interact with elements immediately without a separate snapshot. +## Sessions (parallel browsers) ```bash -agent-browser screenshot --annotate -# Output includes the image path and a legend: -# [1] @e1 button "Submit" -# [2] @e2 link "Home" -# [3] @e3 textbox "Email" -agent-browser click @e2 # Click using ref from annotated screenshot +agent-browser --session test1 open site-a.com +agent-browser --session test2 open site-b.com +agent-browser session list ``` -Use annotated screenshots when: -- The page has unlabeled icon buttons or visual-only elements -- You need to verify visual layout or styling -- Canvas or chart elements are present (invisible to text snapshots) -- You need spatial reasoning about element positions +## JSON output (for parsing) -## Semantic Locators (Alternative to Refs) - -When refs are unavailable or unreliable, use semantic locators: +Add `--json` for machine-readable output: ```bash -agent-browser find text "Sign In" click -agent-browser find label "Email" fill "user@test.com" -agent-browser find role button click --name "Submit" -agent-browser find placeholder "Search" type "query" -agent-browser find testid "submit-btn" click +agent-browser snapshot -i --json +agent-browser get text @e1 --json ``` -## JavaScript Evaluation (eval) - -Use `eval` to run JavaScript in the browser context. **Shell quoting can corrupt complex expressions** -- use `--stdin` or `-b` to avoid issues. +## Debugging ```bash -# Simple expressions work with regular quoting -agent-browser eval 'document.title' -agent-browser eval 'document.querySelectorAll("img").length' - -# Complex JS: use --stdin with heredoc (RECOMMENDED) -agent-browser eval --stdin <<'EVALEOF' -JSON.stringify( - Array.from(document.querySelectorAll("img")) - .filter(i => !i.alt) - .map(i => ({ src: i.src.split("/").pop(), width: i.width })) -) -EVALEOF - -# Alternative: base64 encoding (avoids all shell escaping issues) -agent-browser eval -b "$(echo -n 'Array.from(document.querySelectorAll("a")).map(a => a.href)' | base64)" -``` - -**Why this matters:** When the shell processes your command, inner double quotes, `!` characters (history expansion), backticks, and `$()` can all corrupt the JavaScript before it reaches agent-browser. The `--stdin` and `-b` flags bypass shell interpretation entirely. - -**Rules of thumb:** -- Single-line, no nested quotes -> regular `eval 'expression'` with single quotes is fine -- Nested quotes, arrow functions, template literals, or multiline -> use `eval --stdin <<'EVALEOF'` -- Programmatic/generated scripts -> use `eval -b` with base64 - -## Configuration File - -Create `agent-browser.json` in the project root for persistent settings: - -```json -{ - "headed": true, - "proxy": "http://localhost:8080", - "profile": "./browser-data" -} +agent-browser --headed open example.com # Show browser window +agent-browser --cdp 9222 snapshot # Connect via CDP port +agent-browser connect 9222 # Alternative: connect command +agent-browser console # View console messages +agent-browser console --clear # Clear console +agent-browser errors # View page errors +agent-browser errors --clear # Clear errors +agent-browser highlight @e1 # Highlight element +agent-browser trace start # Start recording trace +agent-browser trace stop trace.zip # Stop and save trace +agent-browser record start ./debug.webm # Record video from current page +agent-browser record stop # Save recording ``` -Priority (lowest to highest): `~/.agent-browser/config.json` < `./agent-browser.json` < env vars < CLI flags. Use `--config ` or `AGENT_BROWSER_CONFIG` env var for a custom config file (exits with error if missing/invalid). All CLI options map to camelCase keys (e.g., `--executable-path` -> `"executablePath"`). Boolean flags accept `true`/`false` values (e.g., `--headed false` overrides config). Extensions from user and project configs are merged, not replaced. +## Deep-dive documentation -## Deep-Dive Documentation +For detailed patterns and best practices, see: -| Reference | When to Use | +| Reference | Description | |-----------|-------------| -| [references/commands.md](references/commands.md) | Full command reference with all options | | [references/snapshot-refs.md](references/snapshot-refs.md) | Ref lifecycle, invalidation rules, troubleshooting | | [references/session-management.md](references/session-management.md) | Parallel sessions, state persistence, concurrent scraping | | [references/authentication.md](references/authentication.md) | Login flows, OAuth, 2FA handling, state reuse | | [references/video-recording.md](references/video-recording.md) | Recording workflows for debugging and documentation | -| [references/profiling.md](references/profiling.md) | Chrome DevTools profiling for performance analysis | | [references/proxy-support.md](references/proxy-support.md) | Proxy configuration, geo-testing, rotating proxies | -## Experimental: Native Mode - -agent-browser has an experimental native Rust daemon that communicates with Chrome directly via CDP, bypassing Node.js and Playwright entirely. It is opt-in and not recommended for production use yet. - -```bash -# Enable via flag -agent-browser --native open example.com - -# Enable via environment variable (avoids passing --native every time) -export AGENT_BROWSER_NATIVE=1 -agent-browser open example.com -``` - -The native daemon supports Chromium and Safari (via WebDriver). Firefox and WebKit are not yet supported. All core commands (navigate, snapshot, click, fill, screenshot, cookies, storage, tabs, eval, etc.) work identically in native mode. Use `agent-browser close` before switching between native and default mode within the same session. - -## Browser Engine Selection - -Use `--engine` to choose a local browser engine. The default is `chrome`. - -```bash -# Use Lightpanda (fast headless browser, requires separate install) -agent-browser --engine lightpanda open example.com - -# Via environment variable -export AGENT_BROWSER_ENGINE=lightpanda -agent-browser open example.com +## Ready-to-use templates -# With custom binary path -agent-browser --engine lightpanda --executable-path /path/to/lightpanda open example.com -``` - -Supported engines: -- `chrome` (default) -- Chrome/Chromium via CDP -- `lightpanda` -- Lightpanda headless browser via CDP (10x faster, 10x less memory than Chrome) - -Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-file-access`. Install Lightpanda from https://lightpanda.io/docs/open-source/installation. - -## Ready-to-Use Templates +Executable workflow scripts for common patterns: | Template | Description | |----------|-------------| @@ -532,8 +341,16 @@ Lightpanda does not support `--extension`, `--profile`, `--state`, or `--allow-f | [templates/authenticated-session.sh](templates/authenticated-session.sh) | Login once, reuse state | | [templates/capture-workflow.sh](templates/capture-workflow.sh) | Content extraction with screenshots | +Usage: ```bash ./templates/form-automation.sh https://example.com/form ./templates/authenticated-session.sh https://app.example.com/login ./templates/capture-workflow.sh https://example.com ./output ``` + +## HTTPS Certificate Errors + +For sites with self-signed or invalid certificates: +```bash +agent-browser open https://localhost:8443 --ignore-https-errors +``` diff --git a/.agents/skills/agent-browser/references/authentication.md b/.agents/skills/agent-browser/references/authentication.md index 12ef5e41..5d801f6a 100644 --- a/.agents/skills/agent-browser/references/authentication.md +++ b/.agents/skills/agent-browser/references/authentication.md @@ -1,20 +1,6 @@ # Authentication Patterns -Login flows, session persistence, OAuth, 2FA, and authenticated browsing. - -**Related**: [session-management.md](session-management.md) for state persistence details, [SKILL.md](../SKILL.md) for quick start. - -## Contents - -- [Basic Login Flow](#basic-login-flow) -- [Saving Authentication State](#saving-authentication-state) -- [Restoring Authentication](#restoring-authentication) -- [OAuth / SSO Flows](#oauth--sso-flows) -- [Two-Factor Authentication](#two-factor-authentication) -- [HTTP Basic Auth](#http-basic-auth) -- [Cookie-Based Auth](#cookie-based-auth) -- [Token Refresh Handling](#token-refresh-handling) -- [Security Best Practices](#security-best-practices) +Patterns for handling login flows, session persistence, and authenticated browsing. ## Basic Login Flow diff --git a/.agents/skills/agent-browser/references/proxy-support.md b/.agents/skills/agent-browser/references/proxy-support.md index e86a8fe3..05fcec26 100644 --- a/.agents/skills/agent-browser/references/proxy-support.md +++ b/.agents/skills/agent-browser/references/proxy-support.md @@ -1,29 +1,13 @@ # Proxy Support -Proxy configuration for geo-testing, rate limiting avoidance, and corporate environments. - -**Related**: [commands.md](commands.md) for global options, [SKILL.md](../SKILL.md) for quick start. - -## Contents - -- [Basic Proxy Configuration](#basic-proxy-configuration) -- [Authenticated Proxy](#authenticated-proxy) -- [SOCKS Proxy](#socks-proxy) -- [Proxy Bypass](#proxy-bypass) -- [Common Use Cases](#common-use-cases) -- [Verifying Proxy Connection](#verifying-proxy-connection) -- [Troubleshooting](#troubleshooting) -- [Best Practices](#best-practices) +Configure proxy servers for browser automation, useful for geo-testing, rate limiting avoidance, and corporate environments. ## Basic Proxy Configuration -Use the `--proxy` flag or set proxy via environment variable: +Set proxy via environment variable before starting: ```bash -# Via CLI flag -agent-browser --proxy "http://proxy.example.com:8080" open https://example.com - -# Via environment variable +# HTTP proxy export HTTP_PROXY="http://proxy.example.com:8080" agent-browser open https://example.com @@ -61,13 +45,10 @@ agent-browser open https://example.com ## Proxy Bypass -Skip proxy for specific domains using `--proxy-bypass` or `NO_PROXY`: +Skip proxy for specific domains: ```bash -# Via CLI flag -agent-browser --proxy "http://proxy.example.com:8080" --proxy-bypass "localhost,*.internal.com" open https://example.com - -# Via environment variable +# Bypass proxy for local addresses export NO_PROXY="localhost,127.0.0.1,.internal.company.com" agent-browser open https://internal.company.com # Direct connection agent-browser open https://external.com # Via proxy diff --git a/.agents/skills/agent-browser/references/session-management.md b/.agents/skills/agent-browser/references/session-management.md index bb5312db..cfc33624 100644 --- a/.agents/skills/agent-browser/references/session-management.md +++ b/.agents/skills/agent-browser/references/session-management.md @@ -1,18 +1,6 @@ # Session Management -Multiple isolated browser sessions with state persistence and concurrent browsing. - -**Related**: [authentication.md](authentication.md) for login patterns, [SKILL.md](../SKILL.md) for quick start. - -## Contents - -- [Named Sessions](#named-sessions) -- [Session Isolation Properties](#session-isolation-properties) -- [Session State Persistence](#session-state-persistence) -- [Common Patterns](#common-patterns) -- [Default Session](#default-session) -- [Session Cleanup](#session-cleanup) -- [Best Practices](#best-practices) +Run multiple isolated browser sessions concurrently with state persistence. ## Named Sessions diff --git a/.agents/skills/agent-browser/references/snapshot-refs.md b/.agents/skills/agent-browser/references/snapshot-refs.md index c5868d51..0b17a4d4 100644 --- a/.agents/skills/agent-browser/references/snapshot-refs.md +++ b/.agents/skills/agent-browser/references/snapshot-refs.md @@ -1,29 +1,21 @@ -# Snapshot and Refs +# Snapshot + Refs Workflow -Compact element references that reduce context usage dramatically for AI agents. +The core innovation of agent-browser: compact element references that reduce context usage dramatically for AI agents. -**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start. +## How It Works -## Contents - -- [How Refs Work](#how-refs-work) -- [Snapshot Command](#the-snapshot-command) -- [Using Refs](#using-refs) -- [Ref Lifecycle](#ref-lifecycle) -- [Best Practices](#best-practices) -- [Ref Notation Details](#ref-notation-details) -- [Troubleshooting](#troubleshooting) - -## How Refs Work - -Traditional approach: +### The Problem +Traditional browser automation sends full DOM to AI agents: ``` -Full DOM/HTML → AI parses → CSS selector → Action (~3000-5000 tokens) +Full DOM/HTML sent → AI parses → Generates CSS selector → Executes action +~3000-5000 tokens per interaction ``` -agent-browser approach: +### The Solution +agent-browser uses compact snapshots with refs: ``` -Compact snapshot → @refs assigned → Direct interaction (~200-400 tokens) +Compact snapshot → @refs assigned → Direct ref interaction +~200-400 tokens per interaction ``` ## The Snapshot Command @@ -174,8 +166,8 @@ agent-browser snapshot -i ### Element Not Visible in Snapshot ```bash -# Scroll down to reveal element -agent-browser scroll down 1000 +# Scroll to reveal element +agent-browser scroll --bottom agent-browser snapshot -i # Or wait for dynamic content diff --git a/.agents/skills/agent-browser/references/video-recording.md b/.agents/skills/agent-browser/references/video-recording.md index e6a9fb4e..98e6b0a1 100644 --- a/.agents/skills/agent-browser/references/video-recording.md +++ b/.agents/skills/agent-browser/references/video-recording.md @@ -1,17 +1,6 @@ # Video Recording -Capture browser automation as video for debugging, documentation, or verification. - -**Related**: [commands.md](commands.md) for full command reference, [SKILL.md](../SKILL.md) for quick start. - -## Contents - -- [Basic Recording](#basic-recording) -- [Recording Commands](#recording-commands) -- [Use Cases](#use-cases) -- [Best Practices](#best-practices) -- [Output Format](#output-format) -- [Limitations](#limitations) +Capture browser automation sessions as video for debugging, documentation, or verification. ## Basic Recording diff --git a/.agents/skills/agent-browser/templates/authenticated-session.sh b/.agents/skills/agent-browser/templates/authenticated-session.sh index b66c9289..e44aaad5 100755 --- a/.agents/skills/agent-browser/templates/authenticated-session.sh +++ b/.agents/skills/agent-browser/templates/authenticated-session.sh @@ -1,81 +1,67 @@ #!/bin/bash # Template: Authenticated Session Workflow -# Purpose: Login once, save state, reuse for subsequent runs -# Usage: ./authenticated-session.sh [state-file] +# Login once, save state, reuse for subsequent runs # -# RECOMMENDED: Use the auth vault instead of this template: -# echo "" | agent-browser auth save myapp --url --username --password-stdin -# agent-browser auth login myapp -# The auth vault stores credentials securely and the LLM never sees passwords. +# Usage: +# ./authenticated-session.sh [state-file] # -# Environment variables: -# APP_USERNAME - Login username/email -# APP_PASSWORD - Login password -# -# Two modes: -# 1. Discovery mode (default): Shows form structure so you can identify refs -# 2. Login mode: Performs actual login after you update the refs -# -# Setup steps: -# 1. Run once to see form structure (discovery mode) -# 2. Update refs in LOGIN FLOW section below -# 3. Set APP_USERNAME and APP_PASSWORD -# 4. Delete the DISCOVERY section +# Setup: +# 1. Run once to see your form structure +# 2. Note the @refs for your fields +# 3. Uncomment LOGIN FLOW section and update refs set -euo pipefail LOGIN_URL="${1:?Usage: $0 [state-file]}" STATE_FILE="${2:-./auth-state.json}" -echo "Authentication workflow: $LOGIN_URL" +echo "Authentication workflow for: $LOGIN_URL" -# ================================================================ -# SAVED STATE: Skip login if valid saved state exists -# ================================================================ +# ══════════════════════════════════════════════════════════════ +# SAVED STATE: Skip login if we have valid saved state +# ══════════════════════════════════════════════════════════════ if [[ -f "$STATE_FILE" ]]; then - echo "Loading saved state from $STATE_FILE..." - if agent-browser --state "$STATE_FILE" open "$LOGIN_URL" 2>/dev/null; then - agent-browser wait --load networkidle + echo "Loading saved authentication state..." + agent-browser state load "$STATE_FILE" + agent-browser open "$LOGIN_URL" + agent-browser wait --load networkidle - CURRENT_URL=$(agent-browser get url) - if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then - echo "Session restored successfully" - agent-browser snapshot -i - exit 0 - fi - echo "Session expired, performing fresh login..." - agent-browser close 2>/dev/null || true - else - echo "Failed to load state, re-authenticating..." + CURRENT_URL=$(agent-browser get url) + if [[ "$CURRENT_URL" != *"login"* ]] && [[ "$CURRENT_URL" != *"signin"* ]]; then + echo "Session restored successfully!" + agent-browser snapshot -i + exit 0 fi + echo "Session expired, performing fresh login..." rm -f "$STATE_FILE" fi -# ================================================================ -# DISCOVERY MODE: Shows form structure (delete after setup) -# ================================================================ +# ══════════════════════════════════════════════════════════════ +# DISCOVERY MODE: Show form structure (remove after setup) +# ══════════════════════════════════════════════════════════════ echo "Opening login page..." agent-browser open "$LOGIN_URL" agent-browser wait --load networkidle echo "" -echo "Login form structure:" -echo "---" +echo "┌─────────────────────────────────────────────────────────┐" +echo "│ LOGIN FORM STRUCTURE │" +echo "├─────────────────────────────────────────────────────────┤" agent-browser snapshot -i -echo "---" +echo "└─────────────────────────────────────────────────────────┘" echo "" echo "Next steps:" -echo " 1. Note the refs: username=@e?, password=@e?, submit=@e?" -echo " 2. Update the LOGIN FLOW section below with your refs" -echo " 3. Set: export APP_USERNAME='...' APP_PASSWORD='...'" +echo " 1. Note refs: @e? = username, @e? = password, @e? = submit" +echo " 2. Uncomment LOGIN FLOW section below" +echo " 3. Replace @e1, @e2, @e3 with your refs" echo " 4. Delete this DISCOVERY MODE section" echo "" agent-browser close exit 0 -# ================================================================ +# ══════════════════════════════════════════════════════════════ # LOGIN FLOW: Uncomment and customize after discovery -# ================================================================ +# ══════════════════════════════════════════════════════════════ # : "${APP_USERNAME:?Set APP_USERNAME environment variable}" # : "${APP_PASSWORD:?Set APP_PASSWORD environment variable}" # @@ -92,14 +78,14 @@ exit 0 # # Verify login succeeded # FINAL_URL=$(agent-browser get url) # if [[ "$FINAL_URL" == *"login"* ]] || [[ "$FINAL_URL" == *"signin"* ]]; then -# echo "Login failed - still on login page" +# echo "ERROR: Login failed - still on login page" # agent-browser screenshot /tmp/login-failed.png # agent-browser close # exit 1 # fi # # # Save state for future runs -# echo "Saving state to $STATE_FILE" +# echo "Saving authentication state to: $STATE_FILE" # agent-browser state save "$STATE_FILE" -# echo "Login successful" +# echo "Login successful!" # agent-browser snapshot -i diff --git a/.agents/skills/agent-browser/templates/capture-workflow.sh b/.agents/skills/agent-browser/templates/capture-workflow.sh index 3bc93ad0..a4eae751 100755 --- a/.agents/skills/agent-browser/templates/capture-workflow.sh +++ b/.agents/skills/agent-browser/templates/capture-workflow.sh @@ -1,69 +1,68 @@ #!/bin/bash # Template: Content Capture Workflow -# Purpose: Extract content from web pages (text, screenshots, PDF) -# Usage: ./capture-workflow.sh [output-dir] -# -# Outputs: -# - page-full.png: Full page screenshot -# - page-structure.txt: Page element structure with refs -# - page-text.txt: All text content -# - page.pdf: PDF version -# -# Optional: Load auth state for protected pages +# Extract content from web pages with optional authentication set -euo pipefail TARGET_URL="${1:?Usage: $0 [output-dir]}" OUTPUT_DIR="${2:-.}" -echo "Capturing: $TARGET_URL" +echo "Capturing content from: $TARGET_URL" mkdir -p "$OUTPUT_DIR" -# Optional: Load authentication state +# Optional: Load authentication state if needed # if [[ -f "./auth-state.json" ]]; then -# echo "Loading authentication state..." # agent-browser state load "./auth-state.json" # fi -# Navigate to target +# Navigate to target page agent-browser open "$TARGET_URL" agent-browser wait --load networkidle -# Get metadata -TITLE=$(agent-browser get title) -URL=$(agent-browser get url) -echo "Title: $TITLE" -echo "URL: $URL" +# Get page metadata +echo "Page title: $(agent-browser get title)" +echo "Page URL: $(agent-browser get url)" # Capture full page screenshot agent-browser screenshot --full "$OUTPUT_DIR/page-full.png" -echo "Saved: $OUTPUT_DIR/page-full.png" +echo "Screenshot saved: $OUTPUT_DIR/page-full.png" -# Get page structure with refs +# Get page structure agent-browser snapshot -i > "$OUTPUT_DIR/page-structure.txt" -echo "Saved: $OUTPUT_DIR/page-structure.txt" +echo "Structure saved: $OUTPUT_DIR/page-structure.txt" -# Extract all text content +# Extract main content +# Adjust selector based on target site structure +# agent-browser get text @e1 > "$OUTPUT_DIR/main-content.txt" + +# Extract specific elements (uncomment as needed) +# agent-browser get text "article" > "$OUTPUT_DIR/article.txt" +# agent-browser get text "main" > "$OUTPUT_DIR/main.txt" +# agent-browser get text ".content" > "$OUTPUT_DIR/content.txt" + +# Get full page text agent-browser get text body > "$OUTPUT_DIR/page-text.txt" -echo "Saved: $OUTPUT_DIR/page-text.txt" +echo "Text content saved: $OUTPUT_DIR/page-text.txt" -# Save as PDF +# Optional: Save as PDF agent-browser pdf "$OUTPUT_DIR/page.pdf" -echo "Saved: $OUTPUT_DIR/page.pdf" - -# Optional: Extract specific elements using refs from structure -# agent-browser get text @e5 > "$OUTPUT_DIR/main-content.txt" +echo "PDF saved: $OUTPUT_DIR/page.pdf" -# Optional: Handle infinite scroll pages -# for i in {1..5}; do -# agent-browser scroll down 1000 -# agent-browser wait 1000 -# done -# agent-browser screenshot --full "$OUTPUT_DIR/page-scrolled.png" +# Optional: Capture with scrolling for infinite scroll pages +# scroll_and_capture() { +# local count=0 +# while [[ $count -lt 5 ]]; do +# agent-browser scroll down 1000 +# agent-browser wait 1000 +# ((count++)) +# done +# agent-browser screenshot --full "$OUTPUT_DIR/page-scrolled.png" +# } +# scroll_and_capture # Cleanup agent-browser close echo "" -echo "Capture complete:" +echo "Capture complete! Files saved to: $OUTPUT_DIR" ls -la "$OUTPUT_DIR" diff --git a/.agents/skills/agent-browser/templates/form-automation.sh b/.agents/skills/agent-browser/templates/form-automation.sh index 6784fcd3..02a7c811 100755 --- a/.agents/skills/agent-browser/templates/form-automation.sh +++ b/.agents/skills/agent-browser/templates/form-automation.sh @@ -1,62 +1,64 @@ #!/bin/bash # Template: Form Automation Workflow -# Purpose: Fill and submit web forms with validation -# Usage: ./form-automation.sh -# -# This template demonstrates the snapshot-interact-verify pattern: -# 1. Navigate to form -# 2. Snapshot to get element refs -# 3. Fill fields using refs -# 4. Submit and verify result -# -# Customize: Update the refs (@e1, @e2, etc.) based on your form's snapshot output +# Fills and submits web forms with validation set -euo pipefail FORM_URL="${1:?Usage: $0 }" -echo "Form automation: $FORM_URL" +echo "Automating form at: $FORM_URL" -# Step 1: Navigate to form +# Navigate to form page agent-browser open "$FORM_URL" agent-browser wait --load networkidle -# Step 2: Snapshot to discover form elements -echo "" -echo "Form structure:" +# Get interactive snapshot to identify form fields +echo "Analyzing form structure..." agent-browser snapshot -i -# Step 3: Fill form fields (customize these refs based on snapshot output) -# -# Common field types: -# agent-browser fill @e1 "John Doe" # Text input -# agent-browser fill @e2 "user@example.com" # Email input -# agent-browser fill @e3 "SecureP@ss123" # Password input -# agent-browser select @e4 "Option Value" # Dropdown -# agent-browser check @e5 # Checkbox -# agent-browser click @e6 # Radio button -# agent-browser fill @e7 "Multi-line text" # Textarea -# agent-browser upload @e8 /path/to/file.pdf # File upload -# -# Uncomment and modify: -# agent-browser fill @e1 "Test User" -# agent-browser fill @e2 "test@example.com" -# agent-browser click @e3 # Submit button - -# Step 4: Wait for submission +# Example: Fill common form fields +# Uncomment and modify refs based on snapshot output + +# Text inputs +# agent-browser fill @e1 "John Doe" # Name field +# agent-browser fill @e2 "user@example.com" # Email field +# agent-browser fill @e3 "+1-555-123-4567" # Phone field + +# Password fields +# agent-browser fill @e4 "SecureP@ssw0rd!" + +# Dropdowns +# agent-browser select @e5 "Option Value" + +# Checkboxes +# agent-browser check @e6 # Check +# agent-browser uncheck @e7 # Uncheck + +# Radio buttons +# agent-browser click @e8 # Select radio option + +# Text areas +# agent-browser fill @e9 "Multi-line text content here" + +# File uploads +# agent-browser upload @e10 /path/to/file.pdf + +# Submit form +# agent-browser click @e11 # Submit button + +# Wait for response # agent-browser wait --load networkidle -# agent-browser wait --url "**/success" # Or wait for redirect +# agent-browser wait --url "**/success" # Or wait for redirect -# Step 5: Verify result -echo "" -echo "Result:" +# Verify submission +echo "Form submission result:" agent-browser get url agent-browser snapshot -i -# Optional: Capture evidence +# Take screenshot of result agent-browser screenshot /tmp/form-result.png -echo "Screenshot saved: /tmp/form-result.png" # Cleanup agent-browser close -echo "Done" + +echo "Form automation complete" diff --git a/.claude/commands/post-release-testing.md b/.claude/commands/post-release-testing.md index 09e2b6ab..771a1d89 100644 --- a/.claude/commands/post-release-testing.md +++ b/.claude/commands/post-release-testing.md @@ -43,7 +43,7 @@ Manually verify the install script works in a fresh environment: ```bash docker run --rm alpine:latest sh -c " apk add --no-cache curl ca-certificates libstdc++ libgcc bash && - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && sandbox-agent --version " ``` diff --git a/.context/notes.md b/.context/notes.md new file mode 100644 index 00000000..e69de29b diff --git a/.context/todos.md b/.context/todos.md new file mode 100644 index 00000000..e69de29b diff --git a/.dockerignore b/.dockerignore index cb035455..1a4fa41a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,15 +9,11 @@ build/ # Cache .cache/ .turbo/ -**/.turbo/ *.tsbuildinfo -.pnpm-store/ -coverage/ # Environment .env .env.* -.openhandoff/ # IDE .idea/ @@ -28,7 +24,3 @@ coverage/ # Git .git/ - -# Tests -**/test/ -**/tests/ diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 476ed12f..e009cad0 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,16 +14,15 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@main - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 20 cache: pnpm - run: pnpm install - - run: npm install -g tsx - name: Run checks - run: ./scripts/release/main.ts --version 0.0.0 --only-steps run-ci-checks + run: ./scripts/release/main.ts --version 0.0.0 --check - name: Run ACP v1 server tests run: | cargo test -p sandbox-agent-agent-management @@ -32,3 +31,5 @@ jobs: cargo test -p sandbox-agent --lib - name: Run SDK tests run: pnpm --dir sdks/typescript test + - name: Run Inspector browser E2E + run: pnpm --filter @sandbox-agent/inspector test:agent-browser diff --git a/.gitignore b/.gitignore index da6874a7..b5406975 100644 --- a/.gitignore +++ b/.gitignore @@ -15,9 +15,6 @@ yarn.lock .astro/ *.tsbuildinfo .turbo/ -**/.turbo/ -.pnpm-store/ -coverage/ # Environment .env @@ -51,7 +48,6 @@ Cargo.lock # Example temp files .tmp-upload/ *.db -.openhandoff/ # CLI binaries (downloaded during npm publish) sdks/cli/platforms/*/bin/ diff --git a/.npmrc b/.npmrc deleted file mode 100644 index f301fedf..00000000 --- a/.npmrc +++ /dev/null @@ -1 +0,0 @@ -auto-install-peers=false diff --git a/CLAUDE.md b/CLAUDE.md index 866a3f18..2934e3ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,8 +54,8 @@ - `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers. - `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers. - `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`. -- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, `onSessionEvent`, `setSessionMode`, `setSessionModel`, `setSessionThoughtLevel`, `setSessionConfigOption`, `getSessionConfigOptions`, and `getSessionModes`. -- `Session` helpers are `prompt(...)`, `send(...)`, `onEvent(...)`, `setMode(...)`, `setModel(...)`, `setThoughtLevel(...)`, `setConfigOption(...)`, `getConfigOptions()`, and `getModes()`. +- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, and `onSessionEvent`. +- `Session` helpers are `prompt(...)`, `send(...)`, and `onEvent(...)`. - Cleanup is `sdk.dispose()`. ### Docs Source Of Truth @@ -86,8 +86,6 @@ - Regenerate `docs/openapi.json` when HTTP contracts change. - Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation. - Append blockers/decisions to `research/acp/friction.md` during ACP work. -- `docs/agent-capabilities.mdx` lists models/modes/thought levels per agent. Update it when adding a new agent or changing `fallback_config_options`. If its "Last updated" date is >2 weeks old, re-run `cd scripts/agent-configs && npx tsx dump.ts` and update the doc to match. Source data: `scripts/agent-configs/resources/*.json` and hardcoded entries in `server/packages/sandbox-agent/src/router/support.rs` (`fallback_config_options`). -- Some agent models are gated by subscription (e.g. Claude `opus`). The live report only shows models available to the current credentials. The static doc and JSON resource files should list all known models regardless of subscription tier. - TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior. - Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests. @@ -109,7 +107,6 @@ - `docs/cli.mdx` - `docs/quickstart.mdx` - `docs/sdk-overview.mdx` - - `docs/react-components.mdx` - `docs/session-persistence.mdx` - `docs/deploy/local.mdx` - `docs/deploy/cloudflare.mdx` diff --git a/Cargo.toml b/Cargo.toml index 95f13c72..ebef66d4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["server/packages/*", "gigacode"] [workspace.package] -version = "0.3.0" +version = "0.2.1" edition = "2021" authors = [ "Rivet Gaming, LLC " ] license = "Apache-2.0" @@ -12,13 +12,13 @@ description = "Universal API for automatic coding agents in sandboxes. Supports [workspace.dependencies] # Internal crates -sandbox-agent = { version = "0.3.0", path = "server/packages/sandbox-agent" } -sandbox-agent-error = { version = "0.3.0", path = "server/packages/error" } -sandbox-agent-agent-management = { version = "0.3.0", path = "server/packages/agent-management" } -sandbox-agent-agent-credentials = { version = "0.3.0", path = "server/packages/agent-credentials" } -sandbox-agent-opencode-adapter = { version = "0.3.0", path = "server/packages/opencode-adapter" } -sandbox-agent-opencode-server-manager = { version = "0.3.0", path = "server/packages/opencode-server-manager" } -acp-http-adapter = { version = "0.3.0", path = "server/packages/acp-http-adapter" } +sandbox-agent = { version = "0.2.1", path = "server/packages/sandbox-agent" } +sandbox-agent-error = { version = "0.2.1", path = "server/packages/error" } +sandbox-agent-agent-management = { version = "0.2.1", path = "server/packages/agent-management" } +sandbox-agent-agent-credentials = { version = "0.2.1", path = "server/packages/agent-credentials" } +sandbox-agent-opencode-adapter = { version = "0.2.1", path = "server/packages/opencode-adapter" } +sandbox-agent-opencode-server-manager = { version = "0.2.1", path = "server/packages/opencode-server-manager" } +acp-http-adapter = { version = "0.2.1", path = "server/packages/acp-http-adapter" } # Serialization serde = { version = "1.0", features = ["derive"] } @@ -32,7 +32,7 @@ schemars = "0.8" utoipa = { version = "4.2", features = ["axum_extras"] } # Web framework -axum = { version = "0.7", features = ["ws"] } +axum = "0.7" tower = { version = "0.5", features = ["util"] } tower-http = { version = "0.5", features = ["cors", "trace"] } diff --git a/README.md b/README.md index 535e5ad4..b7ac39a3 100644 --- a/README.md +++ b/README.md @@ -80,11 +80,11 @@ Import the SDK directly into your Node or browser application. Full type safety **Install** ```bash -npm install sandbox-agent@0.3.x +npm install sandbox-agent@0.2.x ``` ```bash -bun add sandbox-agent@0.3.x +bun add sandbox-agent@0.2.x # Optional: allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -138,7 +138,7 @@ Run as an HTTP server and connect from any language. Deploy to E2B, Daytona, Ver ```bash # Install it -curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh # Run it sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 ``` @@ -165,12 +165,12 @@ sandbox-agent server --no-token --host 127.0.0.1 --port 2468 Install the CLI wrapper (optional but convenient): ```bash -npm install -g @sandbox-agent/cli@0.3.x +npm install -g @sandbox-agent/cli@0.2.x ``` ```bash # Allow Bun to run postinstall scripts for native binaries. -bun add -g @sandbox-agent/cli@0.3.x +bun add -g @sandbox-agent/cli@0.2.x bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -185,11 +185,11 @@ sandbox-agent api sessions send-message-stream my-session --message "Hello" --en You can also use npx like: ```bash -npx @sandbox-agent/cli@0.3.x --help +npx @sandbox-agent/cli@0.2.x --help ``` ```bash -bunx @sandbox-agent/cli@0.3.x --help +bunx @sandbox-agent/cli@0.2.x --help ``` [CLI documentation](https://sandboxagent.dev/docs/cli) diff --git a/docker/release/linux-aarch64.Dockerfile b/docker/release/linux-aarch64.Dockerfile index 412e6c0b..ef70c145 100644 --- a/docker/release/linux-aarch64.Dockerfile +++ b/docker/release/linux-aarch64.Dockerfile @@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ -COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ # Install dependencies @@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/persist-indexeddb ./sdks/persist-indexeddb -COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup -RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build COPY frontend/packages/inspector ./frontend/packages/inspector diff --git a/docker/release/linux-x86_64.Dockerfile b/docker/release/linux-x86_64.Dockerfile index 323e4713..262543bf 100644 --- a/docker/release/linux-x86_64.Dockerfile +++ b/docker/release/linux-x86_64.Dockerfile @@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ -COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ # Install dependencies @@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/persist-indexeddb ./sdks/persist-indexeddb -COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup -RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build COPY frontend/packages/inspector ./frontend/packages/inspector diff --git a/docker/release/macos-aarch64.Dockerfile b/docker/release/macos-aarch64.Dockerfile index 000157eb..e93ab157 100644 --- a/docker/release/macos-aarch64.Dockerfile +++ b/docker/release/macos-aarch64.Dockerfile @@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ -COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ # Install dependencies @@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/persist-indexeddb ./sdks/persist-indexeddb -COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup -RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build COPY frontend/packages/inspector ./frontend/packages/inspector diff --git a/docker/release/macos-x86_64.Dockerfile b/docker/release/macos-x86_64.Dockerfile index 9082018a..af573364 100644 --- a/docker/release/macos-x86_64.Dockerfile +++ b/docker/release/macos-x86_64.Dockerfile @@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ -COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ # Install dependencies @@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/persist-indexeddb ./sdks/persist-indexeddb -COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup -RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build COPY frontend/packages/inspector ./frontend/packages/inspector diff --git a/docker/release/windows.Dockerfile b/docker/release/windows.Dockerfile index 9c7694d0..83b3f372 100644 --- a/docker/release/windows.Dockerfile +++ b/docker/release/windows.Dockerfile @@ -11,7 +11,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ -COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ # Install dependencies @@ -22,15 +21,13 @@ COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/persist-indexeddb ./sdks/persist-indexeddb -COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup -RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build COPY frontend/packages/inspector ./frontend/packages/inspector diff --git a/docker/runtime/Dockerfile b/docker/runtime/Dockerfile index 27b95607..029a969c 100644 --- a/docker/runtime/Dockerfile +++ b/docker/runtime/Dockerfile @@ -13,7 +13,6 @@ COPY frontend/packages/inspector/package.json ./frontend/packages/inspector/ COPY sdks/cli-shared/package.json ./sdks/cli-shared/ COPY sdks/acp-http-client/package.json ./sdks/acp-http-client/ COPY sdks/persist-indexeddb/package.json ./sdks/persist-indexeddb/ -COPY sdks/react/package.json ./sdks/react/ COPY sdks/typescript/package.json ./sdks/typescript/ # Install dependencies @@ -24,15 +23,13 @@ COPY docs/openapi.json ./docs/ COPY sdks/cli-shared ./sdks/cli-shared COPY sdks/acp-http-client ./sdks/acp-http-client COPY sdks/persist-indexeddb ./sdks/persist-indexeddb -COPY sdks/react ./sdks/react COPY sdks/typescript ./sdks/typescript -# Build cli-shared, acp-http-client, SDK, then persist-indexeddb and react (depends on SDK) +# Build cli-shared, acp-http-client, SDK, then persist-indexeddb (depends on SDK) RUN cd sdks/cli-shared && pnpm exec tsup RUN cd sdks/acp-http-client && pnpm exec tsup RUN cd sdks/typescript && SKIP_OPENAPI_GEN=1 pnpm exec tsup RUN cd sdks/persist-indexeddb && pnpm exec tsup -RUN cd sdks/react && pnpm exec tsup # Copy inspector source and build COPY frontend/packages/inspector ./frontend/packages/inspector diff --git a/docs/agent-capabilities.mdx b/docs/agent-capabilities.mdx deleted file mode 100644 index 13f27235..00000000 --- a/docs/agent-capabilities.mdx +++ /dev/null @@ -1,127 +0,0 @@ ---- -title: "Agent Capabilities" -description: "Models, modes, and thought levels supported by each agent." ---- - -Capabilities are subject to change as the agents are updated. See [Agent Sessions](/agent-sessions) for full session configuration API details. - - - - _Last updated: March 5th, 2026. See [Generating a live report](#generating-a-live-report) for up-to-date reference._ - - -## Claude - -| Category | Values | -|----------|--------| -| **Models** | `default`, `sonnet`, `opus`, `haiku` | -| **Modes** | `default`, `acceptEdits`, `plan`, `dontAsk`, `bypassPermissions` | -| **Thought levels** | Unsupported | - -### Configuring Effort Level For Claude - -Claude does not natively support changing effort level after a session starts, so configure it in the filesystem before creating the session. - -```ts -import { mkdir, writeFile } from "node:fs/promises"; -import path from "node:path"; -import { SandboxAgent } from "sandbox-agent"; - -const cwd = "/path/to/workspace"; -await mkdir(path.join(cwd, ".claude"), { recursive: true }); -await writeFile( - path.join(cwd, ".claude", "settings.json"), - JSON.stringify({ effortLevel: "high" }, null, 2), -); - -const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" }); -await sdk.createSession({ - agent: "claude", - sessionInit: { cwd, mcpServers: [] }, -}); -``` - - - -1. `~/.claude/settings.json` -2. `/.claude/settings.json` -3. `/.claude/settings.local.json` - - - -## Codex - -| Category | Values | -|----------|--------| -| **Models** | `gpt-5.3-codex` (default), `gpt-5.3-codex-spark`, `gpt-5.2-codex`, `gpt-5.1-codex-max`, `gpt-5.2`, `gpt-5.1-codex-mini` | -| **Modes** | `read-only` (default), `auto`, `full-access` | -| **Thought levels** | `low`, `medium`, `high` (default), `xhigh` | - -## OpenCode - -| Category | Values | -|----------|--------| -| **Models** | See below | -| **Modes** | `build` (default), `plan` | -| **Thought levels** | Unsupported | - - - -| Provider | Models | -|----------|--------| -| **Anthropic** | `anthropic/claude-3-5-haiku-20241022`, `anthropic/claude-3-5-haiku-latest`, `anthropic/claude-3-5-sonnet-20240620`, `anthropic/claude-3-5-sonnet-20241022`, `anthropic/claude-3-7-sonnet-20250219`, `anthropic/claude-3-7-sonnet-latest`, `anthropic/claude-3-haiku-20240307`, `anthropic/claude-3-opus-20240229`, `anthropic/claude-3-sonnet-20240229`, `anthropic/claude-haiku-4-5`, `anthropic/claude-haiku-4-5-20251001`, `anthropic/claude-opus-4-0`, `anthropic/claude-opus-4-1`, `anthropic/claude-opus-4-1-20250805`, `anthropic/claude-opus-4-20250514`, `anthropic/claude-opus-4-5`, `anthropic/claude-opus-4-5-20251101`, `anthropic/claude-opus-4-6`, `anthropic/claude-sonnet-4-0`, `anthropic/claude-sonnet-4-20250514`, `anthropic/claude-sonnet-4-5`, `anthropic/claude-sonnet-4-5-20250929` | -| **OpenAI** | `openai/gpt-5.1-codex`, `openai/gpt-5.1-codex-max`, `openai/gpt-5.1-codex-mini`, `openai/gpt-5.2`, `openai/gpt-5.2-codex`, `openai/gpt-5.3-codex` | -| **Cerebras** | `cerebras/gpt-oss-120b`, `cerebras/qwen-3-235b-a22b-instruct-2507`, `cerebras/zai-glm-4.7` | -| **OpenCode Zen** | `opencode/big-pickle`, `opencode/claude-3-5-haiku`, `opencode/claude-haiku-4-5`, `opencode/claude-opus-4-1`, `opencode/claude-opus-4-5`, `opencode/claude-opus-4-6`, `opencode/claude-sonnet-4`, `opencode/claude-sonnet-4-5`, `opencode/gemini-3-flash`, `opencode/gemini-3-pro` (default), `opencode/glm-4.6`, `opencode/glm-4.7`, `opencode/gpt-5`, `opencode/gpt-5-codex`, `opencode/gpt-5-nano`, `opencode/gpt-5.1`, `opencode/gpt-5.1-codex`, `opencode/gpt-5.1-codex-max`, `opencode/gpt-5.1-codex-mini`, `opencode/gpt-5.2`, `opencode/gpt-5.2-codex`, `opencode/kimi-k2`, `opencode/kimi-k2-thinking`, `opencode/kimi-k2.5`, `opencode/kimi-k2.5-free`, `opencode/minimax-m2.1`, `opencode/minimax-m2.1-free`, `opencode/trinity-large-preview-free` | - - - -## Cursor - -| Category | Values | -|----------|--------| -| **Models** | See below | -| **Modes** | Unsupported | -| **Thought levels** | Unsupported | - - - -| Group | Models | -|-------|--------| -| **Auto** | `auto` | -| **Composer** | `composer-1.5`, `composer-1` | -| **GPT-5.3 Codex** | `gpt-5.3-codex`, `gpt-5.3-codex-low`, `gpt-5.3-codex-high`, `gpt-5.3-codex-xhigh`, `gpt-5.3-codex-fast`, `gpt-5.3-codex-low-fast`, `gpt-5.3-codex-high-fast`, `gpt-5.3-codex-xhigh-fast` | -| **GPT-5.2** | `gpt-5.2`, `gpt-5.2-high`, `gpt-5.2-codex`, `gpt-5.2-codex-low`, `gpt-5.2-codex-high`, `gpt-5.2-codex-xhigh`, `gpt-5.2-codex-fast`, `gpt-5.2-codex-low-fast`, `gpt-5.2-codex-high-fast`, `gpt-5.2-codex-xhigh-fast` | -| **GPT-5.1** | `gpt-5.1-high`, `gpt-5.1-codex-max`, `gpt-5.1-codex-max-high` | -| **Claude** | `opus-4.6-thinking` (default), `opus-4.6`, `opus-4.5`, `opus-4.5-thinking`, `sonnet-4.5`, `sonnet-4.5-thinking` | -| **Other** | `gemini-3-pro`, `gemini-3-flash`, `grok` | - - - -## Amp - -| Category | Values | -|----------|--------| -| **Models** | `amp-default` | -| **Modes** | `default`, `bypass` | -| **Thought levels** | Unsupported | - -## Pi - -| Category | Values | -|----------|--------| -| **Models** | `default` | -| **Modes** | Unsupported | -| **Thought levels** | Unsupported | - -## Generating a live report - -Requires a running Sandbox Agent server. `--endpoint` defaults to `http://127.0.0.1:2468`. - -```bash -sandbox-agent api agents report -``` - - - The live report reflects what the agent adapter returns for the current credentials. Some models may be gated by subscription (e.g. Claude's `opus` requires a paid plan) and will not appear in the report if the credentials don't have access. - diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx index a224acd0..ac29b9ff 100644 --- a/docs/agent-sessions.mdx +++ b/docs/agent-sessions.mdx @@ -82,49 +82,6 @@ if (sessions.items.length > 0) { } ``` -## Configure model, mode, and thought level - -Set the model, mode, or thought level on a session at creation time or after: - -```ts -// At creation time -const session = await sdk.createSession({ - agent: "codex", - model: "gpt-5.3-codex", - mode: "auto", - thoughtLevel: "high", -}); -``` - -```ts -// After creation -await session.setModel("gpt-5.2-codex"); -await session.setMode("full-access"); -await session.setThoughtLevel("medium"); -``` - -Query available modes: - -```ts -const modes = await session.getModes(); -console.log(modes?.currentModeId, modes?.availableModes); -``` - -### Advanced config options - -For config options beyond model, mode, and thought level, use `getConfigOptions` to discover what the agent supports and `setConfigOption` to set any option by ID: - -```ts -const options = await session.getConfigOptions(); -for (const opt of options) { - console.log(opt.id, opt.category, opt.type); -} -``` - -```ts -await session.setConfigOption("some-agent-option", "value"); -``` - ## Destroy a session ```ts diff --git a/docs/cli.mdx b/docs/cli.mdx index fa6aa4ee..9472a5e3 100644 --- a/docs/cli.mdx +++ b/docs/cli.mdx @@ -167,65 +167,6 @@ Shared option: ```bash sandbox-agent api agents list [--endpoint ] -sandbox-agent api agents report [--endpoint ] sandbox-agent api agents install [--reinstall] [--endpoint ] ``` -#### api agents list - -List all agents and their install status. - -```bash -sandbox-agent api agents list -``` - -#### api agents report - -Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category. - -```bash -sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq . -``` - -Example output: - -```json -{ - "generatedAtMs": 1740000000000, - "endpoint": "http://127.0.0.1:2468", - "agents": [ - { - "id": "claude", - "installed": true, - "models": { - "currentValue": "default", - "values": [ - { "value": "default", "name": "Default" }, - { "value": "sonnet", "name": "Sonnet" }, - { "value": "opus", "name": "Opus" }, - { "value": "haiku", "name": "Haiku" } - ] - }, - "modes": { - "currentValue": "default", - "values": [ - { "value": "default", "name": "Default" }, - { "value": "acceptEdits", "name": "Accept Edits" }, - { "value": "plan", "name": "Plan" }, - { "value": "dontAsk", "name": "Don't Ask" }, - { "value": "bypassPermissions", "name": "Bypass Permissions" } - ] - }, - "thoughtLevels": { "values": [] } - } - ] -} -``` - -See [Agent Capabilities](/agent-capabilities) for a full reference of supported models, modes, and thought levels per agent. - -#### api agents install - -```bash -sandbox-agent api agents install codex --reinstall -``` diff --git a/docs/deploy/boxlite.mdx b/docs/deploy/boxlite.mdx index 115d8b8a..fb2f8d05 100644 --- a/docs/deploy/boxlite.mdx +++ b/docs/deploy/boxlite.mdx @@ -20,7 +20,7 @@ that BoxLite can load directly (BoxLite has its own image store separate from Do ```dockerfile FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent codex ``` diff --git a/docs/deploy/cloudflare.mdx b/docs/deploy/cloudflare.mdx index 0dc1d1fd..0a19448e 100644 --- a/docs/deploy/cloudflare.mdx +++ b/docs/deploy/cloudflare.mdx @@ -25,7 +25,7 @@ cd my-sandbox ```dockerfile FROM cloudflare/sandbox:0.7.0 -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex EXPOSE 8000 diff --git a/docs/deploy/daytona.mdx b/docs/deploy/daytona.mdx index 5eb8f5df..e6d8dc83 100644 --- a/docs/deploy/daytona.mdx +++ b/docs/deploy/daytona.mdx @@ -28,7 +28,7 @@ if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_ const sandbox = await daytona.create({ envVars }); await sandbox.process.executeCommand( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh" ); await sandbox.process.executeCommand("sandbox-agent install-agent claude"); @@ -64,7 +64,7 @@ if (!hasSnapshot) { name: SNAPSHOT, image: Image.base("ubuntu:22.04").runCommands( "apt-get update && apt-get install -y curl ca-certificates", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", "sandbox-agent install-agent claude", "sandbox-agent install-agent codex", ), diff --git a/docs/deploy/docker.mdx b/docs/deploy/docker.mdx index 988382a5..28c9f77e 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -16,11 +16,17 @@ docker run --rm -p 3000:3000 \ -e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ alpine:latest sh -c "\ - apk add --no-cache curl ca-certificates libstdc++ libgcc bash nodejs npm && \ - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh && \ + apk add --no-cache curl ca-certificates libstdc++ libgcc bash && \ + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && \ + sandbox-agent install-agent claude && \ + sandbox-agent install-agent codex && \ sandbox-agent server --no-token --host 0.0.0.0 --port 3000" ``` + +Alpine is required for some agent binaries that target musl libc. + + ## TypeScript with dockerode ```typescript @@ -31,18 +37,17 @@ const docker = new Docker(); const PORT = 3000; const container = await docker.createContainer({ - Image: "node:22-bookworm-slim", + Image: "alpine:latest", Cmd: ["sh", "-c", [ - "apt-get update", - "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", - "rm -rf /var/lib/apt/lists/*", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", + "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", + "sandbox-agent install-agent claude", + "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, ].join(" && ")], Env: [ `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}`, `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}`, - `CODEX_API_KEY=${process.env.CODEX_API_KEY}`, ].filter(Boolean), ExposedPorts: { [`${PORT}/tcp`]: {} }, HostConfig: { @@ -56,7 +61,7 @@ await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; const sdk = await SandboxAgent.connect({ baseUrl }); -const session = await sdk.createSession({ agent: "codex" }); +const session = await sdk.createSession({ agent: "claude" }); await session.prompt([{ type: "text", text: "Summarize this repository." }]); ``` diff --git a/docs/deploy/e2b.mdx b/docs/deploy/e2b.mdx index 8ea4c746..f7ac7ba2 100644 --- a/docs/deploy/e2b.mdx +++ b/docs/deploy/e2b.mdx @@ -21,7 +21,7 @@ if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); await sandbox.commands.run( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh" + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh" ); await sandbox.commands.run("sandbox-agent install-agent claude"); diff --git a/docs/deploy/local.mdx b/docs/deploy/local.mdx index eab8f3f4..8af9f514 100644 --- a/docs/deploy/local.mdx +++ b/docs/deploy/local.mdx @@ -9,7 +9,7 @@ For local development, run Sandbox Agent directly on your machine. ```bash # Install -curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh # Run sandbox-agent server --no-token --host 127.0.0.1 --port 2468 @@ -20,12 +20,12 @@ Or with npm/Bun: ```bash - npx @sandbox-agent/cli@0.3.x server --no-token --host 127.0.0.1 --port 2468 + npx @sandbox-agent/cli@0.2.x server --no-token --host 127.0.0.1 --port 2468 ``` ```bash - bunx @sandbox-agent/cli@0.3.x server --no-token --host 127.0.0.1 --port 2468 + bunx @sandbox-agent/cli@0.2.x server --no-token --host 127.0.0.1 --port 2468 ``` diff --git a/docs/deploy/vercel.mdx b/docs/deploy/vercel.mdx index 2025d670..4a840eef 100644 --- a/docs/deploy/vercel.mdx +++ b/docs/deploy/vercel.mdx @@ -30,7 +30,7 @@ const run = async (cmd: string, args: string[] = []) => { } }; -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); +await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"]); await run("sandbox-agent", ["install-agent", "claude"]); await run("sandbox-agent", ["install-agent", "codex"]); diff --git a/docs/docs.json b/docs/docs.json index 2d572760..b2b3a6a7 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -51,7 +51,6 @@ "pages": [ "quickstart", "sdk-overview", - "react-components", { "group": "Deploy", "icon": "server", @@ -80,7 +79,7 @@ }, { "group": "System", - "pages": ["file-system", "processes"] + "pages": ["file-system"] }, { "group": "Orchestration", @@ -95,7 +94,6 @@ { "group": "Reference", "pages": [ - "agent-capabilities", "cli", "inspector", "opencode-compatibility", diff --git a/docs/inspector.mdx b/docs/inspector.mdx index 06318b2f..f3b3dc61 100644 --- a/docs/inspector.mdx +++ b/docs/inspector.mdx @@ -34,18 +34,9 @@ console.log(url); - Event JSON inspector - Prompt testing - Request/response debugging -- Process management (create, stop, kill, delete, view logs) -- Interactive PTY terminal for tty processes -- One-shot command execution ## When to use - Development: validate session behavior quickly - Debugging: inspect raw event payloads - Integration work: compare UI behavior with SDK/API calls - -## Process terminal - -The Inspector includes an embedded Ghostty-based terminal for interactive tty -processes. The UI uses the SDK's high-level `connectProcessTerminal(...)` -wrapper via the shared `@sandbox-agent/react` `ProcessTerminal` component. diff --git a/docs/observability.mdx b/docs/observability.mdx index 5b5751b4..770fe8bc 100644 --- a/docs/observability.mdx +++ b/docs/observability.mdx @@ -1,7 +1,7 @@ --- title: "Observability" description: "Track session activity with OpenTelemetry." -icon: "chart-line" +icon: "terminal" --- Use OpenTelemetry to instrument session traffic, then ship telemetry to your collector/backend. diff --git a/docs/openapi.json b/docs/openapi.json index b399f745..c6e35f4e 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -10,7 +10,7 @@ "license": { "name": "Apache-2.0" }, - "version": "0.3.0" + "version": "0.2.1" }, "servers": [ { @@ -948,1852 +948,653 @@ } } } - }, - "/v1/processes": { - "get": { - "tags": [ - "v1" - ], - "summary": "List all managed processes.", - "description": "Returns a list of all processes (running and exited) currently tracked\nby the runtime, sorted by process ID.", - "operationId": "get_v1_processes", - "responses": { - "200": { - "description": "List processes", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessListResponse" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "post": { - "tags": [ - "v1" - ], - "summary": "Create a long-lived managed process.", - "description": "Spawns a new process with the given command and arguments. Supports both\npipe-based and PTY (tty) modes. Returns the process descriptor on success.", - "operationId": "post_v1_processes", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessCreateRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Started process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessInfo" - } - } - } - }, - "400": { - "description": "Invalid request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Process limit or state conflict", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/config": { - "get": { - "tags": [ - "v1" - ], - "summary": "Get process runtime configuration.", - "description": "Returns the current runtime configuration for the process management API,\nincluding limits for concurrency, timeouts, and buffer sizes.", - "operationId": "get_v1_processes_config", - "responses": { - "200": { - "description": "Current runtime process config", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessConfig" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "post": { - "tags": [ - "v1" - ], - "summary": "Update process runtime configuration.", - "description": "Replaces the runtime configuration for the process management API.\nValidates that all values are non-zero and clamps default timeout to max.", - "operationId": "post_v1_processes_config", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessConfig" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Updated runtime process config", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessConfig" - } - } - } - }, - "400": { - "description": "Invalid config", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/run": { - "post": { - "tags": [ - "v1" - ], - "summary": "Run a one-shot command.", - "description": "Executes a command to completion and returns its stdout, stderr, exit code,\nand duration. Supports configurable timeout and output size limits.", - "operationId": "post_v1_processes_run", - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessRunRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "One-off command result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessRunResponse" - } - } - } - }, - "400": { - "description": "Invalid request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/{id}": { - "get": { - "tags": [ - "v1" - ], - "summary": "Get a single process by ID.", - "description": "Returns the current state of a managed process including its status,\nPID, exit code, and creation/exit timestamps.", - "operationId": "get_v1_process", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Process ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "Process details", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessInfo" - } - } - } - }, - "404": { - "description": "Unknown process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - }, - "delete": { - "tags": [ - "v1" - ], - "summary": "Delete a process record.", - "description": "Removes a stopped process from the runtime. Returns 409 if the process\nis still running; stop or kill it first.", - "operationId": "delete_v1_process", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Process ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "responses": { - "204": { - "description": "Process deleted" - }, - "404": { - "description": "Unknown process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Process is still running", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/{id}/input": { - "post": { - "tags": [ - "v1" - ], - "summary": "Write input to a process.", - "description": "Sends data to a process's stdin (pipe mode) or PTY writer (tty mode).\nData can be encoded as base64, utf8, or text. Returns 413 if the decoded\npayload exceeds the configured `maxInputBytesPerRequest` limit.", - "operationId": "post_v1_process_input", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Process ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessInputRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Input accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessInputResponse" - } - } - } - }, - "400": { - "description": "Invalid request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Process not writable", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "413": { - "description": "Input exceeds configured limit", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/{id}/kill": { - "post": { - "tags": [ - "v1" - ], - "summary": "Send SIGKILL to a process.", - "description": "Sends SIGKILL to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.", - "operationId": "post_v1_process_kill", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Process ID", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "waitMs", - "in": "query", - "description": "Wait up to N ms for process to exit", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "Kill signal sent", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessInfo" - } - } - } - }, - "404": { - "description": "Unknown process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/{id}/logs": { - "get": { - "tags": [ - "v1" - ], - "summary": "Fetch process logs.", - "description": "Returns buffered log entries for a process. Supports filtering by stream\ntype, tail count, and sequence-based resumption. When `follow=true`,\nreturns an SSE stream that replays buffered entries then streams live output.", - "operationId": "get_v1_process_logs", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Process ID", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "stream", - "in": "query", - "description": "stdout|stderr|combined|pty", - "required": false, - "schema": { - "allOf": [ - { - "$ref": "#/components/schemas/ProcessLogsStream" - } - ], - "nullable": true - } - }, - { - "name": "tail", - "in": "query", - "description": "Tail N entries", - "required": false, - "schema": { - "type": "integer", - "nullable": true, - "minimum": 0 - } - }, - { - "name": "follow", - "in": "query", - "description": "Follow via SSE", - "required": false, - "schema": { - "type": "boolean", - "nullable": true - } - }, - { - "name": "since", - "in": "query", - "description": "Only entries with sequence greater than this", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "Process logs", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessLogsResponse" - } - } - } - }, - "404": { - "description": "Unknown process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/{id}/stop": { - "post": { - "tags": [ - "v1" - ], - "summary": "Send SIGTERM to a process.", - "description": "Sends SIGTERM to the process and optionally waits up to `waitMs`\nmilliseconds for the process to exit before returning.", - "operationId": "post_v1_process_stop", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Process ID", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "waitMs", - "in": "query", - "description": "Wait up to N ms for process to exit", - "required": false, - "schema": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - } - } - ], - "responses": { - "200": { - "description": "Stop signal sent", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessInfo" - } - } - } - }, - "404": { - "description": "Unknown process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/{id}/terminal/resize": { - "post": { - "tags": [ - "v1" - ], - "summary": "Resize a process terminal.", - "description": "Sets the PTY window size (columns and rows) for a tty-mode process and\nsends SIGWINCH so the child process can adapt.", - "operationId": "post_v1_process_terminal_resize", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Process ID", - "required": true, - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessTerminalResizeRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Resize accepted", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProcessTerminalResizeResponse" - } - } - } - }, - "400": { - "description": "Invalid request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Unknown process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Not a terminal process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - }, - "/v1/processes/{id}/terminal/ws": { - "get": { - "tags": [ - "v1" - ], - "summary": "Open an interactive WebSocket terminal session.", - "description": "Upgrades the connection to a WebSocket for bidirectional PTY I/O. Accepts\n`access_token` query param for browser-based auth (WebSocket API cannot\nsend custom headers). Streams raw PTY output as binary frames and accepts\nJSON control frames for input, resize, and close.", - "operationId": "get_v1_process_terminal_ws", - "parameters": [ - { - "name": "id", - "in": "path", - "description": "Process ID", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "access_token", - "in": "query", - "description": "Bearer token alternative for WS auth", - "required": false, - "schema": { - "type": "string", - "nullable": true - } - } - ], - "responses": { - "101": { - "description": "WebSocket upgraded" - }, - "400": { - "description": "Invalid websocket frame or upgrade request", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "404": { - "description": "Unknown process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "409": { - "description": "Not a terminal process", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - }, - "501": { - "description": "Process API unsupported on this platform", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "AcpEnvelope": { - "type": "object", - "required": [ - "jsonrpc" - ], - "properties": { - "error": { - "nullable": true - }, - "id": { - "nullable": true - }, - "jsonrpc": { - "type": "string" - }, - "method": { - "type": "string", - "nullable": true - }, - "params": { - "nullable": true - }, - "result": { - "nullable": true - } - } - }, - "AcpPostQuery": { - "type": "object", - "properties": { - "agent": { - "type": "string", - "nullable": true - } - } - }, - "AcpServerInfo": { - "type": "object", - "required": [ - "serverId", - "agent", - "createdAtMs" - ], - "properties": { - "agent": { - "type": "string" - }, - "createdAtMs": { - "type": "integer", - "format": "int64" - }, - "serverId": { - "type": "string" - } - } - }, - "AcpServerListResponse": { - "type": "object", - "required": [ - "servers" - ], - "properties": { - "servers": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AcpServerInfo" - } - } - } - }, - "AgentCapabilities": { - "type": "object", - "required": [ - "planMode", - "permissions", - "questions", - "toolCalls", - "toolResults", - "textMessages", - "images", - "fileAttachments", - "sessionLifecycle", - "errorEvents", - "reasoning", - "status", - "commandExecution", - "fileChanges", - "mcpTools", - "streamingDeltas", - "itemStarted", - "sharedProcess" - ], - "properties": { - "commandExecution": { - "type": "boolean" - }, - "errorEvents": { - "type": "boolean" - }, - "fileAttachments": { - "type": "boolean" - }, - "fileChanges": { - "type": "boolean" - }, - "images": { - "type": "boolean" - }, - "itemStarted": { - "type": "boolean" - }, - "mcpTools": { - "type": "boolean" - }, - "permissions": { - "type": "boolean" - }, - "planMode": { - "type": "boolean" - }, - "questions": { - "type": "boolean" - }, - "reasoning": { - "type": "boolean" - }, - "sessionLifecycle": { - "type": "boolean" - }, - "sharedProcess": { - "type": "boolean" - }, - "status": { - "type": "boolean" - }, - "streamingDeltas": { - "type": "boolean" - }, - "textMessages": { - "type": "boolean" - }, - "toolCalls": { - "type": "boolean" - }, - "toolResults": { - "type": "boolean" - } - } - }, - "AgentInfo": { - "type": "object", - "required": [ - "id", - "installed", - "credentialsAvailable", - "capabilities" - ], - "properties": { - "capabilities": { - "$ref": "#/components/schemas/AgentCapabilities" - }, - "configError": { - "type": "string", - "nullable": true - }, - "configOptions": { - "type": "array", - "items": {}, - "nullable": true - }, - "credentialsAvailable": { - "type": "boolean" - }, - "id": { - "type": "string" - }, - "installed": { - "type": "boolean" - }, - "path": { - "type": "string", - "nullable": true - }, - "serverStatus": { - "allOf": [ - { - "$ref": "#/components/schemas/ServerStatusInfo" - } - ], - "nullable": true - }, - "version": { - "type": "string", - "nullable": true - } - } - }, - "AgentInstallArtifact": { - "type": "object", - "required": [ - "kind", - "path", - "source" - ], - "properties": { - "kind": { - "type": "string" - }, - "path": { - "type": "string" - }, - "source": { - "type": "string" - }, - "version": { - "type": "string", - "nullable": true - } - } - }, - "AgentInstallRequest": { - "type": "object", - "properties": { - "agentProcessVersion": { - "type": "string", - "nullable": true - }, - "agentVersion": { - "type": "string", - "nullable": true - }, - "reinstall": { - "type": "boolean", - "nullable": true - } - } - }, - "AgentInstallResponse": { - "type": "object", - "required": [ - "already_installed", - "artifacts" - ], - "properties": { - "already_installed": { - "type": "boolean" - }, - "artifacts": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentInstallArtifact" - } - } - } - }, - "AgentListResponse": { - "type": "object", - "required": [ - "agents" - ], - "properties": { - "agents": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentInfo" - } - } - } - }, - "ErrorType": { - "type": "string", - "enum": [ - "invalid_request", - "conflict", - "unsupported_agent", - "agent_not_installed", - "install_failed", - "agent_process_exited", - "token_invalid", - "permission_denied", - "not_acceptable", - "unsupported_media_type", - "not_found", - "session_not_found", - "session_already_exists", - "mode_not_supported", - "stream_error", - "timeout" - ] - }, - "FsActionResponse": { - "type": "object", - "required": [ - "path" - ], - "properties": { - "path": { - "type": "string" - } - } - }, - "FsDeleteQuery": { - "type": "object", - "required": [ - "path" - ], - "properties": { - "path": { - "type": "string" - }, - "recursive": { - "type": "boolean", - "nullable": true - } - } - }, - "FsEntriesQuery": { - "type": "object", - "properties": { - "path": { - "type": "string", - "nullable": true - } - } - }, - "FsEntry": { - "type": "object", - "required": [ - "name", - "path", - "entryType", - "size" - ], - "properties": { - "entryType": { - "$ref": "#/components/schemas/FsEntryType" - }, - "modified": { - "type": "string", - "nullable": true - }, - "name": { - "type": "string" - }, - "path": { - "type": "string" - }, - "size": { - "type": "integer", - "format": "int64", - "minimum": 0 - } - } - }, - "FsEntryType": { - "type": "string", - "enum": [ - "file", - "directory" - ] - }, - "FsMoveRequest": { - "type": "object", - "required": [ - "from", - "to" - ], - "properties": { - "from": { - "type": "string" - }, - "overwrite": { - "type": "boolean", - "nullable": true - }, - "to": { - "type": "string" - } - } - }, - "FsMoveResponse": { - "type": "object", - "required": [ - "from", - "to" - ], - "properties": { - "from": { - "type": "string" - }, - "to": { - "type": "string" - } - } - }, - "FsPathQuery": { - "type": "object", - "required": [ - "path" - ], - "properties": { - "path": { - "type": "string" - } - } - }, - "FsStat": { + } + }, + "components": { + "schemas": { + "AcpEnvelope": { "type": "object", - "required": [ - "path", - "entryType", - "size" + "required": [ + "jsonrpc" ], "properties": { - "entryType": { - "$ref": "#/components/schemas/FsEntryType" + "error": { + "nullable": true }, - "modified": { - "type": "string", + "id": { "nullable": true }, - "path": { + "jsonrpc": { "type": "string" }, - "size": { - "type": "integer", - "format": "int64", - "minimum": 0 + "method": { + "type": "string", + "nullable": true + }, + "params": { + "nullable": true + }, + "result": { + "nullable": true } } }, - "FsUploadBatchQuery": { + "AcpPostQuery": { "type": "object", "properties": { - "path": { + "agent": { "type": "string", "nullable": true } } }, - "FsUploadBatchResponse": { + "AcpServerInfo": { "type": "object", "required": [ - "paths", - "truncated" + "serverId", + "agent", + "createdAtMs" ], "properties": { - "paths": { - "type": "array", - "items": { - "type": "string" - } + "agent": { + "type": "string" }, - "truncated": { - "type": "boolean" - } - } - }, - "FsWriteResponse": { - "type": "object", - "required": [ - "path", - "bytesWritten" - ], - "properties": { - "bytesWritten": { + "createdAtMs": { "type": "integer", - "format": "int64", - "minimum": 0 + "format": "int64" }, - "path": { + "serverId": { "type": "string" } } }, - "HealthResponse": { + "AcpServerListResponse": { "type": "object", "required": [ - "status" + "servers" ], "properties": { - "status": { - "type": "string" + "servers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AcpServerInfo" + } } } }, - "McpConfigQuery": { + "AgentCapabilities": { "type": "object", "required": [ - "directory", - "mcpName" + "planMode", + "permissions", + "questions", + "toolCalls", + "toolResults", + "textMessages", + "images", + "fileAttachments", + "sessionLifecycle", + "errorEvents", + "reasoning", + "status", + "commandExecution", + "fileChanges", + "mcpTools", + "streamingDeltas", + "itemStarted", + "sharedProcess" ], "properties": { - "directory": { - "type": "string" + "commandExecution": { + "type": "boolean" }, - "mcpName": { - "type": "string" - } - } - }, - "McpServerConfig": { - "oneOf": [ - { - "type": "object", - "required": [ - "command", - "type" - ], - "properties": { - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "command": { - "$ref": "#/components/schemas/McpCommand" - }, - "cwd": { - "type": "string", - "nullable": true - }, - "enabled": { - "type": "boolean", - "nullable": true - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "nullable": true - }, - "timeoutMs": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - }, - "type": { - "type": "string", - "enum": [ - "local" - ] - } - } + "errorEvents": { + "type": "boolean" }, - { - "type": "object", - "required": [ - "url", - "type" - ], - "properties": { - "bearerTokenEnvVar": { - "type": "string", - "nullable": true - }, - "enabled": { - "type": "boolean", - "nullable": true - }, - "envHeaders": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "nullable": true - }, - "headers": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "nullable": true - }, - "oauth": { - "allOf": [ - { - "$ref": "#/components/schemas/McpOAuthConfigOrDisabled" - } - ], - "nullable": true - }, - "timeoutMs": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - }, - "transport": { - "allOf": [ - { - "$ref": "#/components/schemas/McpRemoteTransport" - } - ], - "nullable": true - }, - "type": { - "type": "string", - "enum": [ - "remote" - ] - }, - "url": { - "type": "string" - } - } + "fileAttachments": { + "type": "boolean" + }, + "fileChanges": { + "type": "boolean" + }, + "images": { + "type": "boolean" + }, + "itemStarted": { + "type": "boolean" + }, + "mcpTools": { + "type": "boolean" + }, + "permissions": { + "type": "boolean" + }, + "planMode": { + "type": "boolean" + }, + "questions": { + "type": "boolean" + }, + "reasoning": { + "type": "boolean" + }, + "sessionLifecycle": { + "type": "boolean" + }, + "sharedProcess": { + "type": "boolean" + }, + "status": { + "type": "boolean" + }, + "streamingDeltas": { + "type": "boolean" + }, + "textMessages": { + "type": "boolean" + }, + "toolCalls": { + "type": "boolean" + }, + "toolResults": { + "type": "boolean" } - ], - "discriminator": { - "propertyName": "type" } }, - "ProblemDetails": { + "AgentInfo": { "type": "object", "required": [ - "type", - "title", - "status" + "id", + "installed", + "credentialsAvailable", + "capabilities" ], "properties": { - "detail": { + "capabilities": { + "$ref": "#/components/schemas/AgentCapabilities" + }, + "configError": { "type": "string", "nullable": true }, - "instance": { - "type": "string", + "configOptions": { + "type": "array", + "items": {}, "nullable": true }, - "status": { - "type": "integer", - "format": "int32", - "minimum": 0 + "credentialsAvailable": { + "type": "boolean" }, - "title": { + "id": { "type": "string" }, - "type": { - "type": "string" + "installed": { + "type": "boolean" + }, + "path": { + "type": "string", + "nullable": true + }, + "serverStatus": { + "allOf": [ + { + "$ref": "#/components/schemas/ServerStatusInfo" + } + ], + "nullable": true + }, + "version": { + "type": "string", + "nullable": true } - }, - "additionalProperties": {} + } }, - "ProcessConfig": { + "AgentInstallArtifact": { "type": "object", "required": [ - "maxConcurrentProcesses", - "defaultRunTimeoutMs", - "maxRunTimeoutMs", - "maxOutputBytes", - "maxLogBytesPerProcess", - "maxInputBytesPerRequest" + "kind", + "path", + "source" ], "properties": { - "defaultRunTimeoutMs": { - "type": "integer", - "format": "int64", - "minimum": 0 + "kind": { + "type": "string" }, - "maxConcurrentProcesses": { - "type": "integer", - "minimum": 0 + "path": { + "type": "string" }, - "maxInputBytesPerRequest": { - "type": "integer", - "minimum": 0 + "source": { + "type": "string" }, - "maxLogBytesPerProcess": { - "type": "integer", - "minimum": 0 + "version": { + "type": "string", + "nullable": true + } + } + }, + "AgentInstallRequest": { + "type": "object", + "properties": { + "agentProcessVersion": { + "type": "string", + "nullable": true }, - "maxOutputBytes": { - "type": "integer", - "minimum": 0 + "agentVersion": { + "type": "string", + "nullable": true }, - "maxRunTimeoutMs": { - "type": "integer", - "format": "int64", - "minimum": 0 + "reinstall": { + "type": "boolean", + "nullable": true } } }, - "ProcessCreateRequest": { + "AgentInstallResponse": { "type": "object", "required": [ - "command" + "already_installed", + "artifacts" ], "properties": { - "args": { + "already_installed": { + "type": "boolean" + }, + "artifacts": { "type": "array", "items": { - "type": "string" - } - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string", - "nullable": true - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" + "$ref": "#/components/schemas/AgentInstallArtifact" } - }, - "interactive": { - "type": "boolean" - }, - "tty": { - "type": "boolean" } } }, - "ProcessInfo": { + "AgentListResponse": { "type": "object", "required": [ - "id", - "command", - "args", - "tty", - "interactive", - "status", - "createdAtMs" + "agents" ], "properties": { - "args": { + "agents": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/AgentInfo" } - }, - "command": { + } + } + }, + "ErrorType": { + "type": "string", + "enum": [ + "invalid_request", + "conflict", + "unsupported_agent", + "agent_not_installed", + "install_failed", + "agent_process_exited", + "token_invalid", + "permission_denied", + "not_acceptable", + "unsupported_media_type", + "session_not_found", + "session_already_exists", + "mode_not_supported", + "stream_error", + "timeout" + ] + }, + "FsActionResponse": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { + "type": "string" + } + } + }, + "FsDeleteQuery": { + "type": "object", + "required": [ + "path" + ], + "properties": { + "path": { "type": "string" }, - "createdAtMs": { - "type": "integer", - "format": "int64" - }, - "cwd": { - "type": "string", + "recursive": { + "type": "boolean", "nullable": true - }, - "exitCode": { - "type": "integer", - "format": "int32", + } + } + }, + "FsEntriesQuery": { + "type": "object", + "properties": { + "path": { + "type": "string", "nullable": true + } + } + }, + "FsEntry": { + "type": "object", + "required": [ + "name", + "path", + "entryType", + "size" + ], + "properties": { + "entryType": { + "$ref": "#/components/schemas/FsEntryType" }, - "exitedAtMs": { - "type": "integer", - "format": "int64", + "modified": { + "type": "string", "nullable": true }, - "id": { + "name": { "type": "string" }, - "interactive": { - "type": "boolean" + "path": { + "type": "string" }, - "pid": { + "size": { "type": "integer", - "format": "int32", - "nullable": true, + "format": "int64", "minimum": 0 - }, - "status": { - "$ref": "#/components/schemas/ProcessState" - }, - "tty": { - "type": "boolean" } } }, - "ProcessInputRequest": { + "FsEntryType": { + "type": "string", + "enum": [ + "file", + "directory" + ] + }, + "FsMoveRequest": { "type": "object", "required": [ - "data" + "from", + "to" ], "properties": { - "data": { + "from": { "type": "string" }, - "encoding": { - "type": "string", + "overwrite": { + "type": "boolean", "nullable": true + }, + "to": { + "type": "string" } } }, - "ProcessInputResponse": { + "FsMoveResponse": { "type": "object", "required": [ - "bytesWritten" + "from", + "to" ], "properties": { - "bytesWritten": { - "type": "integer", - "minimum": 0 + "from": { + "type": "string" + }, + "to": { + "type": "string" } } }, - "ProcessListResponse": { + "FsPathQuery": { "type": "object", "required": [ - "processes" + "path" ], "properties": { - "processes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/ProcessInfo" - } + "path": { + "type": "string" } } }, - "ProcessLogEntry": { + "FsStat": { "type": "object", "required": [ - "sequence", - "stream", - "timestampMs", - "data", - "encoding" + "path", + "entryType", + "size" ], "properties": { - "data": { - "type": "string" + "entryType": { + "$ref": "#/components/schemas/FsEntryType" + }, + "modified": { + "type": "string", + "nullable": true }, - "encoding": { + "path": { "type": "string" }, - "sequence": { + "size": { "type": "integer", "format": "int64", "minimum": 0 - }, - "stream": { - "$ref": "#/components/schemas/ProcessLogsStream" - }, - "timestampMs": { - "type": "integer", - "format": "int64" } } }, - "ProcessLogsQuery": { + "FsUploadBatchQuery": { "type": "object", "properties": { - "follow": { - "type": "boolean", - "nullable": true - }, - "since": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 - }, - "stream": { - "allOf": [ - { - "$ref": "#/components/schemas/ProcessLogsStream" - } - ], + "path": { + "type": "string", "nullable": true - }, - "tail": { - "type": "integer", - "nullable": true, - "minimum": 0 } } }, - "ProcessLogsResponse": { + "FsUploadBatchResponse": { "type": "object", "required": [ - "processId", - "stream", - "entries" + "paths", + "truncated" ], "properties": { - "entries": { + "paths": { "type": "array", "items": { - "$ref": "#/components/schemas/ProcessLogEntry" + "type": "string" } }, - "processId": { - "type": "string" - }, - "stream": { - "$ref": "#/components/schemas/ProcessLogsStream" + "truncated": { + "type": "boolean" } } }, - "ProcessLogsStream": { - "type": "string", - "enum": [ - "stdout", - "stderr", - "combined", - "pty" - ] - }, - "ProcessRunRequest": { + "FsWriteResponse": { "type": "object", "required": [ - "command" + "path", + "bytesWritten" ], "properties": { - "args": { - "type": "array", - "items": { - "type": "string" - } - }, - "command": { - "type": "string" - }, - "cwd": { - "type": "string", - "nullable": true - }, - "env": { - "type": "object", - "additionalProperties": { - "type": "string" - } - }, - "maxOutputBytes": { - "type": "integer", - "nullable": true, - "minimum": 0 - }, - "timeoutMs": { + "bytesWritten": { "type": "integer", "format": "int64", - "nullable": true, "minimum": 0 + }, + "path": { + "type": "string" } } }, - "ProcessRunResponse": { + "HealthResponse": { "type": "object", "required": [ - "timedOut", - "stdout", - "stderr", - "stdoutTruncated", - "stderrTruncated", - "durationMs" + "status" ], "properties": { - "durationMs": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "exitCode": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "stderr": { - "type": "string" - }, - "stderrTruncated": { - "type": "boolean" - }, - "stdout": { + "status": { "type": "string" - }, - "stdoutTruncated": { - "type": "boolean" - }, - "timedOut": { - "type": "boolean" } } }, - "ProcessSignalQuery": { + "McpConfigQuery": { "type": "object", + "required": [ + "directory", + "mcpName" + ], "properties": { - "waitMs": { - "type": "integer", - "format": "int64", - "nullable": true, - "minimum": 0 + "directory": { + "type": "string" + }, + "mcpName": { + "type": "string" } } }, - "ProcessState": { - "type": "string", - "enum": [ - "running", - "exited" - ] - }, - "ProcessTerminalResizeRequest": { - "type": "object", - "required": [ - "cols", - "rows" - ], - "properties": { - "cols": { - "type": "integer", - "format": "int32", - "minimum": 0 + "McpServerConfig": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "command": { + "$ref": "#/components/schemas/McpCommand" + }, + "cwd": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "timeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "local" + ] + } + } }, - "rows": { - "type": "integer", - "format": "int32", - "minimum": 0 + { + "type": "object", + "required": [ + "url", + "type" + ], + "properties": { + "bearerTokenEnvVar": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "envHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "oauth": { + "allOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfigOrDisabled" + } + ], + "nullable": true + }, + "timeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "transport": { + "allOf": [ + { + "$ref": "#/components/schemas/McpRemoteTransport" + } + ], + "nullable": true + }, + "type": { + "type": "string", + "enum": [ + "remote" + ] + }, + "url": { + "type": "string" + } + } } + ], + "discriminator": { + "propertyName": "type" } }, - "ProcessTerminalResizeResponse": { + "ProblemDetails": { "type": "object", "required": [ - "cols", - "rows" + "type", + "title", + "status" ], "properties": { - "cols": { - "type": "integer", - "format": "int32", - "minimum": 0 + "detail": { + "type": "string", + "nullable": true + }, + "instance": { + "type": "string", + "nullable": true }, - "rows": { + "status": { "type": "integer", "format": "int32", "minimum": 0 + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" } - } + }, + "additionalProperties": {} }, "ServerStatus": { "type": "string", diff --git a/docs/processes.mdx b/docs/processes.mdx deleted file mode 100644 index 45c246c0..00000000 --- a/docs/processes.mdx +++ /dev/null @@ -1,258 +0,0 @@ ---- -title: "Processes" -description: "Run commands and manage long-lived processes inside the sandbox." -sidebarTitle: "Processes" -icon: "terminal" ---- - -The process API supports: - -- **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code -- **Managed processes** — spawn, list, stop, kill, and delete long-lived processes -- **Log streaming** — fetch buffered logs or follow live output via SSE -- **Terminals** — full PTY support with bidirectional WebSocket I/O -- **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime - -## Run a command - -Execute a command to completion and get its output. - - -```ts TypeScript -import { SandboxAgent } from "sandbox-agent"; - -const sdk = await SandboxAgent.connect({ - baseUrl: "http://127.0.0.1:2468", -}); - -const result = await sdk.runProcess({ - command: "ls", - args: ["-la", "/workspace"], -}); - -console.log(result.exitCode); // 0 -console.log(result.stdout); -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ - -H "Content-Type: application/json" \ - -d '{"command":"ls","args":["-la","/workspace"]}' -``` - - -You can set a timeout and cap output size: - - -```ts TypeScript -const result = await sdk.runProcess({ - command: "make", - args: ["build"], - timeoutMs: 60000, - maxOutputBytes: 1048576, -}); - -if (result.timedOut) { - console.log("Build timed out"); -} -if (result.stdoutTruncated) { - console.log("Output was truncated"); -} -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/processes/run" \ - -H "Content-Type: application/json" \ - -d '{"command":"make","args":["build"],"timeoutMs":60000,"maxOutputBytes":1048576}' -``` - - -## Managed processes - -Create a long-lived process that you can interact with, monitor, and stop later. - -### Create - - -```ts TypeScript -const proc = await sdk.createProcess({ - command: "node", - args: ["server.js"], - cwd: "/workspace", -}); - -console.log(proc.id, proc.pid); // proc_1, 12345 -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/processes" \ - -H "Content-Type: application/json" \ - -d '{"command":"node","args":["server.js"],"cwd":"/workspace"}' -``` - - -### List and get - - -```ts TypeScript -const { processes } = await sdk.listProcesses(); - -for (const p of processes) { - console.log(p.id, p.command, p.status); -} - -const proc = await sdk.getProcess("proc_1"); -``` - -```bash cURL -curl "http://127.0.0.1:2468/v1/processes" - -curl "http://127.0.0.1:2468/v1/processes/proc_1" -``` - - -### Stop, kill, and delete - - -```ts TypeScript -// SIGTERM with optional wait -await sdk.stopProcess("proc_1", { waitMs: 5000 }); - -// SIGKILL -await sdk.killProcess("proc_1", { waitMs: 1000 }); - -// Remove exited process record -await sdk.deleteProcess("proc_1"); -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/stop?waitMs=5000" - -curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/kill?waitMs=1000" - -curl -X DELETE "http://127.0.0.1:2468/v1/processes/proc_1" -``` - - -## Logs - -### Fetch buffered logs - - -```ts TypeScript -const logs = await sdk.getProcessLogs("proc_1", { - tail: 50, - stream: "combined", -}); - -for (const entry of logs.entries) { - console.log(entry.stream, atob(entry.data)); -} -``` - -```bash cURL -curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50&stream=combined" -``` - - -### Follow logs via SSE - -Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives. - -```ts TypeScript -const sub = await sdk.followProcessLogs("proc_1", (entry) => { - console.log(entry.stream, atob(entry.data)); -}); - -// Later, stop following -sub.close(); -await sub.closed; -``` - -## Terminals - -Create a process with `tty: true` to allocate a pseudo-terminal, then connect via WebSocket for full bidirectional I/O. - -```ts TypeScript -const proc = await sdk.createProcess({ - command: "bash", - tty: true, -}); -``` - -### Write input - - -```ts TypeScript -await sdk.sendProcessInput("proc_1", { - data: "echo hello\n", - encoding: "utf8", -}); -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/processes/proc_1/input" \ - -H "Content-Type: application/json" \ - -d '{"data":"echo hello\n","encoding":"utf8"}' -``` - - -### Connect to a terminal - -Use `ProcessTerminalSession` unless you need direct frame access. - -```ts TypeScript -const terminal = sdk.connectProcessTerminal("proc_1"); - -terminal.onReady(() => { - terminal.resize({ cols: 120, rows: 40 }); - terminal.sendInput("ls\n"); -}); - -terminal.onData((bytes) => { - process.stdout.write(new TextDecoder().decode(bytes)); -}); - -terminal.onExit((status) => { - console.log("exit:", status.exitCode); -}); - -terminal.onError((error) => { - console.error(error instanceof Error ? error.message : error.message); -}); - -terminal.onClose(() => { - console.log("terminal closed"); -}); -``` - -Since the browser WebSocket API cannot send custom headers, the endpoint accepts an `access_token` query parameter for authentication. The SDK handles this automatically. - -### Browser terminal emulators - -The terminal session works with any browser terminal emulator like ghostty-web or xterm.js. For a drop-in React terminal, see [React Components](/react-components). - -## Configuration - -Adjust runtime limits like max concurrent processes, timeouts, and buffer sizes. - - -```ts TypeScript -const config = await sdk.getProcessConfig(); -console.log(config); - -await sdk.setProcessConfig({ - ...config, - maxConcurrentProcesses: 32, - defaultRunTimeoutMs: 60000, -}); -``` - -```bash cURL -curl "http://127.0.0.1:2468/v1/processes/config" - -curl -X POST "http://127.0.0.1:2468/v1/processes/config" \ - -H "Content-Type: application/json" \ - -d '{"maxConcurrentProcesses":32,"defaultRunTimeoutMs":60000,"maxRunTimeoutMs":300000,"maxOutputBytes":1048576,"maxLogBytesPerProcess":10485760,"maxInputBytesPerRequest":65536}' -``` - diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 0654e615..ad2c2ca5 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -84,7 +84,7 @@ icon: "rocket" Install and run the binary directly. ```bash - curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh sandbox-agent server --no-token --host 0.0.0.0 --port 2468 ``` @@ -93,7 +93,7 @@ icon: "rocket" Run without installing globally. ```bash - npx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 + npx @sandbox-agent/cli@0.2.x server --no-token --host 0.0.0.0 --port 2468 ``` @@ -101,7 +101,7 @@ icon: "rocket" Run without installing globally. ```bash - bunx @sandbox-agent/cli@0.3.x server --no-token --host 0.0.0.0 --port 2468 + bunx @sandbox-agent/cli@0.2.x server --no-token --host 0.0.0.0 --port 2468 ``` @@ -109,7 +109,7 @@ icon: "rocket" Install globally, then run. ```bash - npm install -g @sandbox-agent/cli@0.3.x + npm install -g @sandbox-agent/cli@0.2.x sandbox-agent server --no-token --host 0.0.0.0 --port 2468 ``` @@ -118,7 +118,7 @@ icon: "rocket" Install globally, then run. ```bash - bun add -g @sandbox-agent/cli@0.3.x + bun add -g @sandbox-agent/cli@0.2.x # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 sandbox-agent server --no-token --host 0.0.0.0 --port 2468 @@ -129,7 +129,7 @@ icon: "rocket" For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. ```bash - npm install sandbox-agent@0.3.x + npm install sandbox-agent@0.2.x ``` ```typescript @@ -143,7 +143,7 @@ icon: "rocket" For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. ```bash - bun add sandbox-agent@0.3.x + bun add sandbox-agent@0.2.x # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` diff --git a/docs/react-components.mdx b/docs/react-components.mdx deleted file mode 100644 index e37e2a36..00000000 --- a/docs/react-components.mdx +++ /dev/null @@ -1,103 +0,0 @@ ---- -title: "React Components" -description: "Drop-in React components for Sandbox Agent frontends." -icon: "react" ---- - -`@sandbox-agent/react` exposes small React components built on top of the `sandbox-agent` SDK. - -## Install - -```bash -npm install @sandbox-agent/react@0.3.x -``` - -## Full example - -This example connects to a running Sandbox Agent server, starts a tty shell, renders `ProcessTerminal`, and cleans up the process when the component unmounts. - -```tsx TerminalPane.tsx expandable highlight={5,32-36,71} -"use client"; - -import { useEffect, useState } from "react"; -import { SandboxAgent } from "sandbox-agent"; -import { ProcessTerminal } from "@sandbox-agent/react"; - -export default function TerminalPane() { - const [client, setClient] = useState(null); - const [processId, setProcessId] = useState(null); - const [error, setError] = useState(null); - - useEffect(() => { - let cancelled = false; - let sdk: SandboxAgent | null = null; - let createdProcessId: string | null = null; - - const cleanup = async () => { - if (!sdk || !createdProcessId) { - return; - } - - await sdk.killProcess(createdProcessId, { waitMs: 1_000 }).catch(() => {}); - await sdk.deleteProcess(createdProcessId).catch(() => {}); - }; - - const start = async () => { - try { - sdk = await SandboxAgent.connect({ - baseUrl: "http://127.0.0.1:2468", - }); - - const process = await sdk.createProcess({ - command: "sh", - interactive: true, - tty: true, - }); - - if (cancelled) { - createdProcessId = process.id; - await cleanup(); - await sdk.dispose(); - return; - } - - createdProcessId = process.id; - setClient(sdk); - setProcessId(process.id); - } catch (err) { - const message = err instanceof Error ? err.message : "Failed to start terminal."; - setError(message); - } - }; - - void start(); - - return () => { - cancelled = true; - void cleanup(); - void sdk?.dispose(); - }; - }, []); - - if (error) { - return

{error}
; - } - - if (!client || !processId) { - return
Starting terminal...
; - } - - return ; -} -``` - -## Component - -`ProcessTerminal` attaches to a running tty process. - -- `client`: a `SandboxAgent` client -- `processId`: the process to attach to -- `height`, `style`, `terminalStyle`: optional layout overrides -- `onExit`, `onError`: optional lifecycle callbacks - -See [Processes](/processes) for the lower-level terminal APIs. diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx index 5bd2a508..7974c659 100644 --- a/docs/sdk-overview.mdx +++ b/docs/sdk-overview.mdx @@ -11,12 +11,12 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. ```bash - npm install sandbox-agent@0.3.x + npm install sandbox-agent@0.2.x ``` ```bash - bun add sandbox-agent@0.3.x + bun add sandbox-agent@0.2.x # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-linux-arm64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -26,13 +26,7 @@ The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. ## Optional persistence drivers ```bash -npm install @sandbox-agent/persist-indexeddb@0.3.x @sandbox-agent/persist-sqlite@0.3.x @sandbox-agent/persist-postgres@0.3.x -``` - -## Optional React components - -```bash -npm install @sandbox-agent/react@0.3.x +npm install @sandbox-agent/persist-indexeddb@0.2.x @sandbox-agent/persist-sqlite@0.2.x @sandbox-agent/persist-postgres@0.2.x ``` ## Create a client @@ -45,8 +39,6 @@ const sdk = await SandboxAgent.connect({ }); ``` -`SandboxAgent.connect(...)` now waits for `/v1/health` by default before other SDK requests proceed. To disable that gate, pass `waitForHealth: false`. To keep the default gate but fail after a bounded wait, pass `waitForHealth: { timeoutMs: 120_000 }`. To cancel the startup wait early, pass `signal: abortController.signal`. - With a custom fetch handler (for example, proxying requests inside Workers): ```ts @@ -55,19 +47,6 @@ const sdk = await SandboxAgent.connect({ }); ``` -With an abort signal for the startup health gate: - -```ts -const controller = new AbortController(); - -const sdk = await SandboxAgent.connect({ - baseUrl: "http://127.0.0.1:2468", - signal: controller.signal, -}); - -controller.abort(); -``` - With persistence: ```ts @@ -121,25 +100,6 @@ await restored.prompt([{ type: "text", text: "Continue from previous context." } await sdk.destroySession(restored.id); ``` -## Session configuration - -Set model, mode, or thought level at creation or on an existing session: - -```ts -const session = await sdk.createSession({ - agent: "codex", - model: "gpt-5.3-codex", -}); - -await session.setModel("gpt-5.2-codex"); -await session.setMode("auto"); - -const options = await session.getConfigOptions(); -const modes = await session.getModes(); -``` - -See [Agent Sessions](/agent-sessions) for full details on config options and error handling. - ## Events Subscribe to live events: @@ -210,5 +170,14 @@ Parameters: - `token` (optional): Bearer token for authenticated servers - `headers` (optional): Additional request headers - `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls -- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait -- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()` + +## Types + +```ts +import type { + AgentInfo, + HealthResponse, + SessionEvent, + SessionRecord, +} from "sandbox-agent"; +``` diff --git a/docs/security.mdx b/docs/security.mdx index ec00f49a..b8a28e56 100644 --- a/docs/security.mdx +++ b/docs/security.mdx @@ -53,6 +53,27 @@ export const workspace = actor({ events: [] as Array<{ userId: string; prompt: string; createdAt: number }>, }, + createVars: async (c) => { + // Connect to Sandbox Agent from the actor (server-side only). + // Sandbox credentials never reach the client. + const sdk = await SandboxAgent.connect({ + baseUrl: process.env.SANDBOX_URL!, + token: process.env.SANDBOX_TOKEN, + }); + + const session = await sdk.resumeOrCreateSession({ id: "default", agent: "claude" }); + + const unsubscribe = session.onEvent((event) => { + c.broadcast("session.event", { + eventIndex: event.eventIndex, + sender: event.sender, + payload: event.payload, + }); + }); + + return { sdk, session, unsubscribe }; + }, + onBeforeConnect: async (c, params: ConnParams) => { const claims = await verifyWorkspaceToken(params.accessToken, c.key[0]); if (!claims) { @@ -83,28 +104,7 @@ export const workspace = actor({ throw new UserError("Insufficient permissions", { code: "forbidden" }); } - // Connect to Sandbox Agent from the actor (server-side only). - // Sandbox credentials never reach the client. - const sdk = await SandboxAgent.connect({ - baseUrl: process.env.SANDBOX_URL!, - token: process.env.SANDBOX_TOKEN, - }); - - const session = await sdk.createSession({ - agent: "claude", - sessionInit: { cwd: "/workspace" }, - }); - - session.onEvent((event) => { - c.broadcast("session.event", { - userId: c.conn!.state.userId, - eventIndex: event.eventIndex, - sender: event.sender, - payload: event.payload, - }); - }); - - const result = await session.prompt([ + const result = await c.vars.session.prompt([ { type: "text", text: prompt }, ]); @@ -117,6 +117,11 @@ export const workspace = actor({ return { stopReason: result.stopReason }; }, }, + + onSleep: async (c) => { + c.vars.unsubscribe?.(); + await c.vars.sdk.dispose(); + }, }); ``` diff --git a/docs/session-persistence.mdx b/docs/session-persistence.mdx index eaa4de03..676c9486 100644 --- a/docs/session-persistence.mdx +++ b/docs/session-persistence.mdx @@ -38,7 +38,7 @@ const sdk = await SandboxAgent.connect({ Recommended for sandbox orchestration with actor state. ```bash -npm install @sandbox-agent/persist-rivet@0.3.x +npm install @sandbox-agent/persist-rivet@0.2.x ``` ```ts @@ -90,7 +90,7 @@ export default actor({ Best for browser apps that should survive reloads. ```bash -npm install @sandbox-agent/persist-indexeddb@0.3.x +npm install @sandbox-agent/persist-indexeddb@0.2.x ``` ```ts @@ -112,7 +112,7 @@ const sdk = await SandboxAgent.connect({ Best for local/server Node apps that need durable storage without a DB server. ```bash -npm install @sandbox-agent/persist-sqlite@0.3.x +npm install @sandbox-agent/persist-sqlite@0.2.x ``` ```ts @@ -134,7 +134,7 @@ const sdk = await SandboxAgent.connect({ Use when you already run Postgres and want shared relational storage. ```bash -npm install @sandbox-agent/persist-postgres@0.3.x +npm install @sandbox-agent/persist-postgres@0.2.x ``` ```ts diff --git a/examples/boxlite-python/Dockerfile b/examples/boxlite-python/Dockerfile index 36305118..45644177 100644 --- a/examples/boxlite-python/Dockerfile +++ b/examples/boxlite-python/Dockerfile @@ -1,5 +1,5 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent codex diff --git a/examples/boxlite/Dockerfile b/examples/boxlite/Dockerfile index 36305118..45644177 100644 --- a/examples/boxlite/Dockerfile +++ b/examples/boxlite/Dockerfile @@ -1,5 +1,5 @@ FROM node:22-bookworm-slim RUN apt-get update && apt-get install -y curl ca-certificates && rm -rf /var/lib/apt/lists/* -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh RUN sandbox-agent install-agent claude RUN sandbox-agent install-agent codex diff --git a/examples/boxlite/src/index.ts b/examples/boxlite/src/index.ts index c2401be3..e5ce412a 100644 --- a/examples/boxlite/src/index.ts +++ b/examples/boxlite/src/index.ts @@ -1,6 +1,6 @@ import { SimpleBox } from "@boxlite-ai/boxlite"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; import { setupImage, OCI_DIR } from "./setup-image.ts"; const env: Record = {}; @@ -26,7 +26,9 @@ if (result.exitCode !== 0) throw new Error(`Failed to start server: ${result.std const baseUrl = "http://localhost:3000"; -console.log("Connecting to server..."); +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/cloudflare/Dockerfile b/examples/cloudflare/Dockerfile index d0796cbe..17ddb789 100644 --- a/examples/cloudflare/Dockerfile +++ b/examples/cloudflare/Dockerfile @@ -1,7 +1,7 @@ FROM cloudflare/sandbox:0.7.0 # Install sandbox-agent -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh # Pre-install agents RUN sandbox-agent install-agent claude && \ diff --git a/examples/computesdk/src/computesdk.ts b/examples/computesdk/src/computesdk.ts index 37f413df..bc2ddc6d 100644 --- a/examples/computesdk/src/computesdk.ts +++ b/examples/computesdk/src/computesdk.ts @@ -10,7 +10,7 @@ import { type ProviderName, } from "computesdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; import { fileURLToPath } from "node:url"; import { resolve } from "node:path"; @@ -116,6 +116,9 @@ export async function setupComputeSdkSandboxAgent(): Promise<{ const baseUrl = await sandbox.getUrl({ port: PORT }); + console.log("Waiting for server..."); + await waitForHealth({ baseUrl }); + const cleanup = async () => { try { await sandbox.destroy(); diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts index 2f6ac45d..0ec694d2 100644 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ b/examples/daytona/src/daytona-with-snapshot.ts @@ -1,6 +1,6 @@ import { Daytona, Image } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -13,7 +13,7 @@ if (process.env.OPENAI_API_KEY) // Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs) const image = Image.base("ubuntu:22.04").runCommands( "apt-get update && apt-get install -y curl ca-certificates", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", ); console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)..."); @@ -25,7 +25,9 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; -console.log("Connecting to server..."); +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index 2982d139..ddbd6fbd 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -1,6 +1,6 @@ import { Daytona } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -17,7 +17,7 @@ const sandbox = await daytona.create({ envVars, autoStopInterval: 0 }); // Install sandbox-agent and start server console.log("Installing sandbox-agent..."); await sandbox.process.executeCommand( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", ); console.log("Installing agents..."); @@ -30,7 +30,9 @@ await sandbox.process.executeCommand( const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; -console.log("Connecting to server..."); +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/daytona", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index 6890a1e3..e31d8ede 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -1,16 +1,9 @@ import Docker from "dockerode"; -import fs from "node:fs"; -import path from "node:path"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; -const IMAGE = "node:22-bookworm-slim"; +const IMAGE = "alpine:latest"; const PORT = 3000; -const agent = detectAgent(); -const codexAuthPath = process.env.HOME ? path.join(process.env.HOME, ".codex", "auth.json") : null; -const bindMounts = codexAuthPath && fs.existsSync(codexAuthPath) - ? [`${codexAuthPath}:/root/.codex/auth.json:ro`] - : []; const docker = new Docker({ socketPath: "/var/run/docker.sock" }); @@ -31,30 +24,29 @@ console.log("Starting container..."); const container = await docker.createContainer({ Image: IMAGE, Cmd: ["sh", "-c", [ - "apt-get update", - "DEBIAN_FRONTEND=noninteractive apt-get install -y curl ca-certificates bash libstdc++6", - "rm -rf /var/lib/apt/lists/*", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh", + "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", + "sandbox-agent install-agent claude", + "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, ].join(" && ")], Env: [ process.env.ANTHROPIC_API_KEY ? `ANTHROPIC_API_KEY=${process.env.ANTHROPIC_API_KEY}` : "", process.env.OPENAI_API_KEY ? `OPENAI_API_KEY=${process.env.OPENAI_API_KEY}` : "", - process.env.CODEX_API_KEY ? `CODEX_API_KEY=${process.env.CODEX_API_KEY}` : "", ].filter(Boolean), ExposedPorts: { [`${PORT}/tcp`]: {} }, HostConfig: { AutoRemove: true, PortBindings: { [`${PORT}/tcp`]: [{ HostPort: `${PORT}` }] }, - Binds: bindMounts, }, }); await container.start(); const baseUrl = `http://127.0.0.1:${PORT}`; +await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const session = await client.createSession({ agent, sessionInit: { cwd: "/root", mcpServers: [] } }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root", mcpServers: [] } }); const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); diff --git a/examples/e2b/src/index.ts b/examples/e2b/src/index.ts index 7dd28820..48fcc016 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -16,7 +16,7 @@ const run = async (cmd: string) => { }; console.log("Installing sandbox-agent..."); -await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"); +await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"); console.log("Installing agents..."); await run("sandbox-agent install-agent claude"); @@ -27,7 +27,9 @@ await sandbox.commands.run("sandbox-agent server --no-token --host 0.0.0.0 --por const baseUrl = `https://${sandbox.getHost(3000)}`; -console.log("Connecting to server..."); +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/user", mcpServers: [] } }); const sessionId = session.id; diff --git a/examples/shared/Dockerfile.dev b/examples/shared/Dockerfile.dev index 53a99226..cac363dd 100644 --- a/examples/shared/Dockerfile.dev +++ b/examples/shared/Dockerfile.dev @@ -11,7 +11,6 @@ COPY sdks/typescript/ sdks/typescript/ COPY sdks/acp-http-client/ sdks/acp-http-client/ COPY sdks/cli-shared/ sdks/cli-shared/ COPY sdks/persist-indexeddb/ sdks/persist-indexeddb/ -COPY sdks/react/ sdks/react/ COPY frontend/packages/inspector/ frontend/packages/inspector/ COPY docs/openapi.json docs/ diff --git a/examples/shared/src/docker.ts b/examples/shared/src/docker.ts index adceecb4..5ec8a8c0 100644 --- a/examples/shared/src/docker.ts +++ b/examples/shared/src/docker.ts @@ -4,6 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { PassThrough } from "node:stream"; import { fileURLToPath } from "node:url"; +import { waitForHealth } from "./sandbox-agent-client.ts"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const EXAMPLE_IMAGE = "sandbox-agent-examples:latest"; @@ -172,7 +173,7 @@ async function ensureExampleImage(_docker: Docker): Promise { } /** - * Start a Docker container running sandbox-agent. + * Start a Docker container running sandbox-agent and wait for it to be healthy. * Registers SIGINT/SIGTERM handlers for cleanup. */ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { @@ -274,8 +275,18 @@ export async function startDockerSandbox(opts: DockerSandboxOptions): Promise { stopStartupLogs(); diff --git a/examples/shared/src/sandbox-agent-client.ts b/examples/shared/src/sandbox-agent-client.ts index 5c7e7cf5..df8fa51e 100644 --- a/examples/shared/src/sandbox-agent-client.ts +++ b/examples/shared/src/sandbox-agent-client.ts @@ -3,6 +3,8 @@ * Provides minimal helpers for connecting to and interacting with sandbox-agent servers. */ +import { setTimeout as delay } from "node:timers/promises"; + function normalizeBaseUrl(baseUrl: string): string { return baseUrl.replace(/\/+$/, ""); } @@ -72,6 +74,41 @@ export function buildHeaders({ return headers; } +export async function waitForHealth({ + baseUrl, + token, + extraHeaders, + timeoutMs = 120_000, +}: { + baseUrl: string; + token?: string; + extraHeaders?: Record; + timeoutMs?: number; +}): Promise { + const normalized = normalizeBaseUrl(baseUrl); + const deadline = Date.now() + timeoutMs; + let lastError: unknown; + while (Date.now() < deadline) { + try { + const headers = buildHeaders({ token, extraHeaders }); + const response = await fetch(`${normalized}/v1/health`, { headers }); + if (response.ok) { + const data = await response.json(); + if (data?.status === "ok") { + return; + } + lastError = new Error(`Unexpected health response: ${JSON.stringify(data)}`); + } else { + lastError = new Error(`Health check failed: ${response.status}`); + } + } catch (error) { + lastError = error; + } + await delay(500); + } + throw (lastError ?? new Error("Timed out waiting for /v1/health")) as Error; +} + export function generateSessionId(): string { const chars = "abcdefghijklmnopqrstuvwxyz0123456789"; let id = "session-"; @@ -107,3 +144,4 @@ export function detectAgent(): string { } return "claude"; } + diff --git a/examples/vercel/src/index.ts b/examples/vercel/src/index.ts index 4a63bfcc..56fbfe8a 100644 --- a/examples/vercel/src/index.ts +++ b/examples/vercel/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@vercel/sandbox"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -22,7 +22,7 @@ const run = async (cmd: string, args: string[] = []) => { }; console.log("Installing sandbox-agent..."); -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.3.x/install.sh | sh"]); +await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"]); console.log("Installing agents..."); await run("sandbox-agent", ["install-agent", "claude"]); @@ -38,7 +38,9 @@ await sandbox.runCommand({ const baseUrl = sandbox.domain(3000); -console.log("Connecting to server..."); +console.log("Waiting for server..."); +await waitForHealth({ baseUrl }); + const client = await SandboxAgent.connect({ baseUrl }); const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/home/vercel-sandbox", mcpServers: [] } }); const sessionId = session.id; diff --git a/factory/AGENTS.md b/factory/AGENTS.md deleted file mode 120000 index 681311eb..00000000 --- a/factory/AGENTS.md +++ /dev/null @@ -1 +0,0 @@ -CLAUDE.md \ No newline at end of file diff --git a/factory/CLAUDE.md b/factory/CLAUDE.md deleted file mode 100644 index 689b33c5..00000000 --- a/factory/CLAUDE.md +++ /dev/null @@ -1,230 +0,0 @@ -# Project Instructions - -## Breaking Changes - -Do not preserve legacy compatibility. Implement the best current architecture, even if breaking. - -## Language Policy - -Use TypeScript for all source code. - -- Never add raw JavaScript source files (`.js`, `.mjs`, `.cjs`). -- Prefer `.ts`/`.tsx` for runtime code, scripts, tests, and tooling. -- If touching old JavaScript, migrate it to TypeScript instead of extending it. - -## Monorepo + Tooling - -Use `pnpm` workspaces and Turborepo. - -- Workspace root uses `pnpm-workspace.yaml` and `turbo.json`. -- Packages live in `packages/*`. -- `core` is renamed to `shared`. -- `packages/cli` is disabled and excluded from active workspace validation. -- Integrations and providers live under `packages/backend/src/{integrations,providers}`. - -## CLI Status - -- `packages/cli` is fully disabled for active development. -- Do not implement new behavior in `packages/cli` unless explicitly requested. -- Frontend is the primary product surface; prioritize `packages/frontend` + supporting `packages/client`/`packages/backend`. -- Workspace `build`, `typecheck`, and `test` intentionally exclude `@openhandoff/cli`. -- `pnpm-workspace.yaml` excludes `packages/cli` from workspace package resolution. - -## Common Commands - -- Install deps: `pnpm install` -- Full active-workspace validation: `pnpm -w typecheck`, `pnpm -w build`, `pnpm -w test` -- Start the full dev stack: `just factory-dev` -- Start the local production-build preview stack: `just factory-preview` -- Start only the backend locally: `just factory-backend-start` -- Start only the frontend locally: `pnpm --filter @openhandoff/frontend dev` -- Start the frontend against the mock workbench client: `OPENHANDOFF_FRONTEND_CLIENT_MODE=mock pnpm --filter @openhandoff/frontend dev` -- Stop the compose dev stack: `just factory-dev-down` -- Tail compose logs: `just factory-dev-logs` -- Stop the preview stack: `just factory-preview-down` -- Tail preview logs: `just factory-preview-logs` - -## Frontend + Client Boundary - -- Keep a browser-friendly GUI implementation aligned with the TUI interaction model wherever possible. -- Do not import `rivetkit` directly in CLI or GUI packages. RivetKit client access must stay isolated inside `packages/client`. -- All backend interaction (actor calls, metadata/health checks, backend HTTP endpoint access) must go through the dedicated client library in `packages/client`. -- Outside `packages/client`, do not call backend endpoints directly (for example `fetch(.../api/rivet...)`), except in black-box E2E tests that intentionally exercise raw transport behavior. -- GUI state should update in realtime (no manual refresh buttons). Prefer RivetKit push reactivity and actor-driven events; do not add polling/refetch for normal product flows. -- Keep the mock workbench types and mock client in `packages/shared` + `packages/client` up to date with the frontend contract. The mock is the UI testing reference implementation while backend functionality catches up. -- Keep frontend route/state coverage current in code and tests; there is no separate page-inventory doc to maintain. -- When making UI changes, verify the live flow with `agent-browser`, take screenshots of the updated UI, and offer to open those screenshots in Preview when you finish. -- When asked for screenshots, capture all relevant affected screens and modal states, not just a single viewport. Include empty, populated, success, and blocked/error states when they are part of the changed flow. -- If a screenshot catches a transition frame, blank modal, or otherwise misleading state, retake it before reporting it. - -## Runtime Policy - -- Runtime is Bun-native. -- Use Bun for CLI/backend execution paths and process spawning. -- Do not add Node compatibility fallbacks for OpenTUI/runtime execution. - -## Defensive Error Handling - -- Write code defensively: validate assumptions at boundaries and state transitions. -- If the system reaches an unexpected state, raise an explicit error with actionable context. -- Do not fail silently, swallow errors, or auto-ignore inconsistent data. -- Prefer fail-fast behavior over hidden degradation when correctness is uncertain. - -## RivetKit Dependency Policy - -For all Rivet/RivetKit implementation: - -1. Use SQLite + Drizzle for persistent state. -2. SQLite is **per actor instance** (per actor key), not a shared backend-global database: - - Each actor instance gets its own SQLite DB. - - Schema design should assume a single actor instance owns the entire DB. - - Do not add `workspaceId`/`repoId`/`handoffId` columns just to "namespace" rows for a given actor instance; use actor state and/or the actor key instead. - - Example: the `handoff` actor instance already represents `(workspaceId, repoId, handoffId)`, so its SQLite tables should not need those columns for primary keys. -3. Do not use backend-global SQLite singletons; database access must go through actor `db` providers (`c.db`). -4. Do not use published RivetKit npm packages. -5. RivetKit is linked via pnpm `link:` protocol to `../rivet/rivetkit-typescript/packages/rivetkit`. Sub-packages (`@rivetkit/sqlite-vfs`, etc.) resolve transitively from the rivet workspace. - - Dedicated local checkout for this workspace: `/Users/nathan/conductor/workspaces/handoff/rivet-checkout` - - Dev worktree note: when working on RivetKit fixes for this repo, prefer the dedicated local checkout above and link to `../rivet-checkout/rivetkit-typescript/packages/rivetkit`. -6. Before using, build RivetKit in the rivet repo: - ```bash - cd ../rivet-checkout/rivetkit-typescript - pnpm install - pnpm build -F rivetkit - ``` - -## Inspector HTTP API (Workflow Debugging) - -- The Inspector HTTP routes come from RivetKit `feat: inspector http api (#4144)` and are served from the RivetKit manager endpoint (not `/api/rivet`). -- Resolve manager endpoint from backend metadata: - ```bash - curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint' - ``` -- List actors: - - `GET {manager}/actors?name=handoff` -- Inspector endpoints (path prefix: `/gateway/{actorId}/inspector`): - - `GET /state` - - `PATCH /state` - - `GET /connections` - - `GET /rpcs` - - `POST /action/{name}` - - `GET /queue?limit=50` - - `GET /traces?startMs=0&endMs=&limit=1000` - - `GET /workflow-history` - - `GET /summary` -- Auth: - - Production: send `Authorization: Bearer $RIVET_INSPECTOR_TOKEN`. - - Development: auth can be skipped when no inspector token is configured. -- Handoff workflow quick inspect: - ```bash - MGR="$(curl -sS http://127.0.0.1:7741/api/rivet/metadata | jq -r '.clientEndpoint')" - HID="7df7656e-bbd2-4b8c-bf0f-30d4df2f619a" - AID="$(curl -sS "$MGR/actors?name=handoff" \ - | jq -r --arg hid "$HID" '.actors[] | select(.key | endswith("/handoff/\($hid)")) | .actor_id' \ - | head -n1)" - curl -sS "$MGR/gateway/$AID/inspector/workflow-history" | jq . - curl -sS "$MGR/gateway/$AID/inspector/summary" | jq . - ``` -- If inspector routes return `404 Not Found (RivetKit)`, the running backend is on a RivetKit build that predates `#4144`; rebuild linked RivetKit and restart backend. - -## Workspace + Actor Rules - -- Everything is scoped to a workspace. -- Workspace resolution order: `--workspace` flag -> config default -> `"default"`. -- `ControlPlaneActor` is replaced by `WorkspaceActor` (workspace coordinator). -- Every actor key must be prefixed with workspace namespace (`["ws", workspaceId, ...]`). -- CLI/TUI/GUI must use `@openhandoff/client` (`packages/client`) for backend access; `rivetkit/client` imports are only allowed inside `packages/client`. -- Do not add custom backend REST endpoints (no `/v1/*` shim layer). -- We own the sandbox-agent project; treat sandbox-agent defects as first-party bugs and fix them instead of working around them. -- Keep strict single-writer ownership: each table/row has exactly one actor writer. -- Parent actors (`workspace`, `project`, `handoff`, `history`, `sandbox-instance`) use command-only loops with no timeout. -- Periodic syncing lives in dedicated child actors with one timeout cadence each. -- Actor handle policy: -- Prefer explicit `get` or explicit `create` based on workflow intent; do not default to `getOrCreate`. -- Use `get`/`getForId` when the actor is expected to already exist; if missing, surface an explicit `Actor not found` error with recovery context. -- Use create semantics only on explicit provisioning/create paths where creating a new actor instance is intended. -- `getOrCreate` is a last resort for create paths when an explicit create API is unavailable; never use it in read/command paths. -- For long-lived cross-actor links (for example sandbox/session runtime access), persist actor identity (`actorId`) and keep a fallback lookup path by actor id. -- Docker dev: `compose.dev.yaml` mounts a named volume at `/root/.local/share/openhandoff/repos` to persist backend-managed git clones across restarts. Code must still work if this volume is not present (create directories as needed). -- RivetKit actor `c.state` is durable, but in Docker it is stored under `/root/.local/share/rivetkit`. If that path is not persisted, actor state-derived indexes (for example, in `project` actor state) can be lost after container recreation even when other data still exists. -- Workflow history divergence policy: -- Production: never auto-delete actor state to resolve `HistoryDivergedError`; ship explicit workflow migrations (`ctx.removed(...)`, step compatibility). -- Development: manual local state reset is allowed as an operator recovery path when migrations are not yet available. -- Storage rule of thumb: -- Put simple metadata in `c.state` (KV state): small scalars and identifiers like `{ handoffId }`, `{ repoId }`, booleans, counters, timestamps, status strings. -- If it grows beyond trivial (arrays, maps, histories, query/filter needs, relational consistency), use SQLite + Drizzle in `c.db`. - -## Testing Policy - -- Never use vitest mocks (`vi.mock`, `vi.spyOn`, `vi.fn`). Instead, define driver interfaces for external I/O and pass test implementations via the actor runtime context. -- All external service calls (git CLI, GitHub CLI, sandbox-agent HTTP, tmux) must go through the `BackendDriver` interface on the runtime context. -- Integration tests use `setupTest()` from `rivetkit/test` and are gated behind `HF_ENABLE_ACTOR_INTEGRATION_TESTS=1`. -- End-to-end testing must run against the dev backend started via `docker compose -f compose.dev.yaml up` (host -> container). Do not run E2E against an in-process test runtime. - - E2E tests should talk to the backend over HTTP (default `http://127.0.0.1:7741/api/rivet`) and use real GitHub repos/PRs. - - Secrets (e.g. `OPENAI_API_KEY`, `GITHUB_TOKEN`/`GH_TOKEN`) must be provided via environment variables, never hardcoded in the repo. -- Treat client E2E tests in `packages/client/test` as the primary end-to-end source of truth for product behavior. -- Keep backend tests small and targeted. Only retain backend-only tests for invariants or persistence rules that are not well-covered through client E2E. -- Do not keep large browser E2E suites around in a broken state. If a frontend browser E2E is not maintained and producing signal, remove it until it can be replaced with a reliable test. - -## Config - -- Keep config path at `~/.config/openhandoff/config.toml`. -- Evolve properties in place; do not move config location. - -## Project Guidance - -Project-specific guidance lives in `README.md`, `CONTRIBUTING.md`, and the relevant files under `research/`. - -Keep those updated when: - -- Commands change -- Configuration options change -- Architecture changes -- Plugins/providers change -- Actor ownership changes - -## Friction Logs - -Track friction at: - -- `research/friction/rivet.mdx` -- `research/friction/sandbox-agent.mdx` -- `research/friction/sandboxes.mdx` -- `research/friction/general.mdx` - -Category mapping: - -- `rivet`: Rivet/RivetKit runtime, actor model, queues, keys -- `sandbox-agent`: sandbox-agent SDK/API behavior -- `sandboxes`: provider implementations (worktree/daytona/etc) -- `general`: everything else - -Each entry must include: - -- Date (`YYYY-MM-DD`) -- Commit SHA (or `uncommitted`) -- What you were implementing -- Friction/issue -- Attempted fix/workaround and outcome - -## History Events - -Log notable workflow changes to `events` so `hf history` remains complete: - -- create -- attach -- push/sync/merge -- archive/kill -- status transitions -- PR state transitions - -## Validation After Changes - -Always run and fix failures: - -```bash -pnpm -w typecheck -pnpm -w build -pnpm -w test -``` - -After making code changes, always update the dev server before declaring the work complete. If the dev stack is running through Docker Compose, restart or recreate the relevant dev services so the running app reflects the latest code. diff --git a/factory/CONTRIBUTING.md b/factory/CONTRIBUTING.md deleted file mode 100644 index 759f3487..00000000 --- a/factory/CONTRIBUTING.md +++ /dev/null @@ -1,64 +0,0 @@ -# Contributing - -## Development Setup - -1. Clone: - -```bash -git clone https://github.com/rivet-dev/openhandoff.git -cd openhandoff -``` - -2. Install dependencies: - -```bash -pnpm install -``` - -3. Build all packages: - -```bash -pnpm -w build -``` - -## Package Layout - -- `packages/shared`: contracts/schemas -- `packages/backend`: RivetKit actors + DB + providers + integrations -- `packages/cli`: `hf` and `hf tui` (OpenTUI) - -## Local RivetKit Dependency - -Build local RivetKit before backend changes that depend on Rivet internals: - -```bash -cd ../rivet -pnpm build -F rivetkit - -cd /path/to/openhandoff -just sync-rivetkit -``` - -## Validation - -Run before opening a PR: - -```bash -pnpm -w typecheck -pnpm -w build -pnpm -w test -``` - -## Dev Backend (Docker Compose) - -Start the dev backend (hot reload via `bun --watch`) and Vite frontend via Docker Compose: - -```bash -just factory-dev -``` - -Stop it: - -```bash -just factory-dev-down -``` diff --git a/factory/Dockerfile b/factory/Dockerfile deleted file mode 100644 index 5693650f..00000000 --- a/factory/Dockerfile +++ /dev/null @@ -1,53 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -FROM node:22-bookworm-slim AS base -ENV PNPM_HOME=/pnpm -ENV PATH=$PNPM_HOME:$PATH -WORKDIR /app -RUN corepack enable && corepack prepare pnpm@10.28.2 --activate - -FROM base AS deps -COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json tsconfig.base.json ./ -COPY packages/shared/package.json packages/shared/package.json -COPY packages/backend/package.json packages/backend/package.json -COPY packages/rivetkit-vendor/rivetkit/package.json packages/rivetkit-vendor/rivetkit/package.json -COPY packages/rivetkit-vendor/workflow-engine/package.json packages/rivetkit-vendor/workflow-engine/package.json -COPY packages/rivetkit-vendor/traces/package.json packages/rivetkit-vendor/traces/package.json -COPY packages/rivetkit-vendor/sqlite-vfs/package.json packages/rivetkit-vendor/sqlite-vfs/package.json -COPY packages/rivetkit-vendor/sqlite-vfs-linux-x64/package.json packages/rivetkit-vendor/sqlite-vfs-linux-x64/package.json -COPY packages/rivetkit-vendor/sqlite-vfs-linux-arm64/package.json packages/rivetkit-vendor/sqlite-vfs-linux-arm64/package.json -COPY packages/rivetkit-vendor/sqlite-vfs-darwin-arm64/package.json packages/rivetkit-vendor/sqlite-vfs-darwin-arm64/package.json -COPY packages/rivetkit-vendor/sqlite-vfs-darwin-x64/package.json packages/rivetkit-vendor/sqlite-vfs-darwin-x64/package.json -COPY packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json packages/rivetkit-vendor/sqlite-vfs-win32-x64/package.json -COPY packages/rivetkit-vendor/runner/package.json packages/rivetkit-vendor/runner/package.json -COPY packages/rivetkit-vendor/runner-protocol/package.json packages/rivetkit-vendor/runner-protocol/package.json -COPY packages/rivetkit-vendor/virtual-websocket/package.json packages/rivetkit-vendor/virtual-websocket/package.json -RUN pnpm fetch --frozen-lockfile --filter @openhandoff/backend... - -FROM base AS build -COPY --from=deps /pnpm/store /pnpm/store -COPY . . -RUN pnpm install --frozen-lockfile --prefer-offline --filter @openhandoff/backend... -RUN pnpm --filter @openhandoff/shared build -RUN pnpm --filter @openhandoff/backend build -RUN pnpm --filter @openhandoff/backend deploy --prod --legacy /out - -FROM oven/bun:1.2 AS runtime -ENV NODE_ENV=production -ENV HOME=/home/handoff -WORKDIR /app -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - git \ - gh \ - openssh-client \ - && rm -rf /var/lib/apt/lists/* -RUN addgroup --system --gid 1001 handoff \ - && adduser --system --uid 1001 --home /home/handoff --ingroup handoff handoff \ - && mkdir -p /home/handoff \ - && chown -R handoff:handoff /home/handoff /app -COPY --from=build /out ./ -USER handoff -EXPOSE 7741 -CMD ["bun", "dist/index.js", "start", "--host", "0.0.0.0"] diff --git a/factory/README.md b/factory/README.md deleted file mode 100644 index c49f7cbd..00000000 --- a/factory/README.md +++ /dev/null @@ -1,24 +0,0 @@ -# OpenHandoff - -TypeScript workspace handoff system powered by RivetKit actors, SQLite/Drizzle state, and OpenTUI. - -**Documentation**: [openhandoff.dev](https://openhandoff.dev) - -## Quick Install - -```bash -curl -fsSL https://bun.sh/install | bash -pnpm install -pnpm -w build -``` - -## Project Goals - -- **Simple**: There's one screen. It has everything you need. You can use it blindfolded. -- **Fast**: No waiting around. -- **Collaborative**: Built for fast moving teams that need code reviewed & shipped fast. -- **Pluggable**: Works for small side projects to enterprise teams. - -## License - -MIT diff --git a/factory/compose.dev.yaml b/factory/compose.dev.yaml deleted file mode 100644 index 80fea129..00000000 --- a/factory/compose.dev.yaml +++ /dev/null @@ -1,90 +0,0 @@ -name: openhandoff - -services: - backend: - build: - context: .. - dockerfile: factory/docker/backend.dev.Dockerfile - image: openhandoff-backend-dev - working_dir: /app - environment: - HF_BACKEND_HOST: "0.0.0.0" - HF_BACKEND_PORT: "7741" - HF_RIVET_MANAGER_PORT: "8750" - RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit" - # Pass through credentials needed for agent execution + PR creation in dev/e2e. - # Do not hardcode secrets; set these in your environment when starting compose. - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" - CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}" - OPENAI_API_KEY: "${OPENAI_API_KEY:-}" - # sandbox-agent codex plugin currently expects CODEX_API_KEY. Map from OPENAI_API_KEY for convenience. - CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}" - # Support either GITHUB_TOKEN or GITHUB_PAT in local env files. - GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}" - GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}" - DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}" - DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}" - HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}" - HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}" - ports: - - "7741:7741" - # RivetKit manager (used by browser clients after /api/rivet metadata redirect in dev) - - "8750:8750" - volumes: - - "..:/app" - # The linked RivetKit checkout resolves from factory packages to /handoff/rivet-checkout in-container. - - "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro" - # Reuse the host Codex auth profile for local sandbox-agent Codex sessions in dev. - - "${HOME}/.codex:/root/.codex" - # Keep backend dependency installs Linux-native instead of using host node_modules. - - "openhandoff_backend_root_node_modules:/app/node_modules" - - "openhandoff_backend_backend_node_modules:/app/factory/packages/backend/node_modules" - - "openhandoff_backend_shared_node_modules:/app/factory/packages/shared/node_modules" - - "openhandoff_backend_persist_rivet_node_modules:/app/sdks/persist-rivet/node_modules" - - "openhandoff_backend_typescript_node_modules:/app/sdks/typescript/node_modules" - - "openhandoff_backend_pnpm_store:/root/.local/share/pnpm/store" - # Persist backend-managed local git clones across container restarts. - - "openhandoff_git_repos:/root/.local/share/openhandoff/repos" - # Persist RivetKit local storage across container restarts. - - "openhandoff_rivetkit_storage:/root/.local/share/openhandoff/rivetkit" - - frontend: - build: - context: .. - dockerfile: factory/docker/frontend.dev.Dockerfile - working_dir: /app - depends_on: - - backend - environment: - HOME: "/tmp" - HF_BACKEND_HTTP: "http://backend:7741" - ports: - - "4173:4173" - volumes: - - "..:/app" - # Ensure logs in .openhandoff/ persist on the host even if we change source mounts later. - - "./.openhandoff:/app/factory/.openhandoff" - - "../../../handoff/rivet-checkout:/handoff/rivet-checkout:ro" - # Use Linux-native workspace dependencies inside the container instead of host node_modules. - - "openhandoff_node_modules:/app/node_modules" - - "openhandoff_client_node_modules:/app/factory/packages/client/node_modules" - - "openhandoff_frontend_errors_node_modules:/app/factory/packages/frontend-errors/node_modules" - - "openhandoff_frontend_node_modules:/app/factory/packages/frontend/node_modules" - - "openhandoff_shared_node_modules:/app/factory/packages/shared/node_modules" - - "openhandoff_pnpm_store:/tmp/.local/share/pnpm/store" - -volumes: - openhandoff_backend_root_node_modules: {} - openhandoff_backend_backend_node_modules: {} - openhandoff_backend_shared_node_modules: {} - openhandoff_backend_persist_rivet_node_modules: {} - openhandoff_backend_typescript_node_modules: {} - openhandoff_backend_pnpm_store: {} - openhandoff_git_repos: {} - openhandoff_rivetkit_storage: {} - openhandoff_node_modules: {} - openhandoff_client_node_modules: {} - openhandoff_frontend_errors_node_modules: {} - openhandoff_frontend_node_modules: {} - openhandoff_shared_node_modules: {} - openhandoff_pnpm_store: {} diff --git a/factory/compose.preview.yaml b/factory/compose.preview.yaml deleted file mode 100644 index 88cdad3c..00000000 --- a/factory/compose.preview.yaml +++ /dev/null @@ -1,44 +0,0 @@ -name: openhandoff-preview - -services: - backend: - build: - context: .. - dockerfile: quebec/docker/backend.preview.Dockerfile - image: openhandoff-backend-preview - environment: - HF_BACKEND_HOST: "0.0.0.0" - HF_BACKEND_PORT: "7841" - HF_RIVET_MANAGER_PORT: "8850" - RIVETKIT_STORAGE_PATH: "/root/.local/share/openhandoff/rivetkit" - ANTHROPIC_API_KEY: "${ANTHROPIC_API_KEY:-}" - CLAUDE_API_KEY: "${CLAUDE_API_KEY:-${ANTHROPIC_API_KEY:-}}" - OPENAI_API_KEY: "${OPENAI_API_KEY:-}" - CODEX_API_KEY: "${CODEX_API_KEY:-${OPENAI_API_KEY:-}}" - GITHUB_TOKEN: "${GITHUB_TOKEN:-${GITHUB_PAT:-}}" - GH_TOKEN: "${GH_TOKEN:-${GITHUB_TOKEN:-${GITHUB_PAT:-}}}" - DAYTONA_ENDPOINT: "${DAYTONA_ENDPOINT:-}" - DAYTONA_API_KEY: "${DAYTONA_API_KEY:-}" - HF_DAYTONA_ENDPOINT: "${HF_DAYTONA_ENDPOINT:-}" - HF_DAYTONA_API_KEY: "${HF_DAYTONA_API_KEY:-}" - ports: - - "7841:7841" - - "8850:8850" - volumes: - - "${HOME}/.codex:/root/.codex" - - "openhandoff_preview_git_repos:/root/.local/share/openhandoff/repos" - - "openhandoff_preview_rivetkit_storage:/root/.local/share/openhandoff/rivetkit" - - frontend: - build: - context: .. - dockerfile: quebec/docker/frontend.preview.Dockerfile - image: openhandoff-frontend-preview - depends_on: - - backend - ports: - - "4273:4273" - -volumes: - openhandoff_preview_git_repos: {} - openhandoff_preview_rivetkit_storage: {} diff --git a/factory/docker/backend.dev.Dockerfile b/factory/docker/backend.dev.Dockerfile deleted file mode 100644 index a53e0183..00000000 --- a/factory/docker/backend.dev.Dockerfile +++ /dev/null @@ -1,42 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -FROM oven/bun:1.3 - -ARG GIT_SPICE_VERSION=v0.23.0 -ARG SANDBOX_AGENT_VERSION=0.3.0 - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - git \ - gh \ - nodejs \ - npm \ - openssh-client \ - && rm -rf /var/lib/apt/lists/* - -RUN npm install -g pnpm@10.28.2 - -RUN set -eux; \ - arch="$(dpkg --print-architecture)"; \ - case "$arch" in \ - amd64) spice_arch="x86_64" ;; \ - arm64) spice_arch="aarch64" ;; \ - *) echo "Unsupported architecture for git-spice: $arch" >&2; exit 1 ;; \ - esac; \ - tmpdir="$(mktemp -d)"; \ - curl -fsSL "https://github.com/abhinav/git-spice/releases/download/${GIT_SPICE_VERSION}/git-spice.Linux-${spice_arch}.tar.gz" -o "${tmpdir}/git-spice.tgz"; \ - tar -xzf "${tmpdir}/git-spice.tgz" -C "${tmpdir}"; \ - install -m 0755 "${tmpdir}/gs" /usr/local/bin/gs; \ - ln -sf /usr/local/bin/gs /usr/local/bin/git-spice; \ - rm -rf "${tmpdir}" - -RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION}/install.sh" | sh - -ENV PATH="/root/.local/bin:${PATH}" -ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent" - -WORKDIR /app - -CMD ["bash", "-lc", "git config --global --add safe.directory /app >/dev/null 2>&1 || true; pnpm install --force --frozen-lockfile --filter @openhandoff/backend... && exec bun factory/packages/backend/src/index.ts start --host 0.0.0.0 --port 7741"] diff --git a/factory/docker/backend.preview.Dockerfile b/factory/docker/backend.preview.Dockerfile deleted file mode 100644 index 3ea5aa8d..00000000 --- a/factory/docker/backend.preview.Dockerfile +++ /dev/null @@ -1,49 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -FROM oven/bun:1.3 - -ARG GIT_SPICE_VERSION=v0.23.0 -ARG SANDBOX_AGENT_VERSION=0.3.0 - -RUN apt-get update \ - && apt-get install -y --no-install-recommends \ - ca-certificates \ - curl \ - git \ - gh \ - nodejs \ - npm \ - openssh-client \ - && npm install -g pnpm@10.28.2 \ - && rm -rf /var/lib/apt/lists/* - -RUN set -eux; \ - arch="$(dpkg --print-architecture)"; \ - case "$arch" in \ - amd64) spice_arch="x86_64" ;; \ - arm64) spice_arch="aarch64" ;; \ - *) echo "Unsupported architecture for git-spice: $arch" >&2; exit 1 ;; \ - esac; \ - tmpdir="$(mktemp -d)"; \ - curl -fsSL "https://github.com/abhinav/git-spice/releases/download/${GIT_SPICE_VERSION}/git-spice.Linux-${spice_arch}.tar.gz" -o "${tmpdir}/git-spice.tgz"; \ - tar -xzf "${tmpdir}/git-spice.tgz" -C "${tmpdir}"; \ - install -m 0755 "${tmpdir}/gs" /usr/local/bin/gs; \ - ln -sf /usr/local/bin/gs /usr/local/bin/git-spice; \ - rm -rf "${tmpdir}" - -RUN curl -fsSL "https://releases.rivet.dev/sandbox-agent/${SANDBOX_AGENT_VERSION}/install.sh" | sh - -ENV PATH="/root/.local/bin:${PATH}" -ENV SANDBOX_AGENT_BIN="/root/.local/bin/sandbox-agent" - -WORKDIR /workspace/quebec - -COPY quebec /workspace/quebec -COPY rivet-checkout /workspace/rivet-checkout - -RUN pnpm install --frozen-lockfile -RUN pnpm --filter @openhandoff/shared build -RUN pnpm --filter @openhandoff/client build -RUN pnpm --filter @openhandoff/backend build - -CMD ["bash", "-lc", "git config --global --add safe.directory /workspace/quebec >/dev/null 2>&1 || true; exec bun packages/backend/dist/index.js start --host 0.0.0.0 --port 7841"] diff --git a/factory/docker/frontend.dev.Dockerfile b/factory/docker/frontend.dev.Dockerfile deleted file mode 100644 index 057b88df..00000000 --- a/factory/docker/frontend.dev.Dockerfile +++ /dev/null @@ -1,11 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -FROM node:22-bookworm-slim - -# Install pnpm into the image so we can run as a non-root user at runtime. -# Using npm here avoids Corepack's first-run download behavior. -RUN npm install -g pnpm@10.28.2 - -WORKDIR /app - -CMD ["bash", "-lc", "pnpm install --force --frozen-lockfile --filter @openhandoff/frontend... && cd factory/packages/frontend && exec pnpm vite --host 0.0.0.0 --port 4173"] diff --git a/factory/docker/frontend.preview.Dockerfile b/factory/docker/frontend.preview.Dockerfile deleted file mode 100644 index 7f90b2a5..00000000 --- a/factory/docker/frontend.preview.Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -# syntax=docker/dockerfile:1.7 - -FROM node:22-bookworm-slim AS build - -RUN npm install -g pnpm@10.28.2 - -WORKDIR /workspace/quebec - -COPY quebec /workspace/quebec -COPY rivet-checkout /workspace/rivet-checkout - -RUN pnpm install --frozen-lockfile -RUN pnpm --filter @openhandoff/shared build -RUN pnpm --filter @openhandoff/client build -RUN pnpm --filter @openhandoff/frontend-errors build -RUN pnpm --filter @openhandoff/frontend build - -FROM nginx:1.27-alpine - -COPY quebec/docker/nginx.preview.conf /etc/nginx/conf.d/default.conf -COPY --from=build /workspace/quebec/packages/frontend/dist /usr/share/nginx/html - -EXPOSE 4273 diff --git a/factory/docker/nginx.preview.conf b/factory/docker/nginx.preview.conf deleted file mode 100644 index 33b05ae3..00000000 --- a/factory/docker/nginx.preview.conf +++ /dev/null @@ -1,31 +0,0 @@ -server { - listen 4273; - server_name _; - - root /usr/share/nginx/html; - index index.html; - - location /api/rivet/ { - proxy_pass http://backend:7841/api/rivet/; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - location = /api/rivet { - proxy_pass http://backend:7841/api/rivet; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; - } - - location / { - try_files $uri $uri/ /index.html; - } -} diff --git a/factory/e2e/wb-mmilw7yh.txt b/factory/e2e/wb-mmilw7yh.txt deleted file mode 100644 index abf58874..00000000 --- a/factory/e2e/wb-mmilw7yh.txt +++ /dev/null @@ -1 +0,0 @@ -wb-mmilw7yh diff --git a/factory/e2e/wb-mmilzdwf.txt b/factory/e2e/wb-mmilzdwf.txt deleted file mode 100644 index 81659660..00000000 --- a/factory/e2e/wb-mmilzdwf.txt +++ /dev/null @@ -1 +0,0 @@ -wb-mmilzdwf diff --git a/factory/memory/roadmap.md b/factory/memory/roadmap.md deleted file mode 100644 index 02f47cec..00000000 --- a/factory/memory/roadmap.md +++ /dev/null @@ -1,82 +0,0 @@ -## workflow - -### terminal - -1. hf create "do something" -2. notifies via openclaw - -### claude code/opencode - -1. "handoff this task to do xxxx" -2. ask clarifying questions -3. works in background (attach opencode session with `hf attach` and switch to session with `hf switch`) -4. automatically submits draft pr (if configured) -5. notifies via openclaw (wip) - -### openclaw - -(similar to claude code) - -### mobile - -1. open opencode web ui - -## todo - -- add -a flag to add to create to attach to it -- backend mode -- fix our tests -- update icons.rs to include colors for the icons - -## ideas - -- reminders (ctrl r) -- notifications -- check for duplicates/simlar prs -- if plan -> searches for exsiting funcionality, creates plan asking clarying questions -- automatically check off of todo list when done -- fix opencode path, cannot find config file -- unread indicato - - add inbox that is the source of truth for this - - show this on hf above everything else -- sync command -- refactor sessions: ~/.claude/plans/sleepy-frolicking-nest.md -- keep switch active after archive -- add an icon if there are merge conflicts -- add `hf -` -- ask -> do research in a codebase -- todo list integrations (linear, github, etc) - - show issues due soon in switch - - search issues from cli - - create issues from cli -- keep tmux window name in sync with the agent status -- move all tools (github, graphite, git) too tools/ folder -- show git tree -- editor plugins - - vs code - - tmux - - zed - - opencode web -- have hf switch periodically refresh on agent status -- add new columns - - model (for the agent) -- todo list & plan management -> with simplenote sync -- sqlite (global) -- list of all global handoff repos -- heartbeat status to tell openclaw what it needs to send you -- sandbox agent sdk support -- serve command to run server -- multi-repo support (list for all repos) -- pluggable notification system -- cron jobs -- sandbox support - - auto-boot sandboxes for prs -- menubar -- notes integration - -## cool details - -- automatically uses your opencode theme -- auto symlink target/node_modules/etc -- auto-archives handoffs when closed -- shows agent status in the tmux window name diff --git a/factory/packages/backend/CLAUDE.md b/factory/packages/backend/CLAUDE.md deleted file mode 100644 index b270332f..00000000 --- a/factory/packages/backend/CLAUDE.md +++ /dev/null @@ -1,36 +0,0 @@ -# Backend Notes - -## Actor Hierarchy - -Keep the backend actor tree aligned with this shape unless we explicitly decide to change it: - -```text -WorkspaceActor -├─ HistoryActor(workspace-scoped global feed) -├─ ProjectActor(repo) -│ ├─ ProjectBranchSyncActor -│ ├─ ProjectPrSyncActor -│ └─ HandoffActor(handoff) -│ ├─ HandoffSessionActor(session) × N -│ │ └─ SessionStatusSyncActor(session) × 0..1 -│ └─ Handoff-local workbench state -└─ SandboxInstanceActor(providerId, sandboxId) × N -``` - -## Ownership Rules - -- `WorkspaceActor` is the workspace coordinator and lookup/index owner. -- `HistoryActor` is workspace-scoped. There is one workspace-level history feed. -- `ProjectActor` is the repo coordinator and owns repo-local caches/indexes. -- `HandoffActor` is one branch. Treat `1 handoff = 1 branch` once branch assignment is finalized. -- `HandoffActor` can have many sessions. -- `HandoffActor` can reference many sandbox instances historically, but should have only one active sandbox/session at a time. -- Session unread state and draft prompts are backend-owned workbench state, not frontend-local state. -- Branch rename is a real git operation, not just metadata. -- `SandboxInstanceActor` stays separate from `HandoffActor`; handoffs/sessions reference it by identity. -- Sync actors are polling workers only. They feed parent actors and should not become the source of truth. - -## Maintenance - -- Keep this file up to date whenever actor ownership, hierarchy, or lifecycle responsibilities change. -- If the real actor tree diverges from this document, update this document in the same change. diff --git a/factory/packages/backend/package.json b/factory/packages/backend/package.json deleted file mode 100644 index 68c514c8..00000000 --- a/factory/packages/backend/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@openhandoff/backend", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsup src/index.ts --format esm --external bun:sqlite", - "db:generate": "find src/actors -name drizzle.config.ts -exec pnpm exec drizzle-kit generate --config {} \\; && \"$HOME/.bun/bin/bun\" src/actors/_scripts/generate-actor-migrations.ts", - "typecheck": "tsc --noEmit", - "test": "$HOME/.bun/bin/bun x vitest run", - "start": "bun dist/index.js start" - }, - "dependencies": { - "@daytonaio/sdk": "0.141.0", - "@hono/node-server": "^1.19.7", - "@hono/node-ws": "^1.3.0", - "@iarna/toml": "^2.2.5", - "@openhandoff/shared": "workspace:*", - "@sandbox-agent/persist-rivet": "workspace:*", - "drizzle-orm": "^0.44.5", - "hono": "^4.11.9", - "pino": "^10.3.1", - "rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit", - "sandbox-agent": "workspace:*", - "uuid": "^13.0.0", - "zod": "^4.1.5" - }, - "devDependencies": { - "@types/bun": "^1.3.9", - "drizzle-kit": "^0.31.8", - "tsup": "^8.5.0" - } -} diff --git a/factory/packages/backend/src/actors/_scripts/generate-actor-migrations.ts b/factory/packages/backend/src/actors/_scripts/generate-actor-migrations.ts deleted file mode 100644 index 5234fb21..00000000 --- a/factory/packages/backend/src/actors/_scripts/generate-actor-migrations.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; - -type Journal = { - entries?: Array<{ - idx: number; - when: number; - tag: string; - breakpoints?: boolean; - version?: string; - }>; -}; - -function padMigrationKey(idx: number): string { - return `m${String(idx).padStart(4, "0")}`; -} - -function escapeTemplateLiteral(value: string): string { - return value.replace(/`/g, "\\`").replace(/\$\{/g, "\\${"); -} - -async function fileExists(path: string): Promise { - try { - await readFile(path); - return true; - } catch { - return false; - } -} - -async function walkDirectories(root: string, onDir: (dir: string) => Promise): Promise { - const entries = await readdir(root, { withFileTypes: true }); - await onDir(root); - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) { - continue; - } - await walkDirectories(join(root, entry.name), onDir); - } -} - -async function generateOne(drizzleDir: string): Promise { - const metaDir = resolve(drizzleDir, "meta"); - const journalPath = resolve(metaDir, "_journal.json"); - if (!(await fileExists(journalPath))) { - return; - } - - const drizzleEntries = (await readdir(drizzleDir, { withFileTypes: true })) - .filter((entry) => entry.isFile() && entry.name.endsWith(".sql")) - .map((entry) => entry.name) - .sort(); - - if (drizzleEntries.length === 0) { - return; - } - - const journalRaw = await readFile(journalPath, "utf8"); - const journal = JSON.parse(journalRaw) as Journal; - const entries = journal.entries ?? []; - - const sqlByKey = new Map(); - for (const entry of entries) { - const file = drizzleEntries[entry.idx]; - if (!file) { - throw new Error(`Missing migration SQL file for idx=${entry.idx} in ${drizzleDir}`); - } - const sqlPath = resolve(drizzleDir, file); - const sqlRaw = await readFile(sqlPath, "utf8"); - sqlByKey.set(padMigrationKey(entry.idx), sqlRaw); - } - - const migrationsObjectLines: string[] = []; - for (const entry of entries) { - const key = padMigrationKey(entry.idx); - const sql = sqlByKey.get(key); - if (!sql) continue; - migrationsObjectLines.push(` ${key}: \`${escapeTemplateLiteral(sql)}\`,`); - } - - const banner = `// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. -// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). -// Do not hand-edit this file. -`; - - const journalLiteral = JSON.stringify( - { - entries: entries.map((entry) => ({ - idx: entry.idx, - when: entry.when, - tag: entry.tag, - breakpoints: Boolean(entry.breakpoints), - })), - }, - null, - 2 - ); - - const outPath = resolve(drizzleDir, "..", "migrations.ts"); - const content = `${banner} -const journal = ${journalLiteral} as const; - -export default { - journal, - migrations: { -${migrationsObjectLines.join("\n")} - } as const -}; -`; - - await mkdir(dirname(outPath), { recursive: true }); - await writeFile(outPath, content, "utf8"); - - // drizzle-kit generates a JS helper file by default; delete to keep TS-only sources. - await rm(resolve(drizzleDir, "migrations.js"), { force: true }); -} - -async function main(): Promise { - const packageRoot = resolve(import.meta.dirname, "..", "..", ".."); // packages/backend - const actorsRoot = resolve(packageRoot, "src", "actors"); - - await walkDirectories(actorsRoot, async (dir) => { - if (dir.endsWith(`${join("db", "drizzle")}`)) { - await generateOne(dir); - } - }); -} - -main().catch((error: unknown) => { - const message = error instanceof Error ? error.stack ?? error.message : String(error); - // eslint-disable-next-line no-console - console.error(message); - process.exitCode = 1; -}); - diff --git a/factory/packages/backend/src/actors/context.ts b/factory/packages/backend/src/actors/context.ts deleted file mode 100644 index 3a7a875f..00000000 --- a/factory/packages/backend/src/actors/context.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { AppConfig } from "@openhandoff/shared"; -import type { BackendDriver } from "../driver.js"; -import type { NotificationService } from "../notifications/index.js"; -import type { ProviderRegistry } from "../providers/index.js"; - -let runtimeConfig: AppConfig | null = null; -let providerRegistry: ProviderRegistry | null = null; -let notificationService: NotificationService | null = null; -let runtimeDriver: BackendDriver | null = null; - -export function initActorRuntimeContext( - config: AppConfig, - providers: ProviderRegistry, - notifications?: NotificationService, - driver?: BackendDriver -): void { - runtimeConfig = config; - providerRegistry = providers; - notificationService = notifications ?? null; - runtimeDriver = driver ?? null; -} - -export function getActorRuntimeContext(): { - config: AppConfig; - providers: ProviderRegistry; - notifications: NotificationService | null; - driver: BackendDriver; -} { - if (!runtimeConfig || !providerRegistry) { - throw new Error("Actor runtime context not initialized"); - } - - if (!runtimeDriver) { - throw new Error("Actor runtime context missing driver"); - } - - return { - config: runtimeConfig, - providers: providerRegistry, - notifications: notificationService, - driver: runtimeDriver, - }; -} diff --git a/factory/packages/backend/src/actors/events.ts b/factory/packages/backend/src/actors/events.ts deleted file mode 100644 index 8f9ea282..00000000 --- a/factory/packages/backend/src/actors/events.ts +++ /dev/null @@ -1,112 +0,0 @@ -import type { HandoffStatus, ProviderId } from "@openhandoff/shared"; - -export interface HandoffCreatedEvent { - workspaceId: string; - repoId: string; - handoffId: string; - providerId: ProviderId; - branchName: string; - title: string; -} - -export interface HandoffStatusEvent { - workspaceId: string; - repoId: string; - handoffId: string; - status: HandoffStatus; - message: string; -} - -export interface ProjectSnapshotEvent { - workspaceId: string; - repoId: string; - updatedAt: number; -} - -export interface AgentStartedEvent { - workspaceId: string; - repoId: string; - handoffId: string; - sessionId: string; -} - -export interface AgentIdleEvent { - workspaceId: string; - repoId: string; - handoffId: string; - sessionId: string; -} - -export interface AgentErrorEvent { - workspaceId: string; - repoId: string; - handoffId: string; - message: string; -} - -export interface PrCreatedEvent { - workspaceId: string; - repoId: string; - handoffId: string; - prNumber: number; - url: string; -} - -export interface PrClosedEvent { - workspaceId: string; - repoId: string; - handoffId: string; - prNumber: number; - merged: boolean; -} - -export interface PrReviewEvent { - workspaceId: string; - repoId: string; - handoffId: string; - prNumber: number; - reviewer: string; - status: string; -} - -export interface CiStatusChangedEvent { - workspaceId: string; - repoId: string; - handoffId: string; - prNumber: number; - status: string; -} - -export type HandoffStepName = "auto_commit" | "push" | "pr_submit"; -export type HandoffStepStatus = "started" | "completed" | "skipped" | "failed"; - -export interface HandoffStepEvent { - workspaceId: string; - repoId: string; - handoffId: string; - step: HandoffStepName; - status: HandoffStepStatus; - message: string; -} - -export interface BranchSwitchedEvent { - workspaceId: string; - repoId: string; - handoffId: string; - branchName: string; -} - -export interface SessionAttachedEvent { - workspaceId: string; - repoId: string; - handoffId: string; - sessionId: string; -} - -export interface BranchSyncedEvent { - workspaceId: string; - repoId: string; - handoffId: string; - branchName: string; - strategy: string; -} diff --git a/factory/packages/backend/src/actors/handles.ts b/factory/packages/backend/src/actors/handles.ts deleted file mode 100644 index a05a7fbf..00000000 --- a/factory/packages/backend/src/actors/handles.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { - handoffKey, - handoffStatusSyncKey, - historyKey, - projectBranchSyncKey, - projectKey, - projectPrSyncKey, - sandboxInstanceKey, - workspaceKey -} from "./keys.js"; -import type { ProviderId } from "@openhandoff/shared"; - -export function actorClient(c: any) { - return c.client(); -} - -export async function getOrCreateWorkspace(c: any, workspaceId: string) { - return await actorClient(c).workspace.getOrCreate(workspaceKey(workspaceId), { - createWithInput: workspaceId - }); -} - -export async function getOrCreateProject(c: any, workspaceId: string, repoId: string, remoteUrl: string) { - return await actorClient(c).project.getOrCreate(projectKey(workspaceId, repoId), { - createWithInput: { - workspaceId, - repoId, - remoteUrl - } - }); -} - -export function getProject(c: any, workspaceId: string, repoId: string) { - return actorClient(c).project.get(projectKey(workspaceId, repoId)); -} - -export function getHandoff(c: any, workspaceId: string, repoId: string, handoffId: string) { - return actorClient(c).handoff.get(handoffKey(workspaceId, repoId, handoffId)); -} - -export async function getOrCreateHandoff( - c: any, - workspaceId: string, - repoId: string, - handoffId: string, - createWithInput: Record -) { - return await actorClient(c).handoff.getOrCreate(handoffKey(workspaceId, repoId, handoffId), { - createWithInput - }); -} - -export async function getOrCreateHistory(c: any, workspaceId: string, repoId: string) { - return await actorClient(c).history.getOrCreate(historyKey(workspaceId, repoId), { - createWithInput: { - workspaceId, - repoId - } - }); -} - -export async function getOrCreateProjectPrSync( - c: any, - workspaceId: string, - repoId: string, - repoPath: string, - intervalMs: number -) { - return await actorClient(c).projectPrSync.getOrCreate(projectPrSyncKey(workspaceId, repoId), { - createWithInput: { - workspaceId, - repoId, - repoPath, - intervalMs - } - }); -} - -export async function getOrCreateProjectBranchSync( - c: any, - workspaceId: string, - repoId: string, - repoPath: string, - intervalMs: number -) { - return await actorClient(c).projectBranchSync.getOrCreate(projectBranchSyncKey(workspaceId, repoId), { - createWithInput: { - workspaceId, - repoId, - repoPath, - intervalMs - } - }); -} - -export function getSandboxInstance(c: any, workspaceId: string, providerId: ProviderId, sandboxId: string) { - return actorClient(c).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId)); -} - -export async function getOrCreateSandboxInstance( - c: any, - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - createWithInput: Record -) { - return await actorClient(c).sandboxInstance.getOrCreate( - sandboxInstanceKey(workspaceId, providerId, sandboxId), - { createWithInput } - ); -} - -export async function getOrCreateHandoffStatusSync( - c: any, - workspaceId: string, - repoId: string, - handoffId: string, - sandboxId: string, - sessionId: string, - createWithInput: Record -) { - return await actorClient(c).handoffStatusSync.getOrCreate( - handoffStatusSyncKey(workspaceId, repoId, handoffId, sandboxId, sessionId), - { - createWithInput - } - ); -} - -export function selfProjectPrSync(c: any) { - return actorClient(c).projectPrSync.getForId(c.actorId); -} - -export function selfProjectBranchSync(c: any) { - return actorClient(c).projectBranchSync.getForId(c.actorId); -} - -export function selfHandoffStatusSync(c: any) { - return actorClient(c).handoffStatusSync.getForId(c.actorId); -} - -export function selfHistory(c: any) { - return actorClient(c).history.getForId(c.actorId); -} - -export function selfHandoff(c: any) { - return actorClient(c).handoff.getForId(c.actorId); -} - -export function selfWorkspace(c: any) { - return actorClient(c).workspace.getForId(c.actorId); -} - -export function selfProject(c: any) { - return actorClient(c).project.getForId(c.actorId); -} - -export function selfSandboxInstance(c: any) { - return actorClient(c).sandboxInstance.getForId(c.actorId); -} diff --git a/factory/packages/backend/src/actors/handoff-status-sync/index.ts b/factory/packages/backend/src/actors/handoff-status-sync/index.ts deleted file mode 100644 index 86c8b3dd..00000000 --- a/factory/packages/backend/src/actors/handoff-status-sync/index.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { actor, queue } from "rivetkit"; -import { workflow } from "rivetkit/workflow"; -import type { ProviderId } from "@openhandoff/shared"; -import { getHandoff, getSandboxInstance, selfHandoffStatusSync } from "../handles.js"; -import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js"; -import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js"; - -export interface HandoffStatusSyncInput { - workspaceId: string; - repoId: string; - handoffId: string; - providerId: ProviderId; - sandboxId: string; - sessionId: string; - intervalMs: number; -} - -interface SetIntervalCommand { - intervalMs: number; -} - -interface HandoffStatusSyncState extends PollingControlState { - workspaceId: string; - repoId: string; - handoffId: string; - providerId: ProviderId; - sandboxId: string; - sessionId: string; -} - -const CONTROL = { - start: "handoff.status_sync.control.start", - stop: "handoff.status_sync.control.stop", - setInterval: "handoff.status_sync.control.set_interval", - force: "handoff.status_sync.control.force" -} as const; - -async function pollSessionStatus(c: { state: HandoffStatusSyncState }): Promise { - const sandboxInstance = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, c.state.sandboxId); - const status = await sandboxInstance.sessionStatus({ sessionId: c.state.sessionId }); - - const parent = getHandoff(c, c.state.workspaceId, c.state.repoId, c.state.handoffId); - await parent.syncWorkbenchSessionStatus({ - sessionId: c.state.sessionId, - status: status.status, - at: Date.now() - }); -} - -export const handoffStatusSync = actor({ - queues: { - [CONTROL.start]: queue(), - [CONTROL.stop]: queue(), - [CONTROL.setInterval]: queue(), - [CONTROL.force]: queue(), - }, - options: { - // Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling. - noSleep: true - }, - createState: (_c, input: HandoffStatusSyncInput): HandoffStatusSyncState => ({ - workspaceId: input.workspaceId, - repoId: input.repoId, - handoffId: input.handoffId, - providerId: input.providerId, - sandboxId: input.sandboxId, - sessionId: input.sessionId, - intervalMs: input.intervalMs, - running: true - }), - actions: { - async start(c): Promise { - const self = selfHandoffStatusSync(c); - await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 }); - }, - - async stop(c): Promise { - const self = selfHandoffStatusSync(c); - await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 }); - }, - - async setIntervalMs(c, payload: SetIntervalCommand): Promise { - const self = selfHandoffStatusSync(c); - await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 }); - }, - - async force(c): Promise { - const self = selfHandoffStatusSync(c); - await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 }); - } - }, - run: workflow(async (ctx) => { - await runWorkflowPollingLoop(ctx, { - loopName: "handoff-status-sync-loop", - control: CONTROL, - onPoll: async (loopCtx) => { - try { - await pollSessionStatus(loopCtx); - } catch (error) { - logActorWarning("handoff-status-sync", "poll failed", { - error: resolveErrorMessage(error), - stack: resolveErrorStack(error) - }); - } - } - }); - }) -}); diff --git a/factory/packages/backend/src/actors/handoff/db/db.ts b/factory/packages/backend/src/actors/handoff/db/db.ts deleted file mode 100644 index 979bcf90..00000000 --- a/factory/packages/backend/src/actors/handoff/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const handoffDb = actorSqliteDb({ - actorName: "handoff", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts b/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts deleted file mode 100644 index 2a7346fa..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "rivetkit/db/drizzle"; - -export default defineConfig({ - out: "./src/actors/handoff/db/drizzle", - schema: "./src/actors/handoff/db/schema.ts", -}); - diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0000_condemned_maria_hill.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0000_condemned_maria_hill.sql deleted file mode 100644 index f73b681e..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0000_condemned_maria_hill.sql +++ /dev/null @@ -1,24 +0,0 @@ -CREATE TABLE `handoff` ( - `id` integer PRIMARY KEY NOT NULL, - `branch_name` text NOT NULL, - `title` text NOT NULL, - `task` text NOT NULL, - `provider_id` text NOT NULL, - `status` text NOT NULL, - `agent_type` text DEFAULT 'claude', - `auto_committed` integer DEFAULT 0, - `pushed` integer DEFAULT 0, - `pr_submitted` integer DEFAULT 0, - `needs_push` integer DEFAULT 0, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE `handoff_runtime` ( - `id` integer PRIMARY KEY NOT NULL, - `sandbox_id` text, - `session_id` text, - `switch_target` text, - `status_message` text, - `updated_at` integer NOT NULL -); diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql deleted file mode 100644 index 4aac4ccd..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0001_rapid_eddie_brock.sql +++ /dev/null @@ -1,3 +0,0 @@ -ALTER TABLE `handoff` DROP COLUMN `auto_committed`;--> statement-breakpoint -ALTER TABLE `handoff` DROP COLUMN `pushed`;--> statement-breakpoint -ALTER TABLE `handoff` DROP COLUMN `needs_push`; \ No newline at end of file diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0002_lazy_moira_mactaggert.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0002_lazy_moira_mactaggert.sql deleted file mode 100644 index fdc79bef..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0002_lazy_moira_mactaggert.sql +++ /dev/null @@ -1,38 +0,0 @@ -ALTER TABLE `handoff_runtime` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint -ALTER TABLE `handoff_runtime` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint -ALTER TABLE `handoff_runtime` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint -CREATE TABLE `handoff_sandboxes` ( - `sandbox_id` text PRIMARY KEY NOT NULL, - `provider_id` text NOT NULL, - `switch_target` text NOT NULL, - `cwd` text, - `status_message` text, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); ---> statement-breakpoint -ALTER TABLE `handoff_runtime` ADD `active_cwd` text; ---> statement-breakpoint -INSERT INTO `handoff_sandboxes` ( - `sandbox_id`, - `provider_id`, - `switch_target`, - `cwd`, - `status_message`, - `created_at`, - `updated_at` -) -SELECT - r.`active_sandbox_id`, - (SELECT h.`provider_id` FROM `handoff` h WHERE h.`id` = 1), - r.`active_switch_target`, - r.`active_cwd`, - r.`status_message`, - COALESCE((SELECT h.`created_at` FROM `handoff` h WHERE h.`id` = 1), r.`updated_at`), - r.`updated_at` -FROM `handoff_runtime` r -WHERE - r.`id` = 1 - AND r.`active_sandbox_id` IS NOT NULL - AND r.`active_switch_target` IS NOT NULL -ON CONFLICT(`sandbox_id`) DO NOTHING; diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0003_plucky_bran.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0003_plucky_bran.sql deleted file mode 100644 index f8c87f40..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0003_plucky_bran.sql +++ /dev/null @@ -1,48 +0,0 @@ --- Allow handoffs to exist before their branch/title are determined. --- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table. - -PRAGMA foreign_keys=off; - -CREATE TABLE `handoff__new` ( - `id` integer PRIMARY KEY NOT NULL, - `branch_name` text, - `title` text, - `task` text NOT NULL, - `provider_id` text NOT NULL, - `status` text NOT NULL, - `agent_type` text DEFAULT 'claude', - `pr_submitted` integer DEFAULT 0, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); - -INSERT INTO `handoff__new` ( - `id`, - `branch_name`, - `title`, - `task`, - `provider_id`, - `status`, - `agent_type`, - `pr_submitted`, - `created_at`, - `updated_at` -) -SELECT - `id`, - `branch_name`, - `title`, - `task`, - `provider_id`, - `status`, - `agent_type`, - `pr_submitted`, - `created_at`, - `updated_at` -FROM `handoff`; - -DROP TABLE `handoff`; -ALTER TABLE `handoff__new` RENAME TO `handoff`; - -PRAGMA foreign_keys=on; - diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0004_focused_shuri.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0004_focused_shuri.sql deleted file mode 100644 index aa39c9bc..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0004_focused_shuri.sql +++ /dev/null @@ -1,57 +0,0 @@ --- Fix: make branch_name/title nullable during initial "naming" stage. --- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements. --- Rebuild the table again with proper statement breakpoints. - -PRAGMA foreign_keys=off; ---> statement-breakpoint - -DROP TABLE IF EXISTS `handoff__new`; ---> statement-breakpoint - -CREATE TABLE `handoff__new` ( - `id` integer PRIMARY KEY NOT NULL, - `branch_name` text, - `title` text, - `task` text NOT NULL, - `provider_id` text NOT NULL, - `status` text NOT NULL, - `agent_type` text DEFAULT 'claude', - `pr_submitted` integer DEFAULT 0, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); ---> statement-breakpoint - -INSERT INTO `handoff__new` ( - `id`, - `branch_name`, - `title`, - `task`, - `provider_id`, - `status`, - `agent_type`, - `pr_submitted`, - `created_at`, - `updated_at` -) -SELECT - `id`, - `branch_name`, - `title`, - `task`, - `provider_id`, - `status`, - `agent_type`, - `pr_submitted`, - `created_at`, - `updated_at` -FROM `handoff`; ---> statement-breakpoint - -DROP TABLE `handoff`; ---> statement-breakpoint - -ALTER TABLE `handoff__new` RENAME TO `handoff`; ---> statement-breakpoint - -PRAGMA foreign_keys=on; diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql deleted file mode 100644 index 8853b960..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0005_sandbox_actor_id.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `handoff_sandboxes` ADD `sandbox_actor_id` text; diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/0006_workbench_sessions.sql b/factory/packages/backend/src/actors/handoff/db/drizzle/0006_workbench_sessions.sql deleted file mode 100644 index 1afc5b00..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/0006_workbench_sessions.sql +++ /dev/null @@ -1,14 +0,0 @@ -CREATE TABLE `handoff_workbench_sessions` ( - `session_id` text PRIMARY KEY NOT NULL, - `session_name` text NOT NULL, - `model` text NOT NULL, - `unread` integer DEFAULT 0 NOT NULL, - `draft_text` text DEFAULT '' NOT NULL, - `draft_attachments_json` text DEFAULT '[]' NOT NULL, - `draft_updated_at` integer, - `created` integer DEFAULT 1 NOT NULL, - `closed` integer DEFAULT 0 NOT NULL, - `thinking_since_ms` integer, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0000_snapshot.json b/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0000_snapshot.json deleted file mode 100644 index d5e31b97..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,176 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "9b004d3b-0722-4bb5-a410-d47635db7df3", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "handoff": { - "name": "handoff", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "task": { - "name": "task", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "agent_type": { - "name": "agent_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "'claude'" - }, - "auto_committed": { - "name": "auto_committed", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "pushed": { - "name": "pushed", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "pr_submitted": { - "name": "pr_submitted", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "needs_push": { - "name": "needs_push", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "handoff_runtime": { - "name": "handoff_runtime", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "sandbox_id": { - "name": "sandbox_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "switch_target": { - "name": "switch_target", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status_message": { - "name": "status_message", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0001_snapshot.json b/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0001_snapshot.json deleted file mode 100644 index 16d99b8d..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,152 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "0fca0f14-69df-4fca-bc52-29e902247909", - "prevId": "9b004d3b-0722-4bb5-a410-d47635db7df3", - "tables": { - "handoff": { - "name": "handoff", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "task": { - "name": "task", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "agent_type": { - "name": "agent_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "'claude'" - }, - "pr_submitted": { - "name": "pr_submitted", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "handoff_runtime": { - "name": "handoff_runtime", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "sandbox_id": { - "name": "sandbox_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "session_id": { - "name": "session_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "switch_target": { - "name": "switch_target", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status_message": { - "name": "status_message", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0002_snapshot.json b/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 2cc0f200..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,222 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "72cef919-e545-48be-a7c0-7ac74cfcf9e6", - "prevId": "0fca0f14-69df-4fca-bc52-29e902247909", - "tables": { - "handoff": { - "name": "handoff", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "task": { - "name": "task", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "agent_type": { - "name": "agent_type", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": "'claude'" - }, - "pr_submitted": { - "name": "pr_submitted", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false, - "default": 0 - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "handoff_runtime": { - "name": "handoff_runtime", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "active_sandbox_id": { - "name": "active_sandbox_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "active_session_id": { - "name": "active_session_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "active_switch_target": { - "name": "active_switch_target", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "active_cwd": { - "name": "active_cwd", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status_message": { - "name": "status_message", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "handoff_sandboxes": { - "name": "handoff_sandboxes", - "columns": { - "sandbox_id": { - "name": "sandbox_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "switch_target": { - "name": "switch_target", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "cwd": { - "name": "cwd", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "status_message": { - "name": "status_message", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": { - "\"handoff_runtime\".\"sandbox_id\"": "\"handoff_runtime\".\"active_sandbox_id\"", - "\"handoff_runtime\".\"session_id\"": "\"handoff_runtime\".\"active_session_id\"", - "\"handoff_runtime\".\"switch_target\"": "\"handoff_runtime\".\"active_switch_target\"" - } - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/_journal.json b/factory/packages/backend/src/actors/handoff/db/drizzle/meta/_journal.json deleted file mode 100644 index 208f695d..00000000 --- a/factory/packages/backend/src/actors/handoff/db/drizzle/meta/_journal.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1770924374665, - "tag": "0000_condemned_maria_hill", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1770947251055, - "tag": "0001_rapid_eddie_brock", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1770948428907, - "tag": "0002_lazy_moira_mactaggert", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1771027535276, - "tag": "0003_plucky_bran", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1771097651912, - "tag": "0004_focused_shuri", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1771370000000, - "tag": "0005_sandbox_actor_id", - "breakpoints": true - } - ] -} diff --git a/factory/packages/backend/src/actors/handoff/db/migrations.ts b/factory/packages/backend/src/actors/handoff/db/migrations.ts deleted file mode 100644 index 7fb38664..00000000 --- a/factory/packages/backend/src/actors/handoff/db/migrations.ts +++ /dev/null @@ -1,245 +0,0 @@ -// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. -// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). -// Do not hand-edit this file. - -const journal = { - "entries": [ - { - "idx": 0, - "when": 1770924374665, - "tag": "0000_condemned_maria_hill", - "breakpoints": true - }, - { - "idx": 1, - "when": 1770947251055, - "tag": "0001_rapid_eddie_brock", - "breakpoints": true - }, - { - "idx": 2, - "when": 1770948428907, - "tag": "0002_lazy_moira_mactaggert", - "breakpoints": true - }, - { - "idx": 3, - "when": 1771027535276, - "tag": "0003_plucky_bran", - "breakpoints": true - }, - { - "idx": 4, - "when": 1771097651912, - "tag": "0004_focused_shuri", - "breakpoints": true - }, - { - "idx": 5, - "when": 1771370000000, - "tag": "0005_sandbox_actor_id", - "breakpoints": true - }, - { - "idx": 6, - "when": 1773020000000, - "tag": "0006_workbench_sessions", - "breakpoints": true - } - ] -} as const; - -export default { - journal, - migrations: { - m0000: `CREATE TABLE \`handoff\` ( - \`id\` integer PRIMARY KEY NOT NULL, - \`branch_name\` text NOT NULL, - \`title\` text NOT NULL, - \`task\` text NOT NULL, - \`provider_id\` text NOT NULL, - \`status\` text NOT NULL, - \`agent_type\` text DEFAULT 'claude', - \`auto_committed\` integer DEFAULT 0, - \`pushed\` integer DEFAULT 0, - \`pr_submitted\` integer DEFAULT 0, - \`needs_push\` integer DEFAULT 0, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE \`handoff_runtime\` ( - \`id\` integer PRIMARY KEY NOT NULL, - \`sandbox_id\` text, - \`session_id\` text, - \`switch_target\` text, - \`status_message\` text, - \`updated_at\` integer NOT NULL -); -`, - m0001: `ALTER TABLE \`handoff\` DROP COLUMN \`auto_committed\`;--> statement-breakpoint -ALTER TABLE \`handoff\` DROP COLUMN \`pushed\`;--> statement-breakpoint -ALTER TABLE \`handoff\` DROP COLUMN \`needs_push\`;`, - m0002: `ALTER TABLE \`handoff_runtime\` RENAME COLUMN "sandbox_id" TO "active_sandbox_id";--> statement-breakpoint -ALTER TABLE \`handoff_runtime\` RENAME COLUMN "session_id" TO "active_session_id";--> statement-breakpoint -ALTER TABLE \`handoff_runtime\` RENAME COLUMN "switch_target" TO "active_switch_target";--> statement-breakpoint -CREATE TABLE \`handoff_sandboxes\` ( - \`sandbox_id\` text PRIMARY KEY NOT NULL, - \`provider_id\` text NOT NULL, - \`switch_target\` text NOT NULL, - \`cwd\` text, - \`status_message\` text, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); ---> statement-breakpoint -ALTER TABLE \`handoff_runtime\` ADD \`active_cwd\` text; ---> statement-breakpoint -INSERT INTO \`handoff_sandboxes\` ( - \`sandbox_id\`, - \`provider_id\`, - \`switch_target\`, - \`cwd\`, - \`status_message\`, - \`created_at\`, - \`updated_at\` -) -SELECT - r.\`active_sandbox_id\`, - (SELECT h.\`provider_id\` FROM \`handoff\` h WHERE h.\`id\` = 1), - r.\`active_switch_target\`, - r.\`active_cwd\`, - r.\`status_message\`, - COALESCE((SELECT h.\`created_at\` FROM \`handoff\` h WHERE h.\`id\` = 1), r.\`updated_at\`), - r.\`updated_at\` -FROM \`handoff_runtime\` r -WHERE - r.\`id\` = 1 - AND r.\`active_sandbox_id\` IS NOT NULL - AND r.\`active_switch_target\` IS NOT NULL -ON CONFLICT(\`sandbox_id\`) DO NOTHING; -`, - m0003: `-- Allow handoffs to exist before their branch/title are determined. --- Drizzle doesn't support altering column nullability in SQLite directly, so rebuild the table. - -PRAGMA foreign_keys=off; - -CREATE TABLE \`handoff__new\` ( - \`id\` integer PRIMARY KEY NOT NULL, - \`branch_name\` text, - \`title\` text, - \`task\` text NOT NULL, - \`provider_id\` text NOT NULL, - \`status\` text NOT NULL, - \`agent_type\` text DEFAULT 'claude', - \`pr_submitted\` integer DEFAULT 0, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); - -INSERT INTO \`handoff__new\` ( - \`id\`, - \`branch_name\`, - \`title\`, - \`task\`, - \`provider_id\`, - \`status\`, - \`agent_type\`, - \`pr_submitted\`, - \`created_at\`, - \`updated_at\` -) -SELECT - \`id\`, - \`branch_name\`, - \`title\`, - \`task\`, - \`provider_id\`, - \`status\`, - \`agent_type\`, - \`pr_submitted\`, - \`created_at\`, - \`updated_at\` -FROM \`handoff\`; - -DROP TABLE \`handoff\`; -ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`; - -PRAGMA foreign_keys=on; - -`, - m0004: `-- Fix: make branch_name/title nullable during initial "naming" stage. --- 0003 was missing statement breakpoints, so drizzle's migrator marked it applied without executing all statements. --- Rebuild the table again with proper statement breakpoints. - -PRAGMA foreign_keys=off; ---> statement-breakpoint - -DROP TABLE IF EXISTS \`handoff__new\`; ---> statement-breakpoint - -CREATE TABLE \`handoff__new\` ( - \`id\` integer PRIMARY KEY NOT NULL, - \`branch_name\` text, - \`title\` text, - \`task\` text NOT NULL, - \`provider_id\` text NOT NULL, - \`status\` text NOT NULL, - \`agent_type\` text DEFAULT 'claude', - \`pr_submitted\` integer DEFAULT 0, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); ---> statement-breakpoint - -INSERT INTO \`handoff__new\` ( - \`id\`, - \`branch_name\`, - \`title\`, - \`task\`, - \`provider_id\`, - \`status\`, - \`agent_type\`, - \`pr_submitted\`, - \`created_at\`, - \`updated_at\` -) -SELECT - \`id\`, - \`branch_name\`, - \`title\`, - \`task\`, - \`provider_id\`, - \`status\`, - \`agent_type\`, - \`pr_submitted\`, - \`created_at\`, - \`updated_at\` -FROM \`handoff\`; ---> statement-breakpoint - -DROP TABLE \`handoff\`; ---> statement-breakpoint - -ALTER TABLE \`handoff__new\` RENAME TO \`handoff\`; ---> statement-breakpoint - -PRAGMA foreign_keys=on; -`, - m0005: `ALTER TABLE \`handoff_sandboxes\` ADD \`sandbox_actor_id\` text;`, - m0006: `CREATE TABLE \`handoff_workbench_sessions\` ( - \`session_id\` text PRIMARY KEY NOT NULL, - \`session_name\` text NOT NULL, - \`model\` text NOT NULL, - \`unread\` integer DEFAULT 0 NOT NULL, - \`draft_text\` text DEFAULT '' NOT NULL, - \`draft_attachments_json\` text DEFAULT '[]' NOT NULL, - \`draft_updated_at\` integer, - \`created\` integer DEFAULT 1 NOT NULL, - \`closed\` integer DEFAULT 0 NOT NULL, - \`thinking_since_ms\` integer, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -);`, - } as const -}; diff --git a/factory/packages/backend/src/actors/handoff/db/schema.ts b/factory/packages/backend/src/actors/handoff/db/schema.ts deleted file mode 100644 index 7ce1aca9..00000000 --- a/factory/packages/backend/src/actors/handoff/db/schema.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; - -// SQLite is per handoff actor instance, so these tables only ever store one row (id=1). -export const handoff = sqliteTable("handoff", { - id: integer("id").primaryKey(), - branchName: text("branch_name"), - title: text("title"), - task: text("task").notNull(), - providerId: text("provider_id").notNull(), - status: text("status").notNull(), - agentType: text("agent_type").default("claude"), - prSubmitted: integer("pr_submitted").default(0), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const handoffRuntime = sqliteTable("handoff_runtime", { - id: integer("id").primaryKey(), - activeSandboxId: text("active_sandbox_id"), - activeSessionId: text("active_session_id"), - activeSwitchTarget: text("active_switch_target"), - activeCwd: text("active_cwd"), - statusMessage: text("status_message"), - updatedAt: integer("updated_at").notNull(), -}); - -export const handoffSandboxes = sqliteTable("handoff_sandboxes", { - sandboxId: text("sandbox_id").notNull().primaryKey(), - providerId: text("provider_id").notNull(), - sandboxActorId: text("sandbox_actor_id"), - switchTarget: text("switch_target").notNull(), - cwd: text("cwd"), - statusMessage: text("status_message"), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const handoffWorkbenchSessions = sqliteTable("handoff_workbench_sessions", { - sessionId: text("session_id").notNull().primaryKey(), - sessionName: text("session_name").notNull(), - model: text("model").notNull(), - unread: integer("unread").notNull().default(0), - draftText: text("draft_text").notNull().default(""), - draftAttachmentsJson: text("draft_attachments_json").notNull().default("[]"), - draftUpdatedAt: integer("draft_updated_at"), - created: integer("created").notNull().default(1), - closed: integer("closed").notNull().default(0), - thinkingSinceMs: integer("thinking_since_ms"), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); diff --git a/factory/packages/backend/src/actors/handoff/index.ts b/factory/packages/backend/src/actors/handoff/index.ts deleted file mode 100644 index 2ad52f71..00000000 --- a/factory/packages/backend/src/actors/handoff/index.ts +++ /dev/null @@ -1,399 +0,0 @@ -import { actor, queue } from "rivetkit"; -import { workflow } from "rivetkit/workflow"; -import type { - AgentType, - HandoffRecord, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchUpdateDraftInput, - ProviderId -} from "@openhandoff/shared"; -import { expectQueueResponse } from "../../services/queue.js"; -import { selfHandoff } from "../handles.js"; -import { handoffDb } from "./db/db.js"; -import { getCurrentRecord } from "./workflow/common.js"; -import { - changeWorkbenchModel, - closeWorkbenchSession, - createWorkbenchSession, - getWorkbenchHandoff, - markWorkbenchUnread, - publishWorkbenchPr, - renameWorkbenchBranch, - renameWorkbenchHandoff, - renameWorkbenchSession, - revertWorkbenchFile, - sendWorkbenchMessage, - syncWorkbenchSessionStatus, - setWorkbenchSessionUnread, - stopWorkbenchSession, - updateWorkbenchDraft -} from "./workbench.js"; -import { - HANDOFF_QUEUE_NAMES, - handoffWorkflowQueueName, - runHandoffWorkflow -} from "./workflow/index.js"; - -export interface HandoffInput { - workspaceId: string; - repoId: string; - handoffId: string; - repoRemote: string; - repoLocalPath: string; - branchName: string | null; - title: string | null; - task: string; - providerId: ProviderId; - agentType: AgentType | null; - explicitTitle: string | null; - explicitBranchName: string | null; -} - -interface InitializeCommand { - providerId?: ProviderId; -} - -interface HandoffActionCommand { - reason?: string; -} - -interface HandoffTabCommand { - tabId: string; -} - -interface HandoffStatusSyncCommand { - sessionId: string; - status: "running" | "idle" | "error"; - at: number; -} - -interface HandoffWorkbenchValueCommand { - value: string; -} - -interface HandoffWorkbenchSessionTitleCommand { - sessionId: string; - title: string; -} - -interface HandoffWorkbenchSessionUnreadCommand { - sessionId: string; - unread: boolean; -} - -interface HandoffWorkbenchUpdateDraftCommand { - sessionId: string; - text: string; - attachments: Array; -} - -interface HandoffWorkbenchChangeModelCommand { - sessionId: string; - model: string; -} - -interface HandoffWorkbenchSendMessageCommand { - sessionId: string; - text: string; - attachments: Array; -} - -interface HandoffWorkbenchCreateSessionCommand { - model?: string; -} - -interface HandoffWorkbenchSessionCommand { - sessionId: string; -} - -export const handoff = actor({ - db: handoffDb, - queues: Object.fromEntries(HANDOFF_QUEUE_NAMES.map((name) => [name, queue()])), - options: { - actionTimeout: 5 * 60_000 - }, - createState: (_c, input: HandoffInput) => ({ - workspaceId: input.workspaceId, - repoId: input.repoId, - handoffId: input.handoffId, - repoRemote: input.repoRemote, - repoLocalPath: input.repoLocalPath, - branchName: input.branchName, - title: input.title, - task: input.task, - providerId: input.providerId, - agentType: input.agentType, - explicitTitle: input.explicitTitle, - explicitBranchName: input.explicitBranchName, - initialized: false, - previousStatus: null as string | null, - }), - actions: { - async initialize(c, cmd: InitializeCommand): Promise { - const self = selfHandoff(c); - const result = await self.send(handoffWorkflowQueueName("handoff.command.initialize"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }); - return expectQueueResponse(result); - }, - - async provision(c, cmd: InitializeCommand): Promise<{ ok: true }> { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.provision"), cmd ?? {}, { - wait: true, - timeout: 30 * 60_000, - }); - return { ok: true }; - }, - - async attach(c, cmd?: HandoffActionCommand): Promise<{ target: string; sessionId: string | null }> { - const self = selfHandoff(c); - const result = await self.send(handoffWorkflowQueueName("handoff.command.attach"), cmd ?? {}, { - wait: true, - timeout: 20_000 - }); - return expectQueueResponse<{ target: string; sessionId: string | null }>(result); - }, - - async switch(c): Promise<{ switchTarget: string }> { - const self = selfHandoff(c); - const result = await self.send(handoffWorkflowQueueName("handoff.command.switch"), {}, { - wait: true, - timeout: 20_000 - }); - return expectQueueResponse<{ switchTarget: string }>(result); - }, - - async push(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.push"), cmd ?? {}, { - wait: true, - timeout: 180_000 - }); - }, - - async sync(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.sync"), cmd ?? {}, { - wait: true, - timeout: 30_000 - }); - }, - - async merge(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.merge"), cmd ?? {}, { - wait: true, - timeout: 30_000 - }); - }, - - async archive(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - void self - .send(handoffWorkflowQueueName("handoff.command.archive"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }) - .catch((error: unknown) => { - c.log.warn({ - msg: "archive command failed", - error: error instanceof Error ? error.message : String(error), - }); - }); - }, - - async kill(c, cmd?: HandoffActionCommand): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.kill"), cmd ?? {}, { - wait: true, - timeout: 60_000 - }); - }, - - async get(c): Promise { - return await getCurrentRecord({ db: c.db, state: c.state }); - }, - - async getWorkbench(c) { - return await getWorkbenchHandoff(c); - }, - - async markWorkbenchUnread(c): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.workbench.mark_unread"), {}, { - wait: true, - timeout: 20_000, - }); - }, - - async renameWorkbenchHandoff(c, input: HandoffWorkbenchRenameInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.rename_handoff"), - { value: input.value } satisfies HandoffWorkbenchValueCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async renameWorkbenchBranch(c, input: HandoffWorkbenchRenameInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.rename_branch"), - { value: input.value } satisfies HandoffWorkbenchValueCommand, - { - wait: true, - timeout: 5 * 60_000, - }, - ); - }, - - async createWorkbenchSession(c, input?: { model?: string }): Promise<{ tabId: string }> { - const self = selfHandoff(c); - const result = await self.send( - handoffWorkflowQueueName("handoff.command.workbench.create_session"), - { ...(input?.model ? { model: input.model } : {}) } satisfies HandoffWorkbenchCreateSessionCommand, - { - wait: true, - timeout: 5 * 60_000, - }, - ); - return expectQueueResponse<{ tabId: string }>(result); - }, - - async renameWorkbenchSession(c, input: HandoffWorkbenchRenameSessionInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.rename_session"), - { sessionId: input.tabId, title: input.title } satisfies HandoffWorkbenchSessionTitleCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async setWorkbenchSessionUnread(c, input: HandoffWorkbenchSetSessionUnreadInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.set_session_unread"), - { sessionId: input.tabId, unread: input.unread } satisfies HandoffWorkbenchSessionUnreadCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async updateWorkbenchDraft(c, input: HandoffWorkbenchUpdateDraftInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.update_draft"), - { - sessionId: input.tabId, - text: input.text, - attachments: input.attachments, - } satisfies HandoffWorkbenchUpdateDraftCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async changeWorkbenchModel(c, input: HandoffWorkbenchChangeModelInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.change_model"), - { sessionId: input.tabId, model: input.model } satisfies HandoffWorkbenchChangeModelCommand, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async sendWorkbenchMessage(c, input: HandoffWorkbenchSendMessageInput): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.send_message"), - { - sessionId: input.tabId, - text: input.text, - attachments: input.attachments, - } satisfies HandoffWorkbenchSendMessageCommand, - { - wait: true, - timeout: 10 * 60_000, - }, - ); - }, - - async stopWorkbenchSession(c, input: HandoffTabCommand): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.stop_session"), - { sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand, - { - wait: true, - timeout: 5 * 60_000, - }, - ); - }, - - async syncWorkbenchSessionStatus(c, input: HandoffStatusSyncCommand): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.sync_session_status"), - input, - { - wait: true, - timeout: 20_000, - }, - ); - }, - - async closeWorkbenchSession(c, input: HandoffTabCommand): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.close_session"), - { sessionId: input.tabId } satisfies HandoffWorkbenchSessionCommand, - { - wait: true, - timeout: 5 * 60_000, - }, - ); - }, - - async publishWorkbenchPr(c): Promise { - const self = selfHandoff(c); - await self.send(handoffWorkflowQueueName("handoff.command.workbench.publish_pr"), {}, { - wait: true, - timeout: 10 * 60_000, - }); - }, - - async revertWorkbenchFile(c, input: { path: string }): Promise { - const self = selfHandoff(c); - await self.send( - handoffWorkflowQueueName("handoff.command.workbench.revert_file"), - input, - { - wait: true, - timeout: 5 * 60_000, - }, - ); - } - }, - run: workflow(runHandoffWorkflow) -}); - -export { HANDOFF_QUEUE_NAMES }; diff --git a/factory/packages/backend/src/actors/handoff/workbench.ts b/factory/packages/backend/src/actors/handoff/workbench.ts deleted file mode 100644 index 650126a9..00000000 --- a/factory/packages/backend/src/actors/handoff/workbench.ts +++ /dev/null @@ -1,861 +0,0 @@ -// @ts-nocheck -import { basename } from "node:path"; -import { asc, eq } from "drizzle-orm"; -import { getActorRuntimeContext } from "../context.js"; -import { - getOrCreateHandoffStatusSync, - getOrCreateProject, - getOrCreateWorkspace, - getSandboxInstance, -} from "../handles.js"; -import { handoff as handoffTable, handoffRuntime, handoffWorkbenchSessions } from "./db/schema.js"; -import { getCurrentRecord } from "./workflow/common.js"; - -const STATUS_SYNC_INTERVAL_MS = 1_000; - -async function ensureWorkbenchSessionTable(c: any): Promise { - await c.db.execute(` - CREATE TABLE IF NOT EXISTS handoff_workbench_sessions ( - session_id text PRIMARY KEY NOT NULL, - session_name text NOT NULL, - model text NOT NULL, - unread integer DEFAULT 0 NOT NULL, - draft_text text DEFAULT '' NOT NULL, - draft_attachments_json text DEFAULT '[]' NOT NULL, - draft_updated_at integer, - created integer DEFAULT 1 NOT NULL, - closed integer DEFAULT 0 NOT NULL, - thinking_since_ms integer, - created_at integer NOT NULL, - updated_at integer NOT NULL - ) - `); -} - -function defaultModelForAgent(agentType: string | null | undefined) { - return agentType === "codex" ? "gpt-4o" : "claude-sonnet-4"; -} - -function agentKindForModel(model: string) { - if (model === "gpt-4o" || model === "o3") { - return "Codex"; - } - return "Claude"; -} - -export function agentTypeForModel(model: string) { - if (model === "gpt-4o" || model === "o3") { - return "codex"; - } - return "claude"; -} - -function repoLabelFromRemote(remoteUrl: string): string { - const trimmed = remoteUrl.trim(); - try { - const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`); - const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean); - if (parts.length >= 2) { - return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`; - } - } catch { - // ignore - } - - return basename(trimmed.replace(/\.git$/, "")); -} - -function parseDraftAttachments(value: string | null | undefined): Array { - if (!value) { - return []; - } - - try { - const parsed = JSON.parse(value) as unknown; - return Array.isArray(parsed) ? parsed : []; - } catch { - return []; - } -} - -export function shouldMarkSessionUnreadForStatus(meta: { thinkingSinceMs?: number | null }, status: "running" | "idle" | "error"): boolean { - if (status === "running") { - return false; - } - - // Only mark unread when we observe the transition out of an active thinking state. - // Repeated idle polls for an already-finished session must not flip unread back on. - return Boolean(meta.thinkingSinceMs); -} - -async function listSessionMetaRows(c: any, options?: { includeClosed?: boolean }): Promise> { - await ensureWorkbenchSessionTable(c); - const rows = await c.db - .select() - .from(handoffWorkbenchSessions) - .orderBy(asc(handoffWorkbenchSessions.createdAt)) - .all(); - const mapped = rows.map((row: any) => ({ - ...row, - id: row.sessionId, - sessionId: row.sessionId, - draftAttachments: parseDraftAttachments(row.draftAttachmentsJson), - draftUpdatedAtMs: row.draftUpdatedAt ?? null, - unread: row.unread === 1, - created: row.created === 1, - closed: row.closed === 1, - })); - - if (options?.includeClosed === true) { - return mapped; - } - - return mapped.filter((row: any) => row.closed !== true); -} - -async function nextSessionName(c: any): Promise { - const rows = await listSessionMetaRows(c, { includeClosed: true }); - return `Session ${rows.length + 1}`; -} - -async function readSessionMeta(c: any, sessionId: string): Promise { - await ensureWorkbenchSessionTable(c); - const row = await c.db - .select() - .from(handoffWorkbenchSessions) - .where(eq(handoffWorkbenchSessions.sessionId, sessionId)) - .get(); - - if (!row) { - return null; - } - - return { - ...row, - id: row.sessionId, - sessionId: row.sessionId, - draftAttachments: parseDraftAttachments(row.draftAttachmentsJson), - draftUpdatedAtMs: row.draftUpdatedAt ?? null, - unread: row.unread === 1, - created: row.created === 1, - closed: row.closed === 1, - }; -} - -async function ensureSessionMeta(c: any, params: { - sessionId: string; - model?: string; - sessionName?: string; - unread?: boolean; -}): Promise { - await ensureWorkbenchSessionTable(c); - const existing = await readSessionMeta(c, params.sessionId); - if (existing) { - return existing; - } - - const now = Date.now(); - const sessionName = params.sessionName ?? (await nextSessionName(c)); - const model = params.model ?? defaultModelForAgent(c.state.agentType); - const unread = params.unread ?? false; - - await c.db - .insert(handoffWorkbenchSessions) - .values({ - sessionId: params.sessionId, - sessionName, - model, - unread: unread ? 1 : 0, - draftText: "", - draftAttachmentsJson: "[]", - draftUpdatedAt: null, - created: 1, - closed: 0, - thinkingSinceMs: null, - createdAt: now, - updatedAt: now, - }) - .run(); - - return await readSessionMeta(c, params.sessionId); -} - -async function updateSessionMeta(c: any, sessionId: string, values: Record): Promise { - await ensureSessionMeta(c, { sessionId }); - await c.db - .update(handoffWorkbenchSessions) - .set({ - ...values, - updatedAt: Date.now(), - }) - .where(eq(handoffWorkbenchSessions.sessionId, sessionId)) - .run(); - return await readSessionMeta(c, sessionId); -} - -async function notifyWorkbenchUpdated(c: any): Promise { - const workspace = await getOrCreateWorkspace(c, c.state.workspaceId); - await workspace.notifyWorkbenchUpdated({}); -} - -function shellFragment(parts: string[]): string { - return parts.join(" && "); -} - -async function executeInSandbox(c: any, params: { - sandboxId: string; - cwd: string; - command: string; - label: string; -}): Promise<{ exitCode: number; result: string }> { - const { providers } = getActorRuntimeContext(); - const provider = providers.get(c.state.providerId); - return await provider.executeCommand({ - workspaceId: c.state.workspaceId, - sandboxId: params.sandboxId, - command: `bash -lc ${JSON.stringify(shellFragment([`cd ${JSON.stringify(params.cwd)}`, params.command]))}`, - label: params.label, - }); -} - -function parseGitStatus(output: string): Array<{ path: string; type: "M" | "A" | "D" }> { - return output - .split("\n") - .map((line) => line.trimEnd()) - .filter(Boolean) - .map((line) => { - const status = line.slice(0, 2).trim(); - const rawPath = line.slice(3).trim(); - const path = rawPath.includes(" -> ") ? rawPath.split(" -> ").pop() ?? rawPath : rawPath; - const type = - status.includes("D") - ? "D" - : status.includes("A") || status === "??" - ? "A" - : "M"; - return { path, type }; - }); -} - -function parseNumstat(output: string): Map { - const map = new Map(); - for (const line of output.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) continue; - const [addedRaw, removedRaw, ...pathParts] = trimmed.split("\t"); - const path = pathParts.join("\t").trim(); - if (!path) continue; - map.set(path, { - added: Number.parseInt(addedRaw ?? "0", 10) || 0, - removed: Number.parseInt(removedRaw ?? "0", 10) || 0, - }); - } - return map; -} - -function buildFileTree(paths: string[]): Array { - const root = { - children: new Map(), - }; - - for (const path of paths) { - const parts = path.split("/").filter(Boolean); - let current = root; - let currentPath = ""; - - for (let index = 0; index < parts.length; index += 1) { - const part = parts[index]!; - currentPath = currentPath ? `${currentPath}/${part}` : part; - const isDir = index < parts.length - 1; - let node = current.children.get(part); - if (!node) { - node = { - name: part, - path: currentPath, - isDir, - children: isDir ? new Map() : undefined, - }; - current.children.set(part, node); - } else if (isDir && !(node.children instanceof Map)) { - node.children = new Map(); - } - current = node; - } - } - - function sortNodes(nodes: Iterable): Array { - return [...nodes] - .map((node) => - node.isDir - ? { - name: node.name, - path: node.path, - isDir: true, - children: sortNodes(node.children?.values?.() ?? []), - } - : { - name: node.name, - path: node.path, - isDir: false, - }, - ) - .sort((left, right) => { - if (left.isDir !== right.isDir) { - return left.isDir ? -1 : 1; - } - return left.path.localeCompare(right.path); - }); - } - - return sortNodes(root.children.values()); -} - -async function collectWorkbenchGitState(c: any, record: any) { - const activeSandboxId = record.activeSandboxId; - const activeSandbox = - activeSandboxId != null - ? (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === activeSandboxId) ?? null - : null; - const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null; - if (!activeSandboxId || !cwd) { - return { - fileChanges: [], - diffs: {}, - fileTree: [], - }; - } - - const statusResult = await executeInSandbox(c, { - sandboxId: activeSandboxId, - cwd, - command: "git status --porcelain=v1 -uall", - label: "git status", - }); - if (statusResult.exitCode !== 0) { - return { - fileChanges: [], - diffs: {}, - fileTree: [], - }; - } - - const statusRows = parseGitStatus(statusResult.result); - const numstatResult = await executeInSandbox(c, { - sandboxId: activeSandboxId, - cwd, - command: "git diff --numstat", - label: "git diff numstat", - }); - const numstat = parseNumstat(numstatResult.result); - const diffs: Record = {}; - - for (const row of statusRows) { - const diffResult = await executeInSandbox(c, { - sandboxId: activeSandboxId, - cwd, - command: `if git ls-files --error-unmatch -- ${JSON.stringify(row.path)} >/dev/null 2>&1; then git diff -- ${JSON.stringify(row.path)}; else git diff --no-index -- /dev/null ${JSON.stringify(row.path)} || true; fi`, - label: `git diff ${row.path}`, - }); - diffs[row.path] = diffResult.result; - } - - const filesResult = await executeInSandbox(c, { - sandboxId: activeSandboxId, - cwd, - command: "git ls-files --cached --others --exclude-standard", - label: "git ls-files", - }); - const allPaths = filesResult.result - .split("\n") - .map((line) => line.trim()) - .filter(Boolean); - - return { - fileChanges: statusRows.map((row) => { - const counts = numstat.get(row.path) ?? { added: 0, removed: 0 }; - return { - path: row.path, - added: counts.added, - removed: counts.removed, - type: row.type, - }; - }), - diffs, - fileTree: buildFileTree(allPaths), - }; -} - -async function readSessionTranscript(c: any, record: any, sessionId: string) { - const sandboxId = record.activeSandboxId ?? record.sandboxes?.[0]?.sandboxId ?? null; - if (!sandboxId) { - return []; - } - - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, sandboxId); - const page = await sandbox.listSessionEvents({ - sessionId, - limit: 500, - }); - return page.items.map((event: any) => ({ - id: event.id, - eventIndex: event.eventIndex, - sessionId: event.sessionId, - createdAt: event.createdAt, - connectionId: event.connectionId, - sender: event.sender, - payload: event.payload, - })); -} - -async function activeSessionStatus(c: any, record: any, sessionId: string) { - if (record.activeSessionId !== sessionId || !record.activeSandboxId) { - return "idle"; - } - - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); - const status = await sandbox.sessionStatus({ sessionId }); - return status.status; -} - -async function readPullRequestSummary(c: any, branchName: string | null) { - if (!branchName) { - return null; - } - - try { - const project = await getOrCreateProject( - c, - c.state.workspaceId, - c.state.repoId, - c.state.repoRemote, - ); - return await project.getPullRequestForBranch({ branchName }); - } catch { - return null; - } -} - -export async function ensureWorkbenchSeeded(c: any): Promise { - const record = await getCurrentRecord({ db: c.db, state: c.state }); - if (record.activeSessionId) { - await ensureSessionMeta(c, { - sessionId: record.activeSessionId, - model: defaultModelForAgent(record.agentType), - sessionName: "Session 1", - }); - } - return record; -} - -export async function getWorkbenchHandoff(c: any): Promise { - const record = await ensureWorkbenchSeeded(c); - const gitState = await collectWorkbenchGitState(c, record); - const sessions = await listSessionMetaRows(c); - const tabs = []; - - for (const meta of sessions) { - const status = await activeSessionStatus(c, record, meta.sessionId); - let thinkingSinceMs = meta.thinkingSinceMs ?? null; - let unread = Boolean(meta.unread); - if (thinkingSinceMs && status !== "running") { - thinkingSinceMs = null; - unread = true; - } - - tabs.push({ - id: meta.id, - sessionId: meta.sessionId, - sessionName: meta.sessionName, - agent: agentKindForModel(meta.model), - model: meta.model, - status, - thinkingSinceMs: status === "running" ? thinkingSinceMs : null, - unread, - created: Boolean(meta.created), - draft: { - text: meta.draftText ?? "", - attachments: Array.isArray(meta.draftAttachments) ? meta.draftAttachments : [], - updatedAtMs: meta.draftUpdatedAtMs ?? null, - }, - transcript: await readSessionTranscript(c, record, meta.sessionId), - }); - } - - return { - id: c.state.handoffId, - repoId: c.state.repoId, - title: record.title ?? "New Handoff", - status: record.status === "archived" ? "archived" : record.status === "running" ? "running" : record.status === "idle" ? "idle" : "new", - repoName: repoLabelFromRemote(c.state.repoRemote), - updatedAtMs: record.updatedAt, - branch: record.branchName, - pullRequest: await readPullRequestSummary(c, record.branchName), - tabs, - fileChanges: gitState.fileChanges, - diffs: gitState.diffs, - fileTree: gitState.fileTree, - }; -} - -export async function renameWorkbenchHandoff(c: any, value: string): Promise { - const nextTitle = value.trim(); - if (!nextTitle) { - throw new Error("handoff title is required"); - } - - await c.db - .update(handoffTable) - .set({ - title: nextTitle, - updatedAt: Date.now(), - }) - .where(eq(handoffTable.id, 1)) - .run(); - c.state.title = nextTitle; - await notifyWorkbenchUpdated(c); -} - -export async function renameWorkbenchBranch(c: any, value: string): Promise { - const nextBranch = value.trim(); - if (!nextBranch) { - throw new Error("branch name is required"); - } - - const record = await ensureWorkbenchSeeded(c); - if (!record.branchName) { - throw new Error("cannot rename branch before handoff branch exists"); - } - if (!record.activeSandboxId) { - throw new Error("cannot rename branch without an active sandbox"); - } - const activeSandbox = - (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null; - if (!activeSandbox?.cwd) { - throw new Error("cannot rename branch without a sandbox cwd"); - } - - const renameResult = await executeInSandbox(c, { - sandboxId: record.activeSandboxId, - cwd: activeSandbox.cwd, - command: [ - `git branch -m ${JSON.stringify(record.branchName)} ${JSON.stringify(nextBranch)}`, - `if git ls-remote --exit-code --heads origin ${JSON.stringify(record.branchName)} >/dev/null 2>&1; then git push origin :${JSON.stringify(record.branchName)}; fi`, - `git push origin ${JSON.stringify(nextBranch)}`, - `git branch --set-upstream-to=${JSON.stringify(`origin/${nextBranch}`)} ${JSON.stringify(nextBranch)} || git push --set-upstream origin ${JSON.stringify(nextBranch)}`, - ].join(" && "), - label: `git branch -m ${record.branchName} ${nextBranch}`, - }); - if (renameResult.exitCode !== 0) { - throw new Error(`branch rename failed (${renameResult.exitCode}): ${renameResult.result}`); - } - - await c.db - .update(handoffTable) - .set({ - branchName: nextBranch, - updatedAt: Date.now(), - }) - .where(eq(handoffTable.id, 1)) - .run(); - c.state.branchName = nextBranch; - - const project = await getOrCreateProject(c, c.state.workspaceId, c.state.repoId, c.state.repoRemote); - await project.registerHandoffBranch({ - handoffId: c.state.handoffId, - branchName: nextBranch, - }); - await notifyWorkbenchUpdated(c); -} - -export async function createWorkbenchSession(c: any, model?: string): Promise<{ tabId: string }> { - const record = await ensureWorkbenchSeeded(c); - if (!record.activeSandboxId) { - throw new Error("cannot create session without an active sandbox"); - } - const activeSandbox = - (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null; - const cwd = activeSandbox?.cwd ?? record.sandboxes?.[0]?.cwd ?? null; - if (!cwd) { - throw new Error("cannot create session without a sandbox cwd"); - } - - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); - const created = await sandbox.createSession({ - prompt: "", - cwd, - agent: agentTypeForModel(model ?? defaultModelForAgent(record.agentType)), - }); - if (!created.id) { - throw new Error(created.error ?? "sandbox-agent session creation failed"); - } - - await ensureSessionMeta(c, { - sessionId: created.id, - model: model ?? defaultModelForAgent(record.agentType), - }); - await notifyWorkbenchUpdated(c); - return { tabId: created.id }; -} - -export async function renameWorkbenchSession(c: any, sessionId: string, title: string): Promise { - const trimmed = title.trim(); - if (!trimmed) { - throw new Error("session title is required"); - } - await updateSessionMeta(c, sessionId, { - sessionName: trimmed, - }); - await notifyWorkbenchUpdated(c); -} - -export async function setWorkbenchSessionUnread(c: any, sessionId: string, unread: boolean): Promise { - await updateSessionMeta(c, sessionId, { - unread: unread ? 1 : 0, - }); - await notifyWorkbenchUpdated(c); -} - -export async function updateWorkbenchDraft(c: any, sessionId: string, text: string, attachments: Array): Promise { - await updateSessionMeta(c, sessionId, { - draftText: text, - draftAttachmentsJson: JSON.stringify(attachments), - draftUpdatedAt: Date.now(), - }); - await notifyWorkbenchUpdated(c); -} - -export async function changeWorkbenchModel(c: any, sessionId: string, model: string): Promise { - await updateSessionMeta(c, sessionId, { - model, - }); - await notifyWorkbenchUpdated(c); -} - -export async function sendWorkbenchMessage(c: any, sessionId: string, text: string, attachments: Array): Promise { - const record = await ensureWorkbenchSeeded(c); - if (!record.activeSandboxId) { - throw new Error("cannot send message without an active sandbox"); - } - - await ensureSessionMeta(c, { sessionId }); - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); - const prompt = [ - text.trim(), - ...attachments.map((attachment: any) => `@ ${attachment.filePath}:${attachment.lineNumber}\n${attachment.lineContent}`), - ] - .filter(Boolean) - .join("\n\n"); - if (!prompt) { - throw new Error("message text is required"); - } - - await sandbox.sendPrompt({ - sessionId, - prompt, - notification: true, - }); - - await updateSessionMeta(c, sessionId, { - unread: 0, - created: 1, - draftText: "", - draftAttachmentsJson: "[]", - draftUpdatedAt: Date.now(), - thinkingSinceMs: Date.now(), - }); - - await c.db - .update(handoffRuntime) - .set({ - activeSessionId: sessionId, - updatedAt: Date.now(), - }) - .where(eq(handoffRuntime.id, 1)) - .run(); - - const sync = await getOrCreateHandoffStatusSync( - c, - c.state.workspaceId, - c.state.repoId, - c.state.handoffId, - record.activeSandboxId, - sessionId, - { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId: c.state.handoffId, - providerId: c.state.providerId, - sandboxId: record.activeSandboxId, - sessionId, - intervalMs: STATUS_SYNC_INTERVAL_MS, - }, - ); - await sync.setIntervalMs({ intervalMs: STATUS_SYNC_INTERVAL_MS }); - await sync.start(); - await sync.force(); - await notifyWorkbenchUpdated(c); -} - -export async function stopWorkbenchSession(c: any, sessionId: string): Promise { - const record = await ensureWorkbenchSeeded(c); - if (!record.activeSandboxId) { - return; - } - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); - await sandbox.cancelSession({ sessionId }); - await updateSessionMeta(c, sessionId, { - thinkingSinceMs: null, - }); - await notifyWorkbenchUpdated(c); -} - -export async function syncWorkbenchSessionStatus( - c: any, - sessionId: string, - status: "running" | "idle" | "error", - at: number, -): Promise { - const record = await ensureWorkbenchSeeded(c); - const meta = await ensureSessionMeta(c, { sessionId }); - let changed = false; - - if (record.activeSessionId === sessionId) { - const mappedStatus = status === "running" ? "running" : status === "error" ? "error" : "idle"; - if (record.status !== mappedStatus) { - await c.db - .update(handoffTable) - .set({ - status: mappedStatus, - updatedAt: at, - }) - .where(eq(handoffTable.id, 1)) - .run(); - changed = true; - } - - const statusMessage = `session:${status}`; - if (record.statusMessage !== statusMessage) { - await c.db - .update(handoffRuntime) - .set({ - statusMessage, - updatedAt: at, - }) - .where(eq(handoffRuntime.id, 1)) - .run(); - changed = true; - } - } - - if (status === "running") { - if (!meta.thinkingSinceMs) { - await updateSessionMeta(c, sessionId, { - thinkingSinceMs: at, - }); - changed = true; - } - } else { - if (meta.thinkingSinceMs) { - await updateSessionMeta(c, sessionId, { - thinkingSinceMs: null, - }); - changed = true; - } - if (!meta.unread && shouldMarkSessionUnreadForStatus(meta, status)) { - await updateSessionMeta(c, sessionId, { - unread: 1, - }); - changed = true; - } - } - - if (changed) { - await notifyWorkbenchUpdated(c); - } -} - -export async function closeWorkbenchSession(c: any, sessionId: string): Promise { - const record = await ensureWorkbenchSeeded(c); - if (!record.activeSandboxId) { - return; - } - const sessions = await listSessionMetaRows(c); - if (sessions.filter((candidate) => candidate.closed !== true).length <= 1) { - return; - } - - const sandbox = getSandboxInstance(c, c.state.workspaceId, c.state.providerId, record.activeSandboxId); - await sandbox.destroySession({ sessionId }); - await updateSessionMeta(c, sessionId, { - closed: 1, - thinkingSinceMs: null, - }); - if (record.activeSessionId === sessionId) { - await c.db - .update(handoffRuntime) - .set({ - activeSessionId: null, - updatedAt: Date.now(), - }) - .where(eq(handoffRuntime.id, 1)) - .run(); - } - await notifyWorkbenchUpdated(c); -} - -export async function markWorkbenchUnread(c: any): Promise { - const sessions = await listSessionMetaRows(c); - const latest = sessions[sessions.length - 1]; - if (!latest) { - return; - } - await updateSessionMeta(c, latest.sessionId, { - unread: 1, - }); - await notifyWorkbenchUpdated(c); -} - -export async function publishWorkbenchPr(c: any): Promise { - const record = await ensureWorkbenchSeeded(c); - if (!record.branchName) { - throw new Error("cannot publish PR without a branch"); - } - const { driver } = getActorRuntimeContext(); - const created = await driver.github.createPr( - c.state.repoLocalPath, - record.branchName, - record.title ?? c.state.task, - ); - await c.db - .update(handoffTable) - .set({ - prSubmitted: 1, - updatedAt: Date.now(), - }) - .where(eq(handoffTable.id, 1)) - .run(); - await notifyWorkbenchUpdated(c); -} - -export async function revertWorkbenchFile(c: any, path: string): Promise { - const record = await ensureWorkbenchSeeded(c); - if (!record.activeSandboxId) { - throw new Error("cannot revert file without an active sandbox"); - } - const activeSandbox = - (record.sandboxes ?? []).find((candidate: any) => candidate.sandboxId === record.activeSandboxId) ?? null; - if (!activeSandbox?.cwd) { - throw new Error("cannot revert file without a sandbox cwd"); - } - - const result = await executeInSandbox(c, { - sandboxId: record.activeSandboxId, - cwd: activeSandbox.cwd, - command: `if git ls-files --error-unmatch -- ${JSON.stringify(path)} >/dev/null 2>&1; then git restore --staged --worktree -- ${JSON.stringify(path)} || git checkout -- ${JSON.stringify(path)}; else rm -f ${JSON.stringify(path)}; fi`, - label: `git restore ${path}`, - }); - if (result.exitCode !== 0) { - throw new Error(`file revert failed (${result.exitCode}): ${result.result}`); - } - await notifyWorkbenchUpdated(c); -} diff --git a/factory/packages/backend/src/actors/handoff/workflow/commands.ts b/factory/packages/backend/src/actors/handoff/workflow/commands.ts deleted file mode 100644 index a92995dd..00000000 --- a/factory/packages/backend/src/actors/handoff/workflow/commands.ts +++ /dev/null @@ -1,209 +0,0 @@ -// @ts-nocheck -import { eq } from "drizzle-orm"; -import { getActorRuntimeContext } from "../../context.js"; -import { getOrCreateHandoffStatusSync } from "../../handles.js"; -import { logActorWarning, resolveErrorMessage } from "../../logging.js"; -import { handoff as handoffTable, handoffRuntime } from "../db/schema.js"; -import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord, setHandoffState } from "./common.js"; -import { pushActiveBranchActivity } from "./push.js"; - -async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { - let timer: ReturnType | undefined; - try { - return await Promise.race([ - promise, - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); - }) - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -export async function handleAttachActivity(loopCtx: any, msg: any): Promise { - const record = await getCurrentRecord(loopCtx); - const { providers } = getActorRuntimeContext(); - const activeSandbox = - record.activeSandboxId - ? record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null - : null; - const provider = providers.get(activeSandbox?.providerId ?? record.providerId); - const target = await provider.attachTarget({ - workspaceId: loopCtx.state.workspaceId, - sandboxId: record.activeSandboxId ?? "" - }); - - await appendHistory(loopCtx, "handoff.attach", { - target: target.target, - sessionId: record.activeSessionId - }); - - await msg.complete({ - target: target.target, - sessionId: record.activeSessionId - }); -} - -export async function handleSwitchActivity(loopCtx: any, msg: any): Promise { - const db = loopCtx.db; - const runtime = await db - .select({ switchTarget: handoffRuntime.activeSwitchTarget }) - .from(handoffRuntime) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .get(); - - await msg.complete({ switchTarget: runtime?.switchTarget ?? "" }); -} - -export async function handlePushActivity(loopCtx: any, msg: any): Promise { - await pushActiveBranchActivity(loopCtx, { - reason: msg.body?.reason ?? null, - historyKind: "handoff.push" - }); - await msg.complete({ ok: true }); -} - -export async function handleSimpleCommandActivity( - loopCtx: any, - msg: any, - statusMessage: string, - historyKind: string -): Promise { - const db = loopCtx.db; - await db - .update(handoffRuntime) - .set({ statusMessage, updatedAt: Date.now() }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .run(); - - await appendHistory(loopCtx, historyKind, { reason: msg.body?.reason ?? null }); - await msg.complete({ ok: true }); -} - -export async function handleArchiveActivity(loopCtx: any, msg: any): Promise { - await setHandoffState(loopCtx, "archive_stop_status_sync", "stopping status sync"); - const record = await getCurrentRecord(loopCtx); - - if (record.activeSandboxId && record.activeSessionId) { - try { - const sync = await getOrCreateHandoffStatusSync( - loopCtx, - loopCtx.state.workspaceId, - loopCtx.state.repoId, - loopCtx.state.handoffId, - record.activeSandboxId, - record.activeSessionId, - { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - providerId: record.providerId, - sandboxId: record.activeSandboxId, - sessionId: record.activeSessionId, - intervalMs: 2_000 - } - ); - await withTimeout(sync.stop(), 15_000, "handoff status sync stop"); - } catch (error) { - logActorWarning("handoff.commands", "failed to stop status sync during archive", { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - sandboxId: record.activeSandboxId, - sessionId: record.activeSessionId, - error: resolveErrorMessage(error) - }); - } - } - - if (record.activeSandboxId) { - await setHandoffState(loopCtx, "archive_release_sandbox", "releasing sandbox"); - const { providers } = getActorRuntimeContext(); - const activeSandbox = - record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null; - const provider = providers.get(activeSandbox?.providerId ?? record.providerId); - const workspaceId = loopCtx.state.workspaceId; - const repoId = loopCtx.state.repoId; - const handoffId = loopCtx.state.handoffId; - const sandboxId = record.activeSandboxId; - - // Do not block archive finalization on provider stop. Some provider stop calls can - // run longer than the synchronous archive UX budget. - void withTimeout( - provider.releaseSandbox({ - workspaceId, - sandboxId - }), - 45_000, - "provider releaseSandbox" - ).catch((error) => { - logActorWarning("handoff.commands", "failed to release sandbox during archive", { - workspaceId, - repoId, - handoffId, - sandboxId, - error: resolveErrorMessage(error) - }); - }); - } - - const db = loopCtx.db; - await setHandoffState(loopCtx, "archive_finalize", "finalizing archive"); - await db - .update(handoffTable) - .set({ status: "archived", updatedAt: Date.now() }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .run(); - - await db - .update(handoffRuntime) - .set({ activeSessionId: null, statusMessage: "archived", updatedAt: Date.now() }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .run(); - - await appendHistory(loopCtx, "handoff.archive", { reason: msg.body?.reason ?? null }); - await msg.complete({ ok: true }); -} - -export async function killDestroySandboxActivity(loopCtx: any): Promise { - await setHandoffState(loopCtx, "kill_destroy_sandbox", "destroying sandbox"); - const record = await getCurrentRecord(loopCtx); - if (!record.activeSandboxId) { - return; - } - - const { providers } = getActorRuntimeContext(); - const activeSandbox = - record.sandboxes.find((sb: any) => sb.sandboxId === record.activeSandboxId) ?? null; - const provider = providers.get(activeSandbox?.providerId ?? record.providerId); - await provider.destroySandbox({ - workspaceId: loopCtx.state.workspaceId, - sandboxId: record.activeSandboxId - }); -} - -export async function killWriteDbActivity(loopCtx: any, msg: any): Promise { - await setHandoffState(loopCtx, "kill_finalize", "finalizing kill"); - const db = loopCtx.db; - await db - .update(handoffTable) - .set({ status: "killed", updatedAt: Date.now() }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .run(); - - await db - .update(handoffRuntime) - .set({ statusMessage: "killed", updatedAt: Date.now() }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .run(); - - await appendHistory(loopCtx, "handoff.kill", { reason: msg.body?.reason ?? null }); - await msg.complete({ ok: true }); -} - -export async function handleGetActivity(loopCtx: any, msg: any): Promise { - await msg.complete(await getCurrentRecord(loopCtx)); -} diff --git a/factory/packages/backend/src/actors/handoff/workflow/common.ts b/factory/packages/backend/src/actors/handoff/workflow/common.ts deleted file mode 100644 index 45c1df6e..00000000 --- a/factory/packages/backend/src/actors/handoff/workflow/common.ts +++ /dev/null @@ -1,192 +0,0 @@ -// @ts-nocheck -import { eq } from "drizzle-orm"; -import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared"; -import { getOrCreateWorkspace } from "../../handles.js"; -import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js"; -import { historyKey } from "../../keys.js"; - -export const HANDOFF_ROW_ID = 1; - -export function collectErrorMessages(error: unknown): string[] { - if (error == null) { - return []; - } - - const out: string[] = []; - const seen = new Set(); - let current: unknown = error; - - while (current != null && !seen.has(current)) { - seen.add(current); - - if (current instanceof Error) { - const message = current.message?.trim(); - if (message) { - out.push(message); - } - current = (current as { cause?: unknown }).cause; - continue; - } - - if (typeof current === "string") { - const message = current.trim(); - if (message) { - out.push(message); - } - break; - } - - break; - } - - return out.filter((msg, index) => out.indexOf(msg) === index); -} - -export function resolveErrorDetail(error: unknown): string { - const messages = collectErrorMessages(error); - if (messages.length === 0) { - return String(error); - } - - const nonWorkflowWrapper = messages.find( - (msg) => !/^Step\s+"[^"]+"\s+failed\b/i.test(msg) - ); - return nonWorkflowWrapper ?? messages[0]!; -} - -export function buildAgentPrompt(task: string): string { - return task.trim(); -} - -export async function setHandoffState( - ctx: any, - status: HandoffStatus, - statusMessage?: string -): Promise { - const now = Date.now(); - const db = ctx.db; - await db - .update(handoffTable) - .set({ status, updatedAt: now }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .run(); - - if (statusMessage != null) { - await db - .insert(handoffRuntime) - .values({ - id: HANDOFF_ROW_ID, - activeSandboxId: null, - activeSessionId: null, - activeSwitchTarget: null, - activeCwd: null, - statusMessage, - updatedAt: now - }) - .onConflictDoUpdate({ - target: handoffRuntime.id, - set: { - statusMessage, - updatedAt: now - } - }) - .run(); - } - - const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId); - await workspace.notifyWorkbenchUpdated({}); -} - -export async function getCurrentRecord(ctx: any): Promise { - const db = ctx.db; - const row = await db - .select({ - branchName: handoffTable.branchName, - title: handoffTable.title, - task: handoffTable.task, - providerId: handoffTable.providerId, - status: handoffTable.status, - statusMessage: handoffRuntime.statusMessage, - activeSandboxId: handoffRuntime.activeSandboxId, - activeSessionId: handoffRuntime.activeSessionId, - agentType: handoffTable.agentType, - prSubmitted: handoffTable.prSubmitted, - createdAt: handoffTable.createdAt, - updatedAt: handoffTable.updatedAt - }) - .from(handoffTable) - .leftJoin(handoffRuntime, eq(handoffTable.id, handoffRuntime.id)) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .get(); - - if (!row) { - throw new Error(`Handoff not found: ${ctx.state.handoffId}`); - } - - const sandboxes = await db - .select({ - sandboxId: handoffSandboxes.sandboxId, - providerId: handoffSandboxes.providerId, - sandboxActorId: handoffSandboxes.sandboxActorId, - switchTarget: handoffSandboxes.switchTarget, - cwd: handoffSandboxes.cwd, - createdAt: handoffSandboxes.createdAt, - updatedAt: handoffSandboxes.updatedAt, - }) - .from(handoffSandboxes) - .all(); - - return { - workspaceId: ctx.state.workspaceId, - repoId: ctx.state.repoId, - repoRemote: ctx.state.repoRemote, - handoffId: ctx.state.handoffId, - branchName: row.branchName, - title: row.title, - task: row.task, - providerId: row.providerId, - status: row.status, - statusMessage: row.statusMessage ?? null, - activeSandboxId: row.activeSandboxId ?? null, - activeSessionId: row.activeSessionId ?? null, - sandboxes: sandboxes.map((sb) => ({ - sandboxId: sb.sandboxId, - providerId: sb.providerId, - sandboxActorId: sb.sandboxActorId ?? null, - switchTarget: sb.switchTarget, - cwd: sb.cwd ?? null, - createdAt: sb.createdAt, - updatedAt: sb.updatedAt, - })), - agentType: row.agentType ?? null, - prSubmitted: Boolean(row.prSubmitted), - diffStat: null, - hasUnpushed: null, - conflictsWithMain: null, - parentBranch: null, - prUrl: null, - prAuthor: null, - ciStatus: null, - reviewStatus: null, - reviewer: null, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - } as HandoffRecord; -} - -export async function appendHistory(ctx: any, kind: string, payload: Record): Promise { - const client = ctx.client(); - const history = await client.history.getOrCreate( - historyKey(ctx.state.workspaceId, ctx.state.repoId), - { createWithInput: { workspaceId: ctx.state.workspaceId, repoId: ctx.state.repoId } } - ); - await history.append({ - kind, - handoffId: ctx.state.handoffId, - branchName: ctx.state.branchName, - payload - }); - - const workspace = await getOrCreateWorkspace(ctx, ctx.state.workspaceId); - await workspace.notifyWorkbenchUpdated({}); -} diff --git a/factory/packages/backend/src/actors/handoff/workflow/index.ts b/factory/packages/backend/src/actors/handoff/workflow/index.ts deleted file mode 100644 index 7c090f9e..00000000 --- a/factory/packages/backend/src/actors/handoff/workflow/index.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { Loop } from "rivetkit/workflow"; -import { getActorRuntimeContext } from "../../context.js"; -import { logActorWarning, resolveErrorMessage } from "../../logging.js"; -import { getCurrentRecord } from "./common.js"; -import { - initAssertNameActivity, - initBootstrapDbActivity, - initCompleteActivity, - initCreateSandboxActivity, - initCreateSessionActivity, - initEnsureAgentActivity, - initEnsureNameActivity, - initFailedActivity, - initStartSandboxInstanceActivity, - initStartStatusSyncActivity, - initWriteDbActivity -} from "./init.js"; -import { - handleArchiveActivity, - handleAttachActivity, - handleGetActivity, - handlePushActivity, - handleSimpleCommandActivity, - handleSwitchActivity, - killDestroySandboxActivity, - killWriteDbActivity -} from "./commands.js"; -import { idleNotifyActivity, idleSubmitPrActivity, statusUpdateActivity } from "./status-sync.js"; -import { HANDOFF_QUEUE_NAMES } from "./queue.js"; -import { - changeWorkbenchModel, - closeWorkbenchSession, - createWorkbenchSession, - markWorkbenchUnread, - publishWorkbenchPr, - renameWorkbenchBranch, - renameWorkbenchHandoff, - renameWorkbenchSession, - revertWorkbenchFile, - sendWorkbenchMessage, - setWorkbenchSessionUnread, - stopWorkbenchSession, - syncWorkbenchSessionStatus, - updateWorkbenchDraft, -} from "../workbench.js"; - -export { HANDOFF_QUEUE_NAMES, handoffWorkflowQueueName } from "./queue.js"; - -type HandoffQueueName = (typeof HANDOFF_QUEUE_NAMES)[number]; - -type WorkflowHandler = (loopCtx: any, msg: { name: HandoffQueueName; body: any; complete: (response: unknown) => Promise }) => Promise; - -const commandHandlers: Record = { - "handoff.command.initialize": async (loopCtx, msg) => { - const body = msg.body; - - await loopCtx.step("init-bootstrap-db", async () => initBootstrapDbActivity(loopCtx, body)); - await loopCtx.removed("init-enqueue-provision", "step"); - await loopCtx.removed("init-dispatch-provision-v2", "step"); - const currentRecord = await loopCtx.step( - "init-read-current-record", - async () => getCurrentRecord(loopCtx) - ); - - try { - await msg.complete(currentRecord); - } catch (error) { - logActorWarning("handoff.workflow", "initialize completion failed", { - error: resolveErrorMessage(error) - }); - } - }, - - "handoff.command.provision": async (loopCtx, msg) => { - const body = msg.body; - await loopCtx.removed("init-failed", "step"); - try { - await loopCtx.step("init-ensure-name", async () => initEnsureNameActivity(loopCtx)); - await loopCtx.step("init-assert-name", async () => initAssertNameActivity(loopCtx)); - - const sandbox = await loopCtx.step({ - name: "init-create-sandbox", - timeout: 180_000, - run: async () => initCreateSandboxActivity(loopCtx, body), - }); - const agent = await loopCtx.step({ - name: "init-ensure-agent", - timeout: 180_000, - run: async () => initEnsureAgentActivity(loopCtx, body, sandbox), - }); - const sandboxInstanceReady = await loopCtx.step({ - name: "init-start-sandbox-instance", - timeout: 60_000, - run: async () => initStartSandboxInstanceActivity(loopCtx, body, sandbox, agent), - }); - const session = await loopCtx.step({ - name: "init-create-session", - timeout: 180_000, - run: async () => initCreateSessionActivity(loopCtx, body, sandbox, sandboxInstanceReady), - }); - - await loopCtx.step( - "init-write-db", - async () => initWriteDbActivity(loopCtx, body, sandbox, session, sandboxInstanceReady) - ); - await loopCtx.step("init-start-status-sync", async () => initStartStatusSyncActivity(loopCtx, body, sandbox, session)); - await loopCtx.step("init-complete", async () => initCompleteActivity(loopCtx, body, sandbox, session)); - await msg.complete({ ok: true }); - } catch (error) { - await loopCtx.step("init-failed-v2", async () => initFailedActivity(loopCtx, error)); - await msg.complete({ ok: false }); - } - }, - - "handoff.command.attach": async (loopCtx, msg) => { - await loopCtx.step("handle-attach", async () => handleAttachActivity(loopCtx, msg)); - }, - - "handoff.command.switch": async (loopCtx, msg) => { - await loopCtx.step("handle-switch", async () => handleSwitchActivity(loopCtx, msg)); - }, - - "handoff.command.push": async (loopCtx, msg) => { - await loopCtx.step("handle-push", async () => handlePushActivity(loopCtx, msg)); - }, - - "handoff.command.sync": async (loopCtx, msg) => { - await loopCtx.step( - "handle-sync", - async () => handleSimpleCommandActivity(loopCtx, msg, "sync requested", "handoff.sync") - ); - }, - - "handoff.command.merge": async (loopCtx, msg) => { - await loopCtx.step( - "handle-merge", - async () => handleSimpleCommandActivity(loopCtx, msg, "merge requested", "handoff.merge") - ); - }, - - "handoff.command.archive": async (loopCtx, msg) => { - await loopCtx.step("handle-archive", async () => handleArchiveActivity(loopCtx, msg)); - }, - - "handoff.command.kill": async (loopCtx, msg) => { - await loopCtx.step("kill-destroy-sandbox", async () => killDestroySandboxActivity(loopCtx)); - await loopCtx.step("kill-write-db", async () => killWriteDbActivity(loopCtx, msg)); - }, - - "handoff.command.get": async (loopCtx, msg) => { - await loopCtx.step("handle-get", async () => handleGetActivity(loopCtx, msg)); - }, - - "handoff.command.workbench.mark_unread": async (loopCtx, msg) => { - await loopCtx.step("workbench-mark-unread", async () => markWorkbenchUnread(loopCtx)); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.rename_handoff": async (loopCtx, msg) => { - await loopCtx.step("workbench-rename-handoff", async () => renameWorkbenchHandoff(loopCtx, msg.body.value)); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.rename_branch": async (loopCtx, msg) => { - await loopCtx.step({ - name: "workbench-rename-branch", - timeout: 5 * 60_000, - run: async () => renameWorkbenchBranch(loopCtx, msg.body.value), - }); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.create_session": async (loopCtx, msg) => { - const created = await loopCtx.step({ - name: "workbench-create-session", - timeout: 5 * 60_000, - run: async () => createWorkbenchSession(loopCtx, msg.body?.model), - }); - await msg.complete(created); - }, - - "handoff.command.workbench.rename_session": async (loopCtx, msg) => { - await loopCtx.step("workbench-rename-session", async () => - renameWorkbenchSession(loopCtx, msg.body.sessionId, msg.body.title), - ); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.set_session_unread": async (loopCtx, msg) => { - await loopCtx.step("workbench-set-session-unread", async () => - setWorkbenchSessionUnread(loopCtx, msg.body.sessionId, msg.body.unread), - ); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.update_draft": async (loopCtx, msg) => { - await loopCtx.step("workbench-update-draft", async () => - updateWorkbenchDraft(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments), - ); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.change_model": async (loopCtx, msg) => { - await loopCtx.step("workbench-change-model", async () => - changeWorkbenchModel(loopCtx, msg.body.sessionId, msg.body.model), - ); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.send_message": async (loopCtx, msg) => { - await loopCtx.step({ - name: "workbench-send-message", - timeout: 10 * 60_000, - run: async () => sendWorkbenchMessage(loopCtx, msg.body.sessionId, msg.body.text, msg.body.attachments), - }); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.stop_session": async (loopCtx, msg) => { - await loopCtx.step({ - name: "workbench-stop-session", - timeout: 5 * 60_000, - run: async () => stopWorkbenchSession(loopCtx, msg.body.sessionId), - }); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.sync_session_status": async (loopCtx, msg) => { - await loopCtx.step("workbench-sync-session-status", async () => - syncWorkbenchSessionStatus(loopCtx, msg.body.sessionId, msg.body.status, msg.body.at), - ); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.close_session": async (loopCtx, msg) => { - await loopCtx.step({ - name: "workbench-close-session", - timeout: 5 * 60_000, - run: async () => closeWorkbenchSession(loopCtx, msg.body.sessionId), - }); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.publish_pr": async (loopCtx, msg) => { - await loopCtx.step({ - name: "workbench-publish-pr", - timeout: 10 * 60_000, - run: async () => publishWorkbenchPr(loopCtx), - }); - await msg.complete({ ok: true }); - }, - - "handoff.command.workbench.revert_file": async (loopCtx, msg) => { - await loopCtx.step({ - name: "workbench-revert-file", - timeout: 5 * 60_000, - run: async () => revertWorkbenchFile(loopCtx, msg.body.path), - }); - await msg.complete({ ok: true }); - }, - - "handoff.status_sync.result": async (loopCtx, msg) => { - const transitionedToIdle = await loopCtx.step("status-update", async () => statusUpdateActivity(loopCtx, msg.body)); - - if (transitionedToIdle) { - const { config } = getActorRuntimeContext(); - if (config.auto_submit) { - await loopCtx.step("idle-submit-pr", async () => idleSubmitPrActivity(loopCtx)); - } - await loopCtx.step("idle-notify", async () => idleNotifyActivity(loopCtx)); - } - } -}; - -export async function runHandoffWorkflow(ctx: any): Promise { - await ctx.loop("handoff-command-loop", async (loopCtx: any) => { - const msg = await loopCtx.queue.next("next-command", { - names: [...HANDOFF_QUEUE_NAMES], - completable: true - }); - if (!msg) { - return Loop.continue(undefined); - } - const handler = commandHandlers[msg.name as HandoffQueueName]; - if (handler) { - await handler(loopCtx, msg); - } - return Loop.continue(undefined); - }); -} diff --git a/factory/packages/backend/src/actors/handoff/workflow/init.ts b/factory/packages/backend/src/actors/handoff/workflow/init.ts deleted file mode 100644 index f4135785..00000000 --- a/factory/packages/backend/src/actors/handoff/workflow/init.ts +++ /dev/null @@ -1,643 +0,0 @@ -// @ts-nocheck -import { desc, eq } from "drizzle-orm"; -import { resolveCreateFlowDecision } from "../../../services/create-flow.js"; -import { getActorRuntimeContext } from "../../context.js"; -import { - getOrCreateHandoffStatusSync, - getOrCreateHistory, - getOrCreateProject, - getOrCreateSandboxInstance, - getSandboxInstance, - selfHandoff -} from "../../handles.js"; -import { logActorWarning, resolveErrorMessage } from "../../logging.js"; -import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js"; -import { - HANDOFF_ROW_ID, - appendHistory, - buildAgentPrompt, - collectErrorMessages, - resolveErrorDetail, - setHandoffState -} from "./common.js"; -import { handoffWorkflowQueueName } from "./queue.js"; - -const DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS = 180_000; - -function getInitCreateSandboxActivityTimeoutMs(): number { - const raw = process.env.HF_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS; - if (!raw) { - return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS; - } - const parsed = Number(raw); - if (!Number.isFinite(parsed) || parsed <= 0) { - return DEFAULT_INIT_CREATE_SANDBOX_ACTIVITY_TIMEOUT_MS; - } - return Math.floor(parsed); -} - -function debugInit(loopCtx: any, message: string, context?: Record): void { - loopCtx.log.debug({ - msg: message, - scope: "handoff.init", - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - ...(context ?? {}) - }); -} - -async function withActivityTimeout( - timeoutMs: number, - label: string, - run: () => Promise -): Promise { - let timer: ReturnType | null = null; - try { - return await Promise.race([ - run(), - new Promise((_, reject) => { - timer = setTimeout(() => { - reject(new Error(`${label} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - }) - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } -} - -export async function initBootstrapDbActivity(loopCtx: any, body: any): Promise { - const providerId = body?.providerId ?? loopCtx.state.providerId; - const { config } = getActorRuntimeContext(); - const now = Date.now(); - const db = loopCtx.db; - const initialStatusMessage = loopCtx.state.branchName && loopCtx.state.title ? "provisioning" : "naming"; - - try { - await db - .insert(handoffTable) - .values({ - id: HANDOFF_ROW_ID, - branchName: loopCtx.state.branchName, - title: loopCtx.state.title, - task: loopCtx.state.task, - providerId, - status: "init_bootstrap_db", - agentType: loopCtx.state.agentType ?? config.default_agent, - createdAt: now, - updatedAt: now - }) - .onConflictDoUpdate({ - target: handoffTable.id, - set: { - branchName: loopCtx.state.branchName, - title: loopCtx.state.title, - task: loopCtx.state.task, - providerId, - status: "init_bootstrap_db", - agentType: loopCtx.state.agentType ?? config.default_agent, - updatedAt: now - } - }) - .run(); - - await db - .insert(handoffRuntime) - .values({ - id: HANDOFF_ROW_ID, - activeSandboxId: null, - activeSessionId: null, - activeSwitchTarget: null, - activeCwd: null, - statusMessage: initialStatusMessage, - updatedAt: now - }) - .onConflictDoUpdate({ - target: handoffRuntime.id, - set: { - activeSandboxId: null, - activeSessionId: null, - activeSwitchTarget: null, - activeCwd: null, - statusMessage: initialStatusMessage, - updatedAt: now - } - }) - .run(); - } catch (error) { - const detail = resolveErrorMessage(error); - throw new Error(`handoff init bootstrap db failed: ${detail}`); - } -} - -export async function initEnqueueProvisionActivity(loopCtx: any, body: any): Promise { - await setHandoffState(loopCtx, "init_enqueue_provision", "provision queued"); - const self = selfHandoff(loopCtx); - void self - .send(handoffWorkflowQueueName("handoff.command.provision"), body, { - wait: false, - }) - .catch((error: unknown) => { - logActorWarning("handoff.init", "background provision command failed", { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - error: resolveErrorMessage(error), - }); - }); -} - -export async function initEnsureNameActivity(loopCtx: any): Promise { - await setHandoffState(loopCtx, "init_ensure_name", "determining title and branch"); - const existing = await loopCtx.db - .select({ - branchName: handoffTable.branchName, - title: handoffTable.title - }) - .from(handoffTable) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .get(); - - if (existing?.branchName && existing?.title) { - loopCtx.state.branchName = existing.branchName; - loopCtx.state.title = existing.title; - return; - } - - const { driver } = getActorRuntimeContext(); - try { - await driver.git.fetch(loopCtx.state.repoLocalPath); - } catch (error) { - logActorWarning("handoff.init", "fetch before naming failed", { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - error: resolveErrorMessage(error) - }); - } - const remoteBranches = (await driver.git.listRemoteBranches(loopCtx.state.repoLocalPath)).map( - (branch: any) => branch.branchName - ); - - const project = await getOrCreateProject( - loopCtx, - loopCtx.state.workspaceId, - loopCtx.state.repoId, - loopCtx.state.repoRemote - ); - const reservedBranches = await project.listReservedBranches({}); - - const resolved = resolveCreateFlowDecision({ - task: loopCtx.state.task, - explicitTitle: loopCtx.state.explicitTitle ?? undefined, - explicitBranchName: loopCtx.state.explicitBranchName ?? undefined, - localBranches: remoteBranches, - handoffBranches: reservedBranches - }); - - const now = Date.now(); - await loopCtx.db - .update(handoffTable) - .set({ - branchName: resolved.branchName, - title: resolved.title, - updatedAt: now - }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .run(); - - loopCtx.state.branchName = resolved.branchName; - loopCtx.state.title = resolved.title; - loopCtx.state.explicitTitle = null; - loopCtx.state.explicitBranchName = null; - - await loopCtx.db - .update(handoffRuntime) - .set({ - statusMessage: "provisioning", - updatedAt: now - }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .run(); - - await project.registerHandoffBranch({ - handoffId: loopCtx.state.handoffId, - branchName: resolved.branchName - }); - - await appendHistory(loopCtx, "handoff.named", { - title: resolved.title, - branchName: resolved.branchName - }); -} - -export async function initAssertNameActivity(loopCtx: any): Promise { - await setHandoffState(loopCtx, "init_assert_name", "validating naming"); - if (!loopCtx.state.branchName) { - throw new Error("handoff branchName is not initialized"); - } -} - -export async function initCreateSandboxActivity(loopCtx: any, body: any): Promise { - await setHandoffState(loopCtx, "init_create_sandbox", "creating sandbox"); - const { providers } = getActorRuntimeContext(); - const providerId = body?.providerId ?? loopCtx.state.providerId; - const provider = providers.get(providerId); - const timeoutMs = getInitCreateSandboxActivityTimeoutMs(); - const startedAt = Date.now(); - - debugInit(loopCtx, "init_create_sandbox started", { - providerId, - timeoutMs, - supportsSessionReuse: provider.capabilities().supportsSessionReuse - }); - - if (provider.capabilities().supportsSessionReuse) { - const runtime = await loopCtx.db - .select({ activeSandboxId: handoffRuntime.activeSandboxId }) - .from(handoffRuntime) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .get(); - - const existing = await loopCtx.db - .select({ sandboxId: handoffSandboxes.sandboxId }) - .from(handoffSandboxes) - .where(eq(handoffSandboxes.providerId, providerId)) - .orderBy(desc(handoffSandboxes.updatedAt)) - .limit(1) - .get(); - - const sandboxId = runtime?.activeSandboxId ?? existing?.sandboxId ?? null; - if (sandboxId) { - debugInit(loopCtx, "init_create_sandbox attempting resume", { sandboxId }); - try { - const resumed = await withActivityTimeout( - timeoutMs, - "resumeSandbox", - async () => provider.resumeSandbox({ - workspaceId: loopCtx.state.workspaceId, - sandboxId - }) - ); - - debugInit(loopCtx, "init_create_sandbox resume succeeded", { - sandboxId: resumed.sandboxId, - durationMs: Date.now() - startedAt - }); - return resumed; - } catch (error) { - logActorWarning("handoff.init", "resume sandbox failed; creating a new sandbox", { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - sandboxId, - error: resolveErrorMessage(error) - }); - } - } - } - - debugInit(loopCtx, "init_create_sandbox creating fresh sandbox", { - branchName: loopCtx.state.branchName - }); - - try { - const sandbox = await withActivityTimeout( - timeoutMs, - "createSandbox", - async () => provider.createSandbox({ - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - repoRemote: loopCtx.state.repoRemote, - branchName: loopCtx.state.branchName, - handoffId: loopCtx.state.handoffId, - debug: (message, context) => debugInit(loopCtx, message, context) - }) - ); - - debugInit(loopCtx, "init_create_sandbox create succeeded", { - sandboxId: sandbox.sandboxId, - durationMs: Date.now() - startedAt - }); - return sandbox; - } catch (error) { - debugInit(loopCtx, "init_create_sandbox failed", { - durationMs: Date.now() - startedAt, - error: resolveErrorMessage(error) - }); - throw error; - } -} - -export async function initEnsureAgentActivity(loopCtx: any, body: any, sandbox: any): Promise { - await setHandoffState(loopCtx, "init_ensure_agent", "ensuring sandbox agent"); - const { providers } = getActorRuntimeContext(); - const providerId = body?.providerId ?? loopCtx.state.providerId; - const provider = providers.get(providerId); - return await provider.ensureSandboxAgent({ - workspaceId: loopCtx.state.workspaceId, - sandboxId: sandbox.sandboxId - }); -} - -export async function initStartSandboxInstanceActivity( - loopCtx: any, - body: any, - sandbox: any, - agent: any -): Promise { - await setHandoffState(loopCtx, "init_start_sandbox_instance", "starting sandbox runtime"); - try { - const providerId = body?.providerId ?? loopCtx.state.providerId; - const sandboxInstance = await getOrCreateSandboxInstance( - loopCtx, - loopCtx.state.workspaceId, - providerId, - sandbox.sandboxId, - { - workspaceId: loopCtx.state.workspaceId, - providerId, - sandboxId: sandbox.sandboxId - } - ); - - await sandboxInstance.ensure({ - metadata: sandbox.metadata, - status: "ready", - agentEndpoint: agent.endpoint, - agentToken: agent.token - }); - - const actorId = typeof (sandboxInstance as any).resolve === "function" - ? await (sandboxInstance as any).resolve() - : null; - - return { - ok: true as const, - actorId: typeof actorId === "string" ? actorId : null - }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - return { - ok: false as const, - error: `sandbox-instance ensure failed: ${detail}` - }; - } -} - -export async function initCreateSessionActivity( - loopCtx: any, - body: any, - sandbox: any, - sandboxInstanceReady: any -): Promise { - await setHandoffState(loopCtx, "init_create_session", "creating agent session"); - if (!sandboxInstanceReady.ok) { - return { - id: null, - status: "error", - error: sandboxInstanceReady.error ?? "sandbox instance is not ready" - } as const; - } - - const { config } = getActorRuntimeContext(); - const providerId = body?.providerId ?? loopCtx.state.providerId; - const sandboxInstance = getSandboxInstance(loopCtx, loopCtx.state.workspaceId, providerId, sandbox.sandboxId); - - const cwd = - sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" - ? ((sandbox.metadata as any).cwd as string) - : undefined; - - return await sandboxInstance.createSession({ - prompt: buildAgentPrompt(loopCtx.state.task), - cwd, - agent: (loopCtx.state.agentType ?? config.default_agent) as any - }); -} - -export async function initWriteDbActivity( - loopCtx: any, - body: any, - sandbox: any, - session: any, - sandboxInstanceReady?: { actorId?: string | null } -): Promise { - await setHandoffState(loopCtx, "init_write_db", "persisting handoff runtime"); - const providerId = body?.providerId ?? loopCtx.state.providerId; - const { config } = getActorRuntimeContext(); - const now = Date.now(); - const db = loopCtx.db; - const sessionId = session?.id ?? null; - const sessionHealthy = Boolean(sessionId) && session?.status !== "error"; - const activeSessionId = sessionHealthy ? sessionId : null; - const statusMessage = - sessionHealthy - ? "session created" - : session?.status === "error" - ? (session.error ?? "session create failed") - : "session unavailable"; - - const activeCwd = - sandbox.metadata && typeof (sandbox.metadata as any).cwd === "string" - ? ((sandbox.metadata as any).cwd as string) - : null; - const sandboxActorId = - typeof sandboxInstanceReady?.actorId === "string" && sandboxInstanceReady.actorId.length > 0 - ? sandboxInstanceReady.actorId - : null; - - await db - .update(handoffTable) - .set({ - providerId, - status: sessionHealthy ? "running" : "error", - agentType: loopCtx.state.agentType ?? config.default_agent, - updatedAt: now - }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .run(); - - await db - .insert(handoffSandboxes) - .values({ - sandboxId: sandbox.sandboxId, - providerId, - sandboxActorId, - switchTarget: sandbox.switchTarget, - cwd: activeCwd, - statusMessage, - createdAt: now, - updatedAt: now - }) - .onConflictDoUpdate({ - target: handoffSandboxes.sandboxId, - set: { - providerId, - sandboxActorId, - switchTarget: sandbox.switchTarget, - cwd: activeCwd, - statusMessage, - updatedAt: now - } - }) - .run(); - - await db - .insert(handoffRuntime) - .values({ - id: HANDOFF_ROW_ID, - activeSandboxId: sandbox.sandboxId, - activeSessionId, - activeSwitchTarget: sandbox.switchTarget, - activeCwd, - statusMessage, - updatedAt: now - }) - .onConflictDoUpdate({ - target: handoffRuntime.id, - set: { - activeSandboxId: sandbox.sandboxId, - activeSessionId, - activeSwitchTarget: sandbox.switchTarget, - activeCwd, - statusMessage, - updatedAt: now - } - }) - .run(); -} - -export async function initStartStatusSyncActivity( - loopCtx: any, - body: any, - sandbox: any, - session: any -): Promise { - const sessionId = session?.id ?? null; - if (!sessionId || session?.status === "error") { - return; - } - - await setHandoffState(loopCtx, "init_start_status_sync", "starting session status sync"); - const providerId = body?.providerId ?? loopCtx.state.providerId; - const sync = await getOrCreateHandoffStatusSync( - loopCtx, - loopCtx.state.workspaceId, - loopCtx.state.repoId, - loopCtx.state.handoffId, - sandbox.sandboxId, - sessionId, - { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - providerId, - sandboxId: sandbox.sandboxId, - sessionId, - intervalMs: 2_000 - } - ); - - await sync.start(); - await sync.force(); -} - -export async function initCompleteActivity(loopCtx: any, body: any, sandbox: any, session: any): Promise { - const providerId = body?.providerId ?? loopCtx.state.providerId; - const sessionId = session?.id ?? null; - const sessionHealthy = Boolean(sessionId) && session?.status !== "error"; - if (sessionHealthy) { - await setHandoffState(loopCtx, "init_complete", "handoff initialized"); - - const history = await getOrCreateHistory(loopCtx, loopCtx.state.workspaceId, loopCtx.state.repoId); - await history.append({ - kind: "handoff.initialized", - handoffId: loopCtx.state.handoffId, - branchName: loopCtx.state.branchName, - payload: { providerId, sandboxId: sandbox.sandboxId, sessionId } - }); - - loopCtx.state.initialized = true; - return; - } - - const detail = - session?.status === "error" - ? (session.error ?? "session create failed") - : "session unavailable"; - await setHandoffState(loopCtx, "error", detail); - await appendHistory(loopCtx, "handoff.error", { - detail, - messages: [detail] - }); - loopCtx.state.initialized = false; -} - -export async function initFailedActivity(loopCtx: any, error: unknown): Promise { - const now = Date.now(); - const detail = resolveErrorDetail(error); - const messages = collectErrorMessages(error); - const db = loopCtx.db; - const { config, providers } = getActorRuntimeContext(); - const providerId = loopCtx.state.providerId ?? providers.defaultProviderId(); - - await db - .insert(handoffTable) - .values({ - id: HANDOFF_ROW_ID, - branchName: loopCtx.state.branchName ?? null, - title: loopCtx.state.title ?? null, - task: loopCtx.state.task, - providerId, - status: "error", - agentType: loopCtx.state.agentType ?? config.default_agent, - createdAt: now, - updatedAt: now - }) - .onConflictDoUpdate({ - target: handoffTable.id, - set: { - branchName: loopCtx.state.branchName ?? null, - title: loopCtx.state.title ?? null, - task: loopCtx.state.task, - providerId, - status: "error", - agentType: loopCtx.state.agentType ?? config.default_agent, - updatedAt: now - } - }) - .run(); - - await db - .insert(handoffRuntime) - .values({ - id: HANDOFF_ROW_ID, - activeSandboxId: null, - activeSessionId: null, - activeSwitchTarget: null, - activeCwd: null, - statusMessage: detail, - updatedAt: now - }) - .onConflictDoUpdate({ - target: handoffRuntime.id, - set: { - activeSandboxId: null, - activeSessionId: null, - activeSwitchTarget: null, - activeCwd: null, - statusMessage: detail, - updatedAt: now - } - }) - .run(); - - await appendHistory(loopCtx, "handoff.error", { - detail, - messages - }); -} diff --git a/factory/packages/backend/src/actors/handoff/workflow/push.ts b/factory/packages/backend/src/actors/handoff/workflow/push.ts deleted file mode 100644 index fcd8d642..00000000 --- a/factory/packages/backend/src/actors/handoff/workflow/push.ts +++ /dev/null @@ -1,88 +0,0 @@ -// @ts-nocheck -import { eq } from "drizzle-orm"; -import { getActorRuntimeContext } from "../../context.js"; -import { handoffRuntime, handoffSandboxes } from "../db/schema.js"; -import { HANDOFF_ROW_ID, appendHistory, getCurrentRecord } from "./common.js"; - -export interface PushActiveBranchOptions { - reason?: string | null; - historyKind?: string; -} - -export async function pushActiveBranchActivity( - loopCtx: any, - options: PushActiveBranchOptions = {} -): Promise { - const record = await getCurrentRecord(loopCtx); - const activeSandboxId = record.activeSandboxId; - const branchName = loopCtx.state.branchName ?? record.branchName; - - if (!activeSandboxId) { - throw new Error("cannot push: no active sandbox"); - } - if (!branchName) { - throw new Error("cannot push: handoff branch is not set"); - } - - const activeSandbox = - record.sandboxes.find((sandbox: any) => sandbox.sandboxId === activeSandboxId) ?? null; - const providerId = activeSandbox?.providerId ?? record.providerId; - const cwd = activeSandbox?.cwd ?? null; - if (!cwd) { - throw new Error("cannot push: active sandbox cwd is not set"); - } - - const { providers } = getActorRuntimeContext(); - const provider = providers.get(providerId); - - const now = Date.now(); - await loopCtx.db - .update(handoffRuntime) - .set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .run(); - - await loopCtx.db - .update(handoffSandboxes) - .set({ statusMessage: `pushing branch ${branchName}`, updatedAt: now }) - .where(eq(handoffSandboxes.sandboxId, activeSandboxId)) - .run(); - - const script = [ - "set -euo pipefail", - `cd ${JSON.stringify(cwd)}`, - "git rev-parse --verify HEAD >/dev/null", - "git config credential.helper '!f() { echo username=x-access-token; echo password=${GH_TOKEN:-$GITHUB_TOKEN}; }; f'", - `git push -u origin ${JSON.stringify(branchName)}` - ].join("; "); - - const result = await provider.executeCommand({ - workspaceId: loopCtx.state.workspaceId, - sandboxId: activeSandboxId, - command: ["bash", "-lc", JSON.stringify(script)].join(" "), - label: `git push ${branchName}` - }); - - if (result.exitCode !== 0) { - throw new Error(`git push failed (${result.exitCode}): ${result.result}`); - } - - const updatedAt = Date.now(); - await loopCtx.db - .update(handoffRuntime) - .set({ statusMessage: `push complete for ${branchName}`, updatedAt }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .run(); - - await loopCtx.db - .update(handoffSandboxes) - .set({ statusMessage: `push complete for ${branchName}`, updatedAt }) - .where(eq(handoffSandboxes.sandboxId, activeSandboxId)) - .run(); - - await appendHistory(loopCtx, options.historyKind ?? "handoff.push", { - reason: options.reason ?? null, - branchName, - sandboxId: activeSandboxId - }); -} diff --git a/factory/packages/backend/src/actors/handoff/workflow/queue.ts b/factory/packages/backend/src/actors/handoff/workflow/queue.ts deleted file mode 100644 index 8a641740..00000000 --- a/factory/packages/backend/src/actors/handoff/workflow/queue.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const HANDOFF_QUEUE_NAMES = [ - "handoff.command.initialize", - "handoff.command.provision", - "handoff.command.attach", - "handoff.command.switch", - "handoff.command.push", - "handoff.command.sync", - "handoff.command.merge", - "handoff.command.archive", - "handoff.command.kill", - "handoff.command.get", - "handoff.command.workbench.mark_unread", - "handoff.command.workbench.rename_handoff", - "handoff.command.workbench.rename_branch", - "handoff.command.workbench.create_session", - "handoff.command.workbench.rename_session", - "handoff.command.workbench.set_session_unread", - "handoff.command.workbench.update_draft", - "handoff.command.workbench.change_model", - "handoff.command.workbench.send_message", - "handoff.command.workbench.stop_session", - "handoff.command.workbench.sync_session_status", - "handoff.command.workbench.close_session", - "handoff.command.workbench.publish_pr", - "handoff.command.workbench.revert_file", - "handoff.status_sync.result" -] as const; - -export function handoffWorkflowQueueName(name: string): string { - return name; -} diff --git a/factory/packages/backend/src/actors/handoff/workflow/status-sync.ts b/factory/packages/backend/src/actors/handoff/workflow/status-sync.ts deleted file mode 100644 index e1d632d2..00000000 --- a/factory/packages/backend/src/actors/handoff/workflow/status-sync.ts +++ /dev/null @@ -1,160 +0,0 @@ -// @ts-nocheck -import { eq } from "drizzle-orm"; -import { getActorRuntimeContext } from "../../context.js"; -import { logActorWarning, resolveErrorMessage } from "../../logging.js"; -import { handoff as handoffTable, handoffRuntime, handoffSandboxes } from "../db/schema.js"; -import { HANDOFF_ROW_ID, appendHistory, resolveErrorDetail } from "./common.js"; -import { pushActiveBranchActivity } from "./push.js"; - -function mapSessionStatus(status: "running" | "idle" | "error") { - if (status === "idle") return "idle"; - if (status === "error") return "error"; - return "running"; -} - -export async function statusUpdateActivity(loopCtx: any, body: any): Promise { - const newStatus = mapSessionStatus(body.status); - const wasIdle = loopCtx.state.previousStatus === "idle"; - const didTransition = newStatus === "idle" && !wasIdle; - const isDuplicateStatus = loopCtx.state.previousStatus === newStatus; - - if (isDuplicateStatus) { - return false; - } - - const db = loopCtx.db; - const runtime = await db - .select({ - activeSandboxId: handoffRuntime.activeSandboxId, - activeSessionId: handoffRuntime.activeSessionId - }) - .from(handoffRuntime) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .get(); - - const isActive = - runtime?.activeSandboxId === body.sandboxId && runtime?.activeSessionId === body.sessionId; - - if (isActive) { - await db - .update(handoffTable) - .set({ status: newStatus, updatedAt: body.at }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .run(); - - await db - .update(handoffRuntime) - .set({ statusMessage: `session:${body.status}`, updatedAt: body.at }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .run(); - } - - await db - .update(handoffSandboxes) - .set({ statusMessage: `session:${body.status}`, updatedAt: body.at }) - .where(eq(handoffSandboxes.sandboxId, body.sandboxId)) - .run(); - - await appendHistory(loopCtx, "handoff.status", { - status: body.status, - sessionId: body.sessionId, - sandboxId: body.sandboxId - }); - - if (isActive) { - loopCtx.state.previousStatus = newStatus; - - const { driver } = getActorRuntimeContext(); - if (loopCtx.state.branchName) { - driver.tmux.setWindowStatus(loopCtx.state.branchName, newStatus); - } - return didTransition; - } - - return false; -} - -export async function idleSubmitPrActivity(loopCtx: any): Promise { - const { driver } = getActorRuntimeContext(); - const db = loopCtx.db; - - const self = await db - .select({ prSubmitted: handoffTable.prSubmitted }) - .from(handoffTable) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .get(); - - if (self && self.prSubmitted) return; - - try { - await driver.git.fetch(loopCtx.state.repoLocalPath); - } catch (error) { - logActorWarning("handoff.status-sync", "fetch before PR submit failed", { - workspaceId: loopCtx.state.workspaceId, - repoId: loopCtx.state.repoId, - handoffId: loopCtx.state.handoffId, - error: resolveErrorMessage(error) - }); - } - - if (!loopCtx.state.branchName || !loopCtx.state.title) { - throw new Error("cannot submit PR before handoff has a branch and title"); - } - - try { - await pushActiveBranchActivity(loopCtx, { - reason: "auto_submit_idle", - historyKind: "handoff.push.auto" - }); - - const pr = await driver.github.createPr( - loopCtx.state.repoLocalPath, - loopCtx.state.branchName, - loopCtx.state.title - ); - - await db - .update(handoffTable) - .set({ prSubmitted: 1, updatedAt: Date.now() }) - .where(eq(handoffTable.id, HANDOFF_ROW_ID)) - .run(); - - await appendHistory(loopCtx, "handoff.step", { - step: "pr_submit", - handoffId: loopCtx.state.handoffId, - branchName: loopCtx.state.branchName, - prUrl: pr.url, - prNumber: pr.number - }); - - await appendHistory(loopCtx, "handoff.pr_created", { - handoffId: loopCtx.state.handoffId, - branchName: loopCtx.state.branchName, - prUrl: pr.url, - prNumber: pr.number - }); - } catch (error) { - const detail = resolveErrorDetail(error); - await db - .update(handoffRuntime) - .set({ - statusMessage: `pr submit failed: ${detail}`, - updatedAt: Date.now() - }) - .where(eq(handoffRuntime.id, HANDOFF_ROW_ID)) - .run(); - - await appendHistory(loopCtx, "handoff.pr_create_failed", { - handoffId: loopCtx.state.handoffId, - branchName: loopCtx.state.branchName, - error: detail - }); - } -} - -export async function idleNotifyActivity(loopCtx: any): Promise { - const { notifications } = getActorRuntimeContext(); - if (notifications && loopCtx.state.branchName) { - await notifications.agentIdle(loopCtx.state.branchName); - } -} diff --git a/factory/packages/backend/src/actors/history/db/db.ts b/factory/packages/backend/src/actors/history/db/db.ts deleted file mode 100644 index 8889b35a..00000000 --- a/factory/packages/backend/src/actors/history/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const historyDb = actorSqliteDb({ - actorName: "history", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/history/db/drizzle.config.ts b/factory/packages/backend/src/actors/history/db/drizzle.config.ts deleted file mode 100644 index 783dee1c..00000000 --- a/factory/packages/backend/src/actors/history/db/drizzle.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "rivetkit/db/drizzle"; - -export default defineConfig({ - out: "./src/actors/history/db/drizzle", - schema: "./src/actors/history/db/schema.ts", -}); - diff --git a/factory/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql b/factory/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql deleted file mode 100644 index 961421cf..00000000 --- a/factory/packages/backend/src/actors/history/db/drizzle/0000_watery_bushwacker.sql +++ /dev/null @@ -1,8 +0,0 @@ -CREATE TABLE `events` ( - `id` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - `handoff_id` text, - `branch_name` text, - `kind` text NOT NULL, - `payload_json` text NOT NULL, - `created_at` integer NOT NULL -); diff --git a/factory/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json b/factory/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 96e223e8..00000000 --- a/factory/packages/backend/src/actors/history/db/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,70 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "9d9ebe3c-8341-449c-bd14-2b6fd62853a1", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "events": { - "name": "events", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": true - }, - "handoff_id": { - "name": "handoff_id", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "payload_json": { - "name": "payload_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/history/db/drizzle/meta/_journal.json b/factory/packages/backend/src/actors/history/db/drizzle/meta/_journal.json deleted file mode 100644 index 77960c12..00000000 --- a/factory/packages/backend/src/actors/history/db/drizzle/meta/_journal.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1770924375133, - "tag": "0000_watery_bushwacker", - "breakpoints": true - } - ] -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/history/db/migrations.ts b/factory/packages/backend/src/actors/history/db/migrations.ts deleted file mode 100644 index 36fbadc6..00000000 --- a/factory/packages/backend/src/actors/history/db/migrations.ts +++ /dev/null @@ -1,29 +0,0 @@ -// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. -// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). -// Do not hand-edit this file. - -const journal = { - "entries": [ - { - "idx": 0, - "when": 1770924375133, - "tag": "0000_watery_bushwacker", - "breakpoints": true - } - ] -} as const; - -export default { - journal, - migrations: { - m0000: `CREATE TABLE \`events\` ( - \`id\` integer PRIMARY KEY AUTOINCREMENT NOT NULL, - \`handoff_id\` text, - \`branch_name\` text, - \`kind\` text NOT NULL, - \`payload_json\` text NOT NULL, - \`created_at\` integer NOT NULL -); -`, - } as const -}; diff --git a/factory/packages/backend/src/actors/history/db/schema.ts b/factory/packages/backend/src/actors/history/db/schema.ts deleted file mode 100644 index 1b8a5daa..00000000 --- a/factory/packages/backend/src/actors/history/db/schema.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; - -export const events = sqliteTable("events", { - id: integer("id").primaryKey({ autoIncrement: true }), - handoffId: text("handoff_id"), - branchName: text("branch_name"), - kind: text("kind").notNull(), - payloadJson: text("payload_json").notNull(), - createdAt: integer("created_at").notNull(), -}); diff --git a/factory/packages/backend/src/actors/history/index.ts b/factory/packages/backend/src/actors/history/index.ts deleted file mode 100644 index 15e7ca51..00000000 --- a/factory/packages/backend/src/actors/history/index.ts +++ /dev/null @@ -1,111 +0,0 @@ -// @ts-nocheck -import { and, desc, eq } from "drizzle-orm"; -import { actor, queue } from "rivetkit"; -import { Loop, workflow } from "rivetkit/workflow"; -import type { HistoryEvent } from "@openhandoff/shared"; -import { selfHistory } from "../handles.js"; -import { historyDb } from "./db/db.js"; -import { events } from "./db/schema.js"; - -export interface HistoryInput { - workspaceId: string; - repoId: string; -} - -export interface AppendHistoryCommand { - kind: string; - handoffId?: string; - branchName?: string; - payload: Record; -} - -export interface ListHistoryParams { - branch?: string; - handoffId?: string; - limit?: number; -} - -const HISTORY_QUEUE_NAMES = ["history.command.append"] as const; - -async function appendHistoryRow(loopCtx: any, body: AppendHistoryCommand): Promise { - const now = Date.now(); - await loopCtx.db - .insert(events) - .values({ - handoffId: body.handoffId ?? null, - branchName: body.branchName ?? null, - kind: body.kind, - payloadJson: JSON.stringify(body.payload), - createdAt: now - }) - .run(); -} - -async function runHistoryWorkflow(ctx: any): Promise { - await ctx.loop("history-command-loop", async (loopCtx: any) => { - const msg = await loopCtx.queue.next("next-history-command", { - names: [...HISTORY_QUEUE_NAMES], - completable: true - }); - if (!msg) { - return Loop.continue(undefined); - } - - if (msg.name === "history.command.append") { - await loopCtx.step("append-history-row", async () => appendHistoryRow(loopCtx, msg.body as AppendHistoryCommand)); - await msg.complete({ ok: true }); - } - - return Loop.continue(undefined); - }); -} - -export const history = actor({ - db: historyDb, - queues: { - "history.command.append": queue() - }, - createState: (_c, input: HistoryInput) => ({ - workspaceId: input.workspaceId, - repoId: input.repoId - }), - actions: { - async append(c, command: AppendHistoryCommand): Promise { - const self = selfHistory(c); - await self.send("history.command.append", command, { wait: true, timeout: 15_000 }); - }, - - async list(c, params?: ListHistoryParams): Promise { - const whereParts = []; - if (params?.handoffId) { - whereParts.push(eq(events.handoffId, params.handoffId)); - } - if (params?.branch) { - whereParts.push(eq(events.branchName, params.branch)); - } - - const base = c.db - .select({ - id: events.id, - handoffId: events.handoffId, - branchName: events.branchName, - kind: events.kind, - payloadJson: events.payloadJson, - createdAt: events.createdAt - }) - .from(events); - - const rows = await (whereParts.length > 0 ? base.where(and(...whereParts)) : base) - .orderBy(desc(events.createdAt)) - .limit(params?.limit ?? 100) - .all(); - - return rows.map((row) => ({ - ...row, - workspaceId: c.state.workspaceId, - repoId: c.state.repoId - })); - } - }, - run: workflow(runHistoryWorkflow) -}); diff --git a/factory/packages/backend/src/actors/index.ts b/factory/packages/backend/src/actors/index.ts deleted file mode 100644 index 1e731765..00000000 --- a/factory/packages/backend/src/actors/index.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { setup } from "rivetkit"; -import { handoffStatusSync } from "./handoff-status-sync/index.js"; -import { handoff } from "./handoff/index.js"; -import { history } from "./history/index.js"; -import { projectBranchSync } from "./project-branch-sync/index.js"; -import { projectPrSync } from "./project-pr-sync/index.js"; -import { project } from "./project/index.js"; -import { sandboxInstance } from "./sandbox-instance/index.js"; -import { workspace } from "./workspace/index.js"; - -function resolveManagerPort(): number { - const raw = process.env.HF_RIVET_MANAGER_PORT ?? process.env.RIVETKIT_MANAGER_PORT; - if (!raw) { - return 7750; - } - - const parsed = Number(raw); - if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) { - throw new Error(`Invalid HF_RIVET_MANAGER_PORT/RIVETKIT_MANAGER_PORT: ${raw}`); - } - return parsed; -} - -function resolveManagerHost(): string { - const raw = process.env.HF_RIVET_MANAGER_HOST ?? process.env.RIVETKIT_MANAGER_HOST; - return raw && raw.trim().length > 0 ? raw.trim() : "0.0.0.0"; -} - -export const registry = setup({ - use: { - workspace, - project, - handoff, - sandboxInstance, - history, - projectPrSync, - projectBranchSync, - handoffStatusSync - }, - managerPort: resolveManagerPort(), - managerHost: resolveManagerHost() -}); - -export * from "./context.js"; -export * from "./events.js"; -export * from "./handoff-status-sync/index.js"; -export * from "./handoff/index.js"; -export * from "./history/index.js"; -export * from "./keys.js"; -export * from "./project-branch-sync/index.js"; -export * from "./project-pr-sync/index.js"; -export * from "./project/index.js"; -export * from "./sandbox-instance/index.js"; -export * from "./workspace/index.js"; diff --git a/factory/packages/backend/src/actors/keys.ts b/factory/packages/backend/src/actors/keys.ts deleted file mode 100644 index 5c1eae9a..00000000 --- a/factory/packages/backend/src/actors/keys.ts +++ /dev/null @@ -1,44 +0,0 @@ -export type ActorKey = string[]; - -export function workspaceKey(workspaceId: string): ActorKey { - return ["ws", workspaceId]; -} - -export function projectKey(workspaceId: string, repoId: string): ActorKey { - return ["ws", workspaceId, "project", repoId]; -} - -export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "handoff", handoffId]; -} - -export function sandboxInstanceKey( - workspaceId: string, - providerId: string, - sandboxId: string -): ActorKey { - return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId]; -} - -export function historyKey(workspaceId: string, repoId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "history"]; -} - -export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "pr-sync"]; -} - -export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "branch-sync"]; -} - -export function handoffStatusSyncKey( - workspaceId: string, - repoId: string, - handoffId: string, - sandboxId: string, - sessionId: string -): ActorKey { - // Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff. - return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId]; -} diff --git a/factory/packages/backend/src/actors/logging.ts b/factory/packages/backend/src/actors/logging.ts deleted file mode 100644 index ffc45abb..00000000 --- a/factory/packages/backend/src/actors/logging.ts +++ /dev/null @@ -1,31 +0,0 @@ -export function resolveErrorMessage(error: unknown): string { - if (error instanceof Error) { - return error.message; - } - return String(error); -} - -export function isActorNotFoundError(error: unknown): boolean { - return resolveErrorMessage(error).includes("Actor not found:"); -} - -export function resolveErrorStack(error: unknown): string | undefined { - if (error instanceof Error && typeof error.stack === "string") { - return error.stack; - } - return undefined; -} - -export function logActorWarning( - scope: string, - message: string, - context?: Record -): void { - const payload = { - scope, - message, - ...(context ?? {}) - }; - // eslint-disable-next-line no-console - console.warn("[openhandoff][actor:warn]", payload); -} diff --git a/factory/packages/backend/src/actors/polling.ts b/factory/packages/backend/src/actors/polling.ts deleted file mode 100644 index dc40ae99..00000000 --- a/factory/packages/backend/src/actors/polling.ts +++ /dev/null @@ -1,208 +0,0 @@ -import { Loop } from "rivetkit/workflow"; -import { normalizeMessages } from "../services/queue.js"; - -export interface PollingControlState { - intervalMs: number; - running: boolean; -} - -export interface PollingControlQueueNames { - start: string; - stop: string; - setInterval: string; - force: string; -} - -export interface PollingQueueMessage { - name: string; - body: unknown; - complete(response: unknown): Promise; -} - -interface PollingActorContext { - state: TState; - abortSignal: AbortSignal; - queue: { - nextBatch(options: { - names: readonly string[]; - timeout: number; - count: number; - completable: true; - }): Promise; - }; -} - -interface RunPollingOptions { - control: PollingControlQueueNames; - onPoll(c: PollingActorContext): Promise; -} - -export async function runPollingControlLoop( - c: PollingActorContext, - options: RunPollingOptions -): Promise { - while (!c.abortSignal.aborted) { - const messages = normalizeMessages( - await c.queue.nextBatch({ - names: [ - options.control.start, - options.control.stop, - options.control.setInterval, - options.control.force - ], - timeout: Math.max(500, c.state.intervalMs), - count: 16, - completable: true - }) - ) as PollingQueueMessage[]; - - if (messages.length === 0) { - if (!c.state.running) { - continue; - } - await options.onPoll(c); - continue; - } - - for (const msg of messages) { - if (msg.name === options.control.start) { - c.state.running = true; - await msg.complete({ ok: true }); - continue; - } - - if (msg.name === options.control.stop) { - c.state.running = false; - await msg.complete({ ok: true }); - continue; - } - - if (msg.name === options.control.setInterval) { - const intervalMs = Number((msg.body as { intervalMs?: unknown })?.intervalMs); - c.state.intervalMs = Number.isFinite(intervalMs) ? Math.max(500, intervalMs) : c.state.intervalMs; - await msg.complete({ ok: true }); - continue; - } - - if (msg.name === options.control.force) { - await options.onPoll(c); - await msg.complete({ ok: true }); - } - } - } -} - -interface WorkflowPollingActorContext { - state: TState; - loop(config: { - name: string; - historyEvery: number; - historyKeep: number; - run(ctx: WorkflowPollingActorContext): Promise; - }): Promise; -} - -interface WorkflowPollingQueueMessage extends PollingQueueMessage {} - -interface WorkflowPollingLoopContext { - state: TState; - queue: { - nextBatch(name: string, options: { - names: readonly string[]; - timeout: number; - count: number; - completable: true; - }): Promise; - }; - step( - nameOrConfig: - | string - | { - name: string; - timeout?: number; - run: () => Promise; - }, - run?: () => Promise, - ): Promise; -} - -export async function runWorkflowPollingLoop( - ctx: any, - options: RunPollingOptions & { loopName: string }, -): Promise { - await ctx.loop(options.loopName, async (loopCtx: WorkflowPollingLoopContext) => { - const control = await loopCtx.step("read-control-state", async () => ({ - intervalMs: Math.max(500, Number(loopCtx.state.intervalMs) || 500), - running: Boolean(loopCtx.state.running), - })); - - const messages = normalizeMessages( - await loopCtx.queue.nextBatch("next-polling-control-batch", { - names: [ - options.control.start, - options.control.stop, - options.control.setInterval, - options.control.force, - ], - timeout: control.running ? control.intervalMs : 60_000, - count: 16, - completable: true, - }), - ) as WorkflowPollingQueueMessage[]; - - if (messages.length === 0) { - if (control.running) { - await loopCtx.step({ - name: "poll-tick", - timeout: 5 * 60_000, - run: async () => { - await options.onPoll(loopCtx as unknown as PollingActorContext); - }, - }); - } - return Loop.continue(undefined); - } - - for (const msg of messages) { - if (msg.name === options.control.start) { - await loopCtx.step("control-start", async () => { - loopCtx.state.running = true; - }); - await msg.complete({ ok: true }); - continue; - } - - if (msg.name === options.control.stop) { - await loopCtx.step("control-stop", async () => { - loopCtx.state.running = false; - }); - await msg.complete({ ok: true }); - continue; - } - - if (msg.name === options.control.setInterval) { - await loopCtx.step("control-set-interval", async () => { - const intervalMs = Number((msg.body as { intervalMs?: unknown })?.intervalMs); - loopCtx.state.intervalMs = Number.isFinite(intervalMs) - ? Math.max(500, intervalMs) - : loopCtx.state.intervalMs; - }); - await msg.complete({ ok: true }); - continue; - } - - if (msg.name === options.control.force) { - await loopCtx.step({ - name: "control-force", - timeout: 5 * 60_000, - run: async () => { - await options.onPoll(loopCtx as unknown as PollingActorContext); - }, - }); - await msg.complete({ ok: true }); - } - } - - return Loop.continue(undefined); - }); -} diff --git a/factory/packages/backend/src/actors/project-branch-sync/index.ts b/factory/packages/backend/src/actors/project-branch-sync/index.ts deleted file mode 100644 index abe80730..00000000 --- a/factory/packages/backend/src/actors/project-branch-sync/index.ts +++ /dev/null @@ -1,181 +0,0 @@ -import { actor, queue } from "rivetkit"; -import { workflow } from "rivetkit/workflow"; -import type { GitDriver } from "../../driver.js"; -import { getActorRuntimeContext } from "../context.js"; -import { getProject, selfProjectBranchSync } from "../handles.js"; -import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js"; -import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js"; -import { parentLookupFromStack } from "../project/stack-model.js"; -import { withRepoGitLock } from "../../services/repo-git-lock.js"; - -export interface ProjectBranchSyncInput { - workspaceId: string; - repoId: string; - repoPath: string; - intervalMs: number; -} - -interface SetIntervalCommand { - intervalMs: number; -} - -interface EnrichedBranchSnapshot { - branchName: string; - commitSha: string; - parentBranch: string | null; - trackedInStack: boolean; - diffStat: string | null; - hasUnpushed: boolean; - conflictsWithMain: boolean; -} - -interface ProjectBranchSyncState extends PollingControlState { - workspaceId: string; - repoId: string; - repoPath: string; -} - -const CONTROL = { - start: "project.branch_sync.control.start", - stop: "project.branch_sync.control.stop", - setInterval: "project.branch_sync.control.set_interval", - force: "project.branch_sync.control.force" -} as const; - -async function enrichBranches( - workspaceId: string, - repoId: string, - repoPath: string, - git: GitDriver -): Promise { - return await withRepoGitLock(repoPath, async () => { - await git.fetch(repoPath); - const branches = await git.listRemoteBranches(repoPath); - const { driver } = getActorRuntimeContext(); - const stackEntries = await driver.stack.listStack(repoPath).catch(() => []); - const parentByBranch = parentLookupFromStack(stackEntries); - const enriched: EnrichedBranchSnapshot[] = []; - - const baseRef = await git.remoteDefaultBaseRef(repoPath); - const baseSha = await git.revParse(repoPath, baseRef).catch(() => ""); - - for (const branch of branches) { - let branchDiffStat: string | null = null; - let branchHasUnpushed = false; - let branchConflicts = false; - - try { - branchDiffStat = await git.diffStatForBranch(repoPath, branch.branchName); - } catch (error) { - logActorWarning("project-branch-sync", "diffStatForBranch failed", { - workspaceId, - repoId, - branchName: branch.branchName, - error: resolveErrorMessage(error) - }); - branchDiffStat = null; - } - - try { - const headSha = await git.revParse(repoPath, `origin/${branch.branchName}`); - branchHasUnpushed = Boolean(baseSha && headSha && headSha !== baseSha); - } catch (error) { - logActorWarning("project-branch-sync", "revParse failed", { - workspaceId, - repoId, - branchName: branch.branchName, - error: resolveErrorMessage(error) - }); - branchHasUnpushed = false; - } - - try { - branchConflicts = await git.conflictsWithMain(repoPath, branch.branchName); - } catch (error) { - logActorWarning("project-branch-sync", "conflictsWithMain failed", { - workspaceId, - repoId, - branchName: branch.branchName, - error: resolveErrorMessage(error) - }); - branchConflicts = false; - } - - enriched.push({ - branchName: branch.branchName, - commitSha: branch.commitSha, - parentBranch: parentByBranch.get(branch.branchName) ?? null, - trackedInStack: parentByBranch.has(branch.branchName), - diffStat: branchDiffStat, - hasUnpushed: branchHasUnpushed, - conflictsWithMain: branchConflicts - }); - } - - return enriched; - }); -} - -async function pollBranches(c: { state: ProjectBranchSyncState }): Promise { - const { driver } = getActorRuntimeContext(); - const enrichedItems = await enrichBranches(c.state.workspaceId, c.state.repoId, c.state.repoPath, driver.git); - const parent = getProject(c, c.state.workspaceId, c.state.repoId); - await parent.applyBranchSyncResult({ items: enrichedItems, at: Date.now() }); -} - -export const projectBranchSync = actor({ - queues: { - [CONTROL.start]: queue(), - [CONTROL.stop]: queue(), - [CONTROL.setInterval]: queue(), - [CONTROL.force]: queue(), - }, - options: { - // Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling. - noSleep: true - }, - createState: (_c, input: ProjectBranchSyncInput): ProjectBranchSyncState => ({ - workspaceId: input.workspaceId, - repoId: input.repoId, - repoPath: input.repoPath, - intervalMs: input.intervalMs, - running: true - }), - actions: { - async start(c): Promise { - const self = selfProjectBranchSync(c); - await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 }); - }, - - async stop(c): Promise { - const self = selfProjectBranchSync(c); - await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 }); - }, - - async setIntervalMs(c, payload: SetIntervalCommand): Promise { - const self = selfProjectBranchSync(c); - await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 }); - }, - - async force(c): Promise { - const self = selfProjectBranchSync(c); - await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 }); - } - }, - run: workflow(async (ctx) => { - await runWorkflowPollingLoop(ctx, { - loopName: "project-branch-sync-loop", - control: CONTROL, - onPoll: async (loopCtx) => { - try { - await pollBranches(loopCtx); - } catch (error) { - logActorWarning("project-branch-sync", "poll failed", { - error: resolveErrorMessage(error), - stack: resolveErrorStack(error) - }); - } - } - }); - }) -}); diff --git a/factory/packages/backend/src/actors/project-pr-sync/index.ts b/factory/packages/backend/src/actors/project-pr-sync/index.ts deleted file mode 100644 index 37737f6d..00000000 --- a/factory/packages/backend/src/actors/project-pr-sync/index.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { actor, queue } from "rivetkit"; -import { workflow } from "rivetkit/workflow"; -import { getActorRuntimeContext } from "../context.js"; -import { getProject, selfProjectPrSync } from "../handles.js"; -import { logActorWarning, resolveErrorMessage, resolveErrorStack } from "../logging.js"; -import { type PollingControlState, runWorkflowPollingLoop } from "../polling.js"; - -export interface ProjectPrSyncInput { - workspaceId: string; - repoId: string; - repoPath: string; - intervalMs: number; -} - -interface SetIntervalCommand { - intervalMs: number; -} - -interface ProjectPrSyncState extends PollingControlState { - workspaceId: string; - repoId: string; - repoPath: string; -} - -const CONTROL = { - start: "project.pr_sync.control.start", - stop: "project.pr_sync.control.stop", - setInterval: "project.pr_sync.control.set_interval", - force: "project.pr_sync.control.force" -} as const; - -async function pollPrs(c: { state: ProjectPrSyncState }): Promise { - const { driver } = getActorRuntimeContext(); - const items = await driver.github.listPullRequests(c.state.repoPath); - const parent = getProject(c, c.state.workspaceId, c.state.repoId); - await parent.applyPrSyncResult({ items, at: Date.now() }); -} - -export const projectPrSync = actor({ - queues: { - [CONTROL.start]: queue(), - [CONTROL.stop]: queue(), - [CONTROL.setInterval]: queue(), - [CONTROL.force]: queue(), - }, - options: { - // Polling actors rely on timer-based wakeups; sleeping would pause the timer and stop polling. - noSleep: true - }, - createState: (_c, input: ProjectPrSyncInput): ProjectPrSyncState => ({ - workspaceId: input.workspaceId, - repoId: input.repoId, - repoPath: input.repoPath, - intervalMs: input.intervalMs, - running: true - }), - actions: { - async start(c): Promise { - const self = selfProjectPrSync(c); - await self.send(CONTROL.start, {}, { wait: true, timeout: 15_000 }); - }, - - async stop(c): Promise { - const self = selfProjectPrSync(c); - await self.send(CONTROL.stop, {}, { wait: true, timeout: 15_000 }); - }, - - async setIntervalMs(c, payload: SetIntervalCommand): Promise { - const self = selfProjectPrSync(c); - await self.send(CONTROL.setInterval, payload, { wait: true, timeout: 15_000 }); - }, - - async force(c): Promise { - const self = selfProjectPrSync(c); - await self.send(CONTROL.force, {}, { wait: true, timeout: 5 * 60_000 }); - } - }, - run: workflow(async (ctx) => { - await runWorkflowPollingLoop(ctx, { - loopName: "project-pr-sync-loop", - control: CONTROL, - onPoll: async (loopCtx) => { - try { - await pollPrs(loopCtx); - } catch (error) { - logActorWarning("project-pr-sync", "poll failed", { - error: resolveErrorMessage(error), - stack: resolveErrorStack(error) - }); - } - } - }); - }) -}); diff --git a/factory/packages/backend/src/actors/project/actions.ts b/factory/packages/backend/src/actors/project/actions.ts deleted file mode 100644 index d0fc978f..00000000 --- a/factory/packages/backend/src/actors/project/actions.ts +++ /dev/null @@ -1,1206 +0,0 @@ -// @ts-nocheck -import { randomUUID } from "node:crypto"; -import { and, desc, eq, isNotNull, ne } from "drizzle-orm"; -import { Loop } from "rivetkit/workflow"; -import type { - AgentType, - HandoffRecord, - HandoffSummary, - ProviderId, - RepoOverview, - RepoStackAction, - RepoStackActionResult -} from "@openhandoff/shared"; -import { getActorRuntimeContext } from "../context.js"; -import { - getHandoff, - getOrCreateHandoff, - getOrCreateHistory, - getOrCreateProjectBranchSync, - getOrCreateProjectPrSync, - selfProject -} from "../handles.js"; -import { isActorNotFoundError, logActorWarning, resolveErrorMessage } from "../logging.js"; -import { openhandoffRepoClonePath } from "../../services/openhandoff-paths.js"; -import { expectQueueResponse } from "../../services/queue.js"; -import { withRepoGitLock } from "../../services/repo-git-lock.js"; -import { branches, handoffIndex, prCache, repoMeta } from "./db/schema.js"; -import { deriveFallbackTitle } from "../../services/create-flow.js"; -import { normalizeBaseBranchName } from "../../integrations/git-spice/index.js"; -import { sortBranchesForOverview } from "./stack-model.js"; - -interface EnsureProjectCommand { - remoteUrl: string; -} - -interface EnsureProjectResult { - localPath: string; -} - -interface CreateHandoffCommand { - task: string; - providerId: ProviderId; - agentType: AgentType | null; - explicitTitle: string | null; - explicitBranchName: string | null; - onBranch: string | null; -} - -interface HydrateHandoffIndexCommand {} - -interface ListReservedBranchesCommand {} - -interface RegisterHandoffBranchCommand { - handoffId: string; - branchName: string; - requireExistingRemote?: boolean; -} - -interface ListHandoffSummariesCommand { - includeArchived?: boolean; -} - -interface GetHandoffEnrichedCommand { - handoffId: string; -} - -interface GetPullRequestForBranchCommand { - branchName: string; -} - -interface PrSyncResult { - items: Array<{ - number: number; - headRefName: string; - state: string; - title: string; - url?: string; - author?: string; - isDraft?: boolean; - ciStatus?: string | null; - reviewStatus?: string | null; - reviewer?: string | null; - }>; - at: number; -} - -interface BranchSyncResult { - items: Array<{ - branchName: string; - commitSha: string; - parentBranch?: string | null; - trackedInStack?: boolean; - diffStat?: string | null; - hasUnpushed?: boolean; - conflictsWithMain?: boolean; - }>; - at: number; -} - -interface RepoOverviewCommand {} - -interface RunRepoStackActionCommand { - action: RepoStackAction; - branchName?: string; - parentBranch?: string; -} - -const PROJECT_QUEUE_NAMES = [ - "project.command.ensure", - "project.command.hydrateHandoffIndex", - "project.command.createHandoff", - "project.command.registerHandoffBranch", - "project.command.runRepoStackAction", - "project.command.applyPrSyncResult", - "project.command.applyBranchSyncResult", -] as const; - -type ProjectQueueName = (typeof PROJECT_QUEUE_NAMES)[number]; - -export { PROJECT_QUEUE_NAMES }; - -export function projectWorkflowQueueName(name: ProjectQueueName): ProjectQueueName { - return name; -} - -async function ensureLocalClone(c: any, remoteUrl: string): Promise { - const { config, driver } = getActorRuntimeContext(); - const localPath = openhandoffRepoClonePath(config, c.state.workspaceId, c.state.repoId); - await driver.git.ensureCloned(remoteUrl, localPath); - c.state.localPath = localPath; - return localPath; -} - -async function ensureProjectSyncActors(c: any, localPath: string): Promise { - if (c.state.syncActorsStarted) { - return; - } - - const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000); - await prSync.start(); - - const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000); - await branchSync.start(); - - c.state.syncActorsStarted = true; -} - -async function deleteStaleHandoffIndexRow(c: any, handoffId: string): Promise { - try { - await c.db.delete(handoffIndex).where(eq(handoffIndex.handoffId, handoffId)).run(); - } catch { - // Best-effort cleanup only; preserve the original caller flow. - } -} - -function isStaleHandoffReferenceError(error: unknown): boolean { - const message = resolveErrorMessage(error); - return isActorNotFoundError(error) || message.startsWith("Handoff not found:"); -} - -async function ensureHandoffIndexHydrated(c: any): Promise { - if (c.state.handoffIndexHydrated) { - return; - } - - const existing = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .limit(1) - .get(); - - if (existing) { - c.state.handoffIndexHydrated = true; - return; - } - - // Migration path for old project actors that only tracked handoffs in history. - try { - const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); - const rows = await history.list({ limit: 5_000 }); - const seen = new Set(); - let skippedMissingHandoffActors = 0; - - for (const row of rows) { - if (!row.handoffId || seen.has(row.handoffId)) { - continue; - } - seen.add(row.handoffId); - - try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); - await h.get(); - } catch (error) { - if (isStaleHandoffReferenceError(error)) { - skippedMissingHandoffActors += 1; - continue; - } - throw error; - } - - await c.db - .insert(handoffIndex) - .values({ - handoffId: row.handoffId, - branchName: row.branchName, - createdAt: row.createdAt, - updatedAt: row.createdAt - }) - .onConflictDoNothing() - .run(); - } - - if (skippedMissingHandoffActors > 0) { - logActorWarning("project", "skipped missing handoffs while hydrating index", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - skippedMissingHandoffActors - }); - } - } catch (error) { - logActorWarning("project", "handoff index hydration from history failed", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - error: resolveErrorMessage(error) - }); - } - - c.state.handoffIndexHydrated = true; -} - -async function ensureProjectReady(c: any): Promise { - if (!c.state.remoteUrl) { - throw new Error("project remoteUrl is not initialized"); - } - if (!c.state.localPath) { - await ensureLocalClone(c, c.state.remoteUrl); - } - if (!c.state.localPath) { - throw new Error("project local repo is not initialized"); - } - await ensureProjectSyncActors(c, c.state.localPath); - return c.state.localPath; -} - -async function ensureProjectReadyForRead(c: any): Promise { - if (!c.state.remoteUrl) { - throw new Error("project remoteUrl is not initialized"); - } - - if (!c.state.localPath || !c.state.syncActorsStarted) { - const result = await projectActions.ensure(c, { remoteUrl: c.state.remoteUrl }); - const localPath = result?.localPath ?? c.state.localPath; - if (!localPath) { - throw new Error("project local repo is not initialized"); - } - return localPath; - } - - return c.state.localPath; -} - -async function ensureHandoffIndexHydratedForRead(c: any): Promise { - if (c.state.handoffIndexHydrated) { - return; - } - await projectActions.hydrateHandoffIndex(c, {}); -} - -async function forceProjectSync(c: any, localPath: string): Promise { - const prSync = await getOrCreateProjectPrSync(c, c.state.workspaceId, c.state.repoId, localPath, 30_000); - await prSync.force(); - - const branchSync = await getOrCreateProjectBranchSync(c, c.state.workspaceId, c.state.repoId, localPath, 5_000); - await branchSync.force(); -} - -async function enrichHandoffRecord(c: any, record: HandoffRecord): Promise { - const branchName = record.branchName; - const br = - branchName != null - ? await c.db - .select({ - diffStat: branches.diffStat, - hasUnpushed: branches.hasUnpushed, - conflictsWithMain: branches.conflictsWithMain, - parentBranch: branches.parentBranch - }) - .from(branches) - .where(eq(branches.branchName, branchName)) - .get() - : null; - - const pr = - branchName != null - ? await c.db - .select({ - prUrl: prCache.prUrl, - prAuthor: prCache.prAuthor, - ciStatus: prCache.ciStatus, - reviewStatus: prCache.reviewStatus, - reviewer: prCache.reviewer - }) - .from(prCache) - .where(eq(prCache.branchName, branchName)) - .get() - : null; - - return { - ...record, - diffStat: br?.diffStat ?? null, - hasUnpushed: br?.hasUnpushed != null ? String(br.hasUnpushed) : null, - conflictsWithMain: br?.conflictsWithMain != null ? String(br.conflictsWithMain) : null, - parentBranch: br?.parentBranch ?? null, - prUrl: pr?.prUrl ?? null, - prAuthor: pr?.prAuthor ?? null, - ciStatus: pr?.ciStatus ?? null, - reviewStatus: pr?.reviewStatus ?? null, - reviewer: pr?.reviewer ?? null - }; -} - -async function ensureProjectMutation(c: any, cmd: EnsureProjectCommand): Promise { - c.state.remoteUrl = cmd.remoteUrl; - const localPath = await ensureLocalClone(c, cmd.remoteUrl); - - await c.db - .insert(repoMeta) - .values({ - id: 1, - remoteUrl: cmd.remoteUrl, - updatedAt: Date.now() - }) - .onConflictDoUpdate({ - target: repoMeta.id, - set: { - remoteUrl: cmd.remoteUrl, - updatedAt: Date.now() - } - }) - .run(); - - await ensureProjectSyncActors(c, localPath); - return { localPath }; -} - -async function hydrateHandoffIndexMutation(c: any, _cmd?: HydrateHandoffIndexCommand): Promise { - await ensureHandoffIndexHydrated(c); -} - -async function createHandoffMutation(c: any, cmd: CreateHandoffCommand): Promise { - const localPath = await ensureProjectReady(c); - const onBranch = cmd.onBranch?.trim() || null; - const initialBranchName = onBranch; - const initialTitle = onBranch ? deriveFallbackTitle(cmd.task, cmd.explicitTitle ?? undefined) : null; - const handoffId = randomUUID(); - - if (onBranch) { - await forceProjectSync(c, localPath); - - const branchRow = await c.db - .select({ branchName: branches.branchName }) - .from(branches) - .where(eq(branches.branchName, onBranch)) - .get(); - if (!branchRow) { - throw new Error(`Branch not found in repo snapshot: ${onBranch}`); - } - - await registerHandoffBranchMutation(c, { - handoffId, - branchName: onBranch, - requireExistingRemote: true - }); - } - - let handoff: Awaited>; - try { - handoff = await getOrCreateHandoff(c, c.state.workspaceId, c.state.repoId, handoffId, { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId, - repoRemote: c.state.remoteUrl, - repoLocalPath: localPath, - branchName: initialBranchName, - title: initialTitle, - task: cmd.task, - providerId: cmd.providerId, - agentType: cmd.agentType, - explicitTitle: onBranch ? null : cmd.explicitTitle, - explicitBranchName: onBranch ? null : cmd.explicitBranchName - }); - } catch (error) { - if (onBranch) { - await c.db.delete(handoffIndex).where(eq(handoffIndex.handoffId, handoffId)).run().catch(() => {}); - } - throw error; - } - - if (!onBranch) { - const now = Date.now(); - await c.db - .insert(handoffIndex) - .values({ - handoffId, - branchName: initialBranchName, - createdAt: now, - updatedAt: now - }) - .onConflictDoNothing() - .run(); - } - - const created = await handoff.initialize({ providerId: cmd.providerId }); - - const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); - await history.append({ - kind: "handoff.created", - handoffId, - payload: { - repoId: c.state.repoId, - providerId: cmd.providerId - } - }); - - return created; -} - -async function registerHandoffBranchMutation( - c: any, - cmd: RegisterHandoffBranchCommand, -): Promise<{ branchName: string; headSha: string }> { - const localPath = await ensureProjectReady(c); - - const branchName = cmd.branchName.trim(); - const requireExistingRemote = cmd.requireExistingRemote === true; - if (!branchName) { - throw new Error("branchName is required"); - } - - await ensureHandoffIndexHydrated(c); - - const existingOwner = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .where(and(eq(handoffIndex.branchName, branchName), ne(handoffIndex.handoffId, cmd.handoffId))) - .get(); - - if (existingOwner) { - let ownerMissing = false; - try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, existingOwner.handoffId); - await h.get(); - } catch (error) { - if (isStaleHandoffReferenceError(error)) { - ownerMissing = true; - await deleteStaleHandoffIndexRow(c, existingOwner.handoffId); - logActorWarning("project", "pruned stale handoff index row during branch registration", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId: existingOwner.handoffId, - branchName - }); - } else { - throw error; - } - } - if (!ownerMissing) { - throw new Error(`branch is already assigned to a different handoff: ${branchName}`); - } - } - - const { driver } = getActorRuntimeContext(); - - let headSha = ""; - let trackedInStack = false; - let parentBranch: string | null = null; - - await withRepoGitLock(localPath, async () => { - await driver.git.fetch(localPath); - const baseRef = await driver.git.remoteDefaultBaseRef(localPath); - const normalizedBase = normalizeBaseBranchName(baseRef); - - if (requireExistingRemote) { - try { - headSha = await driver.git.revParse(localPath, `origin/${branchName}`); - } catch { - throw new Error(`Remote branch not found: ${branchName}`); - } - } else { - await driver.git.ensureRemoteBranch(localPath, branchName); - await driver.git.fetch(localPath); - try { - headSha = await driver.git.revParse(localPath, `origin/${branchName}`); - } catch { - headSha = await driver.git.revParse(localPath, baseRef); - } - } - - if (await driver.stack.available(localPath).catch(() => false)) { - let stackRows = await driver.stack.listStack(localPath).catch(() => []); - let stackRow = stackRows.find((entry) => entry.branchName === branchName); - - if (!stackRow) { - try { - await driver.stack.trackBranch(localPath, branchName, normalizedBase); - } catch (error) { - logActorWarning("project", "stack track failed while registering branch", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - branchName, - error: resolveErrorMessage(error) - }); - } - stackRows = await driver.stack.listStack(localPath).catch(() => []); - stackRow = stackRows.find((entry) => entry.branchName === branchName); - } - - trackedInStack = Boolean(stackRow); - parentBranch = stackRow?.parentBranch ?? null; - } - }); - - const now = Date.now(); - await c.db - .insert(branches) - .values({ - branchName, - commitSha: headSha, - parentBranch, - trackedInStack: trackedInStack ? 1 : 0, - firstSeenAt: now, - lastSeenAt: now, - updatedAt: now - }) - .onConflictDoUpdate({ - target: branches.branchName, - set: { - commitSha: headSha, - parentBranch, - trackedInStack: trackedInStack ? 1 : 0, - lastSeenAt: now, - updatedAt: now - } - }) - .run(); - - await c.db - .insert(handoffIndex) - .values({ - handoffId: cmd.handoffId, - branchName, - createdAt: now, - updatedAt: now - }) - .onConflictDoUpdate({ - target: handoffIndex.handoffId, - set: { - branchName, - updatedAt: now - } - }) - .run(); - - return { branchName, headSha }; -} - -async function runRepoStackActionMutation(c: any, cmd: RunRepoStackActionCommand): Promise { - const localPath = await ensureProjectReady(c); - await ensureHandoffIndexHydrated(c); - - const { driver } = getActorRuntimeContext(); - const at = Date.now(); - const action = cmd.action; - const branchName = cmd.branchName?.trim() || null; - const parentBranch = cmd.parentBranch?.trim() || null; - - if (!(await driver.stack.available(localPath).catch(() => false))) { - return { - action, - executed: false, - message: "git-spice is not available for this repo", - at - }; - } - - if ((action === "restack_subtree" || action === "rebase_branch" || action === "reparent_branch") && !branchName) { - throw new Error(`branchName is required for action: ${action}`); - } - if (action === "reparent_branch" && !parentBranch) { - throw new Error("parentBranch is required for action: reparent_branch"); - } - - await forceProjectSync(c, localPath); - - if (branchName) { - const row = await c.db - .select({ branchName: branches.branchName }) - .from(branches) - .where(eq(branches.branchName, branchName)) - .get(); - if (!row) { - throw new Error(`Branch not found in repo snapshot: ${branchName}`); - } - } - - if (action === "reparent_branch") { - if (!parentBranch) { - throw new Error("parentBranch is required for action: reparent_branch"); - } - if (parentBranch === branchName) { - throw new Error("parentBranch must be different from branchName"); - } - const parentRow = await c.db - .select({ branchName: branches.branchName }) - .from(branches) - .where(eq(branches.branchName, parentBranch)) - .get(); - if (!parentRow) { - throw new Error(`Parent branch not found in repo snapshot: ${parentBranch}`); - } - } - - await withRepoGitLock(localPath, async () => { - if (action === "sync_repo") { - await driver.stack.syncRepo(localPath); - } else if (action === "restack_repo") { - await driver.stack.restackRepo(localPath); - } else if (action === "restack_subtree") { - await driver.stack.restackSubtree(localPath, branchName!); - } else if (action === "rebase_branch") { - await driver.stack.rebaseBranch(localPath, branchName!); - } else if (action === "reparent_branch") { - await driver.stack.reparentBranch(localPath, branchName!, parentBranch!); - } else { - throw new Error(`Unsupported repo stack action: ${action}`); - } - }); - - await forceProjectSync(c, localPath); - - try { - const history = await getOrCreateHistory(c, c.state.workspaceId, c.state.repoId); - await history.append({ - kind: "repo.stack_action", - branchName: branchName ?? null, - payload: { - action, - branchName: branchName ?? null, - parentBranch: parentBranch ?? null - } - }); - } catch (error) { - logActorWarning("project", "failed appending repo stack history event", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - action, - error: resolveErrorMessage(error) - }); - } - - return { - action, - executed: true, - message: `stack action executed: ${action}`, - at - }; -} - -async function applyPrSyncResultMutation(c: any, body: PrSyncResult): Promise { - await c.db.delete(prCache).run(); - - for (const item of body.items) { - await c.db - .insert(prCache) - .values({ - branchName: item.headRefName, - prNumber: item.number, - state: item.state, - title: item.title, - prUrl: item.url ?? null, - prAuthor: item.author ?? null, - isDraft: item.isDraft ? 1 : 0, - ciStatus: item.ciStatus ?? null, - reviewStatus: item.reviewStatus ?? null, - reviewer: item.reviewer ?? null, - fetchedAt: body.at, - updatedAt: body.at - }) - .onConflictDoUpdate({ - target: prCache.branchName, - set: { - prNumber: item.number, - state: item.state, - title: item.title, - prUrl: item.url ?? null, - prAuthor: item.author ?? null, - isDraft: item.isDraft ? 1 : 0, - ciStatus: item.ciStatus ?? null, - reviewStatus: item.reviewStatus ?? null, - reviewer: item.reviewer ?? null, - fetchedAt: body.at, - updatedAt: body.at - } - }) - .run(); - } - - for (const item of body.items) { - if (item.state !== "MERGED" && item.state !== "CLOSED") { - continue; - } - - const row = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .where(eq(handoffIndex.branchName, item.headRefName)) - .get(); - if (!row) { - continue; - } - - try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); - await h.archive({ reason: `PR ${item.state.toLowerCase()}` }); - } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, row.handoffId); - logActorWarning("project", "pruned stale handoff index row during PR close archive", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId: row.handoffId, - branchName: item.headRefName, - prState: item.state - }); - continue; - } - logActorWarning("project", "failed to auto-archive handoff after PR close", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId: row.handoffId, - branchName: item.headRefName, - prState: item.state, - error: resolveErrorMessage(error) - }); - } - } -} - -async function applyBranchSyncResultMutation(c: any, body: BranchSyncResult): Promise { - const incoming = new Set(body.items.map((item) => item.branchName)); - - for (const item of body.items) { - const existing = await c.db - .select({ - firstSeenAt: branches.firstSeenAt - }) - .from(branches) - .where(eq(branches.branchName, item.branchName)) - .get(); - - await c.db - .insert(branches) - .values({ - branchName: item.branchName, - commitSha: item.commitSha, - parentBranch: item.parentBranch ?? null, - trackedInStack: item.trackedInStack ? 1 : 0, - diffStat: item.diffStat ?? null, - hasUnpushed: item.hasUnpushed ? 1 : 0, - conflictsWithMain: item.conflictsWithMain ? 1 : 0, - firstSeenAt: existing?.firstSeenAt ?? body.at, - lastSeenAt: body.at, - updatedAt: body.at - }) - .onConflictDoUpdate({ - target: branches.branchName, - set: { - commitSha: item.commitSha, - parentBranch: item.parentBranch ?? null, - trackedInStack: item.trackedInStack ? 1 : 0, - diffStat: item.diffStat ?? null, - hasUnpushed: item.hasUnpushed ? 1 : 0, - conflictsWithMain: item.conflictsWithMain ? 1 : 0, - firstSeenAt: existing?.firstSeenAt ?? body.at, - lastSeenAt: body.at, - updatedAt: body.at - } - }) - .run(); - } - - const existingRows = await c.db - .select({ branchName: branches.branchName }) - .from(branches) - .all(); - - for (const row of existingRows) { - if (incoming.has(row.branchName)) { - continue; - } - await c.db.delete(branches).where(eq(branches.branchName, row.branchName)).run(); - } -} - -export async function runProjectWorkflow(ctx: any): Promise { - await ctx.loop("project-command-loop", async (loopCtx: any) => { - const msg = await loopCtx.queue.next("next-project-command", { - names: [...PROJECT_QUEUE_NAMES], - completable: true, - }); - if (!msg) { - return Loop.continue(undefined); - } - - if (msg.name === "project.command.ensure") { - const result = await loopCtx.step({ - name: "project-ensure", - timeout: 5 * 60_000, - run: async () => ensureProjectMutation(loopCtx, msg.body as EnsureProjectCommand), - }); - await msg.complete(result); - return Loop.continue(undefined); - } - - if (msg.name === "project.command.hydrateHandoffIndex") { - await loopCtx.step("project-hydrate-handoff-index", async () => - hydrateHandoffIndexMutation(loopCtx, msg.body as HydrateHandoffIndexCommand), - ); - await msg.complete({ ok: true }); - return Loop.continue(undefined); - } - - if (msg.name === "project.command.createHandoff") { - const result = await loopCtx.step({ - name: "project-create-handoff", - timeout: 12 * 60_000, - run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffCommand), - }); - await msg.complete(result); - return Loop.continue(undefined); - } - - if (msg.name === "project.command.registerHandoffBranch") { - const result = await loopCtx.step({ - name: "project-register-handoff-branch", - timeout: 5 * 60_000, - run: async () => registerHandoffBranchMutation(loopCtx, msg.body as RegisterHandoffBranchCommand), - }); - await msg.complete(result); - return Loop.continue(undefined); - } - - if (msg.name === "project.command.runRepoStackAction") { - const result = await loopCtx.step({ - name: "project-run-repo-stack-action", - timeout: 12 * 60_000, - run: async () => runRepoStackActionMutation(loopCtx, msg.body as RunRepoStackActionCommand), - }); - await msg.complete(result); - return Loop.continue(undefined); - } - - if (msg.name === "project.command.applyPrSyncResult") { - await loopCtx.step({ - name: "project-apply-pr-sync-result", - timeout: 60_000, - run: async () => applyPrSyncResultMutation(loopCtx, msg.body as PrSyncResult), - }); - await msg.complete({ ok: true }); - return Loop.continue(undefined); - } - - if (msg.name === "project.command.applyBranchSyncResult") { - await loopCtx.step({ - name: "project-apply-branch-sync-result", - timeout: 60_000, - run: async () => applyBranchSyncResultMutation(loopCtx, msg.body as BranchSyncResult), - }); - await msg.complete({ ok: true }); - } - - return Loop.continue(undefined); - }); -} - -export const projectActions = { - async ensure(c: any, cmd: EnsureProjectCommand): Promise { - const self = selfProject(c); - return expectQueueResponse( - await self.send(projectWorkflowQueueName("project.command.ensure"), cmd, { - wait: true, - timeout: 5 * 60_000, - }), - ); - }, - - async createHandoff(c: any, cmd: CreateHandoffCommand): Promise { - const self = selfProject(c); - return expectQueueResponse( - await self.send(projectWorkflowQueueName("project.command.createHandoff"), cmd, { - wait: true, - timeout: 12 * 60_000, - }), - ); - }, - - async listReservedBranches(c: any, _cmd?: ListReservedBranchesCommand): Promise { - await ensureHandoffIndexHydratedForRead(c); - - const rows = await c.db - .select({ branchName: handoffIndex.branchName }) - .from(handoffIndex) - .where(isNotNull(handoffIndex.branchName)) - .all(); - - return rows - .map((row) => row.branchName) - .filter((name): name is string => typeof name === "string" && name.trim().length > 0); - }, - - async registerHandoffBranch(c: any, cmd: RegisterHandoffBranchCommand): Promise<{ branchName: string; headSha: string }> { - const self = selfProject(c); - return expectQueueResponse<{ branchName: string; headSha: string }>( - await self.send(projectWorkflowQueueName("project.command.registerHandoffBranch"), cmd, { - wait: true, - timeout: 5 * 60_000, - }), - ); - }, - - async hydrateHandoffIndex(c: any, cmd?: HydrateHandoffIndexCommand): Promise { - const self = selfProject(c); - await self.send(projectWorkflowQueueName("project.command.hydrateHandoffIndex"), cmd ?? {}, { - wait: true, - timeout: 60_000, - }); - }, - - async listHandoffSummaries(c: any, cmd?: ListHandoffSummariesCommand): Promise { - const body = cmd ?? {}; - const records: HandoffSummary[] = []; - - await ensureHandoffIndexHydratedForRead(c); - - const handoffRows = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .orderBy(desc(handoffIndex.updatedAt)) - .all(); - - for (const row of handoffRows) { - try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); - const record = await h.get(); - - if (!body.includeArchived && record.status === "archived") { - continue; - } - - records.push({ - workspaceId: record.workspaceId, - repoId: record.repoId, - handoffId: record.handoffId, - branchName: record.branchName, - title: record.title, - status: record.status, - updatedAt: record.updatedAt - }); - } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, row.handoffId); - logActorWarning("project", "pruned stale handoff index row during summary listing", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId: row.handoffId - }); - continue; - } - logActorWarning("project", "failed loading handoff summary row", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId: row.handoffId, - error: resolveErrorMessage(error) - }); - } - } - - records.sort((a, b) => b.updatedAt - a.updatedAt); - return records; - }, - - async getHandoffEnriched(c: any, cmd: GetHandoffEnrichedCommand): Promise { - await ensureHandoffIndexHydratedForRead(c); - - const row = await c.db - .select({ handoffId: handoffIndex.handoffId }) - .from(handoffIndex) - .where(eq(handoffIndex.handoffId, cmd.handoffId)) - .get(); - if (!row) { - throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`); - } - - try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, cmd.handoffId); - const record = await h.get(); - return await enrichHandoffRecord(c, record); - } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, cmd.handoffId); - throw new Error(`Unknown handoff in repo ${c.state.repoId}: ${cmd.handoffId}`); - } - throw error; - } - }, - - async getRepoOverview(c: any, _cmd?: RepoOverviewCommand): Promise { - const localPath = await ensureProjectReadyForRead(c); - await ensureHandoffIndexHydratedForRead(c); - await forceProjectSync(c, localPath); - - const { driver } = getActorRuntimeContext(); - const now = Date.now(); - const baseRef = await driver.git.remoteDefaultBaseRef(localPath).catch(() => null); - const stackAvailable = await driver.stack.available(localPath).catch(() => false); - - const branchRowsRaw = await c.db - .select({ - branchName: branches.branchName, - commitSha: branches.commitSha, - parentBranch: branches.parentBranch, - trackedInStack: branches.trackedInStack, - diffStat: branches.diffStat, - hasUnpushed: branches.hasUnpushed, - conflictsWithMain: branches.conflictsWithMain, - firstSeenAt: branches.firstSeenAt, - lastSeenAt: branches.lastSeenAt, - updatedAt: branches.updatedAt - }) - .from(branches) - .all(); - - const handoffRows = await c.db - .select({ - handoffId: handoffIndex.handoffId, - branchName: handoffIndex.branchName, - updatedAt: handoffIndex.updatedAt - }) - .from(handoffIndex) - .all(); - - const handoffMetaByBranch = new Map< - string, - { handoffId: string; title: string | null; status: HandoffRecord["status"] | null; updatedAt: number } - >(); - - for (const row of handoffRows) { - if (!row.branchName) { - continue; - } - try { - const h = getHandoff(c, c.state.workspaceId, c.state.repoId, row.handoffId); - const record = await h.get(); - handoffMetaByBranch.set(row.branchName, { - handoffId: row.handoffId, - title: record.title ?? null, - status: record.status, - updatedAt: record.updatedAt - }); - } catch (error) { - if (isStaleHandoffReferenceError(error)) { - await deleteStaleHandoffIndexRow(c, row.handoffId); - logActorWarning("project", "pruned stale handoff index row during repo overview", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId: row.handoffId, - branchName: row.branchName - }); - continue; - } - logActorWarning("project", "failed loading handoff while building repo overview", { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - handoffId: row.handoffId, - branchName: row.branchName, - error: resolveErrorMessage(error) - }); - } - } - - const prRows = await c.db - .select({ - branchName: prCache.branchName, - prNumber: prCache.prNumber, - prState: prCache.state, - prUrl: prCache.prUrl, - ciStatus: prCache.ciStatus, - reviewStatus: prCache.reviewStatus, - reviewer: prCache.reviewer - }) - .from(prCache) - .all(); - const prByBranch = new Map(prRows.map((row) => [row.branchName, row])); - - const combinedRows = sortBranchesForOverview( - branchRowsRaw.map((row) => ({ - branchName: row.branchName, - parentBranch: row.parentBranch ?? null, - updatedAt: row.updatedAt - })) - ); - - const detailByBranch = new Map(branchRowsRaw.map((row) => [row.branchName, row])); - - const branchRows = combinedRows.map((ordering) => { - const row = detailByBranch.get(ordering.branchName)!; - const handoffMeta = handoffMetaByBranch.get(row.branchName); - const pr = prByBranch.get(row.branchName); - return { - branchName: row.branchName, - commitSha: row.commitSha, - parentBranch: row.parentBranch ?? null, - trackedInStack: Boolean(row.trackedInStack), - diffStat: row.diffStat ?? null, - hasUnpushed: Boolean(row.hasUnpushed), - conflictsWithMain: Boolean(row.conflictsWithMain), - handoffId: handoffMeta?.handoffId ?? null, - handoffTitle: handoffMeta?.title ?? null, - handoffStatus: handoffMeta?.status ?? null, - prNumber: pr?.prNumber ?? null, - prState: pr?.prState ?? null, - prUrl: pr?.prUrl ?? null, - ciStatus: pr?.ciStatus ?? null, - reviewStatus: pr?.reviewStatus ?? null, - reviewer: pr?.reviewer ?? null, - firstSeenAt: row.firstSeenAt ?? null, - lastSeenAt: row.lastSeenAt ?? null, - updatedAt: Math.max(row.updatedAt, handoffMeta?.updatedAt ?? 0) - }; - }); - - return { - workspaceId: c.state.workspaceId, - repoId: c.state.repoId, - remoteUrl: c.state.remoteUrl, - baseRef, - stackAvailable, - fetchedAt: now, - branches: branchRows - }; - }, - - async getPullRequestForBranch( - c: any, - cmd: GetPullRequestForBranchCommand, - ): Promise<{ number: number; status: "draft" | "ready" } | null> { - const branchName = cmd.branchName?.trim(); - if (!branchName) { - return null; - } - - const pr = await c.db - .select({ - prNumber: prCache.prNumber, - prState: prCache.state, - }) - .from(prCache) - .where(eq(prCache.branchName, branchName)) - .get(); - - if (!pr?.prNumber) { - return null; - } - - return { - number: pr.prNumber, - status: pr.prState === "draft" ? "draft" : "ready", - }; - }, - - async runRepoStackAction(c: any, cmd: RunRepoStackActionCommand): Promise { - const self = selfProject(c); - return expectQueueResponse( - await self.send(projectWorkflowQueueName("project.command.runRepoStackAction"), cmd, { - wait: true, - timeout: 12 * 60_000, - }), - ); - }, - - async applyPrSyncResult(c: any, body: PrSyncResult): Promise { - const self = selfProject(c); - await self.send(projectWorkflowQueueName("project.command.applyPrSyncResult"), body, { - wait: true, - timeout: 5 * 60_000, - }); - }, - - async applyBranchSyncResult(c: any, body: BranchSyncResult): Promise { - const self = selfProject(c); - await self.send(projectWorkflowQueueName("project.command.applyBranchSyncResult"), body, { - wait: true, - timeout: 5 * 60_000, - }); - } -}; diff --git a/factory/packages/backend/src/actors/project/db/db.ts b/factory/packages/backend/src/actors/project/db/db.ts deleted file mode 100644 index 20e8b22f..00000000 --- a/factory/packages/backend/src/actors/project/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const projectDb = actorSqliteDb({ - actorName: "project", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/project/db/drizzle.config.ts b/factory/packages/backend/src/actors/project/db/drizzle.config.ts deleted file mode 100644 index c726278e..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "rivetkit/db/drizzle"; - -export default defineConfig({ - out: "./src/actors/project/db/drizzle", - schema: "./src/actors/project/db/schema.ts", -}); - diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql b/factory/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql deleted file mode 100644 index 9019a38b..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle/0000_stormy_the_hunter.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE `branches` ( - `branch_name` text PRIMARY KEY NOT NULL, - `commit_sha` text NOT NULL, - `worktree_path` text, - `parent_branch` text, - `diff_stat` text, - `has_unpushed` integer, - `conflicts_with_main` integer, - `first_seen_at` integer, - `last_seen_at` integer, - `updated_at` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE `pr_cache` ( - `branch_name` text PRIMARY KEY NOT NULL, - `pr_number` integer NOT NULL, - `state` text NOT NULL, - `title` text NOT NULL, - `pr_url` text, - `pr_author` text, - `is_draft` integer, - `ci_status` text, - `review_status` text, - `reviewer` text, - `fetched_at` integer, - `updated_at` integer NOT NULL -); diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql b/factory/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql deleted file mode 100644 index fde2b981..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle/0001_wild_carlie_cooper.sql +++ /dev/null @@ -1,7 +0,0 @@ -CREATE TABLE `repo_meta` ( - `id` integer PRIMARY KEY NOT NULL, - `remote_url` text NOT NULL, - `updated_at` integer NOT NULL -); ---> statement-breakpoint -ALTER TABLE `branches` DROP COLUMN `worktree_path`; \ No newline at end of file diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql b/factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql deleted file mode 100644 index 1ecd2baa..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle/0002_far_war_machine.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE `handoff_index` ( - `handoff_id` text PRIMARY KEY NOT NULL, - `branch_name` text, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); diff --git a/factory/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql b/factory/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql deleted file mode 100644 index 62e5e530..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle/0003_busy_legacy.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE `branches` ADD `tracked_in_stack` integer; diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json b/factory/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json deleted file mode 100644 index f723afa8..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,192 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "03d97613-0108-4197-8660-5f2af5409fe6", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "branches": { - "name": "branches", - "columns": { - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "commit_sha": { - "name": "commit_sha", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "worktree_path": { - "name": "worktree_path", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "parent_branch": { - "name": "parent_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "diff_stat": { - "name": "diff_stat", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "has_unpushed": { - "name": "has_unpushed", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "conflicts_with_main": { - "name": "conflicts_with_main", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "first_seen_at": { - "name": "first_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pr_cache": { - "name": "pr_cache", - "columns": { - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "pr_number": { - "name": "pr_number", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "pr_url": { - "name": "pr_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pr_author": { - "name": "pr_author", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_draft": { - "name": "is_draft", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "ci_status": { - "name": "ci_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "review_status": { - "name": "review_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "reviewer": { - "name": "reviewer", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fetched_at": { - "name": "fetched_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json b/factory/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json deleted file mode 100644 index c4193257..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,216 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "e6d294b6-27ce-424b-a3b3-c100b42e628b", - "prevId": "03d97613-0108-4197-8660-5f2af5409fe6", - "tables": { - "branches": { - "name": "branches", - "columns": { - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "commit_sha": { - "name": "commit_sha", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "parent_branch": { - "name": "parent_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "diff_stat": { - "name": "diff_stat", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "has_unpushed": { - "name": "has_unpushed", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "conflicts_with_main": { - "name": "conflicts_with_main", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "first_seen_at": { - "name": "first_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pr_cache": { - "name": "pr_cache", - "columns": { - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "pr_number": { - "name": "pr_number", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "pr_url": { - "name": "pr_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pr_author": { - "name": "pr_author", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_draft": { - "name": "is_draft", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "ci_status": { - "name": "ci_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "review_status": { - "name": "review_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "reviewer": { - "name": "reviewer", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fetched_at": { - "name": "fetched_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "repo_meta": { - "name": "repo_meta", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "remote_url": { - "name": "remote_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json b/factory/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json deleted file mode 100644 index 0a3fb90a..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle/meta/0002_snapshot.json +++ /dev/null @@ -1,254 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "ac89870f-1630-4a16-9606-7b1225f6da8a", - "prevId": "e6d294b6-27ce-424b-a3b3-c100b42e628b", - "tables": { - "branches": { - "name": "branches", - "columns": { - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "commit_sha": { - "name": "commit_sha", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "parent_branch": { - "name": "parent_branch", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "diff_stat": { - "name": "diff_stat", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "has_unpushed": { - "name": "has_unpushed", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "conflicts_with_main": { - "name": "conflicts_with_main", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "first_seen_at": { - "name": "first_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "last_seen_at": { - "name": "last_seen_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "handoff_index": { - "name": "handoff_index", - "columns": { - "handoff_id": { - "name": "handoff_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "pr_cache": { - "name": "pr_cache", - "columns": { - "branch_name": { - "name": "branch_name", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "pr_number": { - "name": "pr_number", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "pr_url": { - "name": "pr_url", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "pr_author": { - "name": "pr_author", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "is_draft": { - "name": "is_draft", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "ci_status": { - "name": "ci_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "review_status": { - "name": "review_status", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "reviewer": { - "name": "reviewer", - "type": "text", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "fetched_at": { - "name": "fetched_at", - "type": "integer", - "primaryKey": false, - "notNull": false, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "repo_meta": { - "name": "repo_meta", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "remote_url": { - "name": "remote_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/project/db/drizzle/meta/_journal.json b/factory/packages/backend/src/actors/project/db/drizzle/meta/_journal.json deleted file mode 100644 index 1a5b47d9..00000000 --- a/factory/packages/backend/src/actors/project/db/drizzle/meta/_journal.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1770924376062, - "tag": "0000_stormy_the_hunter", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1770947252449, - "tag": "0001_wild_carlie_cooper", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1771276338465, - "tag": "0002_far_war_machine", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1771369000000, - "tag": "0003_busy_legacy", - "breakpoints": true - } - ] -} diff --git a/factory/packages/backend/src/actors/project/db/migrations.ts b/factory/packages/backend/src/actors/project/db/migrations.ts deleted file mode 100644 index 06844f5b..00000000 --- a/factory/packages/backend/src/actors/project/db/migrations.ts +++ /dev/null @@ -1,81 +0,0 @@ -// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. -// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). -// Do not hand-edit this file. - -const journal = { - "entries": [ - { - "idx": 0, - "when": 1770924376062, - "tag": "0000_stormy_the_hunter", - "breakpoints": true - }, - { - "idx": 1, - "when": 1770947252449, - "tag": "0001_wild_carlie_cooper", - "breakpoints": true - }, - { - "idx": 2, - "when": 1771276338465, - "tag": "0002_far_war_machine", - "breakpoints": true - }, - { - "idx": 3, - "when": 1771369000000, - "tag": "0003_busy_legacy", - "breakpoints": true - } - ] -} as const; - -export default { - journal, - migrations: { - m0000: `CREATE TABLE \`branches\` ( - \`branch_name\` text PRIMARY KEY NOT NULL, - \`commit_sha\` text NOT NULL, - \`worktree_path\` text, - \`parent_branch\` text, - \`diff_stat\` text, - \`has_unpushed\` integer, - \`conflicts_with_main\` integer, - \`first_seen_at\` integer, - \`last_seen_at\` integer, - \`updated_at\` integer NOT NULL -); ---> statement-breakpoint -CREATE TABLE \`pr_cache\` ( - \`branch_name\` text PRIMARY KEY NOT NULL, - \`pr_number\` integer NOT NULL, - \`state\` text NOT NULL, - \`title\` text NOT NULL, - \`pr_url\` text, - \`pr_author\` text, - \`is_draft\` integer, - \`ci_status\` text, - \`review_status\` text, - \`reviewer\` text, - \`fetched_at\` integer, - \`updated_at\` integer NOT NULL -); -`, - m0001: `CREATE TABLE \`repo_meta\` ( - \`id\` integer PRIMARY KEY NOT NULL, - \`remote_url\` text NOT NULL, - \`updated_at\` integer NOT NULL -); ---> statement-breakpoint -ALTER TABLE \`branches\` DROP COLUMN \`worktree_path\`;`, - m0002: `CREATE TABLE \`handoff_index\` ( - \`handoff_id\` text PRIMARY KEY NOT NULL, - \`branch_name\` text, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); -`, - m0003: `ALTER TABLE \`branches\` ADD \`tracked_in_stack\` integer;`, - } as const -}; diff --git a/factory/packages/backend/src/actors/project/db/schema.ts b/factory/packages/backend/src/actors/project/db/schema.ts deleted file mode 100644 index 15133688..00000000 --- a/factory/packages/backend/src/actors/project/db/schema.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; - -// SQLite is per project actor instance (workspaceId+repoId), so no workspaceId/repoId columns needed. - -export const branches = sqliteTable("branches", { - branchName: text("branch_name").notNull().primaryKey(), - commitSha: text("commit_sha").notNull(), - parentBranch: text("parent_branch"), - trackedInStack: integer("tracked_in_stack"), - diffStat: text("diff_stat"), - hasUnpushed: integer("has_unpushed"), - conflictsWithMain: integer("conflicts_with_main"), - firstSeenAt: integer("first_seen_at"), - lastSeenAt: integer("last_seen_at"), - updatedAt: integer("updated_at").notNull(), -}); - -export const repoMeta = sqliteTable("repo_meta", { - id: integer("id").primaryKey(), - remoteUrl: text("remote_url").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const prCache = sqliteTable("pr_cache", { - branchName: text("branch_name").notNull().primaryKey(), - prNumber: integer("pr_number").notNull(), - state: text("state").notNull(), - title: text("title").notNull(), - prUrl: text("pr_url"), - prAuthor: text("pr_author"), - isDraft: integer("is_draft"), - ciStatus: text("ci_status"), - reviewStatus: text("review_status"), - reviewer: text("reviewer"), - fetchedAt: integer("fetched_at"), - updatedAt: integer("updated_at").notNull(), -}); - -export const handoffIndex = sqliteTable("handoff_index", { - handoffId: text("handoff_id").notNull().primaryKey(), - branchName: text("branch_name"), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull() -}); diff --git a/factory/packages/backend/src/actors/project/index.ts b/factory/packages/backend/src/actors/project/index.ts deleted file mode 100644 index cd338854..00000000 --- a/factory/packages/backend/src/actors/project/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { actor, queue } from "rivetkit"; -import { workflow } from "rivetkit/workflow"; -import { projectDb } from "./db/db.js"; -import { PROJECT_QUEUE_NAMES, projectActions, runProjectWorkflow } from "./actions.js"; - -export interface ProjectInput { - workspaceId: string; - repoId: string; - remoteUrl: string; -} - -export const project = actor({ - db: projectDb, - queues: Object.fromEntries(PROJECT_QUEUE_NAMES.map((name) => [name, queue()])), - options: { - actionTimeout: 5 * 60_000, - }, - createState: (_c, input: ProjectInput) => ({ - workspaceId: input.workspaceId, - repoId: input.repoId, - remoteUrl: input.remoteUrl, - localPath: null as string | null, - syncActorsStarted: false, - handoffIndexHydrated: false - }), - actions: projectActions, - run: workflow(runProjectWorkflow), -}); diff --git a/factory/packages/backend/src/actors/project/stack-model.ts b/factory/packages/backend/src/actors/project/stack-model.ts deleted file mode 100644 index 78c9888f..00000000 --- a/factory/packages/backend/src/actors/project/stack-model.ts +++ /dev/null @@ -1,69 +0,0 @@ -export interface StackEntry { - branchName: string; - parentBranch: string | null; -} - -export interface OrderedBranchRow { - branchName: string; - parentBranch: string | null; - updatedAt: number; -} - -export function normalizeParentBranch(branchName: string, parentBranch: string | null | undefined): string | null { - const parent = parentBranch?.trim() || null; - if (!parent || parent === branchName) { - return null; - } - return parent; -} - -export function parentLookupFromStack(entries: StackEntry[]): Map { - const lookup = new Map(); - for (const entry of entries) { - const branchName = entry.branchName.trim(); - if (!branchName) { - continue; - } - lookup.set(branchName, normalizeParentBranch(branchName, entry.parentBranch)); - } - return lookup; -} - -export function sortBranchesForOverview(rows: OrderedBranchRow[]): OrderedBranchRow[] { - const byName = new Map(rows.map((row) => [row.branchName, row])); - const depthMemo = new Map(); - const computing = new Set(); - - const depthFor = (branchName: string): number => { - const cached = depthMemo.get(branchName); - if (cached != null) { - return cached; - } - if (computing.has(branchName)) { - return 999; - } - - computing.add(branchName); - const row = byName.get(branchName); - const parent = row?.parentBranch; - let depth = 0; - if (parent && parent !== branchName && byName.has(parent)) { - depth = Math.min(998, depthFor(parent) + 1); - } - computing.delete(branchName); - depthMemo.set(branchName, depth); - return depth; - }; - - return [...rows].sort((a, b) => { - const da = depthFor(a.branchName); - const db = depthFor(b.branchName); - if (da !== db) { - return da - db; - } - if (a.updatedAt !== b.updatedAt) { - return b.updatedAt - a.updatedAt; - } - return a.branchName.localeCompare(b.branchName); - }); -} diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/db.ts b/factory/packages/backend/src/actors/sandbox-instance/db/db.ts deleted file mode 100644 index b42ab0de..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const sandboxInstanceDb = actorSqliteDb({ - actorName: "sandbox-instance", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle.config.ts b/factory/packages/backend/src/actors/sandbox-instance/db/drizzle.config.ts deleted file mode 100644 index 473b2c67..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "rivetkit/db/drizzle"; - -export default defineConfig({ - out: "./src/actors/sandbox-instance/db/drizzle", - schema: "./src/actors/sandbox-instance/db/schema.ts", -}); - diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0000_broad_tyrannus.sql b/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0000_broad_tyrannus.sql deleted file mode 100644 index 05344462..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0000_broad_tyrannus.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE `sandbox_instance` ( - `id` integer PRIMARY KEY NOT NULL, - `metadata_json` text NOT NULL, - `status` text NOT NULL, - `updated_at` integer NOT NULL -); diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0001_sandbox_sessions.sql b/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0001_sandbox_sessions.sql deleted file mode 100644 index ae95c0e9..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/0001_sandbox_sessions.sql +++ /dev/null @@ -1,27 +0,0 @@ -CREATE TABLE `sandbox_sessions` ( - `id` text PRIMARY KEY NOT NULL, - `agent` text NOT NULL, - `agent_session_id` text NOT NULL, - `last_connection_id` text NOT NULL, - `created_at` integer NOT NULL, - `destroyed_at` integer, - `session_init_json` text -); ---> statement-breakpoint - -CREATE TABLE `sandbox_session_events` ( - `id` text PRIMARY KEY NOT NULL, - `session_id` text NOT NULL, - `event_index` integer NOT NULL, - `created_at` integer NOT NULL, - `connection_id` text NOT NULL, - `sender` text NOT NULL, - `payload_json` text NOT NULL -); ---> statement-breakpoint - -CREATE INDEX `sandbox_sessions_created_at_idx` ON `sandbox_sessions` (`created_at`); ---> statement-breakpoint -CREATE INDEX `sandbox_session_events_session_id_event_index_idx` ON `sandbox_session_events` (`session_id`,`event_index`); ---> statement-breakpoint -CREATE INDEX `sandbox_session_events_session_id_created_at_idx` ON `sandbox_session_events` (`session_id`,`created_at`); diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/0000_snapshot.json b/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 7201101a..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,56 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "ef8a919c-64f0-46d9-b8ed-a15f039e6ba7", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "sandbox_instance": { - "name": "sandbox_instance", - "columns": { - "id": { - "name": "id", - "type": "integer", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "metadata_json": { - "name": "metadata_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/_journal.json b/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/_journal.json deleted file mode 100644 index b3df4300..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/drizzle/meta/_journal.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1770924375604, - "tag": "0000_broad_tyrannus", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1776482400000, - "tag": "0001_sandbox_sessions", - "breakpoints": true - } - ] -} diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/migrations.ts b/factory/packages/backend/src/actors/sandbox-instance/db/migrations.ts deleted file mode 100644 index 931fc6e9..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/migrations.ts +++ /dev/null @@ -1,61 +0,0 @@ -// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. -// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). -// Do not hand-edit this file. - -const journal = { - "entries": [ - { - "idx": 0, - "when": 1770924375604, - "tag": "0000_broad_tyrannus", - "breakpoints": true - }, - { - "idx": 1, - "when": 1776482400000, - "tag": "0001_sandbox_sessions", - "breakpoints": true - } - ] -} as const; - -export default { - journal, - migrations: { - m0000: `CREATE TABLE \`sandbox_instance\` ( - \`id\` integer PRIMARY KEY NOT NULL, - \`metadata_json\` text NOT NULL, - \`status\` text NOT NULL, - \`updated_at\` integer NOT NULL -); -`, - m0001: `CREATE TABLE \`sandbox_sessions\` ( - \`id\` text PRIMARY KEY NOT NULL, - \`agent\` text NOT NULL, - \`agent_session_id\` text NOT NULL, - \`last_connection_id\` text NOT NULL, - \`created_at\` integer NOT NULL, - \`destroyed_at\` integer, - \`session_init_json\` text -); ---> statement-breakpoint - -CREATE TABLE \`sandbox_session_events\` ( - \`id\` text PRIMARY KEY NOT NULL, - \`session_id\` text NOT NULL, - \`event_index\` integer NOT NULL, - \`created_at\` integer NOT NULL, - \`connection_id\` text NOT NULL, - \`sender\` text NOT NULL, - \`payload_json\` text NOT NULL -); ---> statement-breakpoint - -CREATE INDEX \`sandbox_sessions_created_at_idx\` ON \`sandbox_sessions\` (\`created_at\`); ---> statement-breakpoint -CREATE INDEX \`sandbox_session_events_session_id_event_index_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`event_index\`); ---> statement-breakpoint -CREATE INDEX \`sandbox_session_events_session_id_created_at_idx\` ON \`sandbox_session_events\` (\`session_id\`,\`created_at\`); -`, - } as const -}; diff --git a/factory/packages/backend/src/actors/sandbox-instance/db/schema.ts b/factory/packages/backend/src/actors/sandbox-instance/db/schema.ts deleted file mode 100644 index fc26b268..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/db/schema.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; - -// SQLite is per sandbox-instance actor instance. -export const sandboxInstance = sqliteTable("sandbox_instance", { - id: integer("id").primaryKey(), - metadataJson: text("metadata_json").notNull(), - status: text("status").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -// Persist sandbox-agent sessions/events in SQLite instead of actor state so they survive -// serverless actor evictions and backend restarts. -export const sandboxSessions = sqliteTable("sandbox_sessions", { - id: text("id").notNull().primaryKey(), - agent: text("agent").notNull(), - agentSessionId: text("agent_session_id").notNull(), - lastConnectionId: text("last_connection_id").notNull(), - createdAt: integer("created_at").notNull(), - destroyedAt: integer("destroyed_at"), - sessionInitJson: text("session_init_json"), -}); - -export const sandboxSessionEvents = sqliteTable("sandbox_session_events", { - id: text("id").notNull().primaryKey(), - sessionId: text("session_id").notNull(), - eventIndex: integer("event_index").notNull(), - createdAt: integer("created_at").notNull(), - connectionId: text("connection_id").notNull(), - sender: text("sender").notNull(), - payloadJson: text("payload_json").notNull(), -}); diff --git a/factory/packages/backend/src/actors/sandbox-instance/index.ts b/factory/packages/backend/src/actors/sandbox-instance/index.ts deleted file mode 100644 index e20b86ae..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/index.ts +++ /dev/null @@ -1,615 +0,0 @@ -import { setTimeout as delay } from "node:timers/promises"; -import { eq } from "drizzle-orm"; -import { actor, queue } from "rivetkit"; -import { Loop, workflow } from "rivetkit/workflow"; -import type { ProviderId } from "@openhandoff/shared"; -import type { SessionEvent, SessionRecord } from "sandbox-agent"; -import { sandboxInstanceDb } from "./db/db.js"; -import { sandboxInstance as sandboxInstanceTable } from "./db/schema.js"; -import { SandboxInstancePersistDriver } from "./persist.js"; -import { getActorRuntimeContext } from "../context.js"; -import { selfSandboxInstance } from "../handles.js"; -import { logActorWarning, resolveErrorMessage } from "../logging.js"; -import { expectQueueResponse } from "../../services/queue.js"; - -export interface SandboxInstanceInput { - workspaceId: string; - providerId: ProviderId; - sandboxId: string; -} - -const SANDBOX_ROW_ID = 1; -const CREATE_SESSION_MAX_ATTEMPTS = 3; -const CREATE_SESSION_RETRY_BASE_MS = 1_000; -const CREATE_SESSION_STEP_TIMEOUT_MS = 10 * 60_000; - -function normalizeStatusFromEventPayload( - payload: unknown, -): "running" | "idle" | "error" | null { - if (payload && typeof payload === "object") { - const envelope = payload as { - error?: unknown; - method?: unknown; - result?: unknown; - }; - - if (envelope.error) { - return "error"; - } - - if (envelope.result && typeof envelope.result === "object") { - const stopReason = (envelope.result as { stopReason?: unknown }).stopReason; - if (typeof stopReason === "string" && stopReason.length > 0) { - return "idle"; - } - } - - if (typeof envelope.method === "string") { - const lowered = envelope.method.toLowerCase(); - if (lowered.includes("error") || lowered.includes("failed")) { - return "error"; - } - if ( - lowered.includes("ended") || - lowered.includes("complete") || - lowered.includes("stopped") - ) { - return "idle"; - } - } - } - - return null; -} - -function stringifyJson(value: unknown): string { - return JSON.stringify(value, (_key, item) => { - if (typeof item === "bigint") return item.toString(); - return item; - }); -} - -function parseMetadata(metadataJson: string): Record { - try { - const parsed = JSON.parse(metadataJson) as unknown; - if (parsed && typeof parsed === "object") return parsed as Record; - return {}; - } catch { - return {}; - } -} - -async function loadPersistedAgentConfig(c: any): Promise<{ endpoint: string; token?: string } | null> { - try { - const row = await c.db - .select({ metadataJson: sandboxInstanceTable.metadataJson }) - .from(sandboxInstanceTable) - .where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID)) - .get(); - - if (row?.metadataJson) { - const metadata = parseMetadata(row.metadataJson); - const endpoint = typeof metadata.agentEndpoint === "string" ? metadata.agentEndpoint.trim() : ""; - const token = typeof metadata.agentToken === "string" ? metadata.agentToken.trim() : ""; - if (endpoint) { - return token ? { endpoint, token } : { endpoint }; - } - } - } catch { - return null; - } - return null; -} - -async function loadFreshDaytonaAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> { - const { config, driver } = getActorRuntimeContext(); - const daytona = driver.daytona.createClient({ - apiUrl: config.providers.daytona.endpoint, - apiKey: config.providers.daytona.apiKey, - }); - const sandbox = await daytona.getSandbox(c.state.sandboxId); - const state = String(sandbox.state ?? "unknown").toLowerCase(); - if (state !== "started" && state !== "running") { - await daytona.startSandbox(c.state.sandboxId, 60); - } - const preview = await daytona.getPreviewEndpoint(c.state.sandboxId, 2468); - return preview.token ? { endpoint: preview.url, token: preview.token } : { endpoint: preview.url }; -} - -async function loadFreshProviderAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> { - const { providers } = getActorRuntimeContext(); - const provider = providers.get(c.state.providerId); - return await provider.ensureSandboxAgent({ - workspaceId: c.state.workspaceId, - sandboxId: c.state.sandboxId, - }); -} - -async function loadAgentConfig(c: any): Promise<{ endpoint: string; token?: string }> { - const persisted = await loadPersistedAgentConfig(c); - if (c.state.providerId === "daytona") { - // Keep one stable signed preview endpoint per sandbox-instance actor. - // Rotating preview URLs on every call fragments SDK client state (sessions/events) - // because client caching keys by endpoint. - if (persisted) { - return persisted; - } - return await loadFreshDaytonaAgentConfig(c); - } - - // Local sandboxes are tied to the current backend process, so the sandbox-agent - // token can rotate on restart. Always refresh from the provider instead of - // trusting persisted metadata. - if (c.state.providerId === "local") { - return await loadFreshProviderAgentConfig(c); - } - - if (persisted) { - return persisted; - } - - return await loadFreshProviderAgentConfig(c); -} - -async function derivePersistedSessionStatus( - persist: SandboxInstancePersistDriver, - sessionId: string, -): Promise<{ id: string; status: "running" | "idle" | "error" }> { - const session = await persist.getSession(sessionId); - if (!session) { - return { id: sessionId, status: "error" }; - } - - if (session.destroyedAt) { - return { id: sessionId, status: "idle" }; - } - - const events = await persist.listEvents({ - sessionId, - limit: 25, - }); - - for (let index = events.items.length - 1; index >= 0; index -= 1) { - const event = events.items[index]; - if (!event) continue; - const status = normalizeStatusFromEventPayload(event.payload); - if (status) { - return { id: sessionId, status }; - } - } - - return { id: sessionId, status: "idle" }; -} - -function isTransientSessionCreateError(detail: string): boolean { - const lowered = detail.toLowerCase(); - if ( - lowered.includes("timed out") || - lowered.includes("timeout") || - lowered.includes("504") || - lowered.includes("gateway timeout") - ) { - // ACP timeout errors are expensive and usually deterministic for the same - // request; immediate retries spawn additional sessions/processes and make - // recovery harder. - return false; - } - - return ( - lowered.includes("502") || - lowered.includes("503") || - lowered.includes("bad gateway") || - lowered.includes("econnreset") || - lowered.includes("econnrefused") - ); -} - -interface EnsureSandboxCommand { - metadata: Record; - status: string; - agentEndpoint?: string; - agentToken?: string; -} - -interface HealthSandboxCommand { - status: string; - message: string; -} - -interface CreateSessionCommand { - prompt: string; - cwd?: string; - agent?: "claude" | "codex" | "opencode"; -} - -interface CreateSessionResult { - id: string | null; - status: "running" | "idle" | "error"; - error?: string; -} - -interface ListSessionsCommand { - cursor?: string; - limit?: number; -} - -interface ListSessionEventsCommand { - sessionId: string; - cursor?: string; - limit?: number; -} - -interface SendPromptCommand { - sessionId: string; - prompt: string; - notification?: boolean; -} - -interface SessionStatusCommand { - sessionId: string; -} - -interface SessionControlCommand { - sessionId: string; -} - -const SANDBOX_INSTANCE_QUEUE_NAMES = [ - "sandboxInstance.command.ensure", - "sandboxInstance.command.updateHealth", - "sandboxInstance.command.destroy", - "sandboxInstance.command.createSession", - "sandboxInstance.command.sendPrompt", - "sandboxInstance.command.cancelSession", - "sandboxInstance.command.destroySession", -] as const; - -type SandboxInstanceQueueName = (typeof SANDBOX_INSTANCE_QUEUE_NAMES)[number]; - -function sandboxInstanceWorkflowQueueName( - name: SandboxInstanceQueueName, -): SandboxInstanceQueueName { - return name; -} - -async function getSandboxAgentClient(c: any) { - const { driver } = getActorRuntimeContext(); - const persist = new SandboxInstancePersistDriver(c.db); - const { endpoint, token } = await loadAgentConfig(c); - return driver.sandboxAgent.createClient({ - endpoint, - token, - persist, - }); -} - -async function ensureSandboxMutation(c: any, command: EnsureSandboxCommand): Promise { - const now = Date.now(); - const metadata = { - ...command.metadata, - agentEndpoint: command.agentEndpoint ?? null, - agentToken: command.agentToken ?? null, - }; - - const metadataJson = stringifyJson(metadata); - await c.db - .insert(sandboxInstanceTable) - .values({ - id: SANDBOX_ROW_ID, - metadataJson, - status: command.status, - updatedAt: now - }) - .onConflictDoUpdate({ - target: sandboxInstanceTable.id, - set: { - metadataJson, - status: command.status, - updatedAt: now - } - }) - .run(); -} - -async function updateHealthMutation(c: any, command: HealthSandboxCommand): Promise { - await c.db - .update(sandboxInstanceTable) - .set({ - status: `${command.status}:${command.message}`, - updatedAt: Date.now() - }) - .where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID)) - .run(); -} - -async function destroySandboxMutation(c: any): Promise { - await c.db - .delete(sandboxInstanceTable) - .where(eq(sandboxInstanceTable.id, SANDBOX_ROW_ID)) - .run(); -} - -async function createSessionMutation(c: any, command: CreateSessionCommand): Promise { - let lastDetail = "sandbox-agent createSession failed"; - let attemptsMade = 0; - - for (let attempt = 1; attempt <= CREATE_SESSION_MAX_ATTEMPTS; attempt += 1) { - attemptsMade = attempt; - try { - const client = await getSandboxAgentClient(c); - - const session = await client.createSession({ - prompt: command.prompt, - cwd: command.cwd, - agent: command.agent, - }); - - return { id: session.id, status: session.status }; - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - lastDetail = detail; - const retryable = isTransientSessionCreateError(detail); - const canRetry = retryable && attempt < CREATE_SESSION_MAX_ATTEMPTS; - - if (!canRetry) { - break; - } - - const waitMs = CREATE_SESSION_RETRY_BASE_MS * attempt; - logActorWarning("sandbox-instance", "createSession transient failure; retrying", { - workspaceId: c.state.workspaceId, - providerId: c.state.providerId, - sandboxId: c.state.sandboxId, - attempt, - maxAttempts: CREATE_SESSION_MAX_ATTEMPTS, - waitMs, - error: detail - }); - await delay(waitMs); - } - } - - const attemptLabel = attemptsMade === 1 ? "attempt" : "attempts"; - return { - id: null, - status: "error", - error: `sandbox-agent createSession failed after ${attemptsMade} ${attemptLabel}: ${lastDetail}` - }; -} - -async function sendPromptMutation(c: any, command: SendPromptCommand): Promise { - const client = await getSandboxAgentClient(c); - await client.sendPrompt({ - sessionId: command.sessionId, - prompt: command.prompt, - notification: command.notification, - }); -} - -async function cancelSessionMutation(c: any, command: SessionControlCommand): Promise { - const client = await getSandboxAgentClient(c); - await client.cancelSession(command.sessionId); -} - -async function destroySessionMutation(c: any, command: SessionControlCommand): Promise { - const client = await getSandboxAgentClient(c); - await client.destroySession(command.sessionId); -} - -async function runSandboxInstanceWorkflow(ctx: any): Promise { - await ctx.loop("sandbox-instance-command-loop", async (loopCtx: any) => { - const msg = await loopCtx.queue.next("next-sandbox-instance-command", { - names: [...SANDBOX_INSTANCE_QUEUE_NAMES], - completable: true, - }); - if (!msg) { - return Loop.continue(undefined); - } - - if (msg.name === "sandboxInstance.command.ensure") { - await loopCtx.step("sandbox-instance-ensure", async () => - ensureSandboxMutation(loopCtx, msg.body as EnsureSandboxCommand), - ); - await msg.complete({ ok: true }); - return Loop.continue(undefined); - } - - if (msg.name === "sandboxInstance.command.updateHealth") { - await loopCtx.step("sandbox-instance-update-health", async () => - updateHealthMutation(loopCtx, msg.body as HealthSandboxCommand), - ); - await msg.complete({ ok: true }); - return Loop.continue(undefined); - } - - if (msg.name === "sandboxInstance.command.destroy") { - await loopCtx.step("sandbox-instance-destroy", async () => - destroySandboxMutation(loopCtx), - ); - await msg.complete({ ok: true }); - return Loop.continue(undefined); - } - - if (msg.name === "sandboxInstance.command.createSession") { - const result = await loopCtx.step({ - name: "sandbox-instance-create-session", - timeout: CREATE_SESSION_STEP_TIMEOUT_MS, - run: async () => createSessionMutation(loopCtx, msg.body as CreateSessionCommand), - }); - await msg.complete(result); - return Loop.continue(undefined); - } - - if (msg.name === "sandboxInstance.command.sendPrompt") { - await loopCtx.step("sandbox-instance-send-prompt", async () => - sendPromptMutation(loopCtx, msg.body as SendPromptCommand), - ); - await msg.complete({ ok: true }); - return Loop.continue(undefined); - } - - if (msg.name === "sandboxInstance.command.cancelSession") { - await loopCtx.step("sandbox-instance-cancel-session", async () => - cancelSessionMutation(loopCtx, msg.body as SessionControlCommand), - ); - await msg.complete({ ok: true }); - return Loop.continue(undefined); - } - - if (msg.name === "sandboxInstance.command.destroySession") { - await loopCtx.step("sandbox-instance-destroy-session", async () => - destroySessionMutation(loopCtx, msg.body as SessionControlCommand), - ); - await msg.complete({ ok: true }); - } - - return Loop.continue(undefined); - }); -} - -export const sandboxInstance = actor({ - db: sandboxInstanceDb, - queues: Object.fromEntries(SANDBOX_INSTANCE_QUEUE_NAMES.map((name) => [name, queue()])), - options: { - actionTimeout: 5 * 60_000, - }, - createState: (_c, input: SandboxInstanceInput) => ({ - workspaceId: input.workspaceId, - providerId: input.providerId, - sandboxId: input.sandboxId, - }), - actions: { - async providerState(c: any): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> { - const at = Date.now(); - const { config, driver } = getActorRuntimeContext(); - - if (c.state.providerId === "daytona") { - const daytona = driver.daytona.createClient({ - apiUrl: config.providers.daytona.endpoint, - apiKey: config.providers.daytona.apiKey, - }); - const sandbox = await daytona.getSandbox(c.state.sandboxId); - const state = String(sandbox.state ?? "unknown").toLowerCase(); - return { providerId: c.state.providerId, sandboxId: c.state.sandboxId, state, at }; - } - - return { - providerId: c.state.providerId, - sandboxId: c.state.sandboxId, - state: "unknown", - at, - }; - }, - - async ensure(c, command: EnsureSandboxCommand): Promise { - const self = selfSandboxInstance(c); - await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.ensure"), command, { - wait: true, - timeout: 60_000, - }); - }, - - async updateHealth(c, command: HealthSandboxCommand): Promise { - const self = selfSandboxInstance(c); - await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.updateHealth"), command, { - wait: true, - timeout: 60_000, - }); - }, - - async destroy(c): Promise { - const self = selfSandboxInstance(c); - await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroy"), {}, { - wait: true, - timeout: 60_000, - }); - }, - - async createSession(c: any, command: CreateSessionCommand): Promise { - const self = selfSandboxInstance(c); - return expectQueueResponse( - await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.createSession"), command, { - wait: true, - timeout: 5 * 60_000, - }), - ); - }, - - async listSessions( - c: any, - command?: ListSessionsCommand - ): Promise<{ items: SessionRecord[]; nextCursor?: string }> { - const persist = new SandboxInstancePersistDriver(c.db); - try { - const client = await getSandboxAgentClient(c); - - const page = await client.listSessions({ - cursor: command?.cursor, - limit: command?.limit, - }); - - return { - items: page.items, - nextCursor: page.nextCursor, - }; - } catch (error) { - logActorWarning("sandbox-instance", "listSessions remote read failed; using persisted fallback", { - workspaceId: c.state.workspaceId, - providerId: c.state.providerId, - sandboxId: c.state.sandboxId, - error: resolveErrorMessage(error) - }); - return await persist.listSessions({ - cursor: command?.cursor, - limit: command?.limit, - }); - } - }, - - async listSessionEvents( - c: any, - command: ListSessionEventsCommand - ): Promise<{ items: SessionEvent[]; nextCursor?: string }> { - const persist = new SandboxInstancePersistDriver(c.db); - return await persist.listEvents({ - sessionId: command.sessionId, - cursor: command.cursor, - limit: command.limit, - }); - }, - - async sendPrompt(c, command: SendPromptCommand): Promise { - const self = selfSandboxInstance(c); - await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.sendPrompt"), command, { - wait: true, - timeout: 5 * 60_000, - }); - }, - - async cancelSession(c, command: SessionControlCommand): Promise { - const self = selfSandboxInstance(c); - await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.cancelSession"), command, { - wait: true, - timeout: 60_000, - }); - }, - - async destroySession(c, command: SessionControlCommand): Promise { - const self = selfSandboxInstance(c); - await self.send(sandboxInstanceWorkflowQueueName("sandboxInstance.command.destroySession"), command, { - wait: true, - timeout: 60_000, - }); - }, - - async sessionStatus( - c, - command: SessionStatusCommand - ): Promise<{ id: string; status: "running" | "idle" | "error" }> { - return await derivePersistedSessionStatus( - new SandboxInstancePersistDriver(c.db), - command.sessionId, - ); - } - }, - run: workflow(runSandboxInstanceWorkflow), -}); diff --git a/factory/packages/backend/src/actors/sandbox-instance/persist.ts b/factory/packages/backend/src/actors/sandbox-instance/persist.ts deleted file mode 100644 index 78cbe351..00000000 --- a/factory/packages/backend/src/actors/sandbox-instance/persist.ts +++ /dev/null @@ -1,294 +0,0 @@ -import { and, asc, count, eq } from "drizzle-orm"; -import type { - ListEventsRequest, - ListPage, - ListPageRequest, - SessionEvent, - SessionPersistDriver, - SessionRecord -} from "sandbox-agent"; -import { sandboxSessionEvents, sandboxSessions } from "./db/schema.js"; - -const DEFAULT_MAX_SESSIONS = 1024; -const DEFAULT_MAX_EVENTS_PER_SESSION = 500; -const DEFAULT_LIST_LIMIT = 100; - -function normalizeCap(value: number | undefined, fallback: number): number { - if (!Number.isFinite(value) || (value ?? 0) < 1) { - return fallback; - } - return Math.floor(value as number); -} - -function parseCursor(cursor: string | undefined): number { - if (!cursor) return 0; - const parsed = Number.parseInt(cursor, 10); - if (!Number.isFinite(parsed) || parsed < 0) return 0; - return parsed; -} - -export function resolveEventListOffset(params: { - cursor?: string; - total: number; - limit: number; -}): number { - if (params.cursor != null) { - return parseCursor(params.cursor); - } - return Math.max(0, params.total - params.limit); -} - -function safeStringify(value: unknown): string { - return JSON.stringify(value, (_key, item) => { - if (typeof item === "bigint") return item.toString(); - return item; - }); -} - -function safeParseJson(value: string | null | undefined, fallback: T): T { - if (!value) return fallback; - try { - return JSON.parse(value) as T; - } catch { - return fallback; - } -} - -export interface SandboxInstancePersistDriverOptions { - maxSessions?: number; - maxEventsPerSession?: number; -} - -export class SandboxInstancePersistDriver implements SessionPersistDriver { - private readonly maxSessions: number; - private readonly maxEventsPerSession: number; - - constructor( - private readonly db: any, - options: SandboxInstancePersistDriverOptions = {} - ) { - this.maxSessions = normalizeCap(options.maxSessions, DEFAULT_MAX_SESSIONS); - this.maxEventsPerSession = normalizeCap( - options.maxEventsPerSession, - DEFAULT_MAX_EVENTS_PER_SESSION - ); - } - - async getSession(id: string): Promise { - const row = await this.db - .select({ - id: sandboxSessions.id, - agent: sandboxSessions.agent, - agentSessionId: sandboxSessions.agentSessionId, - lastConnectionId: sandboxSessions.lastConnectionId, - createdAt: sandboxSessions.createdAt, - destroyedAt: sandboxSessions.destroyedAt, - sessionInitJson: sandboxSessions.sessionInitJson, - }) - .from(sandboxSessions) - .where(eq(sandboxSessions.id, id)) - .get(); - - if (!row) return null; - - return { - id: row.id, - agent: row.agent, - agentSessionId: row.agentSessionId, - lastConnectionId: row.lastConnectionId, - createdAt: row.createdAt, - destroyedAt: row.destroyedAt ?? undefined, - sessionInit: safeParseJson(row.sessionInitJson, undefined), - }; - } - - async listSessions(request: ListPageRequest = {}): Promise> { - const offset = parseCursor(request.cursor); - const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT); - - const rows = await this.db - .select({ - id: sandboxSessions.id, - agent: sandboxSessions.agent, - agentSessionId: sandboxSessions.agentSessionId, - lastConnectionId: sandboxSessions.lastConnectionId, - createdAt: sandboxSessions.createdAt, - destroyedAt: sandboxSessions.destroyedAt, - sessionInitJson: sandboxSessions.sessionInitJson, - }) - .from(sandboxSessions) - .orderBy(asc(sandboxSessions.createdAt), asc(sandboxSessions.id)) - .limit(limit) - .offset(offset) - .all(); - - const items = rows.map((row) => ({ - id: row.id, - agent: row.agent, - agentSessionId: row.agentSessionId, - lastConnectionId: row.lastConnectionId, - createdAt: row.createdAt, - destroyedAt: row.destroyedAt ?? undefined, - sessionInit: safeParseJson(row.sessionInitJson, undefined), - })); - - const totalRow = await this.db - .select({ c: count() }) - .from(sandboxSessions) - .get(); - const total = Number(totalRow?.c ?? 0); - - const nextOffset = offset + items.length; - return { - items, - nextCursor: nextOffset < total ? String(nextOffset) : undefined, - }; - } - - async updateSession(session: SessionRecord): Promise { - const now = Date.now(); - await this.db - .insert(sandboxSessions) - .values({ - id: session.id, - agent: session.agent, - agentSessionId: session.agentSessionId, - lastConnectionId: session.lastConnectionId, - createdAt: session.createdAt ?? now, - destroyedAt: session.destroyedAt ?? null, - sessionInitJson: session.sessionInit ? safeStringify(session.sessionInit) : null, - }) - .onConflictDoUpdate({ - target: sandboxSessions.id, - set: { - agent: session.agent, - agentSessionId: session.agentSessionId, - lastConnectionId: session.lastConnectionId, - createdAt: session.createdAt ?? now, - destroyedAt: session.destroyedAt ?? null, - sessionInitJson: session.sessionInit ? safeStringify(session.sessionInit) : null, - }, - }) - .run(); - - // Evict oldest sessions beyond cap. - const totalRow = await this.db - .select({ c: count() }) - .from(sandboxSessions) - .get(); - const total = Number(totalRow?.c ?? 0); - const overflow = total - this.maxSessions; - if (overflow <= 0) return; - - const toRemove = await this.db - .select({ id: sandboxSessions.id }) - .from(sandboxSessions) - .orderBy(asc(sandboxSessions.createdAt), asc(sandboxSessions.id)) - .limit(overflow) - .all(); - - for (const row of toRemove) { - await this.db.delete(sandboxSessionEvents).where(eq(sandboxSessionEvents.sessionId, row.id)).run(); - await this.db.delete(sandboxSessions).where(eq(sandboxSessions.id, row.id)).run(); - } - } - - async listEvents(request: ListEventsRequest): Promise> { - const limit = normalizeCap(request.limit, DEFAULT_LIST_LIMIT); - const totalRow = await this.db - .select({ c: count() }) - .from(sandboxSessionEvents) - .where(eq(sandboxSessionEvents.sessionId, request.sessionId)) - .get(); - const total = Number(totalRow?.c ?? 0); - const offset = resolveEventListOffset({ - cursor: request.cursor, - total, - limit, - }); - - const rows = await this.db - .select({ - id: sandboxSessionEvents.id, - sessionId: sandboxSessionEvents.sessionId, - eventIndex: sandboxSessionEvents.eventIndex, - createdAt: sandboxSessionEvents.createdAt, - connectionId: sandboxSessionEvents.connectionId, - sender: sandboxSessionEvents.sender, - payloadJson: sandboxSessionEvents.payloadJson, - }) - .from(sandboxSessionEvents) - .where(eq(sandboxSessionEvents.sessionId, request.sessionId)) - .orderBy(asc(sandboxSessionEvents.eventIndex), asc(sandboxSessionEvents.id)) - .limit(limit) - .offset(offset) - .all(); - - const items: SessionEvent[] = rows.map((row) => ({ - id: row.id, - eventIndex: row.eventIndex, - sessionId: row.sessionId, - createdAt: row.createdAt, - connectionId: row.connectionId, - sender: row.sender as any, - payload: safeParseJson(row.payloadJson, null), - })); - - const nextOffset = offset + items.length; - return { - items, - nextCursor: nextOffset < total ? String(nextOffset) : undefined, - }; - } - - async insertEvent(event: SessionEvent): Promise { - await this.db - .insert(sandboxSessionEvents) - .values({ - id: event.id, - sessionId: event.sessionId, - eventIndex: event.eventIndex, - createdAt: event.createdAt, - connectionId: event.connectionId, - sender: event.sender, - payloadJson: safeStringify(event.payload), - }) - .onConflictDoUpdate({ - target: sandboxSessionEvents.id, - set: { - sessionId: event.sessionId, - eventIndex: event.eventIndex, - createdAt: event.createdAt, - connectionId: event.connectionId, - sender: event.sender, - payloadJson: safeStringify(event.payload), - }, - }) - .run(); - - // Trim oldest events beyond cap. - const totalRow = await this.db - .select({ c: count() }) - .from(sandboxSessionEvents) - .where(eq(sandboxSessionEvents.sessionId, event.sessionId)) - .get(); - const total = Number(totalRow?.c ?? 0); - const overflow = total - this.maxEventsPerSession; - if (overflow <= 0) return; - - const toRemove = await this.db - .select({ id: sandboxSessionEvents.id }) - .from(sandboxSessionEvents) - .where(eq(sandboxSessionEvents.sessionId, event.sessionId)) - .orderBy(asc(sandboxSessionEvents.eventIndex), asc(sandboxSessionEvents.id)) - .limit(overflow) - .all(); - - for (const row of toRemove) { - await this.db - .delete(sandboxSessionEvents) - .where(and(eq(sandboxSessionEvents.sessionId, event.sessionId), eq(sandboxSessionEvents.id, row.id))) - .run(); - } - } -} diff --git a/factory/packages/backend/src/actors/workspace/actions.ts b/factory/packages/backend/src/actors/workspace/actions.ts deleted file mode 100644 index 93acf16c..00000000 --- a/factory/packages/backend/src/actors/workspace/actions.ts +++ /dev/null @@ -1,689 +0,0 @@ -// @ts-nocheck -import { desc, eq } from "drizzle-orm"; -import { Loop } from "rivetkit/workflow"; -import type { - AddRepoInput, - CreateHandoffInput, - HandoffRecord, - HandoffSummary, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, - HistoryEvent, - HistoryQueryInput, - ListHandoffsInput, - ProviderId, - RepoOverview, - RepoStackActionInput, - RepoStackActionResult, - RepoRecord, - SwitchResult, - WorkspaceUseInput -} from "@openhandoff/shared"; -import { getActorRuntimeContext } from "../context.js"; -import { getHandoff, getOrCreateHistory, getOrCreateProject, selfWorkspace } from "../handles.js"; -import { logActorWarning, resolveErrorMessage } from "../logging.js"; -import { normalizeRemoteUrl, repoIdFromRemote } from "../../services/repo.js"; -import { handoffLookup, repos, providerProfiles } from "./db/schema.js"; -import { agentTypeForModel } from "../handoff/workbench.js"; -import { expectQueueResponse } from "../../services/queue.js"; - -interface WorkspaceState { - workspaceId: string; -} - -interface RefreshProviderProfilesCommand { - providerId?: ProviderId; -} - -interface GetHandoffInput { - workspaceId: string; - handoffId: string; -} - -interface HandoffProxyActionInput extends GetHandoffInput { - reason?: string; -} - -interface RepoOverviewInput { - workspaceId: string; - repoId: string; -} - -const WORKSPACE_QUEUE_NAMES = [ - "workspace.command.addRepo", - "workspace.command.createHandoff", - "workspace.command.refreshProviderProfiles", -] as const; - -type WorkspaceQueueName = (typeof WORKSPACE_QUEUE_NAMES)[number]; - -export { WORKSPACE_QUEUE_NAMES }; - -export function workspaceWorkflowQueueName(name: WorkspaceQueueName): WorkspaceQueueName { - return name; -} - -function assertWorkspace(c: { state: WorkspaceState }, workspaceId: string): void { - if (workspaceId !== c.state.workspaceId) { - throw new Error(`Workspace actor mismatch: actor=${c.state.workspaceId} command=${workspaceId}`); - } -} - -async function resolveRepoId(c: any, handoffId: string): Promise { - const row = await c.db - .select({ repoId: handoffLookup.repoId }) - .from(handoffLookup) - .where(eq(handoffLookup.handoffId, handoffId)) - .get(); - - if (!row) { - throw new Error(`Unknown handoff: ${handoffId} (not in lookup)`); - } - - return row.repoId; -} - -async function upsertHandoffLookupRow(c: any, handoffId: string, repoId: string): Promise { - await c.db - .insert(handoffLookup) - .values({ - handoffId, - repoId, - }) - .onConflictDoUpdate({ - target: handoffLookup.handoffId, - set: { repoId }, - }) - .run(); -} - -async function collectAllHandoffSummaries(c: any): Promise { - const repoRows = await c.db - .select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl }) - .from(repos) - .orderBy(desc(repos.updatedAt)) - .all(); - - const all: HandoffSummary[] = []; - for (const row of repoRows) { - try { - const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl); - const snapshot = await project.listHandoffSummaries({ includeArchived: true }); - all.push(...snapshot); - } catch (error) { - logActorWarning("workspace", "failed collecting handoffs for repo", { - workspaceId: c.state.workspaceId, - repoId: row.repoId, - error: resolveErrorMessage(error) - }); - } - } - - all.sort((a, b) => b.updatedAt - a.updatedAt); - return all; -} - -function repoLabelFromRemote(remoteUrl: string): string { - try { - const url = new URL(remoteUrl.startsWith("http") ? remoteUrl : `https://${remoteUrl}`); - const parts = url.pathname.replace(/\/+$/, "").split("/").filter(Boolean); - if (parts.length >= 2) { - return `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}`; - } - } catch { - // ignore - } - - return remoteUrl; -} - -async function buildWorkbenchSnapshot(c: any): Promise { - const repoRows = await c.db - .select({ repoId: repos.repoId, remoteUrl: repos.remoteUrl, updatedAt: repos.updatedAt }) - .from(repos) - .orderBy(desc(repos.updatedAt)) - .all(); - - const handoffs: Array = []; - const projects: Array = []; - for (const row of repoRows) { - const projectHandoffs: Array = []; - try { - const project = await getOrCreateProject(c, c.state.workspaceId, row.repoId, row.remoteUrl); - const summaries = await project.listHandoffSummaries({ includeArchived: true }); - for (const summary of summaries) { - try { - await upsertHandoffLookupRow(c, summary.handoffId, row.repoId); - const handoff = getHandoff(c, c.state.workspaceId, row.repoId, summary.handoffId); - const snapshot = await handoff.getWorkbench({}); - handoffs.push(snapshot); - projectHandoffs.push(snapshot); - } catch (error) { - logActorWarning("workspace", "failed collecting workbench handoff", { - workspaceId: c.state.workspaceId, - repoId: row.repoId, - handoffId: summary.handoffId, - error: resolveErrorMessage(error) - }); - } - } - - if (projectHandoffs.length > 0) { - projects.push({ - id: row.repoId, - label: repoLabelFromRemote(row.remoteUrl), - updatedAtMs: projectHandoffs[0]?.updatedAtMs ?? row.updatedAt, - handoffs: projectHandoffs.sort((left, right) => right.updatedAtMs - left.updatedAtMs), - }); - } - } catch (error) { - logActorWarning("workspace", "failed collecting workbench repo snapshot", { - workspaceId: c.state.workspaceId, - repoId: row.repoId, - error: resolveErrorMessage(error) - }); - } - } - - handoffs.sort((left, right) => right.updatedAtMs - left.updatedAtMs); - projects.sort((left, right) => right.updatedAtMs - left.updatedAtMs); - return { - workspaceId: c.state.workspaceId, - repos: repoRows.map((row) => ({ - id: row.repoId, - label: repoLabelFromRemote(row.remoteUrl) - })), - projects, - handoffs, - }; -} - -async function requireWorkbenchHandoff(c: any, handoffId: string) { - const repoId = await resolveRepoId(c, handoffId); - return getHandoff(c, c.state.workspaceId, repoId, handoffId); -} - -async function addRepoMutation(c: any, input: AddRepoInput): Promise { - assertWorkspace(c, input.workspaceId); - - const remoteUrl = normalizeRemoteUrl(input.remoteUrl); - if (!remoteUrl) { - throw new Error("remoteUrl is required"); - } - - const { driver } = getActorRuntimeContext(); - await driver.git.validateRemote(remoteUrl); - - const repoId = repoIdFromRemote(remoteUrl); - const now = Date.now(); - - await c.db - .insert(repos) - .values({ - repoId, - remoteUrl, - createdAt: now, - updatedAt: now - }) - .onConflictDoUpdate({ - target: repos.repoId, - set: { - remoteUrl, - updatedAt: now - } - }) - .run(); - - await workspaceActions.notifyWorkbenchUpdated(c); - return { - workspaceId: c.state.workspaceId, - repoId, - remoteUrl, - createdAt: now, - updatedAt: now - }; -} - -async function createHandoffMutation(c: any, input: CreateHandoffInput): Promise { - assertWorkspace(c, input.workspaceId); - - const { providers } = getActorRuntimeContext(); - const providerId = input.providerId ?? providers.defaultProviderId(); - - const repoId = input.repoId; - const repoRow = await c.db - .select({ remoteUrl: repos.remoteUrl }) - .from(repos) - .where(eq(repos.repoId, repoId)) - .get(); - if (!repoRow) { - throw new Error(`Unknown repo: ${repoId}`); - } - const remoteUrl = repoRow.remoteUrl; - - await c.db - .insert(providerProfiles) - .values({ - providerId, - profileJson: JSON.stringify({ providerId }), - updatedAt: Date.now() - }) - .onConflictDoUpdate({ - target: providerProfiles.providerId, - set: { - profileJson: JSON.stringify({ providerId }), - updatedAt: Date.now() - } - }) - .run(); - - const project = await getOrCreateProject(c, c.state.workspaceId, repoId, remoteUrl); - await project.ensure({ remoteUrl }); - - const created = await project.createHandoff({ - task: input.task, - providerId, - agentType: input.agentType ?? null, - explicitTitle: input.explicitTitle ?? null, - explicitBranchName: input.explicitBranchName ?? null, - onBranch: input.onBranch ?? null - }); - - await c.db - .insert(handoffLookup) - .values({ - handoffId: created.handoffId, - repoId - }) - .onConflictDoUpdate({ - target: handoffLookup.handoffId, - set: { repoId } - }) - .run(); - - const handoff = getHandoff(c, c.state.workspaceId, repoId, created.handoffId); - await handoff.provision({ providerId }); - - await workspaceActions.notifyWorkbenchUpdated(c); - return created; -} - -async function refreshProviderProfilesMutation(c: any, command?: RefreshProviderProfilesCommand): Promise { - const body = command ?? {}; - const { providers } = getActorRuntimeContext(); - const providerIds: ProviderId[] = body.providerId ? [body.providerId] : providers.availableProviderIds(); - - for (const providerId of providerIds) { - await c.db - .insert(providerProfiles) - .values({ - providerId, - profileJson: JSON.stringify({ providerId }), - updatedAt: Date.now() - }) - .onConflictDoUpdate({ - target: providerProfiles.providerId, - set: { - profileJson: JSON.stringify({ providerId }), - updatedAt: Date.now() - } - }) - .run(); - } -} - -export async function runWorkspaceWorkflow(ctx: any): Promise { - await ctx.loop("workspace-command-loop", async (loopCtx: any) => { - const msg = await loopCtx.queue.next("next-workspace-command", { - names: [...WORKSPACE_QUEUE_NAMES], - completable: true, - }); - if (!msg) { - return Loop.continue(undefined); - } - - if (msg.name === "workspace.command.addRepo") { - const result = await loopCtx.step({ - name: "workspace-add-repo", - timeout: 60_000, - run: async () => addRepoMutation(loopCtx, msg.body as AddRepoInput), - }); - await msg.complete(result); - return Loop.continue(undefined); - } - - if (msg.name === "workspace.command.createHandoff") { - const result = await loopCtx.step({ - name: "workspace-create-handoff", - timeout: 12 * 60_000, - run: async () => createHandoffMutation(loopCtx, msg.body as CreateHandoffInput), - }); - await msg.complete(result); - return Loop.continue(undefined); - } - - if (msg.name === "workspace.command.refreshProviderProfiles") { - await loopCtx.step("workspace-refresh-provider-profiles", async () => - refreshProviderProfilesMutation(loopCtx, msg.body as RefreshProviderProfilesCommand), - ); - await msg.complete({ ok: true }); - } - - return Loop.continue(undefined); - }); -} - -export const workspaceActions = { - async useWorkspace(c: any, input: WorkspaceUseInput): Promise<{ workspaceId: string }> { - assertWorkspace(c, input.workspaceId); - return { workspaceId: c.state.workspaceId }; - }, - - async addRepo(c: any, input: AddRepoInput): Promise { - const self = selfWorkspace(c); - return expectQueueResponse( - await self.send(workspaceWorkflowQueueName("workspace.command.addRepo"), input, { - wait: true, - timeout: 60_000, - }), - ); - }, - - async listRepos(c: any, input: WorkspaceUseInput): Promise { - assertWorkspace(c, input.workspaceId); - - const rows = await c.db - .select({ - repoId: repos.repoId, - remoteUrl: repos.remoteUrl, - createdAt: repos.createdAt, - updatedAt: repos.updatedAt - }) - .from(repos) - .orderBy(desc(repos.updatedAt)) - .all(); - - return rows.map((row) => ({ - workspaceId: c.state.workspaceId, - repoId: row.repoId, - remoteUrl: row.remoteUrl, - createdAt: row.createdAt, - updatedAt: row.updatedAt - })); - }, - - async createHandoff(c: any, input: CreateHandoffInput): Promise { - const self = selfWorkspace(c); - return expectQueueResponse( - await self.send(workspaceWorkflowQueueName("workspace.command.createHandoff"), input, { - wait: true, - timeout: 12 * 60_000, - }), - ); - }, - - async getWorkbench(c: any, input: WorkspaceUseInput): Promise { - assertWorkspace(c, input.workspaceId); - return await buildWorkbenchSnapshot(c); - }, - - async notifyWorkbenchUpdated(c: any): Promise { - c.broadcast("workbenchUpdated", { at: Date.now() }); - }, - - async createWorkbenchHandoff(c: any, input: HandoffWorkbenchCreateHandoffInput): Promise<{ handoffId: string }> { - const created = await workspaceActions.createHandoff(c, { - workspaceId: c.state.workspaceId, - repoId: input.repoId, - task: input.task, - ...(input.title ? { explicitTitle: input.title } : {}), - ...(input.branch ? { explicitBranchName: input.branch } : {}), - ...(input.model ? { agentType: agentTypeForModel(input.model) } : {}) - }); - return { handoffId: created.handoffId }; - }, - - async markWorkbenchUnread(c: any, input: HandoffWorkbenchSelectInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.markWorkbenchUnread({}); - }, - - async renameWorkbenchHandoff(c: any, input: HandoffWorkbenchRenameInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchHandoff(input); - }, - - async renameWorkbenchBranch(c: any, input: HandoffWorkbenchRenameInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchBranch(input); - }, - - async createWorkbenchSession(c: any, input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }> { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - return await handoff.createWorkbenchSession({ ...(input.model ? { model: input.model } : {}) }); - }, - - async renameWorkbenchSession(c: any, input: HandoffWorkbenchRenameSessionInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.renameWorkbenchSession(input); - }, - - async setWorkbenchSessionUnread(c: any, input: HandoffWorkbenchSetSessionUnreadInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.setWorkbenchSessionUnread(input); - }, - - async updateWorkbenchDraft(c: any, input: HandoffWorkbenchUpdateDraftInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.updateWorkbenchDraft(input); - }, - - async changeWorkbenchModel(c: any, input: HandoffWorkbenchChangeModelInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.changeWorkbenchModel(input); - }, - - async sendWorkbenchMessage(c: any, input: HandoffWorkbenchSendMessageInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.sendWorkbenchMessage(input); - }, - - async stopWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.stopWorkbenchSession(input); - }, - - async closeWorkbenchSession(c: any, input: HandoffWorkbenchTabInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.closeWorkbenchSession(input); - }, - - async publishWorkbenchPr(c: any, input: HandoffWorkbenchSelectInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.publishWorkbenchPr({}); - }, - - async revertWorkbenchFile(c: any, input: HandoffWorkbenchDiffInput): Promise { - const handoff = await requireWorkbenchHandoff(c, input.handoffId); - await handoff.revertWorkbenchFile(input); - }, - - async listHandoffs(c: any, input: ListHandoffsInput): Promise { - assertWorkspace(c, input.workspaceId); - - if (input.repoId) { - const repoRow = await c.db - .select({ remoteUrl: repos.remoteUrl }) - .from(repos) - .where(eq(repos.repoId, input.repoId)) - .get(); - if (!repoRow) { - throw new Error(`Unknown repo: ${input.repoId}`); - } - - const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl); - return await project.listHandoffSummaries({ includeArchived: true }); - } - - return await collectAllHandoffSummaries(c); - }, - - async getRepoOverview(c: any, input: RepoOverviewInput): Promise { - assertWorkspace(c, input.workspaceId); - - const repoRow = await c.db - .select({ remoteUrl: repos.remoteUrl }) - .from(repos) - .where(eq(repos.repoId, input.repoId)) - .get(); - if (!repoRow) { - throw new Error(`Unknown repo: ${input.repoId}`); - } - - const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl); - await project.ensure({ remoteUrl: repoRow.remoteUrl }); - return await project.getRepoOverview({}); - }, - - async runRepoStackAction(c: any, input: RepoStackActionInput): Promise { - assertWorkspace(c, input.workspaceId); - - const repoRow = await c.db - .select({ remoteUrl: repos.remoteUrl }) - .from(repos) - .where(eq(repos.repoId, input.repoId)) - .get(); - if (!repoRow) { - throw new Error(`Unknown repo: ${input.repoId}`); - } - - const project = await getOrCreateProject(c, c.state.workspaceId, input.repoId, repoRow.remoteUrl); - await project.ensure({ remoteUrl: repoRow.remoteUrl }); - return await project.runRepoStackAction({ - action: input.action, - branchName: input.branchName, - parentBranch: input.parentBranch - }); - }, - - async switchHandoff(c: any, handoffId: string): Promise { - const repoId = await resolveRepoId(c, handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, handoffId); - const record = await h.get(); - const switched = await h.switch(); - - return { - workspaceId: c.state.workspaceId, - handoffId, - providerId: record.providerId, - switchTarget: switched.switchTarget - }; - }, - - async refreshProviderProfiles(c: any, command?: RefreshProviderProfilesCommand): Promise { - const self = selfWorkspace(c); - await self.send(workspaceWorkflowQueueName("workspace.command.refreshProviderProfiles"), command ?? {}, { - wait: true, - timeout: 60_000, - }); - }, - - async history(c: any, input: HistoryQueryInput): Promise { - assertWorkspace(c, input.workspaceId); - - const limit = input.limit ?? 20; - const repoRows = await c.db.select({ repoId: repos.repoId }).from(repos).all(); - - const allEvents: HistoryEvent[] = []; - - for (const row of repoRows) { - try { - const hist = await getOrCreateHistory(c, c.state.workspaceId, row.repoId); - const items = await hist.list({ - branch: input.branch, - handoffId: input.handoffId, - limit - }); - allEvents.push(...items); - } catch (error) { - logActorWarning("workspace", "history lookup failed for repo", { - workspaceId: c.state.workspaceId, - repoId: row.repoId, - error: resolveErrorMessage(error) - }); - } - } - - allEvents.sort((a, b) => b.createdAt - a.createdAt); - return allEvents.slice(0, limit); - }, - - async getHandoff(c: any, input: GetHandoffInput): Promise { - assertWorkspace(c, input.workspaceId); - - const repoId = await resolveRepoId(c, input.handoffId); - - const repoRow = await c.db - .select({ remoteUrl: repos.remoteUrl }) - .from(repos) - .where(eq(repos.repoId, repoId)) - .get(); - if (!repoRow) { - throw new Error(`Unknown repo: ${repoId}`); - } - - const project = await getOrCreateProject(c, c.state.workspaceId, repoId, repoRow.remoteUrl); - return await project.getHandoffEnriched({ handoffId: input.handoffId }); - }, - - async attachHandoff(c: any, input: HandoffProxyActionInput): Promise<{ target: string; sessionId: string | null }> { - assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); - return await h.attach({ reason: input.reason }); - }, - - async pushHandoff(c: any, input: HandoffProxyActionInput): Promise { - assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); - await h.push({ reason: input.reason }); - }, - - async syncHandoff(c: any, input: HandoffProxyActionInput): Promise { - assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); - await h.sync({ reason: input.reason }); - }, - - async mergeHandoff(c: any, input: HandoffProxyActionInput): Promise { - assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); - await h.merge({ reason: input.reason }); - }, - - async archiveHandoff(c: any, input: HandoffProxyActionInput): Promise { - assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); - await h.archive({ reason: input.reason }); - }, - - async killHandoff(c: any, input: HandoffProxyActionInput): Promise { - assertWorkspace(c, input.workspaceId); - const repoId = await resolveRepoId(c, input.handoffId); - const h = getHandoff(c, c.state.workspaceId, repoId, input.handoffId); - await h.kill({ reason: input.reason }); - } -}; diff --git a/factory/packages/backend/src/actors/workspace/db/db.ts b/factory/packages/backend/src/actors/workspace/db/db.ts deleted file mode 100644 index a573a83c..00000000 --- a/factory/packages/backend/src/actors/workspace/db/db.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { actorSqliteDb } from "../../../db/actor-sqlite.js"; -import * as schema from "./schema.js"; -import migrations from "./migrations.js"; - -export const workspaceDb = actorSqliteDb({ - actorName: "workspace", - schema, - migrations, - migrationsFolderUrl: new URL("./drizzle/", import.meta.url), -}); diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle.config.ts b/factory/packages/backend/src/actors/workspace/db/drizzle.config.ts deleted file mode 100644 index 116d6ab0..00000000 --- a/factory/packages/backend/src/actors/workspace/db/drizzle.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { defineConfig } from "rivetkit/db/drizzle"; - -export default defineConfig({ - out: "./src/actors/workspace/db/drizzle", - schema: "./src/actors/workspace/db/schema.ts", -}); - diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/0000_rare_iron_man.sql b/factory/packages/backend/src/actors/workspace/db/drizzle/0000_rare_iron_man.sql deleted file mode 100644 index 85f6bb78..00000000 --- a/factory/packages/backend/src/actors/workspace/db/drizzle/0000_rare_iron_man.sql +++ /dev/null @@ -1,5 +0,0 @@ -CREATE TABLE `provider_profiles` ( - `provider_id` text PRIMARY KEY NOT NULL, - `profile_json` text NOT NULL, - `updated_at` integer NOT NULL -); diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/0001_sleepy_lady_deathstrike.sql b/factory/packages/backend/src/actors/workspace/db/drizzle/0001_sleepy_lady_deathstrike.sql deleted file mode 100644 index d9c91650..00000000 --- a/factory/packages/backend/src/actors/workspace/db/drizzle/0001_sleepy_lady_deathstrike.sql +++ /dev/null @@ -1,6 +0,0 @@ -CREATE TABLE `repos` ( - `repo_id` text PRIMARY KEY NOT NULL, - `remote_url` text NOT NULL, - `created_at` integer NOT NULL, - `updated_at` integer NOT NULL -); diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql b/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql deleted file mode 100644 index 9e7428d0..00000000 --- a/factory/packages/backend/src/actors/workspace/db/drizzle/0002_tiny_silver_surfer.sql +++ /dev/null @@ -1,4 +0,0 @@ -CREATE TABLE `handoff_lookup` ( - `handoff_id` text PRIMARY KEY NOT NULL, - `repo_id` text NOT NULL -); diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/0000_snapshot.json b/factory/packages/backend/src/actors/workspace/db/drizzle/meta/0000_snapshot.json deleted file mode 100644 index 9f0a5d2d..00000000 --- a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/0000_snapshot.json +++ /dev/null @@ -1,49 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "a85809c0-65c2-4f99-92ed-34357c9f83d7", - "prevId": "00000000-0000-0000-0000-000000000000", - "tables": { - "provider_profiles": { - "name": "provider_profiles", - "columns": { - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "profile_json": { - "name": "profile_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/0001_snapshot.json b/factory/packages/backend/src/actors/workspace/db/drizzle/meta/0001_snapshot.json deleted file mode 100644 index b299e952..00000000 --- a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/0001_snapshot.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "version": "6", - "dialect": "sqlite", - "id": "450e2fdf-6349-482f-8a68-5bc0f0a9718a", - "prevId": "a85809c0-65c2-4f99-92ed-34357c9f83d7", - "tables": { - "provider_profiles": { - "name": "provider_profiles", - "columns": { - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "profile_json": { - "name": "profile_json", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - }, - "repos": { - "name": "repos", - "columns": { - "repo_id": { - "name": "repo_id", - "type": "text", - "primaryKey": true, - "notNull": true, - "autoincrement": false - }, - "remote_url": { - "name": "remote_url", - "type": "text", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "created_at": { - "name": "created_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - }, - "updated_at": { - "name": "updated_at", - "type": "integer", - "primaryKey": false, - "notNull": true, - "autoincrement": false - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "checkConstraints": {} - } - }, - "views": {}, - "enums": {}, - "_meta": { - "schemas": {}, - "tables": {}, - "columns": {} - }, - "internal": { - "indexes": {} - } -} \ No newline at end of file diff --git a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/_journal.json b/factory/packages/backend/src/actors/workspace/db/drizzle/meta/_journal.json deleted file mode 100644 index 48662e9e..00000000 --- a/factory/packages/backend/src/actors/workspace/db/drizzle/meta/_journal.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1770924376525, - "tag": "0000_rare_iron_man", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1770947252912, - "tag": "0001_sleepy_lady_deathstrike", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1772668800000, - "tag": "0002_tiny_silver_surfer", - "breakpoints": true - } - ] -} diff --git a/factory/packages/backend/src/actors/workspace/db/migrations.ts b/factory/packages/backend/src/actors/workspace/db/migrations.ts deleted file mode 100644 index 58e3ed55..00000000 --- a/factory/packages/backend/src/actors/workspace/db/migrations.ts +++ /dev/null @@ -1,50 +0,0 @@ -// This file is generated by src/actors/_scripts/generate-actor-migrations.ts. -// Source of truth is drizzle-kit output under ./drizzle (meta/_journal.json + *.sql). -// Do not hand-edit this file. - -const journal = { - "entries": [ - { - "idx": 0, - "when": 1770924376525, - "tag": "0000_rare_iron_man", - "breakpoints": true - }, - { - "idx": 1, - "when": 1770947252912, - "tag": "0001_sleepy_lady_deathstrike", - "breakpoints": true - }, - { - "idx": 2, - "when": 1772668800000, - "tag": "0002_tiny_silver_surfer", - "breakpoints": true - } - ] -} as const; - -export default { - journal, - migrations: { - m0000: `CREATE TABLE \`provider_profiles\` ( - \`provider_id\` text PRIMARY KEY NOT NULL, - \`profile_json\` text NOT NULL, - \`updated_at\` integer NOT NULL -); -`, - m0001: `CREATE TABLE \`repos\` ( - \`repo_id\` text PRIMARY KEY NOT NULL, - \`remote_url\` text NOT NULL, - \`created_at\` integer NOT NULL, - \`updated_at\` integer NOT NULL -); -`, - m0002: `CREATE TABLE \`handoff_lookup\` ( - \`handoff_id\` text PRIMARY KEY NOT NULL, - \`repo_id\` text NOT NULL -); -`, - } as const -}; diff --git a/factory/packages/backend/src/actors/workspace/db/schema.ts b/factory/packages/backend/src/actors/workspace/db/schema.ts deleted file mode 100644 index bd35fb2a..00000000 --- a/factory/packages/backend/src/actors/workspace/db/schema.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { integer, sqliteTable, text } from "rivetkit/db/drizzle"; - -// SQLite is per workspace actor instance, so no workspaceId column needed. -export const providerProfiles = sqliteTable("provider_profiles", { - providerId: text("provider_id").notNull().primaryKey(), - profileJson: text("profile_json").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const repos = sqliteTable("repos", { - repoId: text("repo_id").notNull().primaryKey(), - remoteUrl: text("remote_url").notNull(), - createdAt: integer("created_at").notNull(), - updatedAt: integer("updated_at").notNull(), -}); - -export const handoffLookup = sqliteTable("handoff_lookup", { - handoffId: text("handoff_id").notNull().primaryKey(), - repoId: text("repo_id").notNull(), -}); diff --git a/factory/packages/backend/src/actors/workspace/index.ts b/factory/packages/backend/src/actors/workspace/index.ts deleted file mode 100644 index edab9897..00000000 --- a/factory/packages/backend/src/actors/workspace/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { actor, queue } from "rivetkit"; -import { workflow } from "rivetkit/workflow"; -import { workspaceDb } from "./db/db.js"; -import { runWorkspaceWorkflow, WORKSPACE_QUEUE_NAMES, workspaceActions } from "./actions.js"; - -export const workspace = actor({ - db: workspaceDb, - queues: Object.fromEntries(WORKSPACE_QUEUE_NAMES.map((name) => [name, queue()])), - options: { - actionTimeout: 5 * 60_000, - }, - createState: (_c, workspaceId: string) => ({ - workspaceId - }), - actions: workspaceActions, - run: workflow(runWorkspaceWorkflow), -}); diff --git a/factory/packages/backend/src/config/backend.ts b/factory/packages/backend/src/config/backend.ts deleted file mode 100644 index 66ac3f16..00000000 --- a/factory/packages/backend/src/config/backend.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; -import { homedir } from "node:os"; -import * as toml from "@iarna/toml"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; - -export const CONFIG_PATH = `${homedir()}/.config/openhandoff/config.toml`; - -export function loadConfig(path = CONFIG_PATH): AppConfig { - if (!existsSync(path)) { - return ConfigSchema.parse({}); - } - - const raw = readFileSync(path, "utf8"); - const parsed = toml.parse(raw) as unknown; - return ConfigSchema.parse(parsed); -} - -export function saveConfig(config: AppConfig, path = CONFIG_PATH): void { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, toml.stringify(config), "utf8"); -} diff --git a/factory/packages/backend/src/config/workspace.ts b/factory/packages/backend/src/config/workspace.ts deleted file mode 100644 index a7b4010c..00000000 --- a/factory/packages/backend/src/config/workspace.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { AppConfig } from "@openhandoff/shared"; - -export function defaultWorkspace(config: AppConfig): string { - const ws = config.workspace.default.trim(); - return ws.length > 0 ? ws : "default"; -} - -export function resolveWorkspace(flagWorkspace: string | undefined, config: AppConfig): string { - if (flagWorkspace && flagWorkspace.trim().length > 0) { - return flagWorkspace.trim(); - } - return defaultWorkspace(config); -} diff --git a/factory/packages/backend/src/db/actor-sqlite.ts b/factory/packages/backend/src/db/actor-sqlite.ts deleted file mode 100644 index fdae16f6..00000000 --- a/factory/packages/backend/src/db/actor-sqlite.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { mkdirSync } from "node:fs"; -import { join } from "node:path"; -import { fileURLToPath } from "node:url"; - -import { db as kvDrizzleDb } from "rivetkit/db/drizzle"; - -// Keep this file decoupled from RivetKit's internal type export paths. -// RivetKit consumes database providers structurally. -export interface RawAccess { - execute: (query: string, ...args: unknown[]) => Promise; - close: () => Promise; -} - -export interface DatabaseProviderContext { - actorId: string; -} - -export type DatabaseProvider = { - createClient: (ctx: DatabaseProviderContext) => Promise; - onMigrate: (client: DB) => void | Promise; - onDestroy?: (client: DB) => void | Promise; -}; - -export interface ActorSqliteDbOptions> { - actorName: string; - schema?: TSchema; - migrations?: unknown; - migrationsFolderUrl: URL; - /** - * Override base directory for per-actor SQLite files. - * - * Default: `/.openhandoff/backend/sqlite` - */ - baseDir?: string; -} - -export function actorSqliteDb>( - options: ActorSqliteDbOptions -): DatabaseProvider { - const isBunRuntime = - typeof (globalThis as any).Bun !== "undefined" && typeof (process as any)?.versions?.bun === "string"; - - // Backend tests run in a Node-ish Vitest environment where `bun:sqlite` and - // Bun's sqlite-backed Drizzle driver are not supported. - // - // Additionally, RivetKit's KV-backed SQLite implementation currently has stability - // issues under Bun in this repo's setup (wa-sqlite runtime errors). Prefer Bun's - // native SQLite driver in production backend execution. - if (!isBunRuntime || process.env.VITEST || process.env.NODE_ENV === "test") { - return kvDrizzleDb({ - schema: options.schema, - migrations: options.migrations, - }) as unknown as DatabaseProvider; - } - - const baseDir = options.baseDir ?? join(process.cwd(), ".openhandoff", "backend", "sqlite"); - const migrationsFolder = fileURLToPath(options.migrationsFolderUrl); - - return { - createClient: async (ctx) => { - // Keep Bun-only module out of Vitest/Vite's static import graph. - const { Database } = await import(/* @vite-ignore */ "bun:sqlite"); - const { drizzle } = await import("drizzle-orm/bun-sqlite"); - - const dir = join(baseDir, options.actorName); - mkdirSync(dir, { recursive: true }); - - const dbPath = join(dir, `${ctx.actorId}.sqlite`); - const sqlite = new Database(dbPath); - sqlite.exec("PRAGMA journal_mode = WAL;"); - sqlite.exec("PRAGMA foreign_keys = ON;"); - - const client = drizzle({ - client: sqlite, - schema: options.schema, - }); - - return Object.assign(client, { - execute: async (query: string, ...args: unknown[]) => { - const stmt = sqlite.query(query); - try { - return stmt.all(args as never) as unknown[]; - } catch { - stmt.run(args as never); - return []; - } - }, - close: async () => { - sqlite.close(); - }, - } satisfies RawAccess); - }, - - onMigrate: async (client) => { - const { migrate } = await import("drizzle-orm/bun-sqlite/migrator"); - await migrate(client, { - migrationsFolder, - }); - }, - - onDestroy: async (client) => { - await client.close(); - }, - }; -} diff --git a/factory/packages/backend/src/driver.ts b/factory/packages/backend/src/driver.ts deleted file mode 100644 index d63a4e76..00000000 --- a/factory/packages/backend/src/driver.ts +++ /dev/null @@ -1,180 +0,0 @@ -import type { BranchSnapshot } from "./integrations/git/index.js"; -import type { PullRequestSnapshot } from "./integrations/github/index.js"; -import type { - SandboxSession, - SandboxAgentClientOptions, - SandboxSessionCreateRequest -} from "./integrations/sandbox-agent/client.js"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent"; -import type { - DaytonaClientOptions, - DaytonaCreateSandboxOptions, - DaytonaPreviewEndpoint, - DaytonaSandbox, -} from "./integrations/daytona/client.js"; -import { - validateRemote, - ensureCloned, - fetch, - listRemoteBranches, - remoteDefaultBaseRef, - revParse, - ensureRemoteBranch, - diffStatForBranch, - conflictsWithMain, -} from "./integrations/git/index.js"; -import { - gitSpiceAvailable, - gitSpiceListStack, - gitSpiceRebaseBranch, - gitSpiceReparentBranch, - gitSpiceRestackRepo, - gitSpiceRestackSubtree, - gitSpiceSyncRepo, - gitSpiceTrackBranch, -} from "./integrations/git-spice/index.js"; -import { listPullRequests, createPr } from "./integrations/github/index.js"; -import { SandboxAgentClient } from "./integrations/sandbox-agent/client.js"; -import { DaytonaClient } from "./integrations/daytona/client.js"; - -export interface GitDriver { - validateRemote(remoteUrl: string): Promise; - ensureCloned(remoteUrl: string, targetPath: string): Promise; - fetch(repoPath: string): Promise; - listRemoteBranches(repoPath: string): Promise; - remoteDefaultBaseRef(repoPath: string): Promise; - revParse(repoPath: string, ref: string): Promise; - ensureRemoteBranch(repoPath: string, branchName: string): Promise; - diffStatForBranch(repoPath: string, branchName: string): Promise; - conflictsWithMain(repoPath: string, branchName: string): Promise; -} - -export interface StackBranchSnapshot { - branchName: string; - parentBranch: string | null; -} - -export interface StackDriver { - available(repoPath: string): Promise; - listStack(repoPath: string): Promise; - syncRepo(repoPath: string): Promise; - restackRepo(repoPath: string): Promise; - restackSubtree(repoPath: string, branchName: string): Promise; - rebaseBranch(repoPath: string, branchName: string): Promise; - reparentBranch(repoPath: string, branchName: string, parentBranch: string): Promise; - trackBranch(repoPath: string, branchName: string, parentBranch: string): Promise; -} - -export interface GithubDriver { - listPullRequests(repoPath: string): Promise; - createPr( - repoPath: string, - headBranch: string, - title: string, - body?: string - ): Promise<{ number: number; url: string }>; -} - -export interface SandboxAgentClientLike { - createSession(request: string | SandboxSessionCreateRequest): Promise; - sessionStatus(sessionId: string): Promise; - listSessions(request?: ListPageRequest): Promise>; - listEvents(request: ListEventsRequest): Promise>; - sendPrompt(request: { sessionId: string; prompt: string; notification?: boolean }): Promise; - cancelSession(sessionId: string): Promise; - destroySession(sessionId: string): Promise; -} - -export interface SandboxAgentDriver { - createClient(options: SandboxAgentClientOptions): SandboxAgentClientLike; -} - -export interface DaytonaClientLike { - createSandbox(options: DaytonaCreateSandboxOptions): Promise; - getSandbox(sandboxId: string): Promise; - startSandbox(sandboxId: string, timeoutSeconds?: number): Promise; - stopSandbox(sandboxId: string, timeoutSeconds?: number): Promise; - deleteSandbox(sandboxId: string): Promise; - executeCommand(sandboxId: string, command: string): Promise<{ exitCode: number; result: string }>; - getPreviewEndpoint(sandboxId: string, port: number): Promise; -} - -export interface DaytonaDriver { - createClient(options: DaytonaClientOptions): DaytonaClientLike; -} - -export interface TmuxDriver { - setWindowStatus(branchName: string, status: string): number; -} - -export interface BackendDriver { - git: GitDriver; - stack: StackDriver; - github: GithubDriver; - sandboxAgent: SandboxAgentDriver; - daytona: DaytonaDriver; - tmux: TmuxDriver; -} - -export function createDefaultDriver(): BackendDriver { - const sandboxAgentClients = new Map(); - const daytonaClients = new Map(); - - return { - git: { - validateRemote, - ensureCloned, - fetch, - listRemoteBranches, - remoteDefaultBaseRef, - revParse, - ensureRemoteBranch, - diffStatForBranch, - conflictsWithMain, - }, - stack: { - available: gitSpiceAvailable, - listStack: gitSpiceListStack, - syncRepo: gitSpiceSyncRepo, - restackRepo: gitSpiceRestackRepo, - restackSubtree: gitSpiceRestackSubtree, - rebaseBranch: gitSpiceRebaseBranch, - reparentBranch: gitSpiceReparentBranch, - trackBranch: gitSpiceTrackBranch, - }, - github: { - listPullRequests, - createPr, - }, - sandboxAgent: { - createClient: (opts) => { - if (opts.persist) { - return new SandboxAgentClient(opts); - } - const key = `${opts.endpoint}|${opts.token ?? ""}|${opts.agent ?? ""}`; - const cached = sandboxAgentClients.get(key); - if (cached) { - return cached; - } - const created = new SandboxAgentClient(opts); - sandboxAgentClients.set(key, created); - return created; - }, - }, - daytona: { - createClient: (opts) => { - const key = `${opts.apiUrl ?? ""}|${opts.apiKey ?? ""}|${opts.target ?? ""}`; - const cached = daytonaClients.get(key); - if (cached) { - return cached; - } - const created = new DaytonaClient(opts); - daytonaClients.set(key, created); - return created; - }, - }, - tmux: { - setWindowStatus: () => 0, - }, - }; -} diff --git a/factory/packages/backend/src/index.ts b/factory/packages/backend/src/index.ts deleted file mode 100644 index cd08ab1a..00000000 --- a/factory/packages/backend/src/index.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { Hono } from "hono"; -import { cors } from "hono/cors"; -import { initActorRuntimeContext } from "./actors/context.js"; -import { registry } from "./actors/index.js"; -import { loadConfig } from "./config/backend.js"; -import { createBackends, createNotificationService } from "./notifications/index.js"; -import { createDefaultDriver } from "./driver.js"; -import { createProviderRegistry } from "./providers/index.js"; - -export interface BackendStartOptions { - host?: string; - port?: number; -} - -export async function startBackend(options: BackendStartOptions = {}): Promise { - // sandbox-agent agent plugins vary on which env var they read for OpenAI/Codex auth. - // Normalize to keep local dev + docker-compose simple. - if (!process.env.CODEX_API_KEY && process.env.OPENAI_API_KEY) { - process.env.CODEX_API_KEY = process.env.OPENAI_API_KEY; - } - - const config = loadConfig(); - config.backend.host = options.host ?? config.backend.host; - config.backend.port = options.port ?? config.backend.port; - - // Allow docker-compose/dev environments to supply provider config via env vars - // instead of writing into the container's config.toml. - const envFirst = (...keys: string[]): string | undefined => { - for (const key of keys) { - const raw = process.env[key]; - if (raw && raw.trim().length > 0) return raw.trim(); - } - return undefined; - }; - - config.providers.daytona.endpoint = - envFirst("HF_DAYTONA_ENDPOINT", "DAYTONA_ENDPOINT") ?? config.providers.daytona.endpoint; - config.providers.daytona.apiKey = - envFirst("HF_DAYTONA_API_KEY", "DAYTONA_API_KEY") ?? config.providers.daytona.apiKey; - - const driver = createDefaultDriver(); - const providers = createProviderRegistry(config, driver); - const backends = await createBackends(config.notify); - const notifications = createNotificationService(backends); - initActorRuntimeContext(config, providers, notifications, driver); - - const inner = registry.serve(); - - // Wrap in a Hono app mounted at /api/rivet to serve on the backend port. - // Uses Bun.serve — cannot use @hono/node-server because it conflicts with - // RivetKit's internal Bun.serve manager server (Bun bug: mixing Node HTTP - // server and Bun.serve in the same process breaks Bun.serve's fetch handler). - const app = new Hono(); - app.use( - "/api/rivet/*", - cors({ - origin: "*", - allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"], - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - exposeHeaders: ["Content-Type"], - }) - ); - app.use( - "/api/rivet", - cors({ - origin: "*", - allowHeaders: ["Content-Type", "Authorization", "x-rivet-token"], - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - exposeHeaders: ["Content-Type"], - }) - ); - const forward = async (c: any) => { - try { - // RivetKit serverless handler is configured with basePath `/api/rivet` by default. - return await inner.fetch(c.req.raw); - } catch (err) { - if (err instanceof URIError) { - return c.text("Bad Request: Malformed URI", 400); - } - throw err; - } - }; - app.all("/api/rivet", forward); - app.all("/api/rivet/*", forward); - - const server = Bun.serve({ - fetch: app.fetch, - hostname: config.backend.host, - port: config.backend.port - }); - - process.on("SIGINT", async () => { - server.stop(); - process.exit(0); - }); - - process.on("SIGTERM", async () => { - server.stop(); - process.exit(0); - }); - - // Keep process alive. - await new Promise(() => undefined); -} - -function parseArg(flag: string): string | undefined { - const idx = process.argv.indexOf(flag); - if (idx < 0) return undefined; - return process.argv[idx + 1]; -} - -function parseEnvPort(value: string | undefined): number | undefined { - if (!value) { - return undefined; - } - const port = Number(value); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - return undefined; - } - return port; -} - -async function main(): Promise { - const cmd = process.argv[2] ?? "start"; - if (cmd !== "start") { - throw new Error(`Unsupported backend command: ${cmd}`); - } - - const host = parseArg("--host") ?? process.env.HOST ?? process.env.HF_BACKEND_HOST; - const port = parseArg("--port") ?? process.env.PORT ?? process.env.HF_BACKEND_PORT; - await startBackend({ - host, - port: parseEnvPort(port) - }); -} - -if (import.meta.url === `file://${process.argv[1]}`) { - main().catch((err: unknown) => { - const message = err instanceof Error ? err.stack ?? err.message : String(err); - console.error(message); - process.exit(1); - }); -} diff --git a/factory/packages/backend/src/integrations/daytona/client.ts b/factory/packages/backend/src/integrations/daytona/client.ts deleted file mode 100644 index 697287eb..00000000 --- a/factory/packages/backend/src/integrations/daytona/client.ts +++ /dev/null @@ -1,115 +0,0 @@ -import { Daytona, type Image } from "@daytonaio/sdk"; - -export interface DaytonaSandbox { - id: string; - state?: string; - snapshot?: string; - labels?: Record; -} - -export interface DaytonaCreateSandboxOptions { - image: string | Image; - envVars?: Record; - labels?: Record; - autoStopInterval?: number; -} - -export interface DaytonaPreviewEndpoint { - url: string; - token?: string; -} - -export interface DaytonaClientOptions { - apiUrl?: string; - apiKey?: string; - target?: string; -} - -function normalizeApiUrl(input?: string): string | undefined { - if (!input) return undefined; - const trimmed = input.replace(/\/+$/, ""); - if (trimmed.endsWith("/api")) { - return trimmed; - } - return `${trimmed}/api`; -} - -export class DaytonaClient { - private readonly daytona: Daytona; - - constructor(options: DaytonaClientOptions) { - const apiUrl = normalizeApiUrl(options.apiUrl); - this.daytona = new Daytona({ - _experimental: {}, - ...(apiUrl ? { apiUrl } : {}), - ...(options.apiKey ? { apiKey: options.apiKey } : {}), - ...(options.target ? { target: options.target } : {}), - }); - } - - async createSandbox(options: DaytonaCreateSandboxOptions): Promise { - const sandbox = await this.daytona.create({ - image: options.image, - envVars: options.envVars, - labels: options.labels, - ...(options.autoStopInterval !== undefined - ? { autoStopInterval: options.autoStopInterval } - : {}), - }); - - return { - id: sandbox.id, - state: sandbox.state, - snapshot: sandbox.snapshot, - labels: (sandbox as any).labels, - }; - } - - async getSandbox(sandboxId: string): Promise { - const sandbox = await this.daytona.get(sandboxId); - return { - id: sandbox.id, - state: sandbox.state, - snapshot: sandbox.snapshot, - labels: (sandbox as any).labels, - }; - } - - async startSandbox(sandboxId: string, timeoutSeconds?: number): Promise { - const sandbox = await this.daytona.get(sandboxId); - await sandbox.start(timeoutSeconds); - } - - async stopSandbox(sandboxId: string, timeoutSeconds?: number): Promise { - const sandbox = await this.daytona.get(sandboxId); - await sandbox.stop(timeoutSeconds); - } - - async deleteSandbox(sandboxId: string): Promise { - const sandbox = await this.daytona.get(sandboxId); - await this.daytona.delete(sandbox); - } - - async executeCommand(sandboxId: string, command: string): Promise<{ exitCode: number; result: string }> { - const sandbox = await this.daytona.get(sandboxId); - const response = await sandbox.process.executeCommand(command); - return { - exitCode: response.exitCode, - result: response.result, - }; - } - - async getPreviewEndpoint(sandboxId: string, port: number): Promise { - const sandbox = await this.daytona.get(sandboxId); - // Use signed preview URLs for server-to-sandbox communication. - // The standard preview link may redirect to an interactive Auth0 flow from non-browser clients. - // Signed preview URLs work for direct HTTP access. - // - // Request a longer-lived URL so sessions can run for several minutes without refresh. - const preview = await sandbox.getSignedPreviewUrl(port, 6 * 60 * 60); - return { - url: preview.url, - token: preview.token, - }; - } -} diff --git a/factory/packages/backend/src/integrations/git-spice/index.ts b/factory/packages/backend/src/integrations/git-spice/index.ts deleted file mode 100644 index df547cbb..00000000 --- a/factory/packages/backend/src/integrations/git-spice/index.ts +++ /dev/null @@ -1,246 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -const DEFAULT_TIMEOUT_MS = 2 * 60_000; - -interface SpiceCommand { - command: string; - prefix: string[]; -} - -export interface SpiceStackEntry { - branchName: string; - parentBranch: string | null; -} - -function spiceCommands(): SpiceCommand[] { - const explicit = process.env.HF_GIT_SPICE_BIN?.trim(); - const list: SpiceCommand[] = []; - if (explicit) { - list.push({ command: explicit, prefix: [] }); - } - list.push({ command: "git-spice", prefix: [] }); - list.push({ command: "git", prefix: ["spice"] }); - return list; -} - -function commandLabel(cmd: SpiceCommand): string { - return [cmd.command, ...cmd.prefix].join(" "); -} - -function looksMissing(error: unknown): boolean { - const detail = error instanceof Error ? error.message : String(error); - return ( - detail.includes("ENOENT") || - detail.includes("not a git command") || - detail.includes("command not found") - ); -} - -async function tryRun( - repoPath: string, - cmd: SpiceCommand, - args: string[] -): Promise<{ stdout: string; stderr: string }> { - return await execFileAsync(cmd.command, [...cmd.prefix, ...args], { - cwd: repoPath, - timeout: DEFAULT_TIMEOUT_MS, - maxBuffer: 1024 * 1024 * 8, - env: { - ...process.env, - NO_COLOR: "1", - FORCE_COLOR: "0" - } - }); -} - -async function pickCommand(repoPath: string): Promise { - for (const candidate of spiceCommands()) { - try { - await tryRun(repoPath, candidate, ["--help"]); - return candidate; - } catch (error) { - if (looksMissing(error)) { - continue; - } - } - } - return null; -} - -async function runSpice(repoPath: string, args: string[]): Promise<{ stdout: string; stderr: string }> { - const cmd = await pickCommand(repoPath); - if (!cmd) { - throw new Error("git-spice is not available (set HF_GIT_SPICE_BIN or install git-spice)"); - } - return await tryRun(repoPath, cmd, args); -} - -function parseLogJson(stdout: string): SpiceStackEntry[] { - const trimmed = stdout.trim(); - if (!trimmed) { - return []; - } - - const entries: SpiceStackEntry[] = []; - - // `git-spice log ... --json` prints one JSON object per line. - for (const line of trimmed.split("\n")) { - const raw = line.trim(); - if (!raw.startsWith("{")) { - continue; - } - try { - const value = JSON.parse(raw) as { - name?: string; - branch?: string; - parent?: string | null; - parentBranch?: string | null; - }; - const branchName = (value.name ?? value.branch ?? "").trim(); - if (!branchName) { - continue; - } - const parentRaw = value.parent ?? value.parentBranch ?? null; - const parentBranch = parentRaw ? parentRaw.trim() || null : null; - entries.push({ branchName, parentBranch }); - } catch { - continue; - } - } - - const seen = new Set(); - return entries.filter((entry) => { - if (seen.has(entry.branchName)) { - return false; - } - seen.add(entry.branchName); - return true; - }); -} - -async function runFallbacks(repoPath: string, commands: string[][], errorContext: string): Promise { - const failures: string[] = []; - for (const args of commands) { - try { - await runSpice(repoPath, args); - return; - } catch (error) { - failures.push(`${args.join(" ")} :: ${error instanceof Error ? error.message : String(error)}`); - } - } - throw new Error(`${errorContext}. attempts=${failures.join(" | ")}`); -} - -export async function gitSpiceAvailable(repoPath: string): Promise { - return (await pickCommand(repoPath)) !== null; -} - -export async function gitSpiceListStack(repoPath: string): Promise { - try { - const { stdout } = await runSpice(repoPath, [ - "log", - "short", - "--all", - "--json", - "--no-cr-status", - "--no-prompt" - ]); - return parseLogJson(stdout); - } catch { - return []; - } -} - -export async function gitSpiceSyncRepo(repoPath: string): Promise { - await runFallbacks( - repoPath, - [ - ["repo", "sync", "--restack", "--no-prompt"], - ["repo", "sync", "--restack"], - ["repo", "sync"] - ], - "git-spice repo sync failed" - ); -} - -export async function gitSpiceRestackRepo(repoPath: string): Promise { - await runFallbacks( - repoPath, - [ - ["repo", "restack", "--no-prompt"], - ["repo", "restack"] - ], - "git-spice repo restack failed" - ); -} - -export async function gitSpiceRestackSubtree(repoPath: string, branchName: string): Promise { - await runFallbacks( - repoPath, - [ - ["upstack", "restack", "--branch", branchName, "--no-prompt"], - ["upstack", "restack", "--branch", branchName], - ["branch", "restack", "--branch", branchName, "--no-prompt"], - ["branch", "restack", "--branch", branchName] - ], - `git-spice restack subtree failed for ${branchName}` - ); -} - -export async function gitSpiceRebaseBranch(repoPath: string, branchName: string): Promise { - await runFallbacks( - repoPath, - [ - ["branch", "restack", "--branch", branchName, "--no-prompt"], - ["branch", "restack", "--branch", branchName] - ], - `git-spice branch restack failed for ${branchName}` - ); -} - -export async function gitSpiceReparentBranch( - repoPath: string, - branchName: string, - parentBranch: string -): Promise { - await runFallbacks( - repoPath, - [ - ["upstack", "onto", "--branch", branchName, parentBranch, "--no-prompt"], - ["upstack", "onto", "--branch", branchName, parentBranch], - ["branch", "onto", "--branch", branchName, parentBranch, "--no-prompt"], - ["branch", "onto", "--branch", branchName, parentBranch] - ], - `git-spice reparent failed for ${branchName} -> ${parentBranch}` - ); -} - -export async function gitSpiceTrackBranch( - repoPath: string, - branchName: string, - parentBranch: string -): Promise { - await runFallbacks( - repoPath, - [ - ["branch", "track", branchName, "--base", parentBranch, "--no-prompt"], - ["branch", "track", branchName, "--base", parentBranch] - ], - `git-spice track failed for ${branchName}` - ); -} - -export function normalizeBaseBranchName(ref: string): string { - const trimmed = ref.trim(); - if (!trimmed) { - return "main"; - } - return trimmed.startsWith("origin/") ? trimmed.slice("origin/".length) : trimmed; -} - -export function describeSpiceCommandForLogs(repoPath: string): Promise { - return pickCommand(repoPath).then((cmd) => (cmd ? commandLabel(cmd) : null)); -} diff --git a/factory/packages/backend/src/integrations/git/index.ts b/factory/packages/backend/src/integrations/git/index.ts deleted file mode 100644 index a27d4692..00000000 --- a/factory/packages/backend/src/integrations/git/index.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { execFile } from "node:child_process"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { dirname, resolve } from "node:path"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -const DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS = 15_000; -const DEFAULT_GIT_FETCH_TIMEOUT_MS = 2 * 60_000; -const DEFAULT_GIT_CLONE_TIMEOUT_MS = 5 * 60_000; - -function resolveGithubToken(): string | null { - const token = - process.env.GH_TOKEN ?? - process.env.GITHUB_TOKEN ?? - process.env.HF_GITHUB_TOKEN ?? - process.env.HF_GH_TOKEN ?? - null; - if (!token) return null; - const trimmed = token.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -let cachedAskpassPath: string | null = null; -function ensureAskpassScript(): string { - if (cachedAskpassPath) { - return cachedAskpassPath; - } - - const dir = mkdtempSync(resolve(tmpdir(), "openhandoff-git-askpass-")); - const path = resolve(dir, "askpass.sh"); - - // Git invokes $GIT_ASKPASS with the prompt string as argv[1]. Provide both username and password. - // We avoid embedding the token in this file; it is read from env at runtime. - const content = - [ - "#!/bin/sh", - 'prompt="$1"', - // Prefer GH_TOKEN/GITHUB_TOKEN but support HF_* aliases too. - 'token="${GH_TOKEN:-${GITHUB_TOKEN:-${HF_GITHUB_TOKEN:-${HF_GH_TOKEN:-}}}}"', - 'case "$prompt" in', - ' *Username*) echo "x-access-token" ;;', - ' *Password*) echo "$token" ;;', - ' *) echo "" ;;', - "esac", - "", - ].join("\n"); - - writeFileSync(path, content, "utf8"); - chmodSync(path, 0o700); - cachedAskpassPath = path; - return path; -} - -function gitEnv(): Record { - const env: Record = { ...(process.env as Record) }; - env.GIT_TERMINAL_PROMPT = "0"; - - const token = resolveGithubToken(); - if (token) { - env.GIT_ASKPASS = ensureAskpassScript(); - // Some tooling expects these vars; keep them aligned. - env.GITHUB_TOKEN = env.GITHUB_TOKEN || token; - env.GH_TOKEN = env.GH_TOKEN || token; - } - - return env; -} - -export interface BranchSnapshot { - branchName: string; - commitSha: string; -} - -export async function fetch(repoPath: string): Promise { - await execFileAsync("git", ["-C", repoPath, "fetch", "--prune"], { - timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS, - env: gitEnv(), - }); -} - -export async function revParse(repoPath: string, ref: string): Promise { - const { stdout } = await execFileAsync("git", ["-C", repoPath, "rev-parse", ref], { env: gitEnv() }); - return stdout.trim(); -} - -export async function validateRemote(remoteUrl: string): Promise { - const remote = remoteUrl.trim(); - if (!remote) { - throw new Error("remoteUrl is required"); - } - try { - await execFileAsync("git", ["ls-remote", "--exit-code", remote, "HEAD"], { - // This command does not need repo context. Running from a neutral directory - // avoids inheriting broken worktree .git indirection inside dev containers. - cwd: tmpdir(), - maxBuffer: 1024 * 1024, - timeout: DEFAULT_GIT_VALIDATE_REMOTE_TIMEOUT_MS, - env: gitEnv(), - }); - } catch (error) { - const detail = error instanceof Error ? error.message : String(error); - throw new Error(`git remote validation failed: ${detail}`); - } -} - -function isGitRepo(path: string): boolean { - return existsSync(resolve(path, ".git")); -} - -export async function ensureCloned(remoteUrl: string, targetPath: string): Promise { - const remote = remoteUrl.trim(); - if (!remote) { - throw new Error("remoteUrl is required"); - } - - if (existsSync(targetPath)) { - if (!isGitRepo(targetPath)) { - throw new Error(`targetPath exists but is not a git repo: ${targetPath}`); - } - - // Keep origin aligned with the configured remote URL. - await execFileAsync("git", ["-C", targetPath, "remote", "set-url", "origin", remote], { - maxBuffer: 1024 * 1024, - timeout: DEFAULT_GIT_FETCH_TIMEOUT_MS, - env: gitEnv(), - }); - await fetch(targetPath); - return; - } - - mkdirSync(dirname(targetPath), { recursive: true }); - await execFileAsync("git", ["clone", remote, targetPath], { - maxBuffer: 1024 * 1024 * 8, - timeout: DEFAULT_GIT_CLONE_TIMEOUT_MS, - env: gitEnv(), - }); - await fetch(targetPath); -} - -export async function remoteDefaultBaseRef(repoPath: string): Promise { - try { - const { stdout } = await execFileAsync("git", [ - "-C", - repoPath, - "symbolic-ref", - "refs/remotes/origin/HEAD", - ], { env: gitEnv() }); - const ref = stdout.trim(); // refs/remotes/origin/main - const match = ref.match(/^refs\/remotes\/(.+)$/); - if (match?.[1]) { - return match[1]; - } - } catch { - // fall through - } - - const candidates = ["origin/main", "origin/master", "main", "master"]; - for (const ref of candidates) { - try { - await execFileAsync("git", ["-C", repoPath, "rev-parse", "--verify", ref], { env: gitEnv() }); - return ref; - } catch { - continue; - } - } - return "origin/main"; -} - -export async function listRemoteBranches(repoPath: string): Promise { - const { stdout } = await execFileAsync( - "git", - [ - "-C", - repoPath, - "for-each-ref", - "--format=%(refname:short) %(objectname)", - "refs/remotes/origin", - ], - { maxBuffer: 1024 * 1024, env: gitEnv() } - ); - - return stdout - .trim() - .split("\n") - .filter((line) => line.trim().length > 0) - .map((line) => { - const [refName, commitSha] = line.trim().split(/\s+/, 2); - const short = (refName ?? "").trim(); - const branchName = short.replace(/^origin\//, ""); - return { branchName, commitSha: commitSha ?? "" }; - }) - .filter( - (row) => - row.branchName.length > 0 && - row.branchName !== "HEAD" && - row.branchName !== "origin" && - row.commitSha.length > 0, - ); -} - -async function remoteBranchExists(repoPath: string, branchName: string): Promise { - try { - await execFileAsync("git", [ - "-C", - repoPath, - "show-ref", - "--verify", - `refs/remotes/origin/${branchName}`, - ], { env: gitEnv() }); - return true; - } catch { - return false; - } -} - -export async function ensureRemoteBranch(repoPath: string, branchName: string): Promise { - await fetch(repoPath); - if (await remoteBranchExists(repoPath, branchName)) { - return; - } - - const baseRef = await remoteDefaultBaseRef(repoPath); - await execFileAsync("git", ["-C", repoPath, "push", "origin", `${baseRef}:refs/heads/${branchName}`], { - maxBuffer: 1024 * 1024 * 2, - env: gitEnv(), - }); - await fetch(repoPath); -} - -export async function diffStatForBranch(repoPath: string, branchName: string): Promise { - try { - const baseRef = await remoteDefaultBaseRef(repoPath); - const headRef = `origin/${branchName}`; - const { stdout } = await execFileAsync( - "git", - ["-C", repoPath, "diff", "--shortstat", `${baseRef}...${headRef}`], - { maxBuffer: 1024 * 1024, env: gitEnv() } - ); - const trimmed = stdout.trim(); - if (!trimmed) { - return "+0/-0"; - } - const insertMatch = trimmed.match(/(\d+)\s+insertion/); - const deleteMatch = trimmed.match(/(\d+)\s+deletion/); - const insertions = insertMatch ? insertMatch[1] : "0"; - const deletions = deleteMatch ? deleteMatch[1] : "0"; - return `+${insertions}/-${deletions}`; - } catch { - return "+0/-0"; - } -} - -export async function conflictsWithMain( - repoPath: string, - branchName: string -): Promise { - try { - const baseRef = await remoteDefaultBaseRef(repoPath); - const headRef = `origin/${branchName}`; - // Use merge-tree (git 2.38+) for a clean conflict check. - try { - await execFileAsync( - "git", - ["-C", repoPath, "merge-tree", "--write-tree", "--no-messages", baseRef, headRef], - { env: gitEnv() } - ); - // If merge-tree exits 0, no conflicts. Non-zero exit means conflicts. - return false; - } catch { - // merge-tree exits non-zero when there are conflicts - return true; - } - } catch { - return false; - } -} - -export async function getOriginOwner(repoPath: string): Promise { - try { - const { stdout } = await execFileAsync( - "git", - ["-C", repoPath, "remote", "get-url", "origin"], - { env: gitEnv() } - ); - const url = stdout.trim(); - // Handle SSH: git@github.com:owner/repo.git - const sshMatch = url.match(/[:\/]([^\/]+)\/[^\/]+(?:\.git)?$/); - if (sshMatch) { - return sshMatch[1] ?? ""; - } - // Handle HTTPS: https://github.com/owner/repo.git - const httpsMatch = url.match(/\/\/[^\/]+\/([^\/]+)\//); - if (httpsMatch) { - return httpsMatch[1] ?? ""; - } - return ""; - } catch { - return ""; - } -} diff --git a/factory/packages/backend/src/integrations/github/index.ts b/factory/packages/backend/src/integrations/github/index.ts deleted file mode 100644 index 0e2b4a8e..00000000 --- a/factory/packages/backend/src/integrations/github/index.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -export interface PullRequestSnapshot { - number: number; - headRefName: string; - state: string; - title: string; - url: string; - author: string; - isDraft: boolean; - ciStatus: string | null; - reviewStatus: string | null; - reviewer: string | null; -} - -interface GhPrListItem { - number: number; - headRefName: string; - state: string; - title: string; - url?: string; - author?: { login?: string }; - isDraft?: boolean; - statusCheckRollup?: Array<{ - state?: string; - status?: string; - conclusion?: string; - __typename?: string; - }>; - reviews?: Array<{ - state?: string; - author?: { login?: string }; - }>; -} - -function parseCiStatus( - checks: GhPrListItem["statusCheckRollup"] -): string | null { - if (!checks || checks.length === 0) return null; - - let total = 0; - let successes = 0; - let hasRunning = false; - - for (const check of checks) { - total++; - const conclusion = check.conclusion?.toUpperCase(); - const state = check.state?.toUpperCase(); - const status = check.status?.toUpperCase(); - - if (conclusion === "SUCCESS" || state === "SUCCESS") { - successes++; - } else if ( - status === "IN_PROGRESS" || - status === "QUEUED" || - status === "PENDING" || - state === "PENDING" - ) { - hasRunning = true; - } - } - - if (hasRunning && successes < total) { - return "running"; - } - - return `${successes}/${total}`; -} - -function parseReviewStatus( - reviews: GhPrListItem["reviews"] -): { status: string | null; reviewer: string | null } { - if (!reviews || reviews.length === 0) { - return { status: null, reviewer: null }; - } - - // Build a map of latest review per author - const latestByAuthor = new Map(); - for (const review of reviews) { - const login = review.author?.login ?? "unknown"; - const state = review.state?.toUpperCase() ?? ""; - if (state === "COMMENTED") continue; // Skip comments, only track actionable reviews - latestByAuthor.set(login, { state, login }); - } - - // Check for CHANGES_REQUESTED first (takes priority), then APPROVED - for (const [, entry] of latestByAuthor) { - if (entry.state === "CHANGES_REQUESTED") { - return { status: "CHANGES_REQUESTED", reviewer: entry.login }; - } - } - - for (const [, entry] of latestByAuthor) { - if (entry.state === "APPROVED") { - return { status: "APPROVED", reviewer: entry.login }; - } - } - - // If there are reviews but none are APPROVED or CHANGES_REQUESTED - if (latestByAuthor.size > 0) { - const first = latestByAuthor.values().next().value; - return { status: "PENDING", reviewer: first?.login ?? null }; - } - - return { status: null, reviewer: null }; -} - -function snapshotFromGhItem(item: GhPrListItem): PullRequestSnapshot { - const { status: reviewStatus, reviewer } = parseReviewStatus(item.reviews); - return { - number: item.number, - headRefName: item.headRefName, - state: item.state, - title: item.title, - url: item.url ?? "", - author: item.author?.login ?? "", - isDraft: item.isDraft ?? false, - ciStatus: parseCiStatus(item.statusCheckRollup), - reviewStatus, - reviewer - }; -} - -const PR_JSON_FIELDS = - "number,headRefName,state,title,url,author,isDraft,statusCheckRollup,reviews"; - -export async function listPullRequests(repoPath: string): Promise { - try { - const { stdout } = await execFileAsync( - "gh", - [ - "pr", - "list", - "--json", - PR_JSON_FIELDS, - "--limit", - "200" - ], - { maxBuffer: 1024 * 1024 * 4, cwd: repoPath } - ); - - const parsed = JSON.parse(stdout) as GhPrListItem[]; - - return parsed.map((item) => { - // Handle fork PRs where headRefName may contain "owner:branch" - const headRefName = item.headRefName.includes(":") - ? item.headRefName.split(":").pop() ?? item.headRefName - : item.headRefName; - - return snapshotFromGhItem({ ...item, headRefName }); - }); - } catch { - return []; - } -} - -export async function getPrInfo( - repoPath: string, - branchName: string -): Promise { - try { - const { stdout } = await execFileAsync( - "gh", - [ - "pr", - "view", - branchName, - "--json", - PR_JSON_FIELDS - ], - { maxBuffer: 1024 * 1024 * 4, cwd: repoPath } - ); - - const item = JSON.parse(stdout) as GhPrListItem; - return snapshotFromGhItem(item); - } catch { - return null; - } -} - -export async function createPr( - repoPath: string, - headBranch: string, - title: string, - body?: string -): Promise<{ number: number; url: string }> { - const args = ["pr", "create", "--title", title, "--head", headBranch]; - if (body) { - args.push("--body", body); - } else { - args.push("--body", ""); - } - - const { stdout } = await execFileAsync("gh", args, { - maxBuffer: 1024 * 1024, - cwd: repoPath - }); - - // gh pr create outputs the PR URL on success - const url = stdout.trim(); - // Extract PR number from URL: https://github.com/owner/repo/pull/123 - const numberMatch = url.match(/\/pull\/(\d+)/); - const number = numberMatch ? parseInt(numberMatch[1]!, 10) : 0; - - return { number, url }; -} - -export async function getAllowedMergeMethod( - repoPath: string -): Promise<"squash" | "rebase" | "merge"> { - try { - // Get the repo owner/name from gh - const { stdout: repoJson } = await execFileAsync( - "gh", - ["repo", "view", "--json", "owner,name"], - { cwd: repoPath } - ); - const repo = JSON.parse(repoJson) as { owner: { login: string }; name: string }; - const repoFullName = `${repo.owner.login}/${repo.name}`; - - const { stdout } = await execFileAsync( - "gh", - [ - "api", - `repos/${repoFullName}`, - "--jq", - ".allow_squash_merge, .allow_rebase_merge, .allow_merge_commit" - ], - { maxBuffer: 1024 * 1024, cwd: repoPath } - ); - - const lines = stdout.trim().split("\n"); - const allowSquash = lines[0]?.trim() === "true"; - const allowRebase = lines[1]?.trim() === "true"; - const allowMerge = lines[2]?.trim() === "true"; - - if (allowSquash) return "squash"; - if (allowRebase) return "rebase"; - if (allowMerge) return "merge"; - return "squash"; - } catch { - return "squash"; - } -} - -export async function mergePr(repoPath: string, prNumber: number): Promise { - const method = await getAllowedMergeMethod(repoPath); - await execFileAsync( - "gh", - ["pr", "merge", String(prNumber), `--${method}`, "--delete-branch"], - { cwd: repoPath } - ); -} - -export async function isPrMerged( - repoPath: string, - branchName: string -): Promise { - try { - const { stdout } = await execFileAsync( - "gh", - ["pr", "view", branchName, "--json", "state"], - { cwd: repoPath } - ); - const parsed = JSON.parse(stdout) as { state: string }; - return parsed.state.toUpperCase() === "MERGED"; - } catch { - return false; - } -} - -export async function getPrTitle( - repoPath: string, - branchName: string -): Promise { - try { - const { stdout } = await execFileAsync( - "gh", - ["pr", "view", branchName, "--json", "title"], - { cwd: repoPath } - ); - const parsed = JSON.parse(stdout) as { title: string }; - return parsed.title; - } catch { - return null; - } -} diff --git a/factory/packages/backend/src/integrations/graphite/index.ts b/factory/packages/backend/src/integrations/graphite/index.ts deleted file mode 100644 index 3b8490c1..00000000 --- a/factory/packages/backend/src/integrations/graphite/index.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -export async function graphiteAvailable(repoPath: string): Promise { - try { - await execFileAsync("gt", ["trunk"], { cwd: repoPath }); - return true; - } catch { - return false; - } -} - -export async function graphiteGet(repoPath: string, branchName: string): Promise { - try { - await execFileAsync("gt", ["get", branchName], { cwd: repoPath }); - return true; - } catch { - return false; - } -} - -export async function graphiteCreateBranch( - repoPath: string, - branchName: string -): Promise { - await execFileAsync("gt", ["create", branchName], { cwd: repoPath }); -} - -export async function graphiteCheckout( - repoPath: string, - branchName: string -): Promise { - await execFileAsync("gt", ["checkout", branchName], { cwd: repoPath }); -} - -export async function graphiteSubmit(repoPath: string): Promise { - await execFileAsync("gt", ["submit", "--no-edit"], { cwd: repoPath }); -} - -export async function graphiteMergeBranch( - repoPath: string, - branchName: string -): Promise { - await execFileAsync("gt", ["merge", branchName], { cwd: repoPath }); -} - -export async function graphiteAbandon( - repoPath: string, - branchName: string -): Promise { - await execFileAsync("gt", ["abandon", branchName], { cwd: repoPath }); -} - -export interface GraphiteStackEntry { - branchName: string; - parentBranch: string | null; -} - -export async function graphiteGetStack( - repoPath: string -): Promise { - try { - // Try JSON output first - const { stdout } = await execFileAsync("gt", ["log", "--json"], { - cwd: repoPath, - maxBuffer: 1024 * 1024 - }); - - const parsed = JSON.parse(stdout) as Array<{ - branch?: string; - name?: string; - parent?: string; - parentBranch?: string; - }>; - - return parsed.map((entry) => ({ - branchName: entry.branch ?? entry.name ?? "", - parentBranch: entry.parent ?? entry.parentBranch ?? null - })); - } catch { - // Fall back to text parsing of `gt log` - try { - const { stdout } = await execFileAsync("gt", ["log"], { - cwd: repoPath, - maxBuffer: 1024 * 1024 - }); - - const entries: GraphiteStackEntry[] = []; - const lines = stdout.split("\n").filter((l) => l.trim().length > 0); - - // Parse indented tree output: each line has tree chars (|, /, \, -, etc.) - // followed by branch names. Build parent-child from indentation level. - const branchStack: string[] = []; - - for (const line of lines) { - // Strip ANSI color codes - const clean = line.replace(/\x1b\[[0-9;]*m/g, ""); - // Extract branch name: skip tree characters and whitespace - const branchMatch = clean.match(/[│├└─|/\\*\s]*(?:◉|○|●)?\s*(.+)/); - if (!branchMatch) continue; - - const branchName = branchMatch[1]!.trim(); - if (!branchName || branchName.startsWith("(") || branchName === "") continue; - - // Determine indentation level by counting leading whitespace/tree chars - const indent = clean.search(/[a-zA-Z0-9]/); - const level = Math.max(0, Math.floor(indent / 2)); - - // Trim stack to current level - while (branchStack.length > level) { - branchStack.pop(); - } - - const parentBranch = branchStack.length > 0 - ? branchStack[branchStack.length - 1] ?? null - : null; - - entries.push({ branchName, parentBranch }); - branchStack.push(branchName); - } - - return entries; - } catch { - return []; - } - } -} - -export async function graphiteGetParent( - repoPath: string, - branchName: string -): Promise { - try { - // Try `gt get ` to see parent info - const { stdout } = await execFileAsync("gt", ["get", branchName], { - cwd: repoPath, - maxBuffer: 1024 * 1024 - }); - - // Parse output for parent branch reference - const parentMatch = stdout.match(/parent:\s*(\S+)/i); - if (parentMatch) { - return parentMatch[1] ?? null; - } - } catch { - // Fall through to stack-based lookup - } - - // Fall back to stack info - try { - const stack = await graphiteGetStack(repoPath); - const entry = stack.find((e) => e.branchName === branchName); - return entry?.parentBranch ?? null; - } catch { - return null; - } -} diff --git a/factory/packages/backend/src/integrations/sandbox-agent/client.ts b/factory/packages/backend/src/integrations/sandbox-agent/client.ts deleted file mode 100644 index 1dc28e75..00000000 --- a/factory/packages/backend/src/integrations/sandbox-agent/client.ts +++ /dev/null @@ -1,394 +0,0 @@ -import type { AgentType } from "@openhandoff/shared"; -import type { - ListEventsRequest, - ListPage, - ListPageRequest, - SessionEvent, - SessionPersistDriver, - SessionRecord -} from "sandbox-agent"; -import { SandboxAgent } from "sandbox-agent"; - -export type AgentId = AgentType | "opencode"; - -export interface SandboxSession { - id: string; - status: "running" | "idle" | "error"; -} - -export interface SandboxSessionCreateRequest { - prompt?: string; - cwd?: string; - agent?: AgentId; -} - -export interface SandboxSessionPromptRequest { - sessionId: string; - prompt: string; - notification?: boolean; -} - -export interface SandboxAgentClientOptions { - endpoint: string; - token?: string; - agent?: AgentId; - persist?: SessionPersistDriver; -} - -const DEFAULT_AGENT: AgentId = "codex"; - -function modeIdForAgent(agent: AgentId): string | null { - switch (agent) { - case "codex": - return "full-access"; - case "claude": - return "acceptEdits"; - default: - return null; - } -} - -function normalizeStatusFromMessage(payload: unknown): SandboxSession["status"] | null { - if (payload && typeof payload === "object") { - const envelope = payload as { - error?: unknown; - method?: unknown; - result?: unknown; - }; - - const maybeError = envelope.error; - if (maybeError) { - return "error"; - } - - if (envelope.result && typeof envelope.result === "object") { - const stopReason = (envelope.result as { stopReason?: unknown }).stopReason; - if (typeof stopReason === "string" && stopReason.length > 0) { - return "idle"; - } - } - - const method = envelope.method; - if (typeof method === "string") { - const lowered = method.toLowerCase(); - if (lowered.includes("error") || lowered.includes("failed")) { - return "error"; - } - if (lowered.includes("ended") || lowered.includes("complete") || lowered.includes("stopped")) { - return "idle"; - } - } - } - - return null; -} - -export class SandboxAgentClient { - readonly endpoint: string; - readonly token?: string; - readonly agent: AgentId; - readonly persist?: SessionPersistDriver; - private sdkPromise?: Promise; - private readonly statusBySessionId = new Map(); - - constructor(options: SandboxAgentClientOptions) { - this.endpoint = options.endpoint.replace(/\/$/, ""); - this.token = options.token; - this.agent = options.agent ?? DEFAULT_AGENT; - this.persist = options.persist; - } - - private async sdk(): Promise { - if (!this.sdkPromise) { - this.sdkPromise = SandboxAgent.connect({ - baseUrl: this.endpoint, - token: this.token, - persist: this.persist, - }); - } - - return this.sdkPromise; - } - - private setStatus(sessionId: string, status: SandboxSession["status"]): void { - this.statusBySessionId.set(sessionId, status); - } - - private isLikelyPromptTimeout(err: unknown): boolean { - const message = err instanceof Error ? err.message : String(err); - const lowered = message.toLowerCase(); - // sandbox-agent server times out long-running ACP prompts and returns a 504-like error. - return ( - lowered.includes("timeout waiting for agent response") || - lowered.includes("timed out waiting for agent response") || - lowered.includes("504") - ); - } - - async createSession(request: string | SandboxSessionCreateRequest): Promise { - const normalized: SandboxSessionCreateRequest = - typeof request === "string" - ? { prompt: request } - : request; - const sdk = await this.sdk(); - // Do not wrap createSession in a local Promise.race timeout. The underlying SDK - // call is not abortable, so local timeout races create overlapping ACP requests and - // can produce duplicate/orphaned sessions while the original request is still running. - const session = await sdk.createSession({ - agent: normalized.agent ?? this.agent, - sessionInit: { - cwd: normalized.cwd ?? "/", - mcpServers: [], - }, - }); - const modeId = modeIdForAgent(normalized.agent ?? this.agent); - - // Codex defaults to a restrictive "read-only" preset in some environments. - // For OpenHandoff automation we need to allow edits + command execution + network - // access (git push / PR creation). Use full-access where supported. - // - // If the agent doesn't support session modes, ignore. - // - // Do this in the background: ACP mode updates can occasionally time out (504), - // and waiting here can stall session creation long enough to trip handoff init - // step timeouts even though the session itself was created. - if (modeId) { - void session.send("session/set_mode", { modeId }).catch(() => { - // ignore - }); - } - - const prompt = normalized.prompt?.trim(); - if (!prompt) { - this.setStatus(session.id, "idle"); - return { - id: session.id, - status: "idle", - }; - } - - // Fire the first turn in the background. We intentionally do not await this: - // session creation must remain fast, and we observe completion via events/stopReason. - // - // Note: sandbox-agent's ACP adapter for Codex may take >2 minutes to respond. - // sandbox-agent can return a timeout error (504) even though the agent continues - // running. Treat that timeout as non-fatal and keep polling events. - void session - .prompt([{ type: "text", text: prompt }]) - .then(() => { - this.setStatus(session.id, "idle"); - }) - .catch((err) => { - if (this.isLikelyPromptTimeout(err)) { - this.setStatus(session.id, "running"); - return; - } - this.setStatus(session.id, "error"); - }); - - this.setStatus(session.id, "running"); - return { - id: session.id, - status: "running", - }; - } - - async createSessionNoTask(dir: string): Promise { - return this.createSession({ - cwd: dir, - }); - } - - async listSessions(request: ListPageRequest = {}): Promise> { - const sdk = await this.sdk(); - const page = await sdk.listSessions(request); - return { - items: page.items.map((session) => session.toRecord()), - nextCursor: page.nextCursor, - }; - } - - async listEvents(request: ListEventsRequest): Promise> { - const sdk = await this.sdk(); - return sdk.getEvents(request); - } - - async sendPrompt(request: SandboxSessionPromptRequest): Promise { - const sdk = await this.sdk(); - const existing = await sdk.getSession(request.sessionId); - if (!existing) { - throw new Error(`session '${request.sessionId}' not found`); - } - - const session = await sdk.resumeSession(request.sessionId); - const modeId = modeIdForAgent(this.agent); - // Keep mode update best-effort and non-blocking for the same reason as createSession. - if (modeId) { - void session.send("session/set_mode", { modeId }).catch(() => { - // ignore - }); - } - const text = request.prompt.trim(); - if (!text) return; - - // sandbox-agent's Session.send(notification=true) forwards an extNotification with - // method "session/prompt", which some agents (e.g. codex-acp) do not implement. - // Use Session.prompt and treat notification=true as "fire-and-forget". - const fireAndForget = request.notification ?? true; - if (fireAndForget) { - void session - .prompt([{ type: "text", text }]) - .then(() => { - this.setStatus(request.sessionId, "idle"); - }) - .catch((err) => { - if (this.isLikelyPromptTimeout(err)) { - this.setStatus(request.sessionId, "running"); - return; - } - this.setStatus(request.sessionId, "error"); - }); - } else { - try { - await session.prompt([{ type: "text", text }]); - this.setStatus(request.sessionId, "idle"); - } catch (err) { - if (this.isLikelyPromptTimeout(err)) { - this.setStatus(request.sessionId, "running"); - return; - } - throw err; - } - } - this.setStatus(request.sessionId, "running"); - } - - async cancelSession(sessionId: string): Promise { - const sdk = await this.sdk(); - const existing = await sdk.getSession(sessionId); - if (!existing) { - throw new Error(`session '${sessionId}' not found`); - } - - const session = await sdk.resumeSession(sessionId); - await session.send("session/cancel", {}); - this.setStatus(sessionId, "idle"); - } - - async destroySession(sessionId: string): Promise { - const sdk = await this.sdk(); - await sdk.destroySession(sessionId); - this.setStatus(sessionId, "idle"); - } - - async sessionStatus(sessionId: string): Promise { - const cached = this.statusBySessionId.get(sessionId); - if (cached && cached !== "running") { - return { id: sessionId, status: cached }; - } - - const sdk = await this.sdk(); - const session = await sdk.getSession(sessionId); - - if (!session) { - this.setStatus(sessionId, "error"); - return { id: sessionId, status: "error" }; - } - - const record = session.toRecord(); - if (record.destroyedAt) { - this.setStatus(sessionId, "idle"); - return { id: sessionId, status: "idle" }; - } - - const events = await sdk.getEvents({ - sessionId, - limit: 25, - }); - - for (let i = events.items.length - 1; i >= 0; i--) { - const item = events.items[i]; - if (!item) continue; - const status = normalizeStatusFromMessage(item.payload); - if (status) { - this.setStatus(sessionId, status); - return { id: sessionId, status }; - } - } - - this.setStatus(sessionId, "running"); - return { id: sessionId, status: "running" }; - } - - async killSessionsInDirectory(dir: string): Promise { - const sdk = await this.sdk(); - let cursor: string | undefined; - - do { - const page = await sdk.listSessions({ - cursor, - limit: 100, - }); - - for (const session of page.items) { - const initCwd = session.toRecord().sessionInit?.cwd; - if (initCwd !== dir) { - continue; - } - await sdk.destroySession(session.id); - this.statusBySessionId.delete(session.id); - } - - cursor = page.nextCursor; - } while (cursor); - } - - async generateCommitMessage( - dir: string, - spec: string, - task: string - ): Promise { - const prompt = [ - "Generate a conventional commit message for the following changes.", - "Return ONLY the commit message, no explanation or markdown formatting.", - "", - `Task: ${task}`, - "", - `Spec/diff:\n${spec}` - ].join("\n"); - - const sdk = await this.sdk(); - const session = await sdk.createSession({ - agent: this.agent, - sessionInit: { - cwd: dir, - mcpServers: [], - }, - }); - - await session.prompt([{ type: "text", text: prompt }]); - this.setStatus(session.id, "idle"); - - const events = await sdk.getEvents({ - sessionId: session.id, - limit: 100, - }); - - for (let i = events.items.length - 1; i >= 0; i--) { - const event = events.items[i]; - if (!event) continue; - if (event.sender !== "agent") continue; - - const payload = event.payload as Record; - const params = payload.params; - if (!params || typeof params !== "object") continue; - - const text = (params as { text?: unknown }).text; - if (typeof text === "string" && text.trim().length > 0) { - return text.trim(); - } - } - - throw new Error("sandbox-agent commit message response was empty"); - } -} diff --git a/factory/packages/backend/src/notifications/backends.ts b/factory/packages/backend/src/notifications/backends.ts deleted file mode 100644 index 0054b979..00000000 --- a/factory/packages/backend/src/notifications/backends.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; - -const execFileAsync = promisify(execFile); - -export type NotifyUrgency = "low" | "normal" | "high"; - -export interface NotifyBackend { - name: string; - available(): Promise; - send(title: string, body: string, urgency: NotifyUrgency): Promise; -} - -async function isOnPath(binary: string): Promise { - try { - await execFileAsync("which", [binary]); - return true; - } catch { - return false; - } -} - -export class OpenclawBackend implements NotifyBackend { - readonly name = "openclaw"; - - async available(): Promise { - return isOnPath("openclaw"); - } - - async send(title: string, body: string, _urgency: NotifyUrgency): Promise { - try { - await execFileAsync("openclaw", ["wake", "--title", title, "--body", body]); - return true; - } catch { - return false; - } - } -} - -export class MacOsNotifyBackend implements NotifyBackend { - readonly name = "macos-osascript"; - - async available(): Promise { - return process.platform === "darwin"; - } - - async send(title: string, body: string, _urgency: NotifyUrgency): Promise { - try { - const escaped_body = body.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const escaped_title = title.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); - const script = `display notification "${escaped_body}" with title "${escaped_title}"`; - await execFileAsync("osascript", ["-e", script]); - return true; - } catch { - return false; - } - } -} - -export class LinuxNotifySendBackend implements NotifyBackend { - readonly name = "linux-notify-send"; - - async available(): Promise { - return isOnPath("notify-send"); - } - - async send(title: string, body: string, urgency: NotifyUrgency): Promise { - const urgencyMap: Record = { - low: "low", - normal: "normal", - high: "critical", - }; - - try { - await execFileAsync("notify-send", ["-u", urgencyMap[urgency], title, body]); - return true; - } catch { - return false; - } - } -} - -export class TerminalBellBackend implements NotifyBackend { - readonly name = "terminal"; - - async available(): Promise { - return true; - } - - async send(title: string, body: string, _urgency: NotifyUrgency): Promise { - try { - process.stderr.write("\x07"); - process.stderr.write(`[${title}] ${body}\n`); - return true; - } catch { - return false; - } - } -} - -const backendFactories: Record NotifyBackend> = { - "openclaw": () => new OpenclawBackend(), - "macos-osascript": () => new MacOsNotifyBackend(), - "linux-notify-send": () => new LinuxNotifySendBackend(), - "terminal": () => new TerminalBellBackend(), -}; - -export async function createBackends(configOrder: string[]): Promise { - const backends: NotifyBackend[] = []; - - for (const name of configOrder) { - const factory = backendFactories[name]; - if (!factory) { - continue; - } - - const backend = factory(); - if (await backend.available()) { - backends.push(backend); - } - } - - return backends; -} diff --git a/factory/packages/backend/src/notifications/index.ts b/factory/packages/backend/src/notifications/index.ts deleted file mode 100644 index 292c519f..00000000 --- a/factory/packages/backend/src/notifications/index.ts +++ /dev/null @@ -1,67 +0,0 @@ -import type { NotifyBackend, NotifyUrgency } from "./backends.js"; - -export type { NotifyUrgency } from "./backends.js"; -export { createBackends } from "./backends.js"; - -export interface NotificationService { - notify(title: string, body: string, urgency: NotifyUrgency): Promise; - agentIdle(branchName: string): Promise; - agentError(branchName: string, error: string): Promise; - ciPassed(branchName: string, prNumber: number): Promise; - ciFailed(branchName: string, prNumber: number): Promise; - prApproved(branchName: string, prNumber: number, reviewer: string): Promise; - changesRequested(branchName: string, prNumber: number, reviewer: string): Promise; - prMerged(branchName: string, prNumber: number): Promise; - handoffCreated(branchName: string): Promise; -} - -export function createNotificationService(backends: NotifyBackend[]): NotificationService { - async function notify(title: string, body: string, urgency: NotifyUrgency): Promise { - for (const backend of backends) { - const sent = await backend.send(title, body, urgency); - if (sent) { - return; - } - } - } - - return { - notify, - - async agentIdle(branchName: string): Promise { - await notify("Agent Idle", `Agent finished on ${branchName}`, "normal"); - }, - - async agentError(branchName: string, error: string): Promise { - await notify("Agent Error", `Agent error on ${branchName}: ${error}`, "high"); - }, - - async ciPassed(branchName: string, prNumber: number): Promise { - await notify("CI Passed", `CI passed on ${branchName} (PR #${prNumber})`, "low"); - }, - - async ciFailed(branchName: string, prNumber: number): Promise { - await notify("CI Failed", `CI failed on ${branchName} (PR #${prNumber})`, "high"); - }, - - async prApproved(branchName: string, prNumber: number, reviewer: string): Promise { - await notify("PR Approved", `PR #${prNumber} on ${branchName} approved by ${reviewer}`, "normal"); - }, - - async changesRequested(branchName: string, prNumber: number, reviewer: string): Promise { - await notify( - "Changes Requested", - `Changes requested on PR #${prNumber} (${branchName}) by ${reviewer}`, - "high", - ); - }, - - async prMerged(branchName: string, prNumber: number): Promise { - await notify("PR Merged", `PR #${prNumber} on ${branchName} merged`, "normal"); - }, - - async handoffCreated(branchName: string): Promise { - await notify("Handoff Created", `New handoff on ${branchName}`, "low"); - }, - }; -} diff --git a/factory/packages/backend/src/notifications/state-tracker.ts b/factory/packages/backend/src/notifications/state-tracker.ts deleted file mode 100644 index 12c39d3f..00000000 --- a/factory/packages/backend/src/notifications/state-tracker.ts +++ /dev/null @@ -1,50 +0,0 @@ -export type CiState = "running" | "pass" | "fail" | "unknown"; -export type ReviewState = "approved" | "changes_requested" | "pending" | "none" | "unknown"; - -export interface PrStateTransition { - type: "ci_passed" | "ci_failed" | "pr_approved" | "changes_requested"; - branchName: string; - prNumber: number; - reviewer?: string; -} - -export class PrStateTracker { - private states: Map; - - constructor() { - this.states = new Map(); - } - - update( - repoId: string, - branchName: string, - prNumber: number, - ci: CiState, - review: ReviewState, - reviewer?: string, - ): PrStateTransition[] { - const key = `${repoId}:${branchName}`; - const prev = this.states.get(key); - const transitions: PrStateTransition[] = []; - - if (prev) { - // CI transitions: only fire when moving from "running" to a terminal state - if (prev.ci === "running" && ci === "pass") { - transitions.push({ type: "ci_passed", branchName, prNumber }); - } else if (prev.ci === "running" && ci === "fail") { - transitions.push({ type: "ci_failed", branchName, prNumber }); - } - - // Review transitions: only fire when moving from "pending" to a terminal state - if (prev.review === "pending" && review === "approved") { - transitions.push({ type: "pr_approved", branchName, prNumber, reviewer }); - } else if (prev.review === "pending" && review === "changes_requested") { - transitions.push({ type: "changes_requested", branchName, prNumber, reviewer }); - } - } - - this.states.set(key, { ci, review }); - - return transitions; - } -} diff --git a/factory/packages/backend/src/providers/daytona/index.ts b/factory/packages/backend/src/providers/daytona/index.ts deleted file mode 100644 index 97d6aee4..00000000 --- a/factory/packages/backend/src/providers/daytona/index.ts +++ /dev/null @@ -1,475 +0,0 @@ -import type { - AgentEndpoint, - AttachTarget, - AttachTargetRequest, - CreateSandboxRequest, - DestroySandboxRequest, - EnsureAgentRequest, - ExecuteSandboxCommandRequest, - ExecuteSandboxCommandResult, - ProviderCapabilities, - ReleaseSandboxRequest, - ResumeSandboxRequest, - SandboxHandle, - SandboxHealth, - SandboxHealthRequest, - SandboxProvider -} from "../provider-api/index.js"; -import type { DaytonaDriver } from "../../driver.js"; -import { Image } from "@daytonaio/sdk"; - -export interface DaytonaProviderConfig { - endpoint?: string; - apiKey?: string; - image: string; - target?: string; - /** - * Auto-stop interval in minutes. If omitted, Daytona's default applies. - * Set to `0` to disable auto-stop. - */ - autoStopInterval?: number; -} - -export class DaytonaProvider implements SandboxProvider { - constructor( - private readonly config: DaytonaProviderConfig, - private readonly daytona?: DaytonaDriver - ) {} - - private static readonly SANDBOX_AGENT_PORT = 2468; - private static readonly SANDBOX_AGENT_VERSION = "0.3.0"; - private static readonly DEFAULT_ACP_REQUEST_TIMEOUT_MS = 120_000; - private static readonly AGENT_IDS = ["codex", "claude"] as const; - private static readonly PASSTHROUGH_ENV_KEYS = [ - "ANTHROPIC_API_KEY", - "CLAUDE_API_KEY", - "OPENAI_API_KEY", - "CODEX_API_KEY", - "OPENCODE_API_KEY", - "CEREBRAS_API_KEY", - "GH_TOKEN", - "GITHUB_TOKEN", - ] as const; - - private getRequestTimeoutMs(): number { - const parsed = Number(process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS ?? "120000"); - if (!Number.isFinite(parsed) || parsed <= 0) { - return 120_000; - } - return Math.floor(parsed); - } - - private getAcpRequestTimeoutMs(): number { - const parsed = Number( - process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS - ?? DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS.toString() - ); - if (!Number.isFinite(parsed) || parsed <= 0) { - return DaytonaProvider.DEFAULT_ACP_REQUEST_TIMEOUT_MS; - } - return Math.floor(parsed); - } - - private async withTimeout(label: string, fn: () => Promise): Promise { - const timeoutMs = this.getRequestTimeoutMs(); - let timer: ReturnType | null = null; - - try { - return await Promise.race([ - fn(), - new Promise((_, reject) => { - timer = setTimeout(() => { - reject(new Error(`daytona ${label} timed out after ${timeoutMs}ms`)); - }, timeoutMs); - }), - ]); - } finally { - if (timer) { - clearTimeout(timer); - } - } - } - - private getClient() { - const apiKey = this.config.apiKey?.trim(); - if (!apiKey) { - return undefined; - } - const endpoint = this.config.endpoint?.trim(); - - return this.daytona?.createClient({ - ...(endpoint ? { apiUrl: endpoint } : {}), - apiKey, - target: this.config.target, - }); - } - - private requireClient() { - const client = this.getClient(); - if (client) { - return client; - } - - if (!this.daytona) { - throw new Error("daytona provider requires backend daytona driver"); - } - - throw new Error( - "daytona provider is not configured: missing apiKey. " + - "Set HF_DAYTONA_API_KEY (or DAYTONA_API_KEY). " + - "Optionally set HF_DAYTONA_ENDPOINT (or DAYTONA_ENDPOINT)." - ); - } - - private async ensureStarted(sandboxId: string): Promise { - const client = this.requireClient(); - - const sandbox = await this.withTimeout("get sandbox", () => client.getSandbox(sandboxId)); - const state = String(sandbox.state ?? "unknown").toLowerCase(); - if (state === "started" || state === "running") { - return; - } - - // If the sandbox is stopped (or any non-started state), try starting it. - // Daytona preserves the filesystem across stop/start, which is what we rely on for faster git setup. - await this.withTimeout("start sandbox", () => client.startSandbox(sandboxId, 60)); - } - - private buildEnvVars(): Record { - const envVars: Record = {}; - - for (const key of DaytonaProvider.PASSTHROUGH_ENV_KEYS) { - const value = process.env[key]; - if (value) { - envVars[key] = value; - } - } - - return envVars; - } - - private buildSnapshotImage() { - // Use Daytona image build + snapshot caching so base tooling (git + sandbox-agent) - // is prepared once and reused for subsequent sandboxes. - return Image.base(this.config.image).runCommands( - "apt-get update && apt-get install -y curl ca-certificates git openssh-client nodejs npm", - `curl -fsSL https://releases.rivet.dev/sandbox-agent/${DaytonaProvider.SANDBOX_AGENT_VERSION}/install.sh | sh`, - `bash -lc 'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent codex || true; sandbox-agent install-agent claude || true'` - ); - } - - private async runCheckedCommand( - sandboxId: string, - command: string, - label: string - ): Promise { - const client = this.requireClient(); - - const result = await this.withTimeout(`execute command (${label})`, () => - client.executeCommand(sandboxId, command) - ); - if (result.exitCode !== 0) { - throw new Error(`daytona ${label} failed (${result.exitCode}): ${result.result}`); - } - } - - id() { - return "daytona" as const; - } - - capabilities(): ProviderCapabilities { - return { - remote: true, - supportsSessionReuse: true - }; - } - - async validateConfig(input: unknown): Promise> { - return (input as Record | undefined) ?? {}; - } - - async createSandbox(req: CreateSandboxRequest): Promise { - const client = this.requireClient(); - const emitDebug = req.debug ?? (() => {}); - - emitDebug("daytona.createSandbox.start", { - workspaceId: req.workspaceId, - repoId: req.repoId, - handoffId: req.handoffId, - branchName: req.branchName - }); - - const createStartedAt = Date.now(); - const sandbox = await this.withTimeout("create sandbox", () => - client.createSandbox({ - image: this.buildSnapshotImage(), - envVars: this.buildEnvVars(), - labels: { - "openhandoff.workspace": req.workspaceId, - "openhandoff.handoff": req.handoffId, - "openhandoff.repo_id": req.repoId, - "openhandoff.repo_remote": req.repoRemote, - "openhandoff.branch": req.branchName, - }, - autoStopInterval: this.config.autoStopInterval, - }) - ); - emitDebug("daytona.createSandbox.created", { - sandboxId: sandbox.id, - durationMs: Date.now() - createStartedAt, - state: sandbox.state ?? null - }); - - const repoDir = `/home/daytona/openhandoff/${req.workspaceId}/${req.repoId}/${req.handoffId}/repo`; - - // Prepare a working directory for the agent. This must succeed for the handoff to work. - const installStartedAt = Date.now(); - await this.runCheckedCommand( - sandbox.id, - [ - "bash", - "-lc", - `'set -euo pipefail; export DEBIAN_FRONTEND=noninteractive; if command -v git >/dev/null 2>&1 && command -v npx >/dev/null 2>&1; then exit 0; fi; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y git openssh-client ca-certificates nodejs npm >/tmp/apt-install.log 2>&1'` - ].join(" "), - "install git + node toolchain" - ); - emitDebug("daytona.createSandbox.install_toolchain.done", { - sandboxId: sandbox.id, - durationMs: Date.now() - installStartedAt - }); - - const cloneStartedAt = Date.now(); - await this.runCheckedCommand( - sandbox.id, - [ - "bash", - "-lc", - `${JSON.stringify( - [ - "set -euo pipefail", - "export GIT_TERMINAL_PROMPT=0", - "export GIT_ASKPASS=/bin/echo", - `rm -rf "${repoDir}"`, - `mkdir -p "${repoDir}"`, - `rmdir "${repoDir}"`, - // Clone without embedding credentials. Auth for pushing is configured by the agent at runtime. - `git clone "${req.repoRemote}" "${repoDir}"`, - `cd "${repoDir}"`, - `git fetch origin --prune`, - // The handoff branch may not exist remotely yet (agent push creates it). Base off current branch (default branch). - `if git show-ref --verify --quiet "refs/remotes/origin/${req.branchName}"; then git checkout -B "${req.branchName}" "origin/${req.branchName}"; else git checkout -B "${req.branchName}" "$(git branch --show-current 2>/dev/null || echo main)"; fi`, - `git config user.email "openhandoff@local" >/dev/null 2>&1 || true`, - `git config user.name "OpenHandoff" >/dev/null 2>&1 || true`, - ].join("; ") - )}` - ].join(" "), - "clone repo" - ); - emitDebug("daytona.createSandbox.clone_repo.done", { - sandboxId: sandbox.id, - durationMs: Date.now() - cloneStartedAt - }); - - return { - sandboxId: sandbox.id, - switchTarget: `daytona://${sandbox.id}`, - metadata: { - endpoint: this.config.endpoint ?? null, - image: this.config.image, - snapshot: sandbox.snapshot ?? null, - remote: true, - state: sandbox.state ?? null, - cwd: repoDir, - } - }; - } - - async resumeSandbox(req: ResumeSandboxRequest): Promise { - const client = this.requireClient(); - - await this.ensureStarted(req.sandboxId); - - // Reconstruct cwd from sandbox labels written at create time. - const info = await this.withTimeout("resume get sandbox", () => - client.getSandbox(req.sandboxId) - ); - const labels = info.labels ?? {}; - const workspaceId = labels["openhandoff.workspace"] ?? req.workspaceId; - const repoId = labels["openhandoff.repo_id"] ?? ""; - const handoffId = labels["openhandoff.handoff"] ?? ""; - const cwd = - repoId && handoffId - ? `/home/daytona/openhandoff/${workspaceId}/${repoId}/${handoffId}/repo` - : null; - - return { - sandboxId: req.sandboxId, - switchTarget: `daytona://${req.sandboxId}`, - metadata: { - resumed: true, - endpoint: this.config.endpoint ?? null, - ...(cwd ? { cwd } : {}), - } - }; - } - - async destroySandbox(_req: DestroySandboxRequest): Promise { - const client = this.getClient(); - if (!client) { - return; - } - - try { - await this.withTimeout("delete sandbox", () => client.deleteSandbox(_req.sandboxId)); - } catch (error) { - // Ignore not-found style cleanup failures. - const text = error instanceof Error ? error.message : String(error); - if (text.toLowerCase().includes("not found")) { - return; - } - throw error; - } - } - - async releaseSandbox(req: ReleaseSandboxRequest): Promise { - const client = this.getClient(); - if (!client) { - return; - } - - try { - await this.withTimeout("stop sandbox", () => client.stopSandbox(req.sandboxId, 60)); - } catch (error) { - const text = error instanceof Error ? error.message : String(error); - if (text.toLowerCase().includes("not found")) { - return; - } - throw error; - } - } - - async ensureSandboxAgent(req: EnsureAgentRequest): Promise { - const client = this.requireClient(); - const acpRequestTimeoutMs = this.getAcpRequestTimeoutMs(); - - await this.ensureStarted(req.sandboxId); - - await this.runCheckedCommand( - req.sandboxId, - [ - "bash", - "-lc", - `'set -euo pipefail; if command -v curl >/dev/null 2>&1; then exit 0; fi; export DEBIAN_FRONTEND=noninteractive; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y curl ca-certificates >/tmp/apt-install.log 2>&1'` - ].join(" "), - "install curl" - ); - - await this.runCheckedCommand( - req.sandboxId, - [ - "bash", - "-lc", - `'set -euo pipefail; if command -v npx >/dev/null 2>&1; then exit 0; fi; export DEBIAN_FRONTEND=noninteractive; apt-get update -y >/tmp/apt-update.log 2>&1; apt-get install -y nodejs npm >/tmp/apt-install.log 2>&1'` - ].join(" "), - "install node toolchain" - ); - - await this.runCheckedCommand( - req.sandboxId, - [ - "bash", - "-lc", - `'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; if sandbox-agent --version 2>/dev/null | grep -q "${DaytonaProvider.SANDBOX_AGENT_VERSION}"; then exit 0; fi; curl -fsSL https://releases.rivet.dev/sandbox-agent/${DaytonaProvider.SANDBOX_AGENT_VERSION}/install.sh | sh'` - ].join(" "), - "install sandbox-agent" - ); - - for (const agentId of DaytonaProvider.AGENT_IDS) { - try { - await this.runCheckedCommand( - req.sandboxId, - ["bash", "-lc", `'export PATH="$HOME/.local/bin:$PATH"; sandbox-agent install-agent ${agentId}'`].join(" "), - `install agent ${agentId}` - ); - } catch { - // Some sandbox-agent builds may not ship every agent plugin; treat this as best-effort. - } - } - - await this.runCheckedCommand( - req.sandboxId, - [ - "bash", - "-lc", - `'set -euo pipefail; export PATH="$HOME/.local/bin:$PATH"; command -v sandbox-agent >/dev/null 2>&1; if pgrep -x sandbox-agent >/dev/null; then exit 0; fi; nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=${acpRequestTimeoutMs} sandbox-agent server --no-token --host 0.0.0.0 --port ${DaytonaProvider.SANDBOX_AGENT_PORT} >/tmp/sandbox-agent.log 2>&1 &'` - ].join(" "), - "start sandbox-agent" - ); - - await this.runCheckedCommand( - req.sandboxId, - [ - "bash", - "-lc", - `'for i in $(seq 1 45); do curl -fsS "http://127.0.0.1:${DaytonaProvider.SANDBOX_AGENT_PORT}/v1/health" >/dev/null && exit 0; sleep 1; done; echo "sandbox-agent failed to become healthy" >&2; tail -n 80 /tmp/sandbox-agent.log >&2; exit 1'` - ].join(" "), - "wait for sandbox-agent health" - ); - - const preview = await this.withTimeout("get preview endpoint", () => - client.getPreviewEndpoint(req.sandboxId, DaytonaProvider.SANDBOX_AGENT_PORT) - ); - - return { - endpoint: preview.url, - token: preview.token - }; - } - - async health(req: SandboxHealthRequest): Promise { - const client = this.getClient(); - if (!client) { - return { - status: "degraded", - message: "daytona driver not configured", - }; - } - - try { - const sandbox = await this.withTimeout("health get sandbox", () => - client.getSandbox(req.sandboxId) - ); - const state = String(sandbox.state ?? "unknown"); - if (state.toLowerCase().includes("error")) { - return { - status: "down", - message: `daytona sandbox in error state: ${state}`, - }; - } - return { - status: "healthy", - message: `daytona sandbox state: ${state}`, - }; - } catch (error) { - const text = error instanceof Error ? error.message : String(error); - return { - status: "down", - message: `daytona sandbox health check failed: ${text}`, - }; - } - } - - async attachTarget(req: AttachTargetRequest): Promise { - return { - target: `daytona://${req.sandboxId}` - }; - } - - async executeCommand(req: ExecuteSandboxCommandRequest): Promise { - const client = this.requireClient(); - await this.ensureStarted(req.sandboxId); - return await this.withTimeout(`execute command (${req.label ?? "command"})`, () => - client.executeCommand(req.sandboxId, req.command) - ); - } -} diff --git a/factory/packages/backend/src/providers/index.ts b/factory/packages/backend/src/providers/index.ts deleted file mode 100644 index 1410f94a..00000000 --- a/factory/packages/backend/src/providers/index.ts +++ /dev/null @@ -1,71 +0,0 @@ -import type { ProviderId } from "@openhandoff/shared"; -import type { AppConfig } from "@openhandoff/shared"; -import type { BackendDriver } from "../driver.js"; -import { DaytonaProvider } from "./daytona/index.js"; -import { LocalProvider } from "./local/index.js"; -import type { SandboxProvider } from "./provider-api/index.js"; - -export interface ProviderRegistry { - get(providerId: ProviderId): SandboxProvider; - availableProviderIds(): ProviderId[]; - defaultProviderId(): ProviderId; -} - -export function createProviderRegistry(config: AppConfig, driver?: BackendDriver): ProviderRegistry { - const gitDriver = driver?.git ?? { - validateRemote: async () => { - throw new Error("local provider requires backend git driver"); - }, - ensureCloned: async () => { - throw new Error("local provider requires backend git driver"); - }, - fetch: async () => { - throw new Error("local provider requires backend git driver"); - }, - listRemoteBranches: async () => { - throw new Error("local provider requires backend git driver"); - }, - remoteDefaultBaseRef: async () => { - throw new Error("local provider requires backend git driver"); - }, - revParse: async () => { - throw new Error("local provider requires backend git driver"); - }, - ensureRemoteBranch: async () => { - throw new Error("local provider requires backend git driver"); - }, - diffStatForBranch: async () => { - throw new Error("local provider requires backend git driver"); - }, - conflictsWithMain: async () => { - throw new Error("local provider requires backend git driver"); - }, - }; - - const local = new LocalProvider({ - rootDir: config.providers.local.rootDir, - sandboxAgentPort: config.providers.local.sandboxAgentPort, - }, gitDriver); - const daytona = new DaytonaProvider({ - endpoint: config.providers.daytona.endpoint, - apiKey: config.providers.daytona.apiKey, - image: config.providers.daytona.image - }, driver?.daytona); - - const map: Record = { - local, - daytona - }; - - return { - get(providerId: ProviderId): SandboxProvider { - return map[providerId]; - }, - availableProviderIds(): ProviderId[] { - return Object.keys(map) as ProviderId[]; - }, - defaultProviderId(): ProviderId { - return config.providers.daytona.apiKey ? "daytona" : "local"; - } - }; -} diff --git a/factory/packages/backend/src/providers/local/index.ts b/factory/packages/backend/src/providers/local/index.ts deleted file mode 100644 index 3317c24a..00000000 --- a/factory/packages/backend/src/providers/local/index.ts +++ /dev/null @@ -1,251 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { execFile } from "node:child_process"; -import { existsSync, mkdirSync, rmSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, resolve } from "node:path"; -import { promisify } from "node:util"; -import { InMemorySessionPersistDriver, SandboxAgent } from "sandbox-agent"; -import type { - AgentEndpoint, - AttachTarget, - AttachTargetRequest, - CreateSandboxRequest, - DestroySandboxRequest, - EnsureAgentRequest, - ExecuteSandboxCommandRequest, - ExecuteSandboxCommandResult, - ProviderCapabilities, - ReleaseSandboxRequest, - ResumeSandboxRequest, - SandboxHandle, - SandboxHealth, - SandboxHealthRequest, - SandboxProvider, -} from "../provider-api/index.js"; -import type { GitDriver } from "../../driver.js"; - -const execFileAsync = promisify(execFile); -const DEFAULT_SANDBOX_AGENT_PORT = 2468; - -export interface LocalProviderConfig { - rootDir?: string; - sandboxAgentPort?: number; -} - -function expandHome(value: string): string { - if (value === "~") { - return homedir(); - } - if (value.startsWith("~/")) { - return resolve(homedir(), value.slice(2)); - } - return value; -} - -async function branchExists(repoPath: string, branchName: string): Promise { - try { - await execFileAsync("git", [ - "-C", - repoPath, - "show-ref", - "--verify", - `refs/remotes/origin/${branchName}`, - ]); - return true; - } catch { - return false; - } -} - -async function checkoutBranch(repoPath: string, branchName: string, git: GitDriver): Promise { - await git.fetch(repoPath); - const targetRef = (await branchExists(repoPath, branchName)) - ? `origin/${branchName}` - : await git.remoteDefaultBaseRef(repoPath); - await execFileAsync("git", ["-C", repoPath, "checkout", "-B", branchName, targetRef], { - env: process.env as Record, - }); -} - -export class LocalProvider implements SandboxProvider { - private sdkPromise: Promise | null = null; - - constructor( - private readonly config: LocalProviderConfig, - private readonly git: GitDriver, - ) {} - - private rootDir(): string { - return expandHome( - this.config.rootDir?.trim() || "~/.local/share/openhandoff/local-sandboxes", - ); - } - - private sandboxRoot(workspaceId: string, sandboxId: string): string { - return resolve(this.rootDir(), workspaceId, sandboxId); - } - - private repoDir(workspaceId: string, sandboxId: string): string { - return resolve(this.sandboxRoot(workspaceId, sandboxId), "repo"); - } - - private sandboxHandle( - workspaceId: string, - sandboxId: string, - repoDir: string, - ): SandboxHandle { - return { - sandboxId, - switchTarget: `local://${repoDir}`, - metadata: { - cwd: repoDir, - repoDir, - }, - }; - } - - private async sandboxAgent(): Promise { - if (!this.sdkPromise) { - const sandboxAgentHome = resolve(this.rootDir(), ".sandbox-agent-home"); - mkdirSync(sandboxAgentHome, { recursive: true }); - const spawnHome = process.env.HOME?.trim() || sandboxAgentHome; - this.sdkPromise = SandboxAgent.start({ - persist: new InMemorySessionPersistDriver(), - spawn: { - enabled: true, - host: "127.0.0.1", - port: this.config.sandboxAgentPort ?? DEFAULT_SANDBOX_AGENT_PORT, - log: "silent", - env: { - HOME: spawnHome, - ...(process.env.ANTHROPIC_API_KEY ? { ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY } : {}), - ...(process.env.CLAUDE_API_KEY ? { CLAUDE_API_KEY: process.env.CLAUDE_API_KEY } : {}), - ...(process.env.OPENAI_API_KEY ? { OPENAI_API_KEY: process.env.OPENAI_API_KEY } : {}), - ...(process.env.CODEX_API_KEY ? { CODEX_API_KEY: process.env.CODEX_API_KEY } : {}), - ...(process.env.GH_TOKEN ? { GH_TOKEN: process.env.GH_TOKEN } : {}), - ...(process.env.GITHUB_TOKEN ? { GITHUB_TOKEN: process.env.GITHUB_TOKEN } : {}), - }, - }, - }).then(async (sdk) => { - for (const agentName of ["claude", "codex"] as const) { - try { - const agent = await sdk.getAgent(agentName, { config: true }); - if (!agent.installed) { - await sdk.installAgent(agentName); - } - } catch { - // The local provider can still function if the agent is already available - // through the user's PATH or the install check is unsupported. - } - } - return sdk; - }); - } - return this.sdkPromise; - } - - id() { - return "local" as const; - } - - capabilities(): ProviderCapabilities { - return { - remote: false, - supportsSessionReuse: true, - }; - } - - async validateConfig(input: unknown): Promise> { - return (input as Record | undefined) ?? {}; - } - - async createSandbox(req: CreateSandboxRequest): Promise { - const sandboxId = req.handoffId || `local-${randomUUID()}`; - const repoDir = this.repoDir(req.workspaceId, sandboxId); - mkdirSync(dirname(repoDir), { recursive: true }); - await this.git.ensureCloned(req.repoRemote, repoDir); - await checkoutBranch(repoDir, req.branchName, this.git); - return this.sandboxHandle(req.workspaceId, sandboxId, repoDir); - } - - async resumeSandbox(req: ResumeSandboxRequest): Promise { - const repoDir = this.repoDir(req.workspaceId, req.sandboxId); - if (!existsSync(repoDir)) { - throw new Error(`local sandbox repo is missing: ${repoDir}`); - } - return this.sandboxHandle(req.workspaceId, req.sandboxId, repoDir); - } - - async destroySandbox(req: DestroySandboxRequest): Promise { - rmSync(this.sandboxRoot(req.workspaceId, req.sandboxId), { - force: true, - recursive: true, - }); - } - - async releaseSandbox(_req: ReleaseSandboxRequest): Promise { - // Local sandboxes stay warm on disk to preserve session state and repo context. - } - - async ensureSandboxAgent(_req: EnsureAgentRequest): Promise { - const sdk = await this.sandboxAgent(); - const { baseUrl, token } = sdk as unknown as { - baseUrl?: string; - token?: string; - }; - if (!baseUrl) { - throw new Error("sandbox-agent baseUrl is unavailable"); - } - return token ? { endpoint: baseUrl, token } : { endpoint: baseUrl }; - } - - async health(req: SandboxHealthRequest): Promise { - try { - const repoDir = this.repoDir(req.workspaceId, req.sandboxId); - if (!existsSync(repoDir)) { - return { - status: "down", - message: "local sandbox repo is missing", - }; - } - const sdk = await this.sandboxAgent(); - const health = await sdk.getHealth(); - return { - status: health.status === "ok" ? "healthy" : "degraded", - message: health.status, - }; - } catch (error) { - return { - status: "down", - message: error instanceof Error ? error.message : String(error), - }; - } - } - - async attachTarget(req: AttachTargetRequest): Promise { - return { target: this.repoDir(req.workspaceId, req.sandboxId) }; - } - - async executeCommand(req: ExecuteSandboxCommandRequest): Promise { - const cwd = this.repoDir(req.workspaceId, req.sandboxId); - try { - const { stdout, stderr } = await execFileAsync("bash", ["-lc", req.command], { - cwd, - env: process.env as Record, - maxBuffer: 1024 * 1024 * 16, - }); - return { - exitCode: 0, - result: [stdout, stderr].filter(Boolean).join(""), - }; - } catch (error) { - const detail = error as { stdout?: string; stderr?: string; code?: number }; - return { - exitCode: typeof detail.code === "number" ? detail.code : 1, - result: [detail.stdout, detail.stderr, error instanceof Error ? error.message : String(error)] - .filter(Boolean) - .join(""), - }; - } - } -} diff --git a/factory/packages/backend/src/providers/provider-api/index.ts b/factory/packages/backend/src/providers/provider-api/index.ts deleted file mode 100644 index 5735ec42..00000000 --- a/factory/packages/backend/src/providers/provider-api/index.ts +++ /dev/null @@ -1,99 +0,0 @@ -import type { ProviderId } from "@openhandoff/shared"; - -export interface ProviderCapabilities { - remote: boolean; - supportsSessionReuse: boolean; -} - -export interface CreateSandboxRequest { - workspaceId: string; - repoId: string; - repoRemote: string; - branchName: string; - handoffId: string; - debug?: (message: string, context?: Record) => void; - options?: Record; -} - -export interface ResumeSandboxRequest { - workspaceId: string; - sandboxId: string; - options?: Record; -} - -export interface DestroySandboxRequest { - workspaceId: string; - sandboxId: string; -} - -export interface ReleaseSandboxRequest { - workspaceId: string; - sandboxId: string; -} - -export interface EnsureAgentRequest { - workspaceId: string; - sandboxId: string; -} - -export interface SandboxHealthRequest { - workspaceId: string; - sandboxId: string; -} - -export interface AttachTargetRequest { - workspaceId: string; - sandboxId: string; -} - -export interface ExecuteSandboxCommandRequest { - workspaceId: string; - sandboxId: string; - command: string; - label?: string; -} - -export interface SandboxHandle { - sandboxId: string; - switchTarget: string; - metadata: Record; -} - -export interface AgentEndpoint { - endpoint: string; - token?: string; -} - -export interface SandboxHealth { - status: "healthy" | "degraded" | "down"; - message: string; -} - -export interface AttachTarget { - target: string; -} - -export interface ExecuteSandboxCommandResult { - exitCode: number; - result: string; -} - -export interface SandboxProvider { - id(): ProviderId; - capabilities(): ProviderCapabilities; - validateConfig(input: unknown): Promise>; - - createSandbox(req: CreateSandboxRequest): Promise; - resumeSandbox(req: ResumeSandboxRequest): Promise; - destroySandbox(req: DestroySandboxRequest): Promise; - /** - * Release resources for a sandbox without deleting its filesystem/state. - * For remote providers, this typically maps to "stop"/"suspend". - */ - releaseSandbox(req: ReleaseSandboxRequest): Promise; - - ensureSandboxAgent(req: EnsureAgentRequest): Promise; - health(req: SandboxHealthRequest): Promise; - attachTarget(req: AttachTargetRequest): Promise; - executeCommand(req: ExecuteSandboxCommandRequest): Promise; -} diff --git a/factory/packages/backend/src/services/create-flow.ts b/factory/packages/backend/src/services/create-flow.ts deleted file mode 100644 index 4589e2f3..00000000 --- a/factory/packages/backend/src/services/create-flow.ts +++ /dev/null @@ -1,128 +0,0 @@ -export interface ResolveCreateFlowDecisionInput { - task: string; - explicitTitle?: string; - explicitBranchName?: string; - localBranches: string[]; - handoffBranches: string[]; -} - -export interface ResolveCreateFlowDecisionResult { - title: string; - branchName: string; -} - -function firstNonEmptyLine(input: string): string { - const lines = input - .split(/\r?\n/) - .map((line) => line.trim()) - .filter((line) => line.length > 0); - return lines[0] ?? ""; -} - -export function deriveFallbackTitle(task: string, explicitTitle?: string): string { - const source = (explicitTitle && explicitTitle.trim()) || firstNonEmptyLine(task) || "update handoff"; - const explicitPrefixMatch = source.match(/^\s*(feat|fix|docs|refactor):\s+(.+)$/i); - if (explicitPrefixMatch) { - const explicitTypePrefix = explicitPrefixMatch[1]!.toLowerCase(); - const explicitSummary = explicitPrefixMatch[2]! - .split("") - .map((char) => (/^[a-zA-Z0-9 -]$/.test(char) ? char : " ")) - .join("") - .split(/\s+/) - .filter((token) => token.length > 0) - .join(" ") - .slice(0, 62) - .trim(); - - return `${explicitTypePrefix}: ${explicitSummary || "update handoff"}`; - } - - const lowered = source.toLowerCase(); - - const typePrefix = lowered.includes("fix") || lowered.includes("bug") - ? "fix" - : lowered.includes("doc") || lowered.includes("readme") - ? "docs" - : lowered.includes("refactor") - ? "refactor" - : "feat"; - - const cleaned = source - .split("") - .map((char) => (/^[a-zA-Z0-9 -]$/.test(char) ? char : " ")) - .join("") - .split(/\s+/) - .filter((token) => token.length > 0) - .join(" "); - - const summary = (cleaned || "update handoff").slice(0, 62).trim(); - return `${typePrefix}: ${summary}`.trim(); -} - -export function sanitizeBranchName(input: string): string { - const normalized = input - .toLowerCase() - .split("") - .map((char) => (/^[a-z0-9]$/.test(char) ? char : "-")) - .join(""); - - let result = ""; - let previousDash = false; - for (const char of normalized) { - if (char === "-") { - if (!previousDash && result.length > 0) { - result += char; - } - previousDash = true; - continue; - } - - result += char; - previousDash = false; - } - - const trimmed = result.replace(/-+$/g, ""); - if (trimmed.length <= 50) { - return trimmed; - } - return trimmed.slice(0, 50).replace(/-+$/g, ""); -} - -export function resolveCreateFlowDecision( - input: ResolveCreateFlowDecisionInput -): ResolveCreateFlowDecisionResult { - const explicitBranch = input.explicitBranchName?.trim(); - const title = deriveFallbackTitle(input.task, input.explicitTitle); - const generatedBase = sanitizeBranchName(title) || "handoff"; - - const branchBase = explicitBranch && explicitBranch.length > 0 ? explicitBranch : generatedBase; - - const existingBranches = new Set(input.localBranches.map((value) => value.trim()).filter((value) => value.length > 0)); - const existingHandoffBranches = new Set( - input.handoffBranches.map((value) => value.trim()).filter((value) => value.length > 0) - ); - const conflicts = (name: string): boolean => - existingBranches.has(name) || existingHandoffBranches.has(name); - - if (explicitBranch && conflicts(branchBase)) { - throw new Error( - `Branch '${branchBase}' already exists. Choose a different --name/--branch value.` - ); - } - - if (explicitBranch) { - return { title, branchName: branchBase }; - } - - let candidate = branchBase; - let index = 2; - while (conflicts(candidate)) { - candidate = `${branchBase}-${index}`; - index += 1; - } - - return { - title, - branchName: candidate - }; -} diff --git a/factory/packages/backend/src/services/openhandoff-paths.ts b/factory/packages/backend/src/services/openhandoff-paths.ts deleted file mode 100644 index 79ffca96..00000000 --- a/factory/packages/backend/src/services/openhandoff-paths.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { AppConfig } from "@openhandoff/shared"; -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; - -function expandPath(input: string): string { - if (input.startsWith("~/")) { - return `${homedir()}/${input.slice(2)}`; - } - return input; -} - -export function openhandoffDataDir(config: AppConfig): string { - // Keep data collocated with the backend DB by default. - const dbPath = expandPath(config.backend.dbPath); - return resolve(dirname(dbPath)); -} - -export function openhandoffRepoClonePath( - config: AppConfig, - workspaceId: string, - repoId: string -): string { - return resolve(join(openhandoffDataDir(config), "repos", workspaceId, repoId)); -} - diff --git a/factory/packages/backend/src/services/queue.ts b/factory/packages/backend/src/services/queue.ts deleted file mode 100644 index b366375e..00000000 --- a/factory/packages/backend/src/services/queue.ts +++ /dev/null @@ -1,16 +0,0 @@ -interface QueueSendResult { - status: "completed" | "timedOut"; - response?: unknown; -} - -export function expectQueueResponse(result: QueueSendResult | void): T { - if (!result || result.status === "timedOut") { - throw new Error("Queue command timed out"); - } - return result.response as T; -} - -export function normalizeMessages(input: T | T[] | null | undefined): T[] { - if (!input) return []; - return Array.isArray(input) ? input : [input]; -} diff --git a/factory/packages/backend/src/services/repo-git-lock.ts b/factory/packages/backend/src/services/repo-git-lock.ts deleted file mode 100644 index 971b95c3..00000000 --- a/factory/packages/backend/src/services/repo-git-lock.ts +++ /dev/null @@ -1,45 +0,0 @@ -interface RepoLockState { - locked: boolean; - waiters: Array<() => void>; -} - -const repoLocks = new Map(); - -async function acquireRepoLock(repoPath: string): Promise<() => void> { - let state = repoLocks.get(repoPath); - if (!state) { - state = { locked: false, waiters: [] }; - repoLocks.set(repoPath, state); - } - - if (!state.locked) { - state.locked = true; - return () => releaseRepoLock(repoPath, state); - } - - await new Promise((resolve) => { - state!.waiters.push(resolve); - }); - - return () => releaseRepoLock(repoPath, state!); -} - -function releaseRepoLock(repoPath: string, state: RepoLockState): void { - const next = state.waiters.shift(); - if (next) { - next(); - return; - } - - state.locked = false; - repoLocks.delete(repoPath); -} - -export async function withRepoGitLock(repoPath: string, fn: () => Promise): Promise { - const release = await acquireRepoLock(repoPath); - try { - return await fn(); - } finally { - release(); - } -} diff --git a/factory/packages/backend/src/services/repo.ts b/factory/packages/backend/src/services/repo.ts deleted file mode 100644 index 94c7bd22..00000000 --- a/factory/packages/backend/src/services/repo.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { createHash } from "node:crypto"; - -export function normalizeRemoteUrl(remoteUrl: string): string { - let value = remoteUrl.trim(); - if (!value) return ""; - - // Strip trailing slashes to make hashing stable. - value = value.replace(/\/+$/, ""); - - // GitHub shorthand: owner/repo -> https://github.com/owner/repo.git - if (/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(value)) { - return `https://github.com/${value}.git`; - } - - // If a user pastes "github.com/owner/repo", treat it as HTTPS. - if (/^(?:www\.)?github\.com\/.+/i.test(value)) { - value = `https://${value.replace(/^www\./i, "")}`; - } - - // Canonicalize GitHub URLs to the repo clone URL (drop /tree/*, issues, etc). - // This makes "https://github.com/owner/repo" and ".../tree/main" map to the same repoId. - try { - if (/^https?:\/\//i.test(value)) { - const url = new URL(value); - const hostname = url.hostname.replace(/^www\./i, ""); - if (hostname.toLowerCase() === "github.com") { - const parts = url.pathname.split("/").filter(Boolean); - if (parts.length >= 2) { - const owner = parts[0]!; - const repo = parts[1]!; - const base = `${url.protocol}//${hostname}/${owner}/${repo.replace(/\.git$/i, "")}.git`; - return base; - } - } - // Drop query/fragment for stability. - url.search = ""; - url.hash = ""; - return url.toString().replace(/\/+$/, ""); - } - } catch { - // ignore parse failures; fall through to raw value - } - - return value; -} - -export function repoIdFromRemote(remoteUrl: string): string { - const normalized = normalizeRemoteUrl(remoteUrl); - return createHash("sha1").update(normalized).digest("hex").slice(0, 16); -} diff --git a/factory/packages/backend/src/services/tmux.ts b/factory/packages/backend/src/services/tmux.ts deleted file mode 100644 index cb9e2eab..00000000 --- a/factory/packages/backend/src/services/tmux.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process"; - -const SYMBOL_RUNNING = "▶"; -const SYMBOL_IDLE = "✓"; - -function stripStatusPrefix(windowName: string): string { - return windowName - .trimStart() - .replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "") - .replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "") - .trim(); -} - -export function setWindowStatus(branchName: string, status: string): number { - let symbol: string; - if (status === "running") { - symbol = SYMBOL_RUNNING; - } else if (status === "idle") { - symbol = SYMBOL_IDLE; - } else { - return 0; - } - - let stdout: string; - try { - stdout = execFileSync( - "tmux", - ["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], - { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] } - ); - } catch { - return 0; - } - - const lines = stdout.split(/\r?\n/).filter((line) => line.trim().length > 0); - let count = 0; - - for (const line of lines) { - const parts = line.split(":", 3); - if (parts.length !== 3) { - continue; - } - - const sessionName = parts[0] ?? ""; - const windowId = parts[1] ?? ""; - const windowName = parts[2] ?? ""; - const clean = stripStatusPrefix(windowName); - if (clean !== branchName) { - continue; - } - - const newName = `${symbol} ${branchName}`; - spawnSync("tmux", ["rename-window", "-t", `${sessionName}:${windowId}`, newName], { - stdio: "ignore" - }); - count += 1; - } - - return count; -} diff --git a/factory/packages/backend/test/create-flow.test.ts b/factory/packages/backend/test/create-flow.test.ts deleted file mode 100644 index 11ec1452..00000000 --- a/factory/packages/backend/test/create-flow.test.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - deriveFallbackTitle, - resolveCreateFlowDecision, - sanitizeBranchName -} from "../src/services/create-flow.js"; - -describe("create flow decision", () => { - it("derives a conventional-style fallback title from task text", () => { - const title = deriveFallbackTitle("Fix OAuth callback bug in handler"); - expect(title).toBe("fix: Fix OAuth callback bug in handler"); - }); - - it("preserves an explicit conventional prefix without duplicating it", () => { - const title = deriveFallbackTitle("Reply with exactly: READY", "feat: Browser UI Flow"); - expect(title).toBe("feat: Browser UI Flow"); - }); - - it("sanitizes generated branch names", () => { - expect(sanitizeBranchName("feat: Add @mentions & #hashtags")).toBe("feat-add-mentions-hashtags"); - expect(sanitizeBranchName(" spaces everywhere ")).toBe("spaces-everywhere"); - }); - - it("auto-increments generated branch names for conflicts", () => { - const resolved = resolveCreateFlowDecision({ - task: "Add auth", - localBranches: ["feat-add-auth"], - handoffBranches: ["feat-add-auth-2"] - }); - - expect(resolved.title).toBe("feat: Add auth"); - expect(resolved.branchName).toBe("feat-add-auth-3"); - }); - - it("fails when explicit branch already exists", () => { - expect(() => - resolveCreateFlowDecision({ - task: "new task", - explicitBranchName: "existing-branch", - localBranches: ["existing-branch"], - handoffBranches: [] - }) - ).toThrow("already exists"); - }); -}); diff --git a/factory/packages/backend/test/daytona-provider.test.ts b/factory/packages/backend/test/daytona-provider.test.ts deleted file mode 100644 index 27c98786..00000000 --- a/factory/packages/backend/test/daytona-provider.test.ts +++ /dev/null @@ -1,182 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { DaytonaClientLike, DaytonaDriver } from "../src/driver.js"; -import type { DaytonaCreateSandboxOptions } from "../src/integrations/daytona/client.js"; -import { DaytonaProvider } from "../src/providers/daytona/index.js"; - -class RecordingDaytonaClient implements DaytonaClientLike { - createSandboxCalls: DaytonaCreateSandboxOptions[] = []; - executedCommands: string[] = []; - - async createSandbox(options: DaytonaCreateSandboxOptions) { - this.createSandboxCalls.push(options); - return { - id: "sandbox-1", - state: "started", - snapshot: "snapshot-openhandoff", - labels: {}, - }; - } - - async getSandbox(sandboxId: string) { - return { - id: sandboxId, - state: "started", - snapshot: "snapshot-openhandoff", - labels: {}, - }; - } - - async startSandbox(_sandboxId: string, _timeoutSeconds?: number) {} - - async stopSandbox(_sandboxId: string, _timeoutSeconds?: number) {} - - async deleteSandbox(_sandboxId: string) {} - - async executeCommand(_sandboxId: string, command: string) { - this.executedCommands.push(command); - return { exitCode: 0, result: "" }; - } - - async getPreviewEndpoint(sandboxId: string, port: number) { - return { - url: `https://preview.example/sandbox/${sandboxId}/port/${port}`, - token: "preview-token", - }; - } -} - -function createProviderWithClient(client: DaytonaClientLike): DaytonaProvider { - const daytonaDriver: DaytonaDriver = { - createClient: () => client, - }; - - return new DaytonaProvider( - { - apiKey: "test-key", - image: "ubuntu:24.04", - }, - daytonaDriver - ); -} - -describe("daytona provider snapshot image behavior", () => { - it("creates sandboxes using a snapshot-capable image recipe", async () => { - const client = new RecordingDaytonaClient(); - const provider = createProviderWithClient(client); - - const handle = await provider.createSandbox({ - workspaceId: "default", - repoId: "repo-1", - repoRemote: "https://github.com/acme/repo.git", - branchName: "feature/test", - handoffId: "handoff-1", - }); - - expect(client.createSandboxCalls).toHaveLength(1); - const createCall = client.createSandboxCalls[0]; - if (!createCall) { - throw new Error("expected create sandbox call"); - } - - expect(typeof createCall.image).not.toBe("string"); - if (typeof createCall.image === "string") { - throw new Error("expected daytona image recipe object"); - } - - const dockerfile = createCall.image.dockerfile; - expect(dockerfile).toContain("apt-get install -y curl ca-certificates git openssh-client nodejs npm"); - expect(dockerfile).toContain("sandbox-agent/0.3.0/install.sh"); - const installAgentLines = dockerfile.match(/sandbox-agent install-agent [a-z0-9-]+/gi) ?? []; - expect(installAgentLines.length).toBeGreaterThanOrEqual(2); - const commands = client.executedCommands.join("\n"); - expect(commands).toContain("GIT_TERMINAL_PROMPT=0"); - expect(commands).toContain("GIT_ASKPASS=/bin/echo"); - - expect(handle.metadata.snapshot).toBe("snapshot-openhandoff"); - expect(handle.metadata.image).toBe("ubuntu:24.04"); - expect(handle.metadata.cwd).toBe("/home/daytona/openhandoff/default/repo-1/handoff-1/repo"); - expect(client.executedCommands.length).toBeGreaterThan(0); - }); - - it("starts sandbox-agent with ACP timeout env override", async () => { - const previous = process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS; - process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS = "240000"; - - try { - const client = new RecordingDaytonaClient(); - const provider = createProviderWithClient(client); - - await provider.ensureSandboxAgent({ - workspaceId: "default", - sandboxId: "sandbox-1", - }); - - const startCommand = client.executedCommands.find((command) => - command.includes("nohup env SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=240000 sandbox-agent server") - ); - - const joined = client.executedCommands.join("\n"); - expect(joined).toContain("sandbox-agent/0.3.0/install.sh"); - expect(joined).toContain("SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS=240000"); - expect(joined).toContain("apt-get install -y nodejs npm"); - expect(joined).toContain("sandbox-agent server --no-token --host 0.0.0.0 --port 2468"); - expect(startCommand).toBeTruthy(); - } finally { - if (previous === undefined) { - delete process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS; - } else { - process.env.HF_SANDBOX_AGENT_ACP_REQUEST_TIMEOUT_MS = previous; - } - } - }); - - it("fails with explicit timeout when daytona createSandbox hangs", async () => { - const previous = process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS; - process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS = "120"; - - const hangingClient: DaytonaClientLike = { - createSandbox: async () => await new Promise(() => {}), - getSandbox: async (sandboxId) => ({ id: sandboxId, state: "started" }), - startSandbox: async () => {}, - stopSandbox: async () => {}, - deleteSandbox: async () => {}, - executeCommand: async () => ({ exitCode: 0, result: "" }), - getPreviewEndpoint: async (sandboxId, port) => ({ - url: `https://preview.example/sandbox/${sandboxId}/port/${port}`, - token: "preview-token", - }), - }; - - try { - const provider = createProviderWithClient(hangingClient); - await expect(provider.createSandbox({ - workspaceId: "default", - repoId: "repo-1", - repoRemote: "https://github.com/acme/repo.git", - branchName: "feature/test", - handoffId: "handoff-timeout", - })).rejects.toThrow("daytona create sandbox timed out after 120ms"); - } finally { - if (previous === undefined) { - delete process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS; - } else { - process.env.HF_DAYTONA_REQUEST_TIMEOUT_MS = previous; - } - } - }); - - it("executes backend-managed sandbox commands through provider API", async () => { - const client = new RecordingDaytonaClient(); - const provider = createProviderWithClient(client); - - const result = await provider.executeCommand({ - workspaceId: "default", - sandboxId: "sandbox-1", - command: "echo backend-push", - label: "manual push" - }); - - expect(result.exitCode).toBe(0); - expect(client.executedCommands).toContain("echo backend-push"); - }); -}); diff --git a/factory/packages/backend/test/git-spice.test.ts b/factory/packages/backend/test/git-spice.test.ts deleted file mode 100644 index 9edfc430..00000000 --- a/factory/packages/backend/test/git-spice.test.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { chmodSync, mkdtempSync, writeFileSync, readFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, it } from "vitest"; -import { - gitSpiceAvailable, - gitSpiceListStack, - gitSpiceRestackSubtree -} from "../src/integrations/git-spice/index.js"; - -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)); -} - -function writeScript(path: string, body: string): void { - writeFileSync(path, body, "utf8"); - chmodSync(path, 0o755); -} - -async function withEnv( - updates: Record, - fn: () => Promise -): Promise { - const previous = new Map(); - for (const [key, value] of Object.entries(updates)) { - previous.set(key, process.env[key]); - if (value == null) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - - try { - return await fn(); - } finally { - for (const [key, value] of previous) { - if (value == null) { - delete process.env[key]; - } else { - process.env[key] = value; - } - } - } -} - -describe("git-spice integration", () => { - it("parses stack rows from mixed/malformed json output", async () => { - const repoPath = makeTempDir("hf-git-spice-parse-"); - const scriptPath = join(repoPath, "fake-git-spice.sh"); - writeScript( - scriptPath, - [ - "#!/bin/sh", - 'if [ \"$1\" = \"--help\" ]; then', - " exit 0", - "fi", - 'if [ \"$1\" = \"log\" ]; then', - " echo 'noise line'", - " echo '{\"branch\":\"feature/a\",\"parent\":\"main\"}'", - " echo '{bad json'", - " echo '{\"name\":\"feature/b\",\"parentBranch\":\"feature/a\"}'", - " echo '{\"name\":\"feature/a\",\"parent\":\"main\"}'", - " exit 0", - "fi", - "exit 1" - ].join("\n") - ); - - await withEnv({ HF_GIT_SPICE_BIN: scriptPath }, async () => { - const rows = await gitSpiceListStack(repoPath); - expect(rows).toEqual([ - { branchName: "feature/a", parentBranch: "main" }, - { branchName: "feature/b", parentBranch: "feature/a" } - ]); - }); - }); - - it("falls back across versioned subtree restack command variants", async () => { - const repoPath = makeTempDir("hf-git-spice-fallback-"); - const scriptPath = join(repoPath, "fake-git-spice.sh"); - const logPath = join(repoPath, "calls.log"); - writeScript( - scriptPath, - [ - "#!/bin/sh", - 'echo \"$*\" >> \"$SPICE_LOG_PATH\"', - 'if [ \"$1\" = \"--help\" ]; then', - " exit 0", - "fi", - 'if [ \"$1\" = \"upstack\" ] && [ \"$2\" = \"restack\" ]; then', - " exit 1", - "fi", - 'if [ \"$1\" = \"branch\" ] && [ \"$2\" = \"restack\" ] && [ \"$5\" = \"--no-prompt\" ]; then', - " exit 0", - "fi", - "exit 1" - ].join("\n") - ); - - await withEnv( - { - HF_GIT_SPICE_BIN: scriptPath, - SPICE_LOG_PATH: logPath - }, - async () => { - await gitSpiceRestackSubtree(repoPath, "feature/a"); - } - ); - - const lines = readFileSync(logPath, "utf8") - .trim() - .split("\n") - .filter((line) => line.trim().length > 0); - - expect(lines).toContain("upstack restack --branch feature/a --no-prompt"); - expect(lines).toContain("upstack restack --branch feature/a"); - expect(lines).toContain("branch restack --branch feature/a --no-prompt"); - expect(lines).not.toContain("branch restack --branch feature/a"); - }); - - it("reports unavailable when explicit binary and PATH are missing", async () => { - const repoPath = makeTempDir("hf-git-spice-missing-"); - - await withEnv( - { - HF_GIT_SPICE_BIN: "/non-existent/hf-git-spice-binary", - PATH: "/non-existent/bin" - }, - async () => { - const available = await gitSpiceAvailable(repoPath); - expect(available).toBe(false); - } - ); - }); -}); diff --git a/factory/packages/backend/test/git-validate-remote.test.ts b/factory/packages/backend/test/git-validate-remote.test.ts deleted file mode 100644 index ea15ac73..00000000 --- a/factory/packages/backend/test/git-validate-remote.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { afterEach, beforeEach, describe, expect, test } from "vitest"; -import { mkdtempSync, mkdirSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; -import { promisify } from "node:util"; -import { execFile } from "node:child_process"; -import { validateRemote } from "../src/integrations/git/index.js"; - -const execFileAsync = promisify(execFile); - -describe("validateRemote", () => { - const originalCwd = process.cwd(); - - beforeEach(() => { - process.chdir(originalCwd); - }); - - afterEach(() => { - process.chdir(originalCwd); - }); - - test("ignores broken worktree gitdir in current directory", async () => { - const sandboxDir = mkdtempSync(join(tmpdir(), "validate-remote-cwd-")); - const brokenRepoDir = resolve(sandboxDir, "broken-worktree"); - const remoteRepoDir = resolve(sandboxDir, "remote"); - - mkdirSync(brokenRepoDir, { recursive: true }); - writeFileSync(resolve(brokenRepoDir, ".git"), "gitdir: /definitely/missing/worktree\n", "utf8"); - await execFileAsync("git", ["init", remoteRepoDir]); - await execFileAsync("git", ["-C", remoteRepoDir, "config", "user.name", "OpenHandoff Test"]); - await execFileAsync("git", ["-C", remoteRepoDir, "config", "user.email", "test@example.com"]); - writeFileSync(resolve(remoteRepoDir, "README.md"), "# test\n", "utf8"); - await execFileAsync("git", ["-C", remoteRepoDir, "add", "README.md"]); - await execFileAsync("git", ["-C", remoteRepoDir, "commit", "-m", "init"]); - - process.chdir(brokenRepoDir); - - await expect(validateRemote(remoteRepoDir)).resolves.toBeUndefined(); - }); -}); diff --git a/factory/packages/backend/test/helpers/test-context.ts b/factory/packages/backend/test/helpers/test-context.ts deleted file mode 100644 index d7799153..00000000 --- a/factory/packages/backend/test/helpers/test-context.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; -import type { BackendDriver } from "../../src/driver.js"; -import { initActorRuntimeContext } from "../../src/actors/context.js"; -import { createProviderRegistry } from "../../src/providers/index.js"; - -export function createTestConfig(overrides?: Partial): AppConfig { - return ConfigSchema.parse({ - auto_submit: true, - notify: ["terminal" as const], - workspace: { default: "default" }, - backend: { - host: "127.0.0.1", - port: 7741, - dbPath: join( - tmpdir(), - `hf-test-${Date.now()}-${Math.random().toString(16).slice(2)}.db` - ), - opencode_poll_interval: 2, - github_poll_interval: 30, - backup_interval_secs: 3600, - backup_retention_days: 7, - }, - providers: { - daytona: { image: "ubuntu:24.04" }, - }, - ...overrides, - }); -} - -export function createTestRuntimeContext( - driver: BackendDriver, - configOverrides?: Partial -): { config: AppConfig } { - const config = createTestConfig(configOverrides); - const providers = createProviderRegistry(config, driver); - initActorRuntimeContext(config, providers, undefined, driver); - return { config }; -} diff --git a/factory/packages/backend/test/helpers/test-driver.ts b/factory/packages/backend/test/helpers/test-driver.ts deleted file mode 100644 index 438cbefa..00000000 --- a/factory/packages/backend/test/helpers/test-driver.ts +++ /dev/null @@ -1,127 +0,0 @@ -import type { - BackendDriver, - DaytonaClientLike, - DaytonaDriver, - GitDriver, - GithubDriver, - StackDriver, - SandboxAgentDriver, - SandboxAgentClientLike, - TmuxDriver, -} from "../../src/driver.js"; -import type { ListEventsRequest, ListPage, ListPageRequest, SessionEvent, SessionRecord } from "sandbox-agent"; - -export function createTestDriver(overrides?: Partial): BackendDriver { - return { - git: overrides?.git ?? createTestGitDriver(), - stack: overrides?.stack ?? createTestStackDriver(), - github: overrides?.github ?? createTestGithubDriver(), - sandboxAgent: overrides?.sandboxAgent ?? createTestSandboxAgentDriver(), - daytona: overrides?.daytona ?? createTestDaytonaDriver(), - tmux: overrides?.tmux ?? createTestTmuxDriver(), - }; -} - -export function createTestGitDriver(overrides?: Partial): GitDriver { - return { - validateRemote: async () => {}, - ensureCloned: async () => {}, - fetch: async () => {}, - listRemoteBranches: async () => [], - remoteDefaultBaseRef: async () => "origin/main", - revParse: async () => "abc1234567890", - ensureRemoteBranch: async () => {}, - diffStatForBranch: async () => "+0/-0", - conflictsWithMain: async () => false, - ...overrides, - }; -} - -export function createTestStackDriver(overrides?: Partial): StackDriver { - return { - available: async () => false, - listStack: async () => [], - syncRepo: async () => {}, - restackRepo: async () => {}, - restackSubtree: async () => {}, - rebaseBranch: async () => {}, - reparentBranch: async () => {}, - trackBranch: async () => {}, - ...overrides, - }; -} - -export function createTestGithubDriver(overrides?: Partial): GithubDriver { - return { - listPullRequests: async () => [], - createPr: async (_repoPath, _headBranch, _title) => ({ - number: 1, - url: `https://github.com/test/repo/pull/1`, - }), - ...overrides, - }; -} - -export function createTestSandboxAgentDriver( - overrides?: Partial -): SandboxAgentDriver { - return { - createClient: (_opts) => createTestSandboxAgentClient(), - ...overrides, - }; -} - -export function createTestSandboxAgentClient( - overrides?: Partial -): SandboxAgentClientLike { - return { - createSession: async (_prompt) => ({ id: "test-session-1", status: "running" }), - sessionStatus: async (sessionId) => ({ id: sessionId, status: "running" }), - listSessions: async (_request?: ListPageRequest): Promise> => ({ - items: [], - nextCursor: undefined, - }), - listEvents: async (_request: ListEventsRequest): Promise> => ({ - items: [], - nextCursor: undefined, - }), - sendPrompt: async (_request) => {}, - cancelSession: async (_sessionId) => {}, - destroySession: async (_sessionId) => {}, - ...overrides, - }; -} - -export function createTestDaytonaDriver( - overrides?: Partial -): DaytonaDriver { - return { - createClient: (_opts) => createTestDaytonaClient(), - ...overrides, - }; -} - -export function createTestDaytonaClient( - overrides?: Partial -): DaytonaClientLike { - return { - createSandbox: async () => ({ id: "sandbox-test-1", state: "started" }), - getSandbox: async (sandboxId) => ({ id: sandboxId, state: "started" }), - startSandbox: async () => {}, - stopSandbox: async () => {}, - deleteSandbox: async () => {}, - executeCommand: async () => ({ exitCode: 0, result: "" }), - getPreviewEndpoint: async (sandboxId, port) => ({ - url: `https://preview.example/sandbox/${sandboxId}/port/${port}`, - token: "preview-token", - }), - ...overrides, - }; -} - -export function createTestTmuxDriver(overrides?: Partial): TmuxDriver { - return { - setWindowStatus: () => 0, - ...overrides, - }; -} diff --git a/factory/packages/backend/test/keys.test.ts b/factory/packages/backend/test/keys.test.ts deleted file mode 100644 index d2f52f8d..00000000 --- a/factory/packages/backend/test/keys.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - handoffKey, - handoffStatusSyncKey, - historyKey, - projectBranchSyncKey, - projectKey, - projectPrSyncKey, - sandboxInstanceKey, - workspaceKey -} from "../src/actors/keys.js"; - -describe("actor keys", () => { - it("prefixes every key with workspace namespace", () => { - const keys = [ - workspaceKey("default"), - projectKey("default", "repo"), - handoffKey("default", "repo", "handoff"), - sandboxInstanceKey("default", "daytona", "sbx"), - historyKey("default", "repo"), - projectPrSyncKey("default", "repo"), - projectBranchSyncKey("default", "repo"), - handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1") - ]; - - for (const key of keys) { - expect(key[0]).toBe("ws"); - expect(key[1]).toBe("default"); - } - }); -}); diff --git a/factory/packages/backend/test/malformed-uri.test.ts b/factory/packages/backend/test/malformed-uri.test.ts deleted file mode 100644 index a01aac0b..00000000 --- a/factory/packages/backend/test/malformed-uri.test.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { describe, expect, it } from "vitest"; - -describe("malformed URI handling", () => { - it("safeFetch wrapper returns 400 on URIError", async () => { - // Simulate the pattern used in backend/src/index.ts - const mockApp = { - fetch: async (_req: Request): Promise => { - // Simulate what happens when rivetkit's router encounters a malformed URI - throw new URIError("URI malformed"); - } - }; - - const safeFetch = async (req: Request): Promise => { - try { - return await mockApp.fetch(req); - } catch (err) { - if (err instanceof URIError) { - return new Response("Bad Request: Malformed URI", { status: 400 }); - } - throw err; - } - }; - - const response = await safeFetch(new Request("http://localhost/%ZZ")); - expect(response.status).toBe(400); - expect(await response.text()).toBe("Bad Request: Malformed URI"); - }); - - it("safeFetch wrapper re-throws non-URI errors", async () => { - const mockApp = { - fetch: async (_req: Request): Promise => { - throw new TypeError("some other error"); - } - }; - - const safeFetch = async (req: Request): Promise => { - try { - return await mockApp.fetch(req); - } catch (err) { - if (err instanceof URIError) { - return new Response("Bad Request: Malformed URI", { status: 400 }); - } - throw err; - } - }; - - await expect(safeFetch(new Request("http://localhost/test"))).rejects.toThrow(TypeError); - }); - - it("safeFetch wrapper passes through valid requests", async () => { - const mockApp = { - fetch: async (_req: Request): Promise => { - return new Response("OK", { status: 200 }); - } - }; - - const safeFetch = async (req: Request): Promise => { - try { - return await mockApp.fetch(req); - } catch (err) { - if (err instanceof URIError) { - return new Response("Bad Request: Malformed URI", { status: 400 }); - } - throw err; - } - }; - - const response = await safeFetch(new Request("http://localhost/valid/path")); - expect(response.status).toBe(200); - expect(await response.text()).toBe("OK"); - }); - - it("decodeURIComponent throws on malformed percent-encoding", () => { - // Validates the core issue: decodeURIComponent throws URIError on malformed input - expect(() => decodeURIComponent("%ZZ")).toThrow(URIError); - expect(() => decodeURIComponent("%")).toThrow(URIError); - expect(() => decodeURIComponent("%E0%A4%A")).toThrow(URIError); - - // Valid encoding should not throw - expect(decodeURIComponent("%20")).toBe(" "); - expect(decodeURIComponent("hello%20world")).toBe("hello world"); - }); -}); diff --git a/factory/packages/backend/test/providers.test.ts b/factory/packages/backend/test/providers.test.ts deleted file mode 100644 index 6e3cfb2f..00000000 --- a/factory/packages/backend/test/providers.test.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; -import { createProviderRegistry } from "../src/providers/index.js"; - -function makeConfig(): AppConfig { - return ConfigSchema.parse({ - auto_submit: true, - notify: ["terminal"], - workspace: { default: "default" }, - backend: { - host: "127.0.0.1", - port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", - opencode_poll_interval: 2, - github_poll_interval: 30, - backup_interval_secs: 3600, - backup_retention_days: 7 - }, - providers: { - local: {}, - daytona: { image: "ubuntu:24.04" } - } - }); -} - -describe("provider registry", () => { - it("defaults to local when daytona is not configured", () => { - const registry = createProviderRegistry(makeConfig()); - expect(registry.defaultProviderId()).toBe("local"); - }); - - it("prefers daytona when an api key is configured", () => { - const registry = createProviderRegistry( - ConfigSchema.parse({ - ...makeConfig(), - providers: { - ...makeConfig().providers, - daytona: { - ...makeConfig().providers.daytona, - apiKey: "test-token", - }, - }, - }) - ); - expect(registry.defaultProviderId()).toBe("daytona"); - }); - - it("returns the built-in provider", () => { - const registry = createProviderRegistry(makeConfig()); - expect(registry.get("daytona").id()).toBe("daytona"); - }); -}); diff --git a/factory/packages/backend/test/repo-normalize.test.ts b/factory/packages/backend/test/repo-normalize.test.ts deleted file mode 100644 index 593e26bb..00000000 --- a/factory/packages/backend/test/repo-normalize.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { normalizeRemoteUrl, repoIdFromRemote } from "../src/services/repo.js"; - -describe("normalizeRemoteUrl", () => { - test("accepts GitHub shorthand owner/repo", () => { - expect(normalizeRemoteUrl("rivet-dev/openhandoff")).toBe( - "https://github.com/rivet-dev/openhandoff.git" - ); - }); - - test("accepts github.com/owner/repo without scheme", () => { - expect(normalizeRemoteUrl("github.com/rivet-dev/openhandoff")).toBe( - "https://github.com/rivet-dev/openhandoff.git" - ); - }); - - test("canonicalizes GitHub repo URLs without .git", () => { - expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff")).toBe( - "https://github.com/rivet-dev/openhandoff.git" - ); - }); - - test("canonicalizes GitHub non-clone URLs (e.g. /tree/main)", () => { - expect(normalizeRemoteUrl("https://github.com/rivet-dev/openhandoff/tree/main")).toBe( - "https://github.com/rivet-dev/openhandoff.git" - ); - }); - - test("does not rewrite scp-style ssh remotes", () => { - expect(normalizeRemoteUrl("git@github.com:rivet-dev/openhandoff.git")).toBe( - "git@github.com:rivet-dev/openhandoff.git" - ); - }); -}); - -describe("repoIdFromRemote", () => { - test("repoId is stable across equivalent GitHub inputs", () => { - const a = repoIdFromRemote("rivet-dev/openhandoff"); - const b = repoIdFromRemote("https://github.com/rivet-dev/openhandoff.git"); - const c = repoIdFromRemote("https://github.com/rivet-dev/openhandoff/tree/main"); - expect(a).toBe(b); - expect(b).toBe(c); - }); -}); diff --git a/factory/packages/backend/test/sandbox-instance-persist.test.ts b/factory/packages/backend/test/sandbox-instance-persist.test.ts deleted file mode 100644 index a3692ea4..00000000 --- a/factory/packages/backend/test/sandbox-instance-persist.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { resolveEventListOffset } from "../src/actors/sandbox-instance/persist.js"; - -describe("sandbox-instance persist event offset", () => { - it("returns newest tail when cursor is omitted", () => { - expect(resolveEventListOffset({ total: 180, limit: 50 })).toBe(130); - }); - - it("returns zero when total rows are below page size", () => { - expect(resolveEventListOffset({ total: 20, limit: 50 })).toBe(0); - }); - - it("uses explicit cursor when provided", () => { - expect(resolveEventListOffset({ cursor: "7", total: 180, limit: 50 })).toBe(7); - }); - - it("normalizes invalid cursors to zero", () => { - expect(resolveEventListOffset({ cursor: "-3", total: 180, limit: 50 })).toBe(0); - expect(resolveEventListOffset({ cursor: "not-a-number", total: 180, limit: 50 })).toBe(0); - }); -}); diff --git a/factory/packages/backend/test/setup.ts b/factory/packages/backend/test/setup.ts deleted file mode 100644 index cfb7223d..00000000 --- a/factory/packages/backend/test/setup.ts +++ /dev/null @@ -1,9 +0,0 @@ -// Suppress RivetKit traces driver flush errors that occur during test cleanup. -// These happen when the traces driver tries to write after actor state is unloaded. -process.on("unhandledRejection", (reason) => { - if (reason instanceof Error && reason.message.includes("state not loaded")) { - return; - } - // Re-throw non-suppressed rejections - throw reason; -}); diff --git a/factory/packages/backend/test/stack-model.test.ts b/factory/packages/backend/test/stack-model.test.ts deleted file mode 100644 index 35b5df5f..00000000 --- a/factory/packages/backend/test/stack-model.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - normalizeParentBranch, - parentLookupFromStack, - sortBranchesForOverview, -} from "../src/actors/project/stack-model.js"; - -describe("stack-model", () => { - it("normalizes self-parent references to null", () => { - expect(normalizeParentBranch("feature/a", "feature/a")).toBeNull(); - expect(normalizeParentBranch("feature/a", "main")).toBe("main"); - expect(normalizeParentBranch("feature/a", null)).toBeNull(); - }); - - it("builds parent lookup with sanitized entries", () => { - const lookup = parentLookupFromStack([ - { branchName: "feature/a", parentBranch: "main" }, - { branchName: "feature/b", parentBranch: "feature/b" }, - { branchName: " ", parentBranch: "main" }, - ]); - - expect(lookup.get("feature/a")).toBe("main"); - expect(lookup.get("feature/b")).toBeNull(); - expect(lookup.has(" ")).toBe(false); - }); - - it("orders branches by graph depth and handles cycles safely", () => { - const rows = sortBranchesForOverview([ - { branchName: "feature/b", parentBranch: "feature/a", updatedAt: 200 }, - { branchName: "feature/a", parentBranch: "main", updatedAt: 100 }, - { branchName: "main", parentBranch: null, updatedAt: 50 }, - { branchName: "cycle-a", parentBranch: "cycle-b", updatedAt: 300 }, - { branchName: "cycle-b", parentBranch: "cycle-a", updatedAt: 250 }, - ]); - - expect(rows.map((row) => row.branchName)).toEqual([ - "main", - "feature/a", - "feature/b", - "cycle-a", - "cycle-b", - ]); - }); -}); diff --git a/factory/packages/backend/test/workbench-unread.test.ts b/factory/packages/backend/test/workbench-unread.test.ts deleted file mode 100644 index 28a05d9c..00000000 --- a/factory/packages/backend/test/workbench-unread.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { shouldMarkSessionUnreadForStatus } from "../src/actors/handoff/workbench.js"; - -describe("workbench unread status transitions", () => { - it("marks unread when a running session first becomes idle", () => { - expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "idle")).toBe(true); - }); - - it("does not re-mark unread on repeated idle polls after thinking has cleared", () => { - expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: null }, "idle")).toBe(false); - }); - - it("does not mark unread while the session is still running", () => { - expect(shouldMarkSessionUnreadForStatus({ thinkingSinceMs: Date.now() - 1_000 }, "running")).toBe(false); - }); -}); diff --git a/factory/packages/backend/test/workspace-isolation.test.ts b/factory/packages/backend/test/workspace-isolation.test.ts deleted file mode 100644 index ef31a40b..00000000 --- a/factory/packages/backend/test/workspace-isolation.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -// @ts-nocheck -import { mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { execFileSync } from "node:child_process"; -import { setTimeout as delay } from "node:timers/promises"; -import { describe, expect, it } from "vitest"; -import { setupTest } from "rivetkit/test"; -import { workspaceKey } from "../src/actors/keys.js"; -import { registry } from "../src/actors/index.js"; -import { createTestDriver } from "./helpers/test-driver.js"; -import { createTestRuntimeContext } from "./helpers/test-context.js"; - -const runActorIntegration = process.env.HF_ENABLE_ACTOR_INTEGRATION_TESTS === "1"; - -function createRepo(): { repoPath: string } { - const repoPath = mkdtempSync(join(tmpdir(), "hf-isolation-repo-")); - execFileSync("git", ["init"], { cwd: repoPath }); - execFileSync("git", ["config", "user.email", "test@example.com"], { cwd: repoPath }); - execFileSync("git", ["config", "user.name", "OpenHandoff Test"], { cwd: repoPath }); - writeFileSync(join(repoPath, "README.md"), "hello\n", "utf8"); - execFileSync("git", ["add", "README.md"], { cwd: repoPath }); - execFileSync("git", ["commit", "-m", "init"], { cwd: repoPath }); - return { repoPath }; -} - -async function waitForWorkspaceRows( - ws: any, - workspaceId: string, - expectedCount: number -) { - for (let attempt = 0; attempt < 40; attempt += 1) { - const rows = await ws.listHandoffs({ workspaceId }); - if (rows.length >= expectedCount) { - return rows; - } - await delay(50); - } - return ws.listHandoffs({ workspaceId }); -} - -describe("workspace isolation", () => { - it.skipIf(!runActorIntegration)( - "keeps handoff lists isolated by workspace", - async (t) => { - const testDriver = createTestDriver(); - createTestRuntimeContext(testDriver); - - const { client } = await setupTest(t, registry); - const wsA = await client.workspace.getOrCreate(workspaceKey("alpha"), { - createWithInput: "alpha" - }); - const wsB = await client.workspace.getOrCreate(workspaceKey("beta"), { - createWithInput: "beta" - }); - - const { repoPath } = createRepo(); - const repoA = await wsA.addRepo({ workspaceId: "alpha", remoteUrl: repoPath }); - const repoB = await wsB.addRepo({ workspaceId: "beta", remoteUrl: repoPath }); - - await wsA.createHandoff({ - workspaceId: "alpha", - repoId: repoA.repoId, - task: "task A", - providerId: "daytona", - explicitBranchName: "feature/a", - explicitTitle: "A" - }); - - await wsB.createHandoff({ - workspaceId: "beta", - repoId: repoB.repoId, - task: "task B", - providerId: "daytona", - explicitBranchName: "feature/b", - explicitTitle: "B" - }); - - const aRows = await waitForWorkspaceRows(wsA, "alpha", 1); - const bRows = await waitForWorkspaceRows(wsB, "beta", 1); - - expect(aRows.length).toBe(1); - expect(bRows.length).toBe(1); - expect(aRows[0]?.workspaceId).toBe("alpha"); - expect(bRows[0]?.workspaceId).toBe("beta"); - expect(aRows[0]?.handoffId).not.toBe(bRows[0]?.handoffId); - } - ); -}); diff --git a/factory/packages/backend/tmp-decode-actors.mjs b/factory/packages/backend/tmp-decode-actors.mjs deleted file mode 100644 index a25790b9..00000000 --- a/factory/packages/backend/tmp-decode-actors.mjs +++ /dev/null @@ -1,72 +0,0 @@ -import { Database } from "bun:sqlite"; -import { TO_CLIENT_VERSIONED, decodeWorkflowHistoryTransport } from "rivetkit/inspector"; - -const targets = [ - { actorId: "2e443238457137bf", handoffId: "7df7656e-bbd2-4b8c-bf0f-30d4df2f619a" }, - { actorId: "0e53dd77ef06862f", handoffId: "0e01a31c-2dc1-4a1d-8ab0-9f0816359a85" }, - { actorId: "ea8c0e764c836e5f", handoffId: "cdc22436-4020-4f73-b3e7-7782fec29ae4" }, -]; - -function decodeAscii(u8) { - return new TextDecoder().decode(u8).replace(/[\x00-\x1F\x7F-\xFF]/g, "."); -} - -function locationToNames(entry, names) { - return entry.location.map((seg) => { - if (seg.tag === "WorkflowNameIndex") return names[seg.val] ?? `#${seg.val}`; - if (seg.tag === "WorkflowLoopIterationMarker") return `iter(${seg.val.iteration})`; - return seg.tag; - }); -} - -for (const t of targets) { - const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${t.actorId}.db`, { readonly: true }); - const token = new TextDecoder().decode(db.query("SELECT value FROM kv WHERE hex(key)=?").get("03").value); - - await new Promise((resolve, reject) => { - const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${t.actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]); - ws.binaryType = "arraybuffer"; - const to = setTimeout(() => reject(new Error("timeout")), 15000); - - ws.onmessage = (ev) => { - const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer); - const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data); - if (msg.body.tag !== "Init") return; - - const wh = decodeWorkflowHistoryTransport(msg.body.val.workflowHistory); - const entryMetadata = wh.entryMetadata; - const enriched = wh.entries.map((e) => { - const meta = entryMetadata.get(e.id); - return { - id: e.id, - path: locationToNames(e, wh.nameRegistry).join("/"), - kind: e.kind.tag, - status: meta?.status ?? null, - error: meta?.error ?? null, - attempts: meta?.attempts ?? null, - entryError: e.kind.tag === "WorkflowStepEntry" ? (e.kind.val.error ?? null) : null, - }; - }); - - const wfStateRow = db.query("SELECT value FROM kv WHERE hex(key)=?").get("0715041501"); - const wfState = wfStateRow?.value ? decodeAscii(new Uint8Array(wfStateRow.value)) : null; - - console.log(JSON.stringify({ - handoffId: t.handoffId, - actorId: t.actorId, - wfState, - names: wh.nameRegistry, - entries: enriched, - }, null, 2)); - - clearTimeout(to); - ws.close(); - resolve(); - }; - - ws.onerror = (err) => { - clearTimeout(to); - reject(err); - }; - }); -} diff --git a/factory/packages/backend/tmp-dump-wfkeys.mjs b/factory/packages/backend/tmp-dump-wfkeys.mjs deleted file mode 100644 index 41df2744..00000000 --- a/factory/packages/backend/tmp-dump-wfkeys.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import { Database } from "bun:sqlite"; - -const db = new Database("/root/.local/share/openhandoff/rivetkit/databases/2e443238457137bf.db", { readonly: true }); -const rows = db.query("SELECT hex(key) as k, value as v FROM kv WHERE hex(key) LIKE ? ORDER BY key").all("07%"); -const out = rows.map((r) => { - const bytes = new Uint8Array(r.v); - const txt = new TextDecoder().decode(bytes).replace(/[\x00-\x1F\x7F-\xFF]/g, "."); - return { k: r.k, vlen: bytes.length, txt: txt.slice(0, 260) }; -}); -console.log(JSON.stringify(out, null, 2)); diff --git a/factory/packages/backend/tmp-inspect-deep.mjs b/factory/packages/backend/tmp-inspect-deep.mjs deleted file mode 100644 index fa5f8db9..00000000 --- a/factory/packages/backend/tmp-inspect-deep.mjs +++ /dev/null @@ -1,75 +0,0 @@ -import { Database } from "bun:sqlite"; -import { - TO_CLIENT_VERSIONED, - TO_SERVER_VERSIONED, - CURRENT_VERSION, - decodeWorkflowHistoryTransport, -} from "rivetkit/inspector"; -import { decodeReadRangeWire } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/encoding.ts"; -import { readRangeWireToOtlp } from "/rivet-handoff-fixes/rivetkit-typescript/packages/traces/src/read-range.ts"; - -const actorId = "2e443238457137bf"; -const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`, { readonly: true }); -const row = db.query("SELECT value FROM kv WHERE hex(key)=?").get("03"); -const token = new TextDecoder().decode(row.value); - -const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]); -ws.binaryType = "arraybuffer"; - -let sent = false; -const timeout = setTimeout(() => { - console.error("timeout"); - process.exit(2); -}, 20000); - -function send(body) { - const bytes = TO_SERVER_VERSIONED.serializeWithEmbeddedVersion({ body }, CURRENT_VERSION); - ws.send(bytes); -} - -ws.onmessage = (ev) => { - const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer); - const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data); - - if (!sent && msg.body.tag === "Init") { - const init = msg.body.val; - const wh = decodeWorkflowHistoryTransport(init.workflowHistory); - const queueSize = Number(init.queueSize); - console.log(JSON.stringify({ tag: "InitSummary", queueSize, rpcs: init.rpcs, historyEntries: wh.entries.length, names: wh.nameRegistry }, null, 2)); - - send({ tag: "QueueRequest", val: { id: 1n, limit: 20n } }); - send({ tag: "WorkflowHistoryRequest", val: { id: 2n } }); - send({ tag: "TraceQueryRequest", val: { id: 3n, startMs: 0n, endMs: BigInt(Date.now()), limit: 2000n } }); - sent = true; - return; - } - - if (msg.body.tag === "QueueResponse") { - const status = msg.body.val.status; - console.log(JSON.stringify({ tag: "QueueResponse", size: Number(status.size), truncated: status.truncated, messages: status.messages.map((m) => ({ id: Number(m.id), name: m.name, createdAtMs: Number(m.createdAtMs) })) }, null, 2)); - return; - } - - if (msg.body.tag === "WorkflowHistoryResponse") { - const wh = decodeWorkflowHistoryTransport(msg.body.val.history); - console.log(JSON.stringify({ tag: "WorkflowHistoryResponse", isWorkflowEnabled: msg.body.val.isWorkflowEnabled, entryCount: wh.entries.length, names: wh.nameRegistry }, null, 2)); - return; - } - - if (msg.body.tag === "TraceQueryResponse") { - const wire = decodeReadRangeWire(new Uint8Array(msg.body.val.payload)); - const otlp = readRangeWireToOtlp(wire, { attributes: [], droppedAttributesCount: 0 }); - const spans = (((otlp?.resourceSpans ?? [])[0]?.scopeSpans ?? [])[0]?.spans ?? []).map((s) => ({ name: s.name, status: s.status?.code })); - console.log(JSON.stringify({ tag: "TraceQueryResponse", spanCount: spans.length, tail: spans.slice(-25) }, null, 2)); - clearTimeout(timeout); - ws.close(); - process.exit(0); - return; - } -}; - -ws.onerror = (e) => { - console.error("ws error", e); - clearTimeout(timeout); - process.exit(1); -}; diff --git a/factory/packages/backend/tmp-inspect-stuck.mjs b/factory/packages/backend/tmp-inspect-stuck.mjs deleted file mode 100644 index 7bb8c086..00000000 --- a/factory/packages/backend/tmp-inspect-stuck.mjs +++ /dev/null @@ -1,45 +0,0 @@ -import { Database } from "bun:sqlite"; - -const actorIds = [ - "2e443238457137bf", // 7df... - "2b3fe1c099327eed", // 706... - "331b7f2a0cd19973", // 70c... - "329a70fc689f56ca", // 1f14... - "0e53dd77ef06862f", // 0e01... - "ea8c0e764c836e5f", // cdc error -]; - -function decodeAscii(u8) { - return new TextDecoder().decode(u8).replace(/[\x00-\x1F\x7F-\xFF]/g, "."); -} - -for (const actorId of actorIds) { - const dbPath = `/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`; - const db = new Database(dbPath, { readonly: true }); - - const wfStateRow = db.query("SELECT value FROM kv WHERE hex(key)=?").get("0715041501"); - const wfState = wfStateRow?.value ? decodeAscii(new Uint8Array(wfStateRow.value)) : null; - - const names = db - .query("SELECT value FROM kv WHERE hex(key) LIKE ? ORDER BY key") - .all("07150115%") - .map((r) => decodeAscii(new Uint8Array(r.value))); - - const queueRows = db - .query("SELECT hex(key) as k, value FROM kv WHERE hex(key) LIKE ? ORDER BY key") - .all("05%") - .map((r) => ({ - key: r.k, - preview: decodeAscii(new Uint8Array(r.value)).slice(0, 220), - })); - - const hasCreateSandboxStepName = names.includes("init-create-sandbox") || names.includes("init_create_sandbox"); - - console.log(JSON.stringify({ - actorId, - wfState, - hasCreateSandboxStepName, - names, - queue: queueRows, - }, null, 2)); -} diff --git a/factory/packages/backend/tmp-inspect-workflow.mjs b/factory/packages/backend/tmp-inspect-workflow.mjs deleted file mode 100644 index 3bc93551..00000000 --- a/factory/packages/backend/tmp-inspect-workflow.mjs +++ /dev/null @@ -1,30 +0,0 @@ -import { Database } from "bun:sqlite"; -import { TO_CLIENT_VERSIONED, decodeWorkflowHistoryTransport } from "rivetkit/inspector"; -import util from "node:util"; - -const actorId = "2e443238457137bf"; -const db = new Database(`/root/.local/share/openhandoff/rivetkit/databases/${actorId}.db`, { readonly: true }); -const row = db.query("SELECT value FROM kv WHERE hex(key) = ?").get("03"); -const token = new TextDecoder().decode(row.value); - -const ws = new WebSocket(`ws://127.0.0.1:7750/gateway/${actorId}/inspector/connect`, [`rivet_inspector_token.${token}`]); -ws.binaryType = "arraybuffer"; -const timeout = setTimeout(() => process.exit(2), 15000); -ws.onmessage = (ev) => { - const data = ev.data instanceof ArrayBuffer ? new Uint8Array(ev.data) : new Uint8Array(ev.data.buffer); - const msg = TO_CLIENT_VERSIONED.deserializeWithEmbeddedVersion(data); - const init = msg.body?.tag === "Init" ? msg.body.val : null; - if (!init) { - console.log("unexpected", util.inspect(msg, { depth: 4 })); - process.exit(1); - } - const decoded = decodeWorkflowHistoryTransport(init.workflowHistory); - console.log(util.inspect(decoded, { depth: 10, colors: false, compact: false, breakLength: 140 })); - clearTimeout(timeout); - ws.close(); - process.exit(0); -}; -ws.onerror = () => { - clearTimeout(timeout); - process.exit(1); -}; diff --git a/factory/packages/backend/tsconfig.json b/factory/packages/backend/tsconfig.json deleted file mode 100644 index 6a579dfd..00000000 --- a/factory/packages/backend/tsconfig.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist", - "strict": false, - "declaration": false - }, - "include": ["src", "test"] -} diff --git a/factory/packages/backend/vitest.config.ts b/factory/packages/backend/vitest.config.ts deleted file mode 100644 index 5723788e..00000000 --- a/factory/packages/backend/vitest.config.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - fileParallelism: false, - testTimeout: 15_000, - hookTimeout: 20_000, - setupFiles: ["./test/setup.ts"], - } -}); - diff --git a/factory/packages/cli/package.json b/factory/packages/cli/package.json deleted file mode 100644 index e6ff8f4d..00000000 --- a/factory/packages/cli/package.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "name": "@openhandoff/cli", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "bin": { - "hf": "dist/index.js" - }, - "scripts": { - "build": "tsup --config tsup.config.ts", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@iarna/toml": "^2.2.5", - "@opentui/core": "^0.1.77", - "@openhandoff/client": "workspace:*", - "@openhandoff/shared": "workspace:*", - "zod": "^4.1.5" - }, - "devDependencies": { - "tsup": "^8.5.0" - } -} diff --git a/factory/packages/cli/src/backend/manager.ts b/factory/packages/cli/src/backend/manager.ts deleted file mode 100644 index 0ae01cb0..00000000 --- a/factory/packages/cli/src/backend/manager.ts +++ /dev/null @@ -1,446 +0,0 @@ -import * as childProcess from "node:child_process"; -import { - closeSync, - existsSync, - mkdirSync, - openSync, - readFileSync, - rmSync, - writeFileSync -} from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { checkBackendHealth } from "@openhandoff/client"; -import type { AppConfig } from "@openhandoff/shared"; -import { CLI_BUILD_ID } from "../build-id.js"; - -const HEALTH_TIMEOUT_MS = 1_500; -const START_TIMEOUT_MS = 30_000; -const STOP_TIMEOUT_MS = 5_000; -const POLL_INTERVAL_MS = 150; - -function sleep(ms: number): Promise { - return new Promise((resolveSleep) => setTimeout(resolveSleep, ms)); -} - -function sanitizeHost(host: string): string { - return host - .split("") - .map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-")) - .join(""); -} - -function backendStateDir(): string { - const override = process.env.HF_BACKEND_STATE_DIR?.trim(); - if (override) { - return override; - } - - const xdgDataHome = process.env.XDG_DATA_HOME?.trim(); - if (xdgDataHome) { - return join(xdgDataHome, "openhandoff", "backend"); - } - - return join(homedir(), ".local", "share", "openhandoff", "backend"); -} - -function backendPidPath(host: string, port: number): string { - return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.pid`); -} - -function backendVersionPath(host: string, port: number): string { - return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.version`); -} - -function backendLogPath(host: string, port: number): string { - return join(backendStateDir(), `backend-${sanitizeHost(host)}-${port}.log`); -} - -function readText(path: string): string | null { - try { - return readFileSync(path, "utf8").trim(); - } catch { - return null; - } -} - -function readPid(host: string, port: number): number | null { - const raw = readText(backendPidPath(host, port)); - if (!raw) { - return null; - } - - const pid = Number.parseInt(raw, 10); - if (!Number.isInteger(pid) || pid <= 0) { - return null; - } - return pid; -} - -function writePid(host: string, port: number, pid: number): void { - const path = backendPidPath(host, port); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, String(pid), "utf8"); -} - -function removePid(host: string, port: number): void { - const path = backendPidPath(host, port); - if (existsSync(path)) { - rmSync(path); - } -} - -function readBackendVersion(host: string, port: number): string | null { - return readText(backendVersionPath(host, port)); -} - -function writeBackendVersion(host: string, port: number, buildId: string): void { - const path = backendVersionPath(host, port); - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, buildId, "utf8"); -} - -function removeBackendVersion(host: string, port: number): void { - const path = backendVersionPath(host, port); - if (existsSync(path)) { - rmSync(path); - } -} - -function readCliBuildId(): string { - const override = process.env.HF_BUILD_ID?.trim(); - if (override) { - return override; - } - - return CLI_BUILD_ID; -} - -function isVersionCurrent(host: string, port: number): boolean { - return readBackendVersion(host, port) === readCliBuildId(); -} - -function isProcessRunning(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException | undefined)?.code === "EPERM") { - return true; - } - return false; - } -} - -function removeStateFiles(host: string, port: number): void { - removePid(host, port); - removeBackendVersion(host, port); -} - -async function checkHealth(host: string, port: number): Promise { - return await checkBackendHealth({ - endpoint: `http://${host}:${port}/api/rivet`, - timeoutMs: HEALTH_TIMEOUT_MS - }); -} - -async function waitForHealth(host: string, port: number, timeoutMs: number, pid?: number): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if (pid && !isProcessRunning(pid)) { - throw new Error(`backend process ${pid} exited before becoming healthy`); - } - - if (await checkHealth(host, port)) { - return; - } - - await sleep(POLL_INTERVAL_MS); - } - - throw new Error(`backend did not become healthy within ${timeoutMs}ms`); -} - -async function waitForChildPid(child: childProcess.ChildProcess): Promise { - if (child.pid && child.pid > 0) { - return child.pid; - } - - for (let i = 0; i < 20; i += 1) { - await sleep(50); - if (child.pid && child.pid > 0) { - return child.pid; - } - } - - return null; -} - -interface LaunchSpec { - command: string; - args: string[]; - cwd: string; -} - -function resolveBunCommand(): string { - const override = process.env.HF_BUN?.trim(); - if (override && (override === "bun" || existsSync(override))) { - return override; - } - - const homeBun = join(homedir(), ".bun", "bin", "bun"); - if (existsSync(homeBun)) { - return homeBun; - } - - return "bun"; -} - -function resolveLaunchSpec(host: string, port: number): LaunchSpec { - const repoRoot = resolve(fileURLToPath(new URL("../../..", import.meta.url))); - const backendEntry = resolve(fileURLToPath(new URL("../../backend/dist/index.js", import.meta.url))); - - if (existsSync(backendEntry)) { - return { - command: resolveBunCommand(), - args: [backendEntry, "start", "--host", host, "--port", String(port)], - cwd: repoRoot - }; - } - - return { - command: "pnpm", - args: [ - "--filter", - "@openhandoff/backend", - "exec", - "bun", - "src/index.ts", - "start", - "--host", - host, - "--port", - String(port) - ], - cwd: repoRoot - }; -} - -async function startBackend(host: string, port: number): Promise { - if (await checkHealth(host, port)) { - return; - } - - const existingPid = readPid(host, port); - if (existingPid && isProcessRunning(existingPid)) { - await waitForHealth(host, port, START_TIMEOUT_MS, existingPid); - return; - } - - if (existingPid) { - removeStateFiles(host, port); - } - - const logPath = backendLogPath(host, port); - mkdirSync(dirname(logPath), { recursive: true }); - const fd = openSync(logPath, "a"); - - const launch = resolveLaunchSpec(host, port); - const child = childProcess.spawn(launch.command, launch.args, { - cwd: launch.cwd, - detached: true, - stdio: ["ignore", fd, fd], - env: process.env - }); - - child.on("error", (error) => { - console.error(`failed to launch backend: ${String(error)}`); - }); - - child.unref(); - closeSync(fd); - - const pid = await waitForChildPid(child); - - writeBackendVersion(host, port, readCliBuildId()); - if (pid) { - writePid(host, port, pid); - } - - try { - await waitForHealth(host, port, START_TIMEOUT_MS, pid ?? undefined); - } catch (error) { - if (pid) { - removeStateFiles(host, port); - } else { - removeBackendVersion(host, port); - } - throw error; - } -} - -function trySignal(pid: number, signal: NodeJS.Signals): boolean { - try { - process.kill(pid, signal); - return true; - } catch (error) { - if ((error as NodeJS.ErrnoException | undefined)?.code === "ESRCH") { - return false; - } - throw error; - } -} - -function findProcessOnPort(port: number): number | null { - try { - const out = childProcess - .execFileSync("lsof", ["-i", `:${port}`, "-t", "-sTCP:LISTEN"], { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"] - }) - .trim(); - - const pidRaw = out.split("\n")[0]?.trim(); - if (!pidRaw) { - return null; - } - - const pid = Number.parseInt(pidRaw, 10); - if (!Number.isInteger(pid) || pid <= 0) { - return null; - } - - return pid; - } catch { - return null; - } -} - -export async function stopBackend(host: string, port: number): Promise { - let pid = readPid(host, port); - - if (!pid) { - if (!(await checkHealth(host, port))) { - removeStateFiles(host, port); - return; - } - - pid = findProcessOnPort(port); - if (!pid) { - throw new Error(`backend is healthy at ${host}:${port} but no PID could be resolved`); - } - } - - if (!isProcessRunning(pid)) { - removeStateFiles(host, port); - return; - } - - trySignal(pid, "SIGTERM"); - - const deadline = Date.now() + STOP_TIMEOUT_MS; - while (Date.now() < deadline) { - if (!isProcessRunning(pid)) { - removeStateFiles(host, port); - return; - } - await sleep(100); - } - - trySignal(pid, "SIGKILL"); - removeStateFiles(host, port); -} - -export interface BackendStatus { - running: boolean; - pid: number | null; - version: string | null; - versionCurrent: boolean; - logPath: string; -} - -export async function getBackendStatus(host: string, port: number): Promise { - const logPath = backendLogPath(host, port); - const pid = readPid(host, port); - - if (pid) { - if (isProcessRunning(pid)) { - return { - running: true, - pid, - version: readBackendVersion(host, port), - versionCurrent: isVersionCurrent(host, port), - logPath - }; - } - removeStateFiles(host, port); - } - - if (await checkHealth(host, port)) { - return { - running: true, - pid: null, - version: readBackendVersion(host, port), - versionCurrent: isVersionCurrent(host, port), - logPath - }; - } - - return { - running: false, - pid: null, - version: readBackendVersion(host, port), - versionCurrent: false, - logPath - }; -} - -export async function ensureBackendRunning(config: AppConfig): Promise { - const host = config.backend.host; - const port = config.backend.port; - - if (await checkHealth(host, port)) { - if (!isVersionCurrent(host, port)) { - await stopBackend(host, port); - await startBackend(host, port); - } - return; - } - - const pid = readPid(host, port); - if (pid && isProcessRunning(pid)) { - try { - await waitForHealth(host, port, START_TIMEOUT_MS, pid); - if (!isVersionCurrent(host, port)) { - await stopBackend(host, port); - await startBackend(host, port); - } - return; - } catch { - await stopBackend(host, port); - await startBackend(host, port); - return; - } - } - - if (pid) { - removeStateFiles(host, port); - } - - await startBackend(host, port); -} - -export function parseBackendPort(value: string | undefined, fallback: number): number { - if (!value) { - return fallback; - } - - const port = Number(value); - if (!Number.isInteger(port) || port <= 0 || port > 65535) { - throw new Error(`Invalid backend port: ${value}`); - } - - return port; -} diff --git a/factory/packages/cli/src/build-id.ts b/factory/packages/cli/src/build-id.ts deleted file mode 100644 index 1f952af3..00000000 --- a/factory/packages/cli/src/build-id.ts +++ /dev/null @@ -1,7 +0,0 @@ -declare const __HF_BUILD_ID__: string | undefined; - -export const CLI_BUILD_ID = - typeof __HF_BUILD_ID__ === "string" && __HF_BUILD_ID__.trim().length > 0 - ? __HF_BUILD_ID__.trim() - : "dev"; - diff --git a/factory/packages/cli/src/index.ts b/factory/packages/cli/src/index.ts deleted file mode 100644 index 9d764bae..00000000 --- a/factory/packages/cli/src/index.ts +++ /dev/null @@ -1,754 +0,0 @@ -#!/usr/bin/env bun -import { spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; -import { AgentTypeSchema, CreateHandoffInputSchema, type HandoffRecord } from "@openhandoff/shared"; -import { - readBackendMetadata, - createBackendClientFromConfig, - formatRelativeAge, - groupHandoffStatus, - summarizeHandoffs -} from "@openhandoff/client"; -import { - ensureBackendRunning, - getBackendStatus, - parseBackendPort, - stopBackend -} from "./backend/manager.js"; -import { openEditorForTask } from "./task-editor.js"; -import { spawnCreateTmuxWindow } from "./tmux.js"; -import { loadConfig, resolveWorkspace, saveConfig } from "./workspace/config.js"; - -async function ensureBunRuntime(): Promise { - if (typeof (globalThis as { Bun?: unknown }).Bun !== "undefined") { - return; - } - - const preferred = process.env.HF_BUN?.trim(); - const candidates = [ - preferred, - `${homedir()}/.bun/bin/bun`, - "bun" - ].filter((item): item is string => Boolean(item && item.length > 0)); - - for (const candidate of candidates) { - const command = candidate; - const canExec = command === "bun" || existsSync(command); - if (!canExec) { - continue; - } - - const child = spawnSync(command, [process.argv[1] ?? "", ...process.argv.slice(2)], { - stdio: "inherit", - env: process.env - }); - - if (child.error) { - continue; - } - - const code = child.status ?? 1; - process.exit(code); - } - - throw new Error("hf requires Bun runtime. Set HF_BUN or install Bun at ~/.bun/bin/bun."); -} - -async function runTuiCommand(config: ReturnType, workspaceId: string): Promise { - const mod = await import("./tui.js"); - await mod.runTui(config, workspaceId); -} - -function readOption(args: string[], flag: string): string | undefined { - const idx = args.indexOf(flag); - if (idx < 0) return undefined; - return args[idx + 1]; -} - -function hasFlag(args: string[], flag: string): boolean { - return args.includes(flag); -} - -function parseIntOption( - value: string | undefined, - fallback: number, - label: string -): number { - if (!value) { - return fallback; - } - const parsed = Number.parseInt(value, 10); - if (!Number.isInteger(parsed) || parsed <= 0) { - throw new Error(`Invalid ${label}: ${value}`); - } - return parsed; -} - -function positionals(args: string[]): string[] { - const out: string[] = []; - for (let i = 0; i < args.length; i += 1) { - const item = args[i]; - if (!item) { - continue; - } - - if (item.startsWith("--")) { - const next = args[i + 1]; - if (next && !next.startsWith("--")) { - i += 1; - } - continue; - } - out.push(item); - } - return out; -} - -function printUsage(): void { - console.log(` -Usage: - hf backend start [--host HOST] [--port PORT] - hf backend stop [--host HOST] [--port PORT] - hf backend status - hf backend inspect - hf status [--workspace WS] [--json] - hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json] - hf workspace use - hf tui [--workspace WS] - - hf create [task] [--workspace WS] --repo [--name NAME|--branch NAME] [--title TITLE] [--agent claude|codex] [--on BRANCH] - hf list [--workspace WS] [--format table|json] [--full] - hf switch [handoff-id | -] [--workspace WS] - hf attach [--workspace WS] - hf merge [--workspace WS] - hf archive [--workspace WS] - hf push [--workspace WS] - hf sync [--workspace WS] - hf kill [--workspace WS] [--delete-branch] [--abandon] - hf prune [--workspace WS] [--dry-run] [--yes] - hf statusline [--workspace WS] [--format table|claude-code] - hf db path - hf db nuke - -Tips: - hf status --help Show status output format and examples - hf history --help Show history output format and examples - hf switch - Switch to most recently updated handoff -`); -} - -function printStatusUsage(): void { - console.log(` -Usage: - hf status [--workspace WS] [--json] - -Text Output: - workspace= - backend running= pid= version= - handoffs total= - status queued= running= idle= archived= killed= error= - providers = ... - providers - - -JSON Output: - { - "workspaceId": "default", - "backend": { ...backend status object... }, - "handoffs": { - "total": 4, - "byStatus": { "queued": 0, "running": 1, "idle": 2, "archived": 1, "killed": 0, "error": 0 }, - "byProvider": { "daytona": 4 } - } - } -`); -} - -function printHistoryUsage(): void { - console.log(` -Usage: - hf history [--workspace WS] [--limit N] [--branch NAME] [--handoff ID] [--json] - -Text Output: - \t\t\t - \t\t\t - no events - -Notes: - - payload is truncated to 120 characters in text mode. - - --limit defaults to 20. - -JSON Output: - [ - { - "id": "...", - "workspaceId": "default", - "kind": "handoff.created", - "handoffId": "...", - "repoId": "...", - "branchName": "feature/foo", - "payloadJson": "{\\"providerId\\":\\"daytona\\"}", - "createdAt": 1770607522229 - } - ] -`); -} - -async function handleBackend(args: string[]): Promise { - const sub = args[0] ?? "start"; - const config = loadConfig(); - const host = readOption(args, "--host") ?? config.backend.host; - const port = parseBackendPort(readOption(args, "--port"), config.backend.port); - const backendConfig = { - ...config, - backend: { - ...config.backend, - host, - port - } - }; - - if (sub === "start") { - await ensureBackendRunning(backendConfig); - const status = await getBackendStatus(host, port); - const pid = status.pid ?? "unknown"; - const version = status.version ?? "unknown"; - const stale = status.running && !status.versionCurrent ? " [outdated]" : ""; - console.log(`running=true pid=${pid} version=${version}${stale} log=${status.logPath}`); - return; - } - - if (sub === "stop") { - await stopBackend(host, port); - console.log(`running=false host=${host} port=${port}`); - return; - } - - if (sub === "status") { - const status = await getBackendStatus(host, port); - const pid = status.pid ?? "unknown"; - const version = status.version ?? "unknown"; - const stale = status.running && !status.versionCurrent ? " [outdated]" : ""; - console.log( - `running=${status.running} pid=${pid} version=${version}${stale} host=${host} port=${port} log=${status.logPath}` - ); - return; - } - - if (sub === "inspect") { - await ensureBackendRunning(backendConfig); - const metadata = await readBackendMetadata({ - endpoint: `http://${host}:${port}/api/rivet`, - timeoutMs: 4_000 - }); - const managerEndpoint = metadata.clientEndpoint ?? `http://${host}:${port}`; - const inspectorUrl = `https://inspect.rivet.dev?u=${encodeURIComponent(managerEndpoint)}`; - const openCmd = process.platform === "darwin" ? "open" : "xdg-open"; - spawnSync(openCmd, [inspectorUrl], { stdio: "ignore" }); - console.log(inspectorUrl); - return; - } - - throw new Error(`Unknown backend subcommand: ${sub}`); -} - -async function handleWorkspace(args: string[]): Promise { - const sub = args[0]; - if (sub !== "use") { - throw new Error("Usage: hf workspace use "); - } - - const name = args[1]; - if (!name) { - throw new Error("Missing workspace name"); - } - - const config = loadConfig(); - config.workspace.default = name; - saveConfig(config); - - const client = createBackendClientFromConfig(config); - try { - await client.useWorkspace(name); - } catch { - // Backend may not be running yet. Config is already updated. - } - - console.log(`workspace=${name}`); -} - -async function handleList(args: string[]): Promise { - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const format = readOption(args, "--format") ?? "table"; - const full = hasFlag(args, "--full"); - const client = createBackendClientFromConfig(config); - const rows = await client.listHandoffs(workspaceId); - - if (format === "json") { - console.log(JSON.stringify(rows, null, 2)); - return; - } - - if (rows.length === 0) { - console.log("no handoffs"); - return; - } - - for (const row of rows) { - const age = formatRelativeAge(row.updatedAt); - let line = `${row.handoffId}\t${row.branchName}\t${row.status}\t${row.providerId}\t${age}`; - if (full) { - const task = row.task.length > 60 ? `${row.task.slice(0, 57)}...` : row.task; - line += `\t${row.title}\t${task}\t${row.activeSessionId ?? "-"}\t${row.activeSandboxId ?? "-"}`; - } - console.log(line); - } -} - -async function handlePush(args: string[]): Promise { - const handoffId = positionals(args)[0]; - if (!handoffId) { - throw new Error("Missing handoff id for push"); - } - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const client = createBackendClientFromConfig(config); - await client.runAction(workspaceId, handoffId, "push"); - console.log("ok"); -} - -async function handleSync(args: string[]): Promise { - const handoffId = positionals(args)[0]; - if (!handoffId) { - throw new Error("Missing handoff id for sync"); - } - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const client = createBackendClientFromConfig(config); - await client.runAction(workspaceId, handoffId, "sync"); - console.log("ok"); -} - -async function handleKill(args: string[]): Promise { - const handoffId = positionals(args)[0]; - if (!handoffId) { - throw new Error("Missing handoff id for kill"); - } - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const deleteBranch = hasFlag(args, "--delete-branch"); - const abandon = hasFlag(args, "--abandon"); - - if (deleteBranch) { - console.log("info: --delete-branch flag set, branch will be deleted after kill"); - } - if (abandon) { - console.log("info: --abandon flag set, Graphite abandon will be attempted"); - } - - const client = createBackendClientFromConfig(config); - await client.runAction(workspaceId, handoffId, "kill"); - console.log("ok"); -} - -async function handlePrune(args: string[]): Promise { - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const dryRun = hasFlag(args, "--dry-run"); - const yes = hasFlag(args, "--yes"); - const client = createBackendClientFromConfig(config); - const rows = await client.listHandoffs(workspaceId); - const prunable = rows.filter((r) => r.status === "archived" || r.status === "killed"); - - if (prunable.length === 0) { - console.log("nothing to prune"); - return; - } - - for (const row of prunable) { - const age = formatRelativeAge(row.updatedAt); - console.log(`${dryRun ? "[dry-run] " : ""}${row.handoffId}\t${row.branchName}\t${row.status}\t${age}`); - } - - if (dryRun) { - console.log(`\n${prunable.length} handoff(s) would be pruned`); - return; - } - - if (!yes) { - console.log("\nnot yet implemented: auto-pruning requires confirmation"); - return; - } - - console.log(`\n${prunable.length} handoff(s) would be pruned (pruning not yet implemented)`); -} - -async function handleStatusline(args: string[]): Promise { - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const format = readOption(args, "--format") ?? "table"; - const client = createBackendClientFromConfig(config); - const rows = await client.listHandoffs(workspaceId); - const summary = summarizeHandoffs(rows); - const running = summary.byStatus.running; - const idle = summary.byStatus.idle; - const errorCount = summary.byStatus.error; - - if (format === "claude-code") { - console.log(`hf:${running}R/${idle}I/${errorCount}E`); - return; - } - - console.log(`running=${running} idle=${idle} error=${errorCount}`); -} - -async function handleDb(args: string[]): Promise { - const sub = args[0]; - if (sub === "path") { - const config = loadConfig(); - const dbPath = config.backend.dbPath.replace(/^~/, homedir()); - console.log(dbPath); - return; - } - - if (sub === "nuke") { - console.log("WARNING: hf db nuke would delete the entire database. This is a placeholder and does not delete anything."); - return; - } - - throw new Error("Usage: hf db path | hf db nuke"); -} - -async function waitForHandoffReady( - client: ReturnType, - workspaceId: string, - handoffId: string, - timeoutMs: number -): Promise { - const start = Date.now(); - let delayMs = 250; - - for (;;) { - const record = await client.getHandoff(workspaceId, handoffId); - const hasName = Boolean(record.branchName && record.title); - const hasSandbox = Boolean(record.activeSandboxId); - - if (record.status === "error") { - throw new Error(`handoff entered error state while provisioning: ${handoffId}`); - } - if (hasName && hasSandbox) { - return record; - } - - if (Date.now() - start > timeoutMs) { - throw new Error(`timed out waiting for handoff provisioning: ${handoffId}`); - } - - await new Promise((r) => setTimeout(r, delayMs)); - delayMs = Math.min(Math.round(delayMs * 1.5), 2_000); - } -} - -async function handleCreate(args: string[]): Promise { - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - - const repoRemote = readOption(args, "--repo"); - if (!repoRemote) { - throw new Error("Missing required --repo "); - } - const explicitBranchName = readOption(args, "--name") ?? readOption(args, "--branch"); - const explicitTitle = readOption(args, "--title"); - - const agentRaw = readOption(args, "--agent"); - const agentType = agentRaw ? AgentTypeSchema.parse(agentRaw) : undefined; - const onBranch = readOption(args, "--on"); - - const taskFromArgs = positionals(args).join(" ").trim(); - const task = taskFromArgs || openEditorForTask(); - - const client = createBackendClientFromConfig(config); - const repo = await client.addRepo(workspaceId, repoRemote); - - const payload = CreateHandoffInputSchema.parse({ - workspaceId, - repoId: repo.repoId, - task, - explicitTitle: explicitTitle || undefined, - explicitBranchName: explicitBranchName || undefined, - agentType, - onBranch - }); - - const created = await client.createHandoff(payload); - const handoff = await waitForHandoffReady(client, workspaceId, created.handoffId, 180_000); - const switched = await client.switchHandoff(workspaceId, handoff.handoffId); - const attached = await client.attachHandoff(workspaceId, handoff.handoffId); - - console.log(`Branch: ${handoff.branchName ?? "-"}`); - console.log(`Handoff: ${handoff.handoffId}`); - console.log(`Provider: ${handoff.providerId}`); - console.log(`Session: ${attached.sessionId ?? "none"}`); - console.log(`Target: ${switched.switchTarget || attached.target}`); - console.log(`Title: ${handoff.title ?? "-"}`); - - const tmuxResult = spawnCreateTmuxWindow({ - branchName: handoff.branchName ?? handoff.handoffId, - targetPath: switched.switchTarget || attached.target, - sessionId: attached.sessionId - }); - - if (tmuxResult.created) { - console.log(`Window: created (${handoff.branchName})`); - return; - } - - console.log(""); - console.log(`Run: hf switch ${handoff.handoffId}`); - if ((switched.switchTarget || attached.target).startsWith("/")) { - console.log(`cd ${(switched.switchTarget || attached.target)}`); - } -} - -async function handleTui(args: string[]): Promise { - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - await runTuiCommand(config, workspaceId); -} - -async function handleStatus(args: string[]): Promise { - if (hasFlag(args, "--help") || hasFlag(args, "-h")) { - printStatusUsage(); - return; - } - - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const client = createBackendClientFromConfig(config); - const backendStatus = await getBackendStatus(config.backend.host, config.backend.port); - const rows = await client.listHandoffs(workspaceId); - const summary = summarizeHandoffs(rows); - - if (hasFlag(args, "--json")) { - console.log( - JSON.stringify( - { - workspaceId, - backend: backendStatus, - handoffs: { - total: summary.total, - byStatus: summary.byStatus, - byProvider: summary.byProvider - } - }, - null, - 2 - ) - ); - return; - } - - console.log(`workspace=${workspaceId}`); - console.log( - `backend running=${backendStatus.running} pid=${backendStatus.pid ?? "unknown"} version=${backendStatus.version ?? "unknown"}` - ); - console.log(`handoffs total=${summary.total}`); - console.log( - `status queued=${summary.byStatus.queued} running=${summary.byStatus.running} idle=${summary.byStatus.idle} archived=${summary.byStatus.archived} killed=${summary.byStatus.killed} error=${summary.byStatus.error}` - ); - const providerSummary = Object.entries(summary.byProvider) - .map(([provider, count]) => `${provider}=${count}`) - .join(" "); - console.log(`providers ${providerSummary || "-"}`); -} - -async function handleHistory(args: string[]): Promise { - if (hasFlag(args, "--help") || hasFlag(args, "-h")) { - printHistoryUsage(); - return; - } - - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const limit = parseIntOption(readOption(args, "--limit"), 20, "limit"); - const branch = readOption(args, "--branch"); - const handoffId = readOption(args, "--handoff"); - const client = createBackendClientFromConfig(config); - const rows = await client.listHistory({ - workspaceId, - limit, - branch: branch || undefined, - handoffId: handoffId || undefined - }); - - if (hasFlag(args, "--json")) { - console.log(JSON.stringify(rows, null, 2)); - return; - } - - if (rows.length === 0) { - console.log("no events"); - return; - } - - for (const row of rows) { - const ts = new Date(row.createdAt).toISOString(); - const target = row.branchName || row.handoffId || row.repoId || "-"; - let payload = row.payloadJson; - if (payload.length > 120) { - payload = `${payload.slice(0, 117)}...`; - } - console.log(`${ts}\t${row.kind}\t${target}\t${payload}`); - } -} - -async function handleSwitchLike(cmd: string, args: string[]): Promise { - let handoffId = positionals(args)[0]; - if (!handoffId && cmd === "switch") { - await handleTui(args); - return; - } - - if (!handoffId) { - throw new Error(`Missing handoff id for ${cmd}`); - } - - const config = loadConfig(); - const workspaceId = resolveWorkspace(readOption(args, "--workspace"), config); - const client = createBackendClientFromConfig(config); - - if (cmd === "switch" && handoffId === "-") { - const rows = await client.listHandoffs(workspaceId); - const active = rows.filter((r) => { - const group = groupHandoffStatus(r.status); - return group === "running" || group === "idle" || group === "queued"; - }); - const sorted = active.sort((a, b) => b.updatedAt - a.updatedAt); - const target = sorted[0]; - if (!target) { - throw new Error("No active handoffs to switch to"); - } - handoffId = target.handoffId; - } - - if (cmd === "switch") { - const result = await client.switchHandoff(workspaceId, handoffId); - console.log(`cd ${result.switchTarget}`); - return; - } - - if (cmd === "attach") { - const result = await client.attachHandoff(workspaceId, handoffId); - console.log(`target=${result.target} session=${result.sessionId ?? "none"}`); - return; - } - - if (cmd === "merge" || cmd === "archive") { - await client.runAction(workspaceId, handoffId, cmd); - console.log("ok"); - return; - } - - throw new Error(`Unsupported action: ${cmd}`); -} - -async function main(): Promise { - await ensureBunRuntime(); - - const args = process.argv.slice(2); - const cmd = args[0]; - const rest = args.slice(1); - - if (cmd === "help" || cmd === "--help" || cmd === "-h") { - printUsage(); - return; - } - - if (cmd === "backend") { - await handleBackend(rest); - return; - } - - const config = loadConfig(); - await ensureBackendRunning(config); - - if (!cmd || cmd.startsWith("--")) { - await handleTui(args); - return; - } - - if (cmd === "workspace") { - await handleWorkspace(rest); - return; - } - - if (cmd === "create") { - await handleCreate(rest); - return; - } - - if (cmd === "list") { - await handleList(rest); - return; - } - - if (cmd === "tui") { - await handleTui(rest); - return; - } - - if (cmd === "status") { - await handleStatus(rest); - return; - } - - if (cmd === "history") { - await handleHistory(rest); - return; - } - - if (cmd === "push") { - await handlePush(rest); - return; - } - - if (cmd === "sync") { - await handleSync(rest); - return; - } - - if (cmd === "kill") { - await handleKill(rest); - return; - } - - if (cmd === "prune") { - await handlePrune(rest); - return; - } - - if (cmd === "statusline") { - await handleStatusline(rest); - return; - } - - if (cmd === "db") { - await handleDb(rest); - return; - } - - if (["switch", "attach", "merge", "archive"].includes(cmd)) { - await handleSwitchLike(cmd, rest); - return; - } - - printUsage(); - throw new Error(`Unknown command: ${cmd}`); -} - -main().catch((err: unknown) => { - const msg = err instanceof Error ? err.stack ?? err.message : String(err); - console.error(msg); - process.exit(1); -}); diff --git a/factory/packages/cli/src/task-editor.ts b/factory/packages/cli/src/task-editor.ts deleted file mode 100644 index 3c94367a..00000000 --- a/factory/packages/cli/src/task-editor.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { spawnSync } from "node:child_process"; - -const DEFAULT_EDITOR_TEMPLATE = [ - "# Enter handoff task details below.", - "# Lines starting with # are ignored.", - "" -].join("\n"); - -export function sanitizeEditorTask(input: string): string { - return input - .split(/\r?\n/) - .filter((line) => !line.trim().startsWith("#")) - .join("\n") - .trim(); -} - -export function openEditorForTask(): string { - const editor = process.env.VISUAL?.trim() || process.env.EDITOR?.trim() || "vi"; - const tempDir = mkdtempSync(join(tmpdir(), "hf-task-")); - const taskPath = join(tempDir, "task.md"); - - try { - writeFileSync(taskPath, DEFAULT_EDITOR_TEMPLATE, "utf8"); - const result = spawnSync(editor, [taskPath], { stdio: "inherit" }); - - if (result.error) { - throw result.error; - } - if ((result.status ?? 1) !== 0) { - throw new Error(`Editor exited with status ${result.status ?? "unknown"}`); - } - - const raw = readFileSync(taskPath, "utf8"); - const task = sanitizeEditorTask(raw); - if (!task) { - throw new Error("Missing handoff task text"); - } - return task; - } finally { - rmSync(tempDir, { recursive: true, force: true }); - } -} diff --git a/factory/packages/cli/src/theme.ts b/factory/packages/cli/src/theme.ts deleted file mode 100644 index 5c6a9176..00000000 --- a/factory/packages/cli/src/theme.ts +++ /dev/null @@ -1,811 +0,0 @@ -import { existsSync, readFileSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, isAbsolute, join, resolve } from "node:path"; -import { cwd } from "node:process"; -import * as toml from "@iarna/toml"; -import type { AppConfig } from "@openhandoff/shared"; -import opencodeThemePackJson from "./themes/opencode-pack.json" with { type: "json" }; - -export type ThemeMode = "dark" | "light"; - -export interface TuiTheme { - background: string; - text: string; - muted: string; - header: string; - status: string; - highlightBg: string; - highlightFg: string; - selectionBorder: string; - success: string; - warning: string; - error: string; - info: string; - diffAdd: string; - diffDel: string; - diffSep: string; - agentRunning: string; - agentIdle: string; - agentNone: string; - agentError: string; - prUnpushed: string; - author: string; - ciRunning: string; - ciPass: string; - ciFail: string; - ciNone: string; - reviewApproved: string; - reviewChanges: string; - reviewPending: string; - reviewNone: string; -} - -export interface TuiThemeResolution { - theme: TuiTheme; - name: string; - source: string; - mode: ThemeMode; -} - -interface ThemeCandidate { - theme: TuiTheme; - name: string; -} - -type JsonObject = Record; - -type ConfigLike = AppConfig & { theme?: string }; - -const DEFAULT_THEME: TuiTheme = { - background: "#282828", - text: "#ffffff", - muted: "#6b7280", - header: "#6b7280", - status: "#6b7280", - highlightBg: "#282828", - highlightFg: "#ffffff", - selectionBorder: "#d946ef", - success: "#22c55e", - warning: "#eab308", - error: "#ef4444", - info: "#22d3ee", - diffAdd: "#22c55e", - diffDel: "#ef4444", - diffSep: "#6b7280", - agentRunning: "#22c55e", - agentIdle: "#eab308", - agentNone: "#6b7280", - agentError: "#ef4444", - prUnpushed: "#eab308", - author: "#22d3ee", - ciRunning: "#eab308", - ciPass: "#22c55e", - ciFail: "#ef4444", - ciNone: "#6b7280", - reviewApproved: "#22c55e", - reviewChanges: "#ef4444", - reviewPending: "#eab308", - reviewNone: "#6b7280" -}; - -const OPENCODE_THEME_PACK = opencodeThemePackJson as Record; - -export function resolveTuiTheme(config: AppConfig, baseDir = cwd()): TuiThemeResolution { - const mode = opencodeStateThemeMode() ?? "dark"; - const configWithTheme = config as ConfigLike; - const override = typeof configWithTheme.theme === "string" ? configWithTheme.theme.trim() : ""; - - if (override) { - const candidate = loadFromSpec(override, [], mode, baseDir); - if (candidate) { - return { - theme: candidate.theme, - name: candidate.name, - source: "openhandoff config", - mode - }; - } - } - - const fromConfig = loadOpencodeThemeFromConfig(mode, baseDir); - if (fromConfig) { - return fromConfig; - } - - const fromState = loadOpencodeThemeFromState(mode, baseDir); - if (fromState) { - return fromState; - } - - return { - theme: DEFAULT_THEME, - name: "opencode-default", - source: "default", - mode - }; -} - -function loadOpencodeThemeFromConfig(mode: ThemeMode, baseDir: string): TuiThemeResolution | null { - for (const path of opencodeConfigPaths(baseDir)) { - if (!existsSync(path)) { - continue; - } - - const value = readJsonWithComments(path); - if (!value) { - continue; - } - - const themeValue = findOpencodeThemeValue(value); - if (themeValue === undefined) { - continue; - } - - const candidate = themeFromOpencodeValue(themeValue, opencodeThemeDirs(dirname(path), baseDir), mode, baseDir); - if (!candidate) { - continue; - } - - return { - theme: candidate.theme, - name: candidate.name, - source: `opencode config (${path})`, - mode - }; - } - - return null; -} - -function loadOpencodeThemeFromState(mode: ThemeMode, baseDir: string): TuiThemeResolution | null { - const path = opencodeStatePath(); - if (!path || !existsSync(path)) { - return null; - } - - const value = readJsonWithComments(path); - if (!isObject(value)) { - return null; - } - - const spec = value.theme; - if (typeof spec !== "string" || !spec.trim()) { - return null; - } - - const candidate = loadFromSpec(spec.trim(), opencodeThemeDirs(undefined, baseDir), mode, baseDir); - if (!candidate) { - return null; - } - - return { - theme: candidate.theme, - name: candidate.name, - source: `opencode state (${path})`, - mode - }; -} - -function loadFromSpec( - spec: string, - searchDirs: string[], - mode: ThemeMode, - baseDir: string -): ThemeCandidate | null { - if (isDefaultThemeName(spec)) { - return { - theme: DEFAULT_THEME, - name: "opencode-default" - }; - } - - if (isPathLike(spec)) { - const resolved = resolvePath(spec, baseDir); - if (existsSync(resolved)) { - const candidate = loadThemeFromPath(resolved, mode); - if (candidate) { - return candidate; - } - } - } - - for (const dir of searchDirs) { - for (const ext of ["json", "toml"]) { - const path = join(dir, `${spec}.${ext}`); - if (!existsSync(path)) { - continue; - } - - const candidate = loadThemeFromPath(path, mode); - if (candidate) { - return candidate; - } - } - } - - const builtIn = OPENCODE_THEME_PACK[spec]; - if (builtIn !== undefined) { - const theme = themeFromOpencodeJson(builtIn, mode); - if (theme) { - return { - theme, - name: spec - }; - } - } - - return null; -} - -function loadThemeFromPath(path: string, mode: ThemeMode): ThemeCandidate | null { - const content = safeReadText(path); - if (!content) { - return null; - } - - const lower = path.toLowerCase(); - if (lower.endsWith(".toml")) { - try { - const parsed = toml.parse(content); - const theme = themeFromAny(parsed); - if (!theme) { - return null; - } - return { - theme, - name: themeNameFromPath(path) - }; - } catch { - return null; - } - } - - const value = parseJsonWithComments(content); - if (!value) { - return null; - } - - const opencodeTheme = themeFromOpencodeJson(value, mode); - if (opencodeTheme) { - return { - theme: opencodeTheme, - name: themeNameFromPath(path) - }; - } - - const paletteTheme = themeFromAny(value); - if (!paletteTheme) { - return null; - } - - return { - theme: paletteTheme, - name: themeNameFromPath(path) - }; -} - -function themeNameFromPath(path: string): string { - const base = path.split(/[\\/]/).pop() ?? path; - if (base.endsWith(".json") || base.endsWith(".toml")) { - return base.replace(/\.(json|toml)$/i, ""); - } - return base; -} - -function themeFromOpencodeValue( - value: unknown, - searchDirs: string[], - mode: ThemeMode, - baseDir: string -): ThemeCandidate | null { - if (typeof value === "string") { - return loadFromSpec(value, searchDirs, mode, baseDir); - } - - if (!isObject(value)) { - return null; - } - - if (value.theme !== undefined) { - const theme = themeFromOpencodeJson(value, mode); - if (theme) { - return { - theme, - name: typeof value.name === "string" ? value.name : "inline" - }; - } - } - - const paletteTheme = themeFromAny(value.colors ?? value.palette ?? value); - if (paletteTheme) { - return { - theme: paletteTheme, - name: typeof value.name === "string" ? value.name : "inline" - }; - } - - if (typeof value.name === "string") { - const named = loadFromSpec(value.name, searchDirs, mode, baseDir); - if (named) { - return named; - } - } - - const pathLike = value.path ?? value.file; - if (typeof pathLike === "string") { - const resolved = resolvePath(pathLike, baseDir); - const candidate = loadThemeFromPath(resolved, mode); - if (candidate) { - return candidate; - } - } - - return null; -} - -function themeFromOpencodeJson(value: unknown, mode: ThemeMode): TuiTheme | null { - if (!isObject(value)) { - return null; - } - - const themeMap = value.theme; - if (!isObject(themeMap)) { - return null; - } - - const defs = isObject(value.defs) ? value.defs : {}; - - const background = - opencodeColor(themeMap, defs, mode, "background") ?? - opencodeColor(themeMap, defs, mode, "backgroundPanel") ?? - opencodeColor(themeMap, defs, mode, "backgroundElement") ?? - DEFAULT_THEME.background; - - const text = opencodeColor(themeMap, defs, mode, "text") ?? DEFAULT_THEME.text; - const muted = opencodeColor(themeMap, defs, mode, "textMuted") ?? DEFAULT_THEME.muted; - const highlightBg = opencodeColor(themeMap, defs, mode, "text") ?? text; - const highlightFg = - opencodeColor(themeMap, defs, mode, "backgroundElement") ?? - opencodeColor(themeMap, defs, mode, "backgroundPanel") ?? - opencodeColor(themeMap, defs, mode, "background") ?? - DEFAULT_THEME.highlightFg; - - const selectionBorder = - opencodeColor(themeMap, defs, mode, "secondary") ?? - opencodeColor(themeMap, defs, mode, "accent") ?? - opencodeColor(themeMap, defs, mode, "primary") ?? - DEFAULT_THEME.selectionBorder; - - const success = opencodeColor(themeMap, defs, mode, "success") ?? DEFAULT_THEME.success; - const warning = opencodeColor(themeMap, defs, mode, "warning") ?? DEFAULT_THEME.warning; - const error = opencodeColor(themeMap, defs, mode, "error") ?? DEFAULT_THEME.error; - const info = opencodeColor(themeMap, defs, mode, "info") ?? DEFAULT_THEME.info; - const diffAdd = opencodeColor(themeMap, defs, mode, "diffAdded") ?? success; - const diffDel = opencodeColor(themeMap, defs, mode, "diffRemoved") ?? error; - const diffSep = - opencodeColor(themeMap, defs, mode, "diffContext") ?? - opencodeColor(themeMap, defs, mode, "diffHunkHeader") ?? - muted; - - return { - background, - text, - muted, - header: muted, - status: muted, - highlightBg, - highlightFg, - selectionBorder, - success, - warning, - error, - info, - diffAdd, - diffDel, - diffSep, - agentRunning: success, - agentIdle: warning, - agentNone: muted, - agentError: error, - prUnpushed: warning, - author: info, - ciRunning: warning, - ciPass: success, - ciFail: error, - ciNone: muted, - reviewApproved: success, - reviewChanges: error, - reviewPending: warning, - reviewNone: muted - }; -} - -function opencodeColor(themeMap: JsonObject, defs: JsonObject, mode: ThemeMode, key: string): string | null { - const raw = themeMap[key]; - if (raw === undefined) { - return null; - } - return resolveOpencodeColor(raw, themeMap, defs, mode, 0); -} - -function resolveOpencodeColor( - value: unknown, - themeMap: JsonObject, - defs: JsonObject, - mode: ThemeMode, - depth: number -): string | null { - if (depth > 12) { - return null; - } - - if (typeof value === "string") { - const trimmed = value.trim(); - if (!trimmed || trimmed.toLowerCase() === "transparent" || trimmed.toLowerCase() === "none") { - return null; - } - - const fromDefs = defs[trimmed]; - if (fromDefs !== undefined) { - return resolveOpencodeColor(fromDefs, themeMap, defs, mode, depth + 1); - } - - const fromTheme = themeMap[trimmed]; - if (fromTheme !== undefined) { - return resolveOpencodeColor(fromTheme, themeMap, defs, mode, depth + 1); - } - - if (isColorLike(trimmed)) { - return trimmed; - } - - return null; - } - - if (isObject(value)) { - const nested = value[mode]; - if (nested !== undefined) { - return resolveOpencodeColor(nested, themeMap, defs, mode, depth + 1); - } - } - - return null; -} - -function themeFromAny(value: unknown): TuiTheme | null { - const palette = extractPalette(value); - if (!palette) { - return null; - } - - const pick = (keys: string[], fallback: string): string => { - for (const key of keys) { - const v = palette[normalizeKey(key)]; - if (v && isColorLike(v)) { - return v; - } - } - return fallback; - }; - - const background = pick(["background", "bg", "base", "background_color"], DEFAULT_THEME.background); - const text = pick(["text", "foreground", "fg", "primary"], DEFAULT_THEME.text); - const muted = pick(["muted", "subtle", "secondary", "dim"], DEFAULT_THEME.muted); - const header = pick(["header", "header_text"], muted); - const status = pick(["status", "status_text"], muted); - const highlightBg = pick(["highlight_bg", "selection", "highlight", "accent_bg"], DEFAULT_THEME.highlightBg); - const highlightFg = pick(["highlight_fg", "selection_fg", "accent_fg"], text); - const selectionBorder = pick(["selection_border", "highlight_border", "accent", "secondary"], DEFAULT_THEME.selectionBorder); - const success = pick(["success", "green"], DEFAULT_THEME.success); - const warning = pick(["warning", "yellow"], DEFAULT_THEME.warning); - const error = pick(["error", "red"], DEFAULT_THEME.error); - const info = pick(["info", "cyan", "blue"], DEFAULT_THEME.info); - const diffAdd = pick(["diff_add", "diff_addition", "add"], success); - const diffDel = pick(["diff_del", "diff_deletion", "delete"], error); - const diffSep = pick(["diff_sep", "diff_separator", "separator"], muted); - - return { - background, - text, - muted, - header, - status, - highlightBg, - highlightFg, - selectionBorder, - success, - warning, - error, - info, - diffAdd, - diffDel, - diffSep, - agentRunning: pick(["agent_running", "running"], success), - agentIdle: pick(["agent_idle", "idle"], warning), - agentNone: pick(["agent_none", "none"], muted), - agentError: pick(["agent_error", "agent_failed"], error), - prUnpushed: pick(["pr_unpushed", "unpushed"], warning), - author: pick(["author"], info), - ciRunning: pick(["ci_running"], warning), - ciPass: pick(["ci_pass", "ci_success"], success), - ciFail: pick(["ci_fail", "ci_error"], error), - ciNone: pick(["ci_none", "ci_unknown"], muted), - reviewApproved: pick(["review_approved", "approved"], success), - reviewChanges: pick(["review_changes", "changes"], error), - reviewPending: pick(["review_pending", "pending"], warning), - reviewNone: pick(["review_none", "review_unknown"], muted) - }; -} - -function extractPalette(value: unknown): Record | null { - if (!isObject(value)) { - return null; - } - - const colors = isObject(value.colors) ? value.colors : undefined; - const palette = isObject(value.palette) ? value.palette : undefined; - const source = colors ?? palette ?? value; - if (!isObject(source)) { - return null; - } - - const out: Record = {}; - for (const [key, raw] of Object.entries(source)) { - if (typeof raw !== "string") { - continue; - } - out[normalizeKey(key)] = raw; - } - - return Object.keys(out).length > 0 ? out : null; -} - -function normalizeKey(key: string): string { - return key.toLowerCase().replace(/[\-\s.]/g, "_"); -} - -function isColorLike(value: string): boolean { - const lower = value.trim().toLowerCase(); - if (!lower) { - return false; - } - - if (/^#[0-9a-f]{3}$/.test(lower) || /^#[0-9a-f]{6}$/.test(lower) || /^#[0-9a-f]{8}$/.test(lower)) { - return true; - } - - if (/^rgba?\(\s*\d+\s*,\s*\d+\s*,\s*\d+(\s*,\s*[\d.]+)?\s*\)$/.test(lower)) { - return true; - } - - return /^[a-z_\-]+$/.test(lower); -} - -function findOpencodeThemeValue(value: unknown): unknown { - if (!isObject(value)) { - return undefined; - } - - if (value.theme !== undefined) { - return value.theme; - } - - return pointer(value, ["ui", "theme"]) ?? pointer(value, ["tui", "theme"]) ?? pointer(value, ["options", "theme"]); -} - -function pointer(obj: JsonObject, parts: string[]): unknown { - let current: unknown = obj; - for (const part of parts) { - if (!isObject(current)) { - return undefined; - } - current = current[part]; - } - return current; -} - -function opencodeConfigPaths(baseDir: string): string[] { - const paths: string[] = []; - - const rootish = opencodeProjectConfigPaths(baseDir); - paths.push(...rootish); - - const configDir = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); - const opencodeDir = join(configDir, "opencode"); - paths.push(join(opencodeDir, "opencode.json")); - paths.push(join(opencodeDir, "opencode.jsonc")); - paths.push(join(opencodeDir, "config.json")); - - return paths; -} - -function opencodeThemeDirs(configDir: string | undefined, baseDir: string): string[] { - const dirs: string[] = []; - - if (configDir) { - dirs.push(join(configDir, "themes")); - } - - const xdgConfig = process.env.XDG_CONFIG_HOME || join(homedir(), ".config"); - dirs.push(join(xdgConfig, "opencode", "themes")); - dirs.push(join(homedir(), ".opencode", "themes")); - - dirs.push(...opencodeProjectThemeDirs(baseDir)); - - return dirs; -} - -function opencodeProjectConfigPaths(baseDir: string): string[] { - const dirs = ancestorDirs(baseDir); - const out: string[] = []; - for (const dir of dirs) { - out.push(join(dir, "opencode.json")); - out.push(join(dir, "opencode.jsonc")); - out.push(join(dir, ".opencode", "opencode.json")); - out.push(join(dir, ".opencode", "opencode.jsonc")); - } - return out; -} - -function opencodeProjectThemeDirs(baseDir: string): string[] { - const dirs = ancestorDirs(baseDir); - const out: string[] = []; - for (const dir of dirs) { - out.push(join(dir, ".opencode", "themes")); - } - return out; -} - -function ancestorDirs(start: string): string[] { - const out: string[] = []; - let current = resolve(start); - - while (true) { - out.push(current); - const parent = dirname(current); - if (parent === current) { - break; - } - current = parent; - } - - return out; -} - -function opencodeStatePath(): string | null { - const stateHome = process.env.XDG_STATE_HOME || join(homedir(), ".local", "state"); - return join(stateHome, "opencode", "kv.json"); -} - -function opencodeStateThemeMode(): ThemeMode | null { - const path = opencodeStatePath(); - if (!path || !existsSync(path)) { - return null; - } - - const value = readJsonWithComments(path); - if (!isObject(value)) { - return null; - } - - const mode = value.theme_mode; - if (typeof mode !== "string") { - return null; - } - - const lower = mode.toLowerCase(); - if (lower === "dark" || lower === "light") { - return lower; - } - - return null; -} - -function parseJsonWithComments(content: string): unknown { - try { - return JSON.parse(content); - } catch { - // Fall through. - } - - try { - return JSON.parse(stripJsoncComments(content)); - } catch { - return null; - } -} - -function readJsonWithComments(path: string): unknown { - const content = safeReadText(path); - if (!content) { - return null; - } - return parseJsonWithComments(content); -} - -function stripJsoncComments(input: string): string { - let output = ""; - let i = 0; - let inString = false; - let escaped = false; - - while (i < input.length) { - const ch = input[i]; - - if (inString) { - output += ch; - if (escaped) { - escaped = false; - } else if (ch === "\\") { - escaped = true; - } else if (ch === '"') { - inString = false; - } - i += 1; - continue; - } - - if (ch === '"') { - inString = true; - output += ch; - i += 1; - continue; - } - - if (ch === "/" && input[i + 1] === "/") { - i += 2; - while (i < input.length && input[i] !== "\n") { - i += 1; - } - continue; - } - - if (ch === "/" && input[i + 1] === "*") { - i += 2; - while (i < input.length) { - if (input[i] === "*" && input[i + 1] === "/") { - i += 2; - break; - } - i += 1; - } - continue; - } - - output += ch; - i += 1; - } - - return output; -} - -function safeReadText(path: string): string | null { - try { - return readFileSync(path, "utf8"); - } catch { - return null; - } -} - -function resolvePath(path: string, baseDir: string): string { - if (path.startsWith("~/")) { - return join(homedir(), path.slice(2)); - } - if (isAbsolute(path)) { - return path; - } - return resolve(baseDir, path); -} - -function isPathLike(spec: string): boolean { - return spec.includes("/") || spec.includes("\\") || spec.endsWith(".json") || spec.endsWith(".toml"); -} - -function isDefaultThemeName(spec: string): boolean { - const lower = spec.toLowerCase(); - return lower === "default" || lower === "opencode" || lower === "opencode-default" || lower === "system"; -} - -function isObject(value: unknown): value is JsonObject { - return typeof value === "object" && value !== null && !Array.isArray(value); -} diff --git a/factory/packages/cli/src/themes/opencode-pack.json b/factory/packages/cli/src/themes/opencode-pack.json deleted file mode 100644 index 391bca19..00000000 --- a/factory/packages/cli/src/themes/opencode-pack.json +++ /dev/null @@ -1,7408 +0,0 @@ -{ - "aura": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#0f0f0f", - "darkBgPanel": "#15141b", - "darkBorder": "#2d2d2d", - "darkFgMuted": "#6d6d6d", - "darkFg": "#edecee", - "purple": "#a277ff", - "pink": "#f694ff", - "blue": "#82e2ff", - "red": "#ff6767", - "orange": "#ffca85", - "cyan": "#61ffca", - "green": "#9dff65" - }, - "theme": { - "primary": "purple", - "secondary": "pink", - "accent": "purple", - "error": "red", - "warning": "orange", - "success": "cyan", - "info": "purple", - "text": "darkFg", - "textMuted": "darkFgMuted", - "background": "darkBg", - "backgroundPanel": "darkBgPanel", - "backgroundElement": "darkBgPanel", - "border": "darkBorder", - "borderActive": "darkFgMuted", - "borderSubtle": "darkBorder", - "diffAdded": "cyan", - "diffRemoved": "red", - "diffContext": "darkFgMuted", - "diffHunkHeader": "darkFgMuted", - "diffHighlightAdded": "cyan", - "diffHighlightRemoved": "red", - "diffAddedBg": "#354933", - "diffRemovedBg": "#3f191a", - "diffContextBg": "darkBgPanel", - "diffLineNumber": "darkBorder", - "diffAddedLineNumberBg": "#162620", - "diffRemovedLineNumberBg": "#26161a", - "markdownText": "darkFg", - "markdownHeading": "purple", - "markdownLink": "pink", - "markdownLinkText": "purple", - "markdownCode": "cyan", - "markdownBlockQuote": "darkFgMuted", - "markdownEmph": "orange", - "markdownStrong": "purple", - "markdownHorizontalRule": "darkFgMuted", - "markdownListItem": "purple", - "markdownListEnumeration": "purple", - "markdownImage": "pink", - "markdownImageText": "purple", - "markdownCodeBlock": "darkFg", - "syntaxComment": "darkFgMuted", - "syntaxKeyword": "pink", - "syntaxFunction": "purple", - "syntaxVariable": "purple", - "syntaxString": "cyan", - "syntaxNumber": "green", - "syntaxType": "purple", - "syntaxOperator": "pink", - "syntaxPunctuation": "darkFg" - } - }, - "ayu": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#0B0E14", - "darkBgAlt": "#0D1017", - "darkLine": "#11151C", - "darkPanel": "#0F131A", - "darkFg": "#BFBDB6", - "darkFgMuted": "#565B66", - "darkGutter": "#6C7380", - "darkTag": "#39BAE6", - "darkFunc": "#FFB454", - "darkEntity": "#59C2FF", - "darkString": "#AAD94C", - "darkRegexp": "#95E6CB", - "darkMarkup": "#F07178", - "darkKeyword": "#FF8F40", - "darkSpecial": "#E6B673", - "darkComment": "#ACB6BF", - "darkConstant": "#D2A6FF", - "darkOperator": "#F29668", - "darkAdded": "#7FD962", - "darkRemoved": "#F26D78", - "darkAccent": "#E6B450", - "darkError": "#D95757", - "darkIndentActive": "#6C7380" - }, - "theme": { - "primary": "darkEntity", - "secondary": "darkConstant", - "accent": "darkAccent", - "error": "darkError", - "warning": "darkSpecial", - "success": "darkAdded", - "info": "darkTag", - "text": "darkFg", - "textMuted": "darkFgMuted", - "background": "darkBg", - "backgroundPanel": "darkPanel", - "backgroundElement": "darkBgAlt", - "border": "darkGutter", - "borderActive": "darkIndentActive", - "borderSubtle": "darkLine", - "diffAdded": "darkAdded", - "diffRemoved": "darkRemoved", - "diffContext": "darkComment", - "diffHunkHeader": "darkComment", - "diffHighlightAdded": "darkString", - "diffHighlightRemoved": "darkMarkup", - "diffAddedBg": "#20303b", - "diffRemovedBg": "#37222c", - "diffContextBg": "darkPanel", - "diffLineNumber": "darkGutter", - "diffAddedLineNumberBg": "#1b2b34", - "diffRemovedLineNumberBg": "#2d1f26", - "markdownText": "darkFg", - "markdownHeading": "darkConstant", - "markdownLink": "darkEntity", - "markdownLinkText": "darkTag", - "markdownCode": "darkString", - "markdownBlockQuote": "darkSpecial", - "markdownEmph": "darkSpecial", - "markdownStrong": "darkFunc", - "markdownHorizontalRule": "darkFgMuted", - "markdownListItem": "darkEntity", - "markdownListEnumeration": "darkTag", - "markdownImage": "darkEntity", - "markdownImageText": "darkTag", - "markdownCodeBlock": "darkFg", - "syntaxComment": "darkComment", - "syntaxKeyword": "darkKeyword", - "syntaxFunction": "darkFunc", - "syntaxVariable": "darkEntity", - "syntaxString": "darkString", - "syntaxNumber": "darkConstant", - "syntaxType": "darkSpecial", - "syntaxOperator": "darkOperator", - "syntaxPunctuation": "darkFg" - } - }, - "carbonfox": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "bg0": "#0d0d0d", - "bg1": "#161616", - "bg1a": "#1a1a1a", - "bg2": "#1e1e1e", - "bg3": "#262626", - "bg4": "#303030", - "fg0": "#ffffff", - "fg1": "#f2f4f8", - "fg2": "#a9afbc", - "fg3": "#7d848f", - "lbg0": "#ffffff", - "lbg1": "#f4f4f4", - "lbg2": "#e8e8e8", - "lbg3": "#dcdcdc", - "lfg0": "#000000", - "lfg1": "#161616", - "lfg2": "#525252", - "lfg3": "#6f6f6f", - "red": "#ee5396", - "green": "#25be6a", - "yellow": "#08bdba", - "blue": "#78a9ff", - "magenta": "#be95ff", - "cyan": "#33b1ff", - "white": "#dfdfe0", - "orange": "#3ddbd9", - "pink": "#ff7eb6", - "blueBright": "#8cb6ff", - "cyanBright": "#52c7ff", - "greenBright": "#46c880", - "redLight": "#9f1853", - "greenLight": "#198038", - "yellowLight": "#007d79", - "blueLight": "#0043ce", - "magentaLight": "#6929c4", - "cyanLight": "#0072c3", - "warning": "#f1c21b", - "diffGreen": "#50fa7b", - "diffRed": "#ff6b6b", - "diffGreenBg": "#0f2418", - "diffRedBg": "#2a1216" - }, - "theme": { - "primary": { - "dark": "cyan", - "light": "blueLight" - }, - "secondary": { - "dark": "blue", - "light": "blueLight" - }, - "accent": { - "dark": "pink", - "light": "redLight" - }, - "error": { - "dark": "red", - "light": "redLight" - }, - "warning": { - "dark": "warning", - "light": "yellowLight" - }, - "success": { - "dark": "green", - "light": "greenLight" - }, - "info": { - "dark": "blue", - "light": "blueLight" - }, - "text": { - "dark": "fg1", - "light": "lfg1" - }, - "textMuted": { - "dark": "fg3", - "light": "lfg3" - }, - "background": { - "dark": "bg1", - "light": "lbg0" - }, - "backgroundPanel": { - "dark": "bg1a", - "light": "lbg1" - }, - "backgroundElement": { - "dark": "bg2", - "light": "lbg1" - }, - "border": { - "dark": "bg4", - "light": "lbg3" - }, - "borderActive": { - "dark": "cyan", - "light": "blueLight" - }, - "borderSubtle": { - "dark": "bg3", - "light": "lbg2" - }, - "diffAdded": { - "dark": "diffGreen", - "light": "greenLight" - }, - "diffRemoved": { - "dark": "diffRed", - "light": "redLight" - }, - "diffContext": { - "dark": "fg3", - "light": "lfg3" - }, - "diffHunkHeader": { - "dark": "blue", - "light": "blueLight" - }, - "diffHighlightAdded": { - "dark": "#7dffaa", - "light": "greenLight" - }, - "diffHighlightRemoved": { - "dark": "#ff9999", - "light": "redLight" - }, - "diffAddedBg": { - "dark": "diffGreenBg", - "light": "#defbe6" - }, - "diffRemovedBg": { - "dark": "diffRedBg", - "light": "#fff1f1" - }, - "diffContextBg": { - "dark": "bg1", - "light": "lbg1" - }, - "diffLineNumber": { - "dark": "fg3", - "light": "lfg3" - }, - "diffAddedLineNumberBg": { - "dark": "diffGreenBg", - "light": "#defbe6" - }, - "diffRemovedLineNumberBg": { - "dark": "diffRedBg", - "light": "#fff1f1" - }, - "markdownText": { - "dark": "fg1", - "light": "lfg1" - }, - "markdownHeading": { - "dark": "blueBright", - "light": "blueLight" - }, - "markdownLink": { - "dark": "blue", - "light": "blueLight" - }, - "markdownLinkText": { - "dark": "cyan", - "light": "cyanLight" - }, - "markdownCode": { - "dark": "green", - "light": "greenLight" - }, - "markdownBlockQuote": { - "dark": "fg3", - "light": "lfg3" - }, - "markdownEmph": { - "dark": "magenta", - "light": "magentaLight" - }, - "markdownStrong": { - "dark": "fg0", - "light": "lfg0" - }, - "markdownHorizontalRule": { - "dark": "bg4", - "light": "lbg3" - }, - "markdownListItem": { - "dark": "cyan", - "light": "cyanLight" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "cyanLight" - }, - "markdownImage": { - "dark": "blue", - "light": "blueLight" - }, - "markdownImageText": { - "dark": "cyan", - "light": "cyanLight" - }, - "markdownCodeBlock": { - "dark": "fg2", - "light": "lfg2" - }, - "syntaxComment": { - "dark": "fg3", - "light": "lfg3" - }, - "syntaxKeyword": { - "dark": "magenta", - "light": "magentaLight" - }, - "syntaxFunction": { - "dark": "blueBright", - "light": "blueLight" - }, - "syntaxVariable": { - "dark": "white", - "light": "lfg1" - }, - "syntaxString": { - "dark": "green", - "light": "greenLight" - }, - "syntaxNumber": { - "dark": "orange", - "light": "yellowLight" - }, - "syntaxType": { - "dark": "yellow", - "light": "yellowLight" - }, - "syntaxOperator": { - "dark": "fg2", - "light": "lfg2" - }, - "syntaxPunctuation": { - "dark": "fg2", - "light": "lfg1" - } - } - }, - "catppuccin-frappe": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "frappeRosewater": "#f2d5cf", - "frappeFlamingo": "#eebebe", - "frappePink": "#f4b8e4", - "frappeMauve": "#ca9ee6", - "frappeRed": "#e78284", - "frappeMaroon": "#ea999c", - "frappePeach": "#ef9f76", - "frappeYellow": "#e5c890", - "frappeGreen": "#a6d189", - "frappeTeal": "#81c8be", - "frappeSky": "#99d1db", - "frappeSapphire": "#85c1dc", - "frappeBlue": "#8da4e2", - "frappeLavender": "#babbf1", - "frappeText": "#c6d0f5", - "frappeSubtext1": "#b5bfe2", - "frappeSubtext0": "#a5adce", - "frappeOverlay2": "#949cb8", - "frappeOverlay1": "#838ba7", - "frappeOverlay0": "#737994", - "frappeSurface2": "#626880", - "frappeSurface1": "#51576d", - "frappeSurface0": "#414559", - "frappeBase": "#303446", - "frappeMantle": "#292c3c", - "frappeCrust": "#232634" - }, - "theme": { - "primary": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "secondary": { - "dark": "frappeMauve", - "light": "frappeMauve" - }, - "accent": { - "dark": "frappePink", - "light": "frappePink" - }, - "error": { - "dark": "frappeRed", - "light": "frappeRed" - }, - "warning": { - "dark": "frappeYellow", - "light": "frappeYellow" - }, - "success": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "info": { - "dark": "frappeTeal", - "light": "frappeTeal" - }, - "text": { - "dark": "frappeText", - "light": "frappeText" - }, - "textMuted": { - "dark": "frappeSubtext1", - "light": "frappeSubtext1" - }, - "background": { - "dark": "frappeBase", - "light": "frappeBase" - }, - "backgroundPanel": { - "dark": "frappeMantle", - "light": "frappeMantle" - }, - "backgroundElement": { - "dark": "frappeCrust", - "light": "frappeCrust" - }, - "border": { - "dark": "frappeSurface0", - "light": "frappeSurface0" - }, - "borderActive": { - "dark": "frappeSurface1", - "light": "frappeSurface1" - }, - "borderSubtle": { - "dark": "frappeSurface2", - "light": "frappeSurface2" - }, - "diffAdded": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "diffRemoved": { - "dark": "frappeRed", - "light": "frappeRed" - }, - "diffContext": { - "dark": "frappeOverlay2", - "light": "frappeOverlay2" - }, - "diffHunkHeader": { - "dark": "frappePeach", - "light": "frappePeach" - }, - "diffHighlightAdded": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "diffHighlightRemoved": { - "dark": "frappeRed", - "light": "frappeRed" - }, - "diffAddedBg": { - "dark": "#29342b", - "light": "#29342b" - }, - "diffRemovedBg": { - "dark": "#3a2a31", - "light": "#3a2a31" - }, - "diffContextBg": { - "dark": "frappeMantle", - "light": "frappeMantle" - }, - "diffLineNumber": { - "dark": "frappeSurface1", - "light": "frappeSurface1" - }, - "diffAddedLineNumberBg": { - "dark": "#223025", - "light": "#223025" - }, - "diffRemovedLineNumberBg": { - "dark": "#2f242b", - "light": "#2f242b" - }, - "markdownText": { - "dark": "frappeText", - "light": "frappeText" - }, - "markdownHeading": { - "dark": "frappeMauve", - "light": "frappeMauve" - }, - "markdownLink": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "markdownLinkText": { - "dark": "frappeSky", - "light": "frappeSky" - }, - "markdownCode": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "markdownBlockQuote": { - "dark": "frappeYellow", - "light": "frappeYellow" - }, - "markdownEmph": { - "dark": "frappeYellow", - "light": "frappeYellow" - }, - "markdownStrong": { - "dark": "frappePeach", - "light": "frappePeach" - }, - "markdownHorizontalRule": { - "dark": "frappeSubtext0", - "light": "frappeSubtext0" - }, - "markdownListItem": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "markdownListEnumeration": { - "dark": "frappeSky", - "light": "frappeSky" - }, - "markdownImage": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "markdownImageText": { - "dark": "frappeSky", - "light": "frappeSky" - }, - "markdownCodeBlock": { - "dark": "frappeText", - "light": "frappeText" - }, - "syntaxComment": { - "dark": "frappeOverlay2", - "light": "frappeOverlay2" - }, - "syntaxKeyword": { - "dark": "frappeMauve", - "light": "frappeMauve" - }, - "syntaxFunction": { - "dark": "frappeBlue", - "light": "frappeBlue" - }, - "syntaxVariable": { - "dark": "frappeRed", - "light": "frappeRed" - }, - "syntaxString": { - "dark": "frappeGreen", - "light": "frappeGreen" - }, - "syntaxNumber": { - "dark": "frappePeach", - "light": "frappePeach" - }, - "syntaxType": { - "dark": "frappeYellow", - "light": "frappeYellow" - }, - "syntaxOperator": { - "dark": "frappeSky", - "light": "frappeSky" - }, - "syntaxPunctuation": { - "dark": "frappeText", - "light": "frappeText" - } - } - }, - "catppuccin-macchiato": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "macRosewater": "#f4dbd6", - "macFlamingo": "#f0c6c6", - "macPink": "#f5bde6", - "macMauve": "#c6a0f6", - "macRed": "#ed8796", - "macMaroon": "#ee99a0", - "macPeach": "#f5a97f", - "macYellow": "#eed49f", - "macGreen": "#a6da95", - "macTeal": "#8bd5ca", - "macSky": "#91d7e3", - "macSapphire": "#7dc4e4", - "macBlue": "#8aadf4", - "macLavender": "#b7bdf8", - "macText": "#cad3f5", - "macSubtext1": "#b8c0e0", - "macSubtext0": "#a5adcb", - "macOverlay2": "#939ab7", - "macOverlay1": "#8087a2", - "macOverlay0": "#6e738d", - "macSurface2": "#5b6078", - "macSurface1": "#494d64", - "macSurface0": "#363a4f", - "macBase": "#24273a", - "macMantle": "#1e2030", - "macCrust": "#181926" - }, - "theme": { - "primary": { - "dark": "macBlue", - "light": "macBlue" - }, - "secondary": { - "dark": "macMauve", - "light": "macMauve" - }, - "accent": { - "dark": "macPink", - "light": "macPink" - }, - "error": { - "dark": "macRed", - "light": "macRed" - }, - "warning": { - "dark": "macYellow", - "light": "macYellow" - }, - "success": { - "dark": "macGreen", - "light": "macGreen" - }, - "info": { - "dark": "macTeal", - "light": "macTeal" - }, - "text": { - "dark": "macText", - "light": "macText" - }, - "textMuted": { - "dark": "macSubtext1", - "light": "macSubtext1" - }, - "background": { - "dark": "macBase", - "light": "macBase" - }, - "backgroundPanel": { - "dark": "macMantle", - "light": "macMantle" - }, - "backgroundElement": { - "dark": "macCrust", - "light": "macCrust" - }, - "border": { - "dark": "macSurface0", - "light": "macSurface0" - }, - "borderActive": { - "dark": "macSurface1", - "light": "macSurface1" - }, - "borderSubtle": { - "dark": "macSurface2", - "light": "macSurface2" - }, - "diffAdded": { - "dark": "macGreen", - "light": "macGreen" - }, - "diffRemoved": { - "dark": "macRed", - "light": "macRed" - }, - "diffContext": { - "dark": "macOverlay2", - "light": "macOverlay2" - }, - "diffHunkHeader": { - "dark": "macPeach", - "light": "macPeach" - }, - "diffHighlightAdded": { - "dark": "macGreen", - "light": "macGreen" - }, - "diffHighlightRemoved": { - "dark": "macRed", - "light": "macRed" - }, - "diffAddedBg": { - "dark": "#29342b", - "light": "#29342b" - }, - "diffRemovedBg": { - "dark": "#3a2a31", - "light": "#3a2a31" - }, - "diffContextBg": { - "dark": "macMantle", - "light": "macMantle" - }, - "diffLineNumber": { - "dark": "macSurface1", - "light": "macSurface1" - }, - "diffAddedLineNumberBg": { - "dark": "#223025", - "light": "#223025" - }, - "diffRemovedLineNumberBg": { - "dark": "#2f242b", - "light": "#2f242b" - }, - "markdownText": { - "dark": "macText", - "light": "macText" - }, - "markdownHeading": { - "dark": "macMauve", - "light": "macMauve" - }, - "markdownLink": { - "dark": "macBlue", - "light": "macBlue" - }, - "markdownLinkText": { - "dark": "macSky", - "light": "macSky" - }, - "markdownCode": { - "dark": "macGreen", - "light": "macGreen" - }, - "markdownBlockQuote": { - "dark": "macYellow", - "light": "macYellow" - }, - "markdownEmph": { - "dark": "macYellow", - "light": "macYellow" - }, - "markdownStrong": { - "dark": "macPeach", - "light": "macPeach" - }, - "markdownHorizontalRule": { - "dark": "macSubtext0", - "light": "macSubtext0" - }, - "markdownListItem": { - "dark": "macBlue", - "light": "macBlue" - }, - "markdownListEnumeration": { - "dark": "macSky", - "light": "macSky" - }, - "markdownImage": { - "dark": "macBlue", - "light": "macBlue" - }, - "markdownImageText": { - "dark": "macSky", - "light": "macSky" - }, - "markdownCodeBlock": { - "dark": "macText", - "light": "macText" - }, - "syntaxComment": { - "dark": "macOverlay2", - "light": "macOverlay2" - }, - "syntaxKeyword": { - "dark": "macMauve", - "light": "macMauve" - }, - "syntaxFunction": { - "dark": "macBlue", - "light": "macBlue" - }, - "syntaxVariable": { - "dark": "macRed", - "light": "macRed" - }, - "syntaxString": { - "dark": "macGreen", - "light": "macGreen" - }, - "syntaxNumber": { - "dark": "macPeach", - "light": "macPeach" - }, - "syntaxType": { - "dark": "macYellow", - "light": "macYellow" - }, - "syntaxOperator": { - "dark": "macSky", - "light": "macSky" - }, - "syntaxPunctuation": { - "dark": "macText", - "light": "macText" - } - } - }, - "catppuccin": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "lightRosewater": "#dc8a78", - "lightFlamingo": "#dd7878", - "lightPink": "#ea76cb", - "lightMauve": "#8839ef", - "lightRed": "#d20f39", - "lightMaroon": "#e64553", - "lightPeach": "#fe640b", - "lightYellow": "#df8e1d", - "lightGreen": "#40a02b", - "lightTeal": "#179299", - "lightSky": "#04a5e5", - "lightSapphire": "#209fb5", - "lightBlue": "#1e66f5", - "lightLavender": "#7287fd", - "lightText": "#4c4f69", - "lightSubtext1": "#5c5f77", - "lightSubtext0": "#6c6f85", - "lightOverlay2": "#7c7f93", - "lightOverlay1": "#8c8fa1", - "lightOverlay0": "#9ca0b0", - "lightSurface2": "#acb0be", - "lightSurface1": "#bcc0cc", - "lightSurface0": "#ccd0da", - "lightBase": "#eff1f5", - "lightMantle": "#e6e9ef", - "lightCrust": "#dce0e8", - "darkRosewater": "#f5e0dc", - "darkFlamingo": "#f2cdcd", - "darkPink": "#f5c2e7", - "darkMauve": "#cba6f7", - "darkRed": "#f38ba8", - "darkMaroon": "#eba0ac", - "darkPeach": "#fab387", - "darkYellow": "#f9e2af", - "darkGreen": "#a6e3a1", - "darkTeal": "#94e2d5", - "darkSky": "#89dceb", - "darkSapphire": "#74c7ec", - "darkBlue": "#89b4fa", - "darkLavender": "#b4befe", - "darkText": "#cdd6f4", - "darkSubtext1": "#bac2de", - "darkSubtext0": "#a6adc8", - "darkOverlay2": "#9399b2", - "darkOverlay1": "#7f849c", - "darkOverlay0": "#6c7086", - "darkSurface2": "#585b70", - "darkSurface1": "#45475a", - "darkSurface0": "#313244", - "darkBase": "#1e1e2e", - "darkMantle": "#181825", - "darkCrust": "#11111b" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkMauve", - "light": "lightMauve" - }, - "accent": { - "dark": "darkPink", - "light": "lightPink" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkTeal", - "light": "lightTeal" - }, - "text": { - "dark": "darkText", - "light": "lightText" - }, - "textMuted": { - "dark": "darkSubtext1", - "light": "lightSubtext1" - }, - "background": { - "dark": "darkBase", - "light": "lightBase" - }, - "backgroundPanel": { - "dark": "darkMantle", - "light": "lightMantle" - }, - "backgroundElement": { - "dark": "darkCrust", - "light": "lightCrust" - }, - "border": { - "dark": "darkSurface0", - "light": "lightSurface0" - }, - "borderActive": { - "dark": "darkSurface1", - "light": "lightSurface1" - }, - "borderSubtle": { - "dark": "darkSurface2", - "light": "lightSurface2" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkOverlay2", - "light": "lightOverlay2" - }, - "diffHunkHeader": { - "dark": "darkPeach", - "light": "lightPeach" - }, - "diffHighlightAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffHighlightRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffAddedBg": { - "dark": "#24312b", - "light": "#d6f0d9" - }, - "diffRemovedBg": { - "dark": "#3c2a32", - "light": "#f6dfe2" - }, - "diffContextBg": { - "dark": "darkMantle", - "light": "lightMantle" - }, - "diffLineNumber": { - "dark": "darkSurface1", - "light": "lightSurface1" - }, - "diffAddedLineNumberBg": { - "dark": "#1e2a25", - "light": "#c9e3cb" - }, - "diffRemovedLineNumberBg": { - "dark": "#32232a", - "light": "#e9d3d6" - }, - "markdownText": { - "dark": "darkText", - "light": "lightText" - }, - "markdownHeading": { - "dark": "darkMauve", - "light": "lightMauve" - }, - "markdownLink": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLinkText": { - "dark": "darkSky", - "light": "lightSky" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkPeach", - "light": "lightPeach" - }, - "markdownHorizontalRule": { - "dark": "darkSubtext0", - "light": "lightSubtext0" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkSky", - "light": "lightSky" - }, - "markdownImage": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownImageText": { - "dark": "darkSky", - "light": "lightSky" - }, - "markdownCodeBlock": { - "dark": "darkText", - "light": "lightText" - }, - "syntaxComment": { - "dark": "darkOverlay2", - "light": "lightOverlay2" - }, - "syntaxKeyword": { - "dark": "darkMauve", - "light": "lightMauve" - }, - "syntaxFunction": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkPeach", - "light": "lightPeach" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkSky", - "light": "lightSky" - }, - "syntaxPunctuation": { - "dark": "darkText", - "light": "lightText" - } - } - }, - "cobalt2": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#193549", - "backgroundAlt": "#122738", - "backgroundPanel": "#1f4662", - "foreground": "#ffffff", - "foregroundMuted": "#adb7c9", - "yellow": "#ffc600", - "yellowBright": "#ffe14c", - "orange": "#ff9d00", - "orangeBright": "#ffb454", - "mint": "#2affdf", - "mintBright": "#7efff5", - "blue": "#0088ff", - "blueBright": "#5cb7ff", - "pink": "#ff628c", - "pinkBright": "#ff86a5", - "green": "#9eff80", - "greenBright": "#b9ff9f", - "purple": "#9a5feb", - "purpleBright": "#b88cfd", - "red": "#ff0088", - "redBright": "#ff5fb3" - }, - "theme": { - "primary": { - "dark": "blue", - "light": "#0066cc" - }, - "secondary": { - "dark": "purple", - "light": "#7c4dff" - }, - "accent": { - "dark": "mint", - "light": "#00acc1" - }, - "error": { - "dark": "red", - "light": "#e91e63" - }, - "warning": { - "dark": "yellow", - "light": "#ff9800" - }, - "success": { - "dark": "green", - "light": "#4caf50" - }, - "info": { - "dark": "orange", - "light": "#ff5722" - }, - "text": { - "dark": "foreground", - "light": "#193549" - }, - "textMuted": { - "dark": "foregroundMuted", - "light": "#5c6b7d" - }, - "background": { - "dark": "#193549", - "light": "#ffffff" - }, - "backgroundPanel": { - "dark": "#122738", - "light": "#f5f7fa" - }, - "backgroundElement": { - "dark": "#1f4662", - "light": "#e8ecf1" - }, - "border": { - "dark": "#1f4662", - "light": "#d3dae3" - }, - "borderActive": { - "dark": "blue", - "light": "#0066cc" - }, - "borderSubtle": { - "dark": "#0e1e2e", - "light": "#e8ecf1" - }, - "diffAdded": { - "dark": "green", - "light": "#4caf50" - }, - "diffRemoved": { - "dark": "red", - "light": "#e91e63" - }, - "diffContext": { - "dark": "foregroundMuted", - "light": "#5c6b7d" - }, - "diffHunkHeader": { - "dark": "mint", - "light": "#00acc1" - }, - "diffHighlightAdded": { - "dark": "greenBright", - "light": "#4caf50" - }, - "diffHighlightRemoved": { - "dark": "redBright", - "light": "#e91e63" - }, - "diffAddedBg": { - "dark": "#1a3a2a", - "light": "#e8f5e9" - }, - "diffRemovedBg": { - "dark": "#3a1a2a", - "light": "#ffebee" - }, - "diffContextBg": { - "dark": "#122738", - "light": "#f5f7fa" - }, - "diffLineNumber": { - "dark": "#2d5a7b", - "light": "#b0bec5" - }, - "diffAddedLineNumberBg": { - "dark": "#1a3a2a", - "light": "#e8f5e9" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1a2a", - "light": "#ffebee" - }, - "markdownText": { - "dark": "foreground", - "light": "#193549" - }, - "markdownHeading": { - "dark": "yellow", - "light": "#ff9800" - }, - "markdownLink": { - "dark": "blue", - "light": "#0066cc" - }, - "markdownLinkText": { - "dark": "mint", - "light": "#00acc1" - }, - "markdownCode": { - "dark": "green", - "light": "#4caf50" - }, - "markdownBlockQuote": { - "dark": "foregroundMuted", - "light": "#5c6b7d" - }, - "markdownEmph": { - "dark": "orange", - "light": "#ff5722" - }, - "markdownStrong": { - "dark": "pink", - "light": "#e91e63" - }, - "markdownHorizontalRule": { - "dark": "#2d5a7b", - "light": "#d3dae3" - }, - "markdownListItem": { - "dark": "blue", - "light": "#0066cc" - }, - "markdownListEnumeration": { - "dark": "mint", - "light": "#00acc1" - }, - "markdownImage": { - "dark": "blue", - "light": "#0066cc" - }, - "markdownImageText": { - "dark": "mint", - "light": "#00acc1" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#193549" - }, - "syntaxComment": { - "dark": "#0088ff", - "light": "#5c6b7d" - }, - "syntaxKeyword": { - "dark": "orange", - "light": "#ff5722" - }, - "syntaxFunction": { - "dark": "yellow", - "light": "#ff9800" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#193549" - }, - "syntaxString": { - "dark": "green", - "light": "#4caf50" - }, - "syntaxNumber": { - "dark": "pink", - "light": "#e91e63" - }, - "syntaxType": { - "dark": "mint", - "light": "#00acc1" - }, - "syntaxOperator": { - "dark": "orange", - "light": "#ff5722" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#193549" - } - } - }, - "cursor": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#181818", - "darkPanel": "#141414", - "darkElement": "#262626", - "darkFg": "#e4e4e4", - "darkMuted": "#e4e4e45e", - "darkBorder": "#e4e4e413", - "darkBorderActive": "#e4e4e426", - "darkCyan": "#88c0d0", - "darkBlue": "#81a1c1", - "darkGreen": "#3fa266", - "darkGreenBright": "#70b489", - "darkRed": "#e34671", - "darkRedBright": "#fc6b83", - "darkYellow": "#f1b467", - "darkOrange": "#d2943e", - "darkPink": "#E394DC", - "darkPurple": "#AAA0FA", - "darkTeal": "#82D2CE", - "darkSyntaxYellow": "#F8C762", - "darkSyntaxOrange": "#EFB080", - "darkSyntaxGreen": "#A8CC7C", - "darkSyntaxBlue": "#87C3FF", - "lightBg": "#fcfcfc", - "lightPanel": "#f3f3f3", - "lightElement": "#ededed", - "lightFg": "#141414", - "lightMuted": "#141414ad", - "lightBorder": "#14141413", - "lightBorderActive": "#14141426", - "lightTeal": "#6f9ba6", - "lightBlue": "#3c7cab", - "lightBlueDark": "#206595", - "lightGreen": "#1f8a65", - "lightGreenBright": "#55a583", - "lightRed": "#cf2d56", - "lightRedBright": "#e75e78", - "lightOrange": "#db704b", - "lightYellow": "#c08532", - "lightPurple": "#9e94d5", - "lightPurpleDark": "#6049b3", - "lightPink": "#b8448b", - "lightMagenta": "#b3003f" - }, - "theme": { - "primary": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "secondary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "accent": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkPanel", - "light": "lightPanel" - }, - "backgroundElement": { - "dark": "darkElement", - "light": "lightElement" - }, - "border": { - "dark": "darkBorder", - "light": "lightBorder" - }, - "borderActive": { - "dark": "darkCyan", - "light": "lightTeal" - }, - "borderSubtle": { - "dark": "#0f0f0f", - "light": "#e0e0e0" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "diffHunkHeader": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "diffHighlightAdded": { - "dark": "darkGreenBright", - "light": "lightGreenBright" - }, - "diffHighlightRemoved": { - "dark": "darkRedBright", - "light": "lightRedBright" - }, - "diffAddedBg": { - "dark": "#3fa26633", - "light": "#1f8a651f" - }, - "diffRemovedBg": { - "dark": "#b8004933", - "light": "#cf2d5614" - }, - "diffContextBg": { - "dark": "darkPanel", - "light": "lightPanel" - }, - "diffLineNumber": { - "dark": "#e4e4e442", - "light": "#1414147a" - }, - "diffAddedLineNumberBg": { - "dark": "#3fa26633", - "light": "#1f8a651f" - }, - "diffRemovedLineNumberBg": { - "dark": "#b8004933", - "light": "#cf2d5614" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightBlueDark" - }, - "markdownLink": { - "dark": "darkTeal", - "light": "lightBlueDark" - }, - "markdownLinkText": { - "dark": "darkBlue", - "light": "lightMuted" - }, - "markdownCode": { - "dark": "darkPink", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "markdownEmph": { - "dark": "darkTeal", - "light": "lightFg" - }, - "markdownStrong": { - "dark": "darkSyntaxYellow", - "light": "lightFg" - }, - "markdownHorizontalRule": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "markdownListItem": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightMuted" - }, - "markdownImage": { - "dark": "darkCyan", - "light": "lightBlueDark" - }, - "markdownImageText": { - "dark": "darkBlue", - "light": "lightMuted" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkMuted", - "light": "lightMuted" - }, - "syntaxKeyword": { - "dark": "darkTeal", - "light": "lightMagenta" - }, - "syntaxFunction": { - "dark": "darkSyntaxOrange", - "light": "lightOrange" - }, - "syntaxVariable": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxString": { - "dark": "darkPink", - "light": "lightPurple" - }, - "syntaxNumber": { - "dark": "darkSyntaxYellow", - "light": "lightPink" - }, - "syntaxType": { - "dark": "darkSyntaxOrange", - "light": "lightBlueDark" - }, - "syntaxOperator": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } - }, - "dracula": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#282a36", - "currentLine": "#44475a", - "selection": "#44475a", - "foreground": "#f8f8f2", - "comment": "#6272a4", - "cyan": "#8be9fd", - "green": "#50fa7b", - "orange": "#ffb86c", - "pink": "#ff79c6", - "purple": "#bd93f9", - "red": "#ff5555", - "yellow": "#f1fa8c" - }, - "theme": { - "primary": { - "dark": "purple", - "light": "purple" - }, - "secondary": { - "dark": "pink", - "light": "pink" - }, - "accent": { - "dark": "cyan", - "light": "cyan" - }, - "error": { - "dark": "red", - "light": "red" - }, - "warning": { - "dark": "yellow", - "light": "yellow" - }, - "success": { - "dark": "green", - "light": "green" - }, - "info": { - "dark": "orange", - "light": "orange" - }, - "text": { - "dark": "foreground", - "light": "#282a36" - }, - "textMuted": { - "dark": "comment", - "light": "#6272a4" - }, - "background": { - "dark": "#282a36", - "light": "#f8f8f2" - }, - "backgroundPanel": { - "dark": "#21222c", - "light": "#e8e8e2" - }, - "backgroundElement": { - "dark": "currentLine", - "light": "#d8d8d2" - }, - "border": { - "dark": "currentLine", - "light": "#c8c8c2" - }, - "borderActive": { - "dark": "purple", - "light": "purple" - }, - "borderSubtle": { - "dark": "#191a21", - "light": "#e0e0e0" - }, - "diffAdded": { - "dark": "green", - "light": "green" - }, - "diffRemoved": { - "dark": "red", - "light": "red" - }, - "diffContext": { - "dark": "comment", - "light": "#6272a4" - }, - "diffHunkHeader": { - "dark": "comment", - "light": "#6272a4" - }, - "diffHighlightAdded": { - "dark": "green", - "light": "green" - }, - "diffHighlightRemoved": { - "dark": "red", - "light": "red" - }, - "diffAddedBg": { - "dark": "#1a3a1a", - "light": "#e0ffe0" - }, - "diffRemovedBg": { - "dark": "#3a1a1a", - "light": "#ffe0e0" - }, - "diffContextBg": { - "dark": "#21222c", - "light": "#e8e8e2" - }, - "diffLineNumber": { - "dark": "currentLine", - "light": "#c8c8c2" - }, - "diffAddedLineNumberBg": { - "dark": "#1a3a1a", - "light": "#e0ffe0" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1a1a", - "light": "#ffe0e0" - }, - "markdownText": { - "dark": "foreground", - "light": "#282a36" - }, - "markdownHeading": { - "dark": "purple", - "light": "purple" - }, - "markdownLink": { - "dark": "cyan", - "light": "cyan" - }, - "markdownLinkText": { - "dark": "pink", - "light": "pink" - }, - "markdownCode": { - "dark": "green", - "light": "green" - }, - "markdownBlockQuote": { - "dark": "comment", - "light": "#6272a4" - }, - "markdownEmph": { - "dark": "yellow", - "light": "yellow" - }, - "markdownStrong": { - "dark": "orange", - "light": "orange" - }, - "markdownHorizontalRule": { - "dark": "comment", - "light": "#6272a4" - }, - "markdownListItem": { - "dark": "purple", - "light": "purple" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "cyan" - }, - "markdownImage": { - "dark": "cyan", - "light": "cyan" - }, - "markdownImageText": { - "dark": "pink", - "light": "pink" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#282a36" - }, - "syntaxComment": { - "dark": "comment", - "light": "#6272a4" - }, - "syntaxKeyword": { - "dark": "pink", - "light": "pink" - }, - "syntaxFunction": { - "dark": "green", - "light": "green" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#282a36" - }, - "syntaxString": { - "dark": "yellow", - "light": "yellow" - }, - "syntaxNumber": { - "dark": "purple", - "light": "purple" - }, - "syntaxType": { - "dark": "cyan", - "light": "cyan" - }, - "syntaxOperator": { - "dark": "pink", - "light": "pink" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#282a36" - } - } - }, - "everforest": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#2d353b", - "darkStep2": "#333c43", - "darkStep3": "#343f44", - "darkStep4": "#3d484d", - "darkStep5": "#475258", - "darkStep6": "#7a8478", - "darkStep7": "#859289", - "darkStep8": "#9da9a0", - "darkStep9": "#a7c080", - "darkStep10": "#83c092", - "darkStep11": "#7a8478", - "darkStep12": "#d3c6aa", - "darkRed": "#e67e80", - "darkOrange": "#e69875", - "darkGreen": "#a7c080", - "darkCyan": "#83c092", - "darkYellow": "#dbbc7f", - "lightStep1": "#fdf6e3", - "lightStep2": "#efebd4", - "lightStep3": "#f4f0d9", - "lightStep4": "#efebd4", - "lightStep5": "#e6e2cc", - "lightStep6": "#a6b0a0", - "lightStep7": "#939f91", - "lightStep8": "#829181", - "lightStep9": "#8da101", - "lightStep10": "#35a77c", - "lightStep11": "#a6b0a0", - "lightStep12": "#5c6a72", - "lightRed": "#f85552", - "lightOrange": "#f57d26", - "lightGreen": "#8da101", - "lightCyan": "#35a77c", - "lightYellow": "#dfa000" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "#7fbbb3", - "light": "#3a94c5" - }, - "accent": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "#d699b6", - "light": "#df69ba" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "flexoki": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "black": "#100F0F", - "base950": "#1C1B1A", - "base900": "#282726", - "base850": "#343331", - "base800": "#403E3C", - "base700": "#575653", - "base600": "#6F6E69", - "base500": "#878580", - "base300": "#B7B5AC", - "base200": "#CECDC3", - "base150": "#DAD8CE", - "base100": "#E6E4D9", - "base50": "#F2F0E5", - "paper": "#FFFCF0", - "red400": "#D14D41", - "red600": "#AF3029", - "orange400": "#DA702C", - "orange600": "#BC5215", - "yellow400": "#D0A215", - "yellow600": "#AD8301", - "green400": "#879A39", - "green600": "#66800B", - "cyan400": "#3AA99F", - "cyan600": "#24837B", - "blue400": "#4385BE", - "blue600": "#205EA6", - "purple400": "#8B7EC8", - "purple600": "#5E409D", - "magenta400": "#CE5D97", - "magenta600": "#A02F6F" - }, - "theme": { - "primary": { - "dark": "orange400", - "light": "blue600" - }, - "secondary": { - "dark": "blue400", - "light": "purple600" - }, - "accent": { - "dark": "purple400", - "light": "orange600" - }, - "error": { - "dark": "red400", - "light": "red600" - }, - "warning": { - "dark": "orange400", - "light": "orange600" - }, - "success": { - "dark": "green400", - "light": "green600" - }, - "info": { - "dark": "cyan400", - "light": "cyan600" - }, - "text": { - "dark": "base200", - "light": "black" - }, - "textMuted": { - "dark": "base600", - "light": "base600" - }, - "background": { - "dark": "black", - "light": "paper" - }, - "backgroundPanel": { - "dark": "base950", - "light": "base50" - }, - "backgroundElement": { - "dark": "base900", - "light": "base100" - }, - "border": { - "dark": "base700", - "light": "base300" - }, - "borderActive": { - "dark": "base600", - "light": "base500" - }, - "borderSubtle": { - "dark": "base800", - "light": "base200" - }, - "diffAdded": { - "dark": "green400", - "light": "green600" - }, - "diffRemoved": { - "dark": "red400", - "light": "red600" - }, - "diffContext": { - "dark": "base600", - "light": "base600" - }, - "diffHunkHeader": { - "dark": "blue400", - "light": "blue600" - }, - "diffHighlightAdded": { - "dark": "green400", - "light": "green600" - }, - "diffHighlightRemoved": { - "dark": "red400", - "light": "red600" - }, - "diffAddedBg": { - "dark": "#1A2D1A", - "light": "#D5E5D5" - }, - "diffRemovedBg": { - "dark": "#2D1A1A", - "light": "#F7D8DB" - }, - "diffContextBg": { - "dark": "base950", - "light": "base50" - }, - "diffLineNumber": { - "dark": "base600", - "light": "base600" - }, - "diffAddedLineNumberBg": { - "dark": "#152515", - "light": "#C5D5C5" - }, - "diffRemovedLineNumberBg": { - "dark": "#251515", - "light": "#E7C8CB" - }, - "markdownText": { - "dark": "base200", - "light": "black" - }, - "markdownHeading": { - "dark": "purple400", - "light": "purple600" - }, - "markdownLink": { - "dark": "blue400", - "light": "blue600" - }, - "markdownLinkText": { - "dark": "cyan400", - "light": "cyan600" - }, - "markdownCode": { - "dark": "cyan400", - "light": "cyan600" - }, - "markdownBlockQuote": { - "dark": "yellow400", - "light": "yellow600" - }, - "markdownEmph": { - "dark": "yellow400", - "light": "yellow600" - }, - "markdownStrong": { - "dark": "orange400", - "light": "orange600" - }, - "markdownHorizontalRule": { - "dark": "base600", - "light": "base600" - }, - "markdownListItem": { - "dark": "orange400", - "light": "orange600" - }, - "markdownListEnumeration": { - "dark": "cyan400", - "light": "cyan600" - }, - "markdownImage": { - "dark": "magenta400", - "light": "magenta600" - }, - "markdownImageText": { - "dark": "cyan400", - "light": "cyan600" - }, - "markdownCodeBlock": { - "dark": "base200", - "light": "black" - }, - "syntaxComment": { - "dark": "base600", - "light": "base600" - }, - "syntaxKeyword": { - "dark": "green400", - "light": "green600" - }, - "syntaxFunction": { - "dark": "orange400", - "light": "orange600" - }, - "syntaxVariable": { - "dark": "blue400", - "light": "blue600" - }, - "syntaxString": { - "dark": "cyan400", - "light": "cyan600" - }, - "syntaxNumber": { - "dark": "purple400", - "light": "purple600" - }, - "syntaxType": { - "dark": "yellow400", - "light": "yellow600" - }, - "syntaxOperator": { - "dark": "base300", - "light": "base600" - }, - "syntaxPunctuation": { - "dark": "base300", - "light": "base600" - } - } - }, - "github": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#0d1117", - "darkBgAlt": "#010409", - "darkBgPanel": "#161b22", - "darkFg": "#c9d1d9", - "darkFgMuted": "#8b949e", - "darkBlue": "#58a6ff", - "darkGreen": "#3fb950", - "darkRed": "#f85149", - "darkOrange": "#d29922", - "darkPurple": "#bc8cff", - "darkPink": "#ff7b72", - "darkYellow": "#e3b341", - "darkCyan": "#39c5cf", - "lightBg": "#ffffff", - "lightBgAlt": "#f6f8fa", - "lightBgPanel": "#f0f3f6", - "lightFg": "#24292f", - "lightFgMuted": "#57606a", - "lightBlue": "#0969da", - "lightGreen": "#1a7f37", - "lightRed": "#cf222e", - "lightOrange": "#bc4c00", - "lightPurple": "#8250df", - "lightPink": "#bf3989", - "lightYellow": "#9a6700", - "lightCyan": "#1b7c83" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "backgroundElement": { - "dark": "darkBgPanel", - "light": "lightBgPanel" - }, - "border": { - "dark": "#30363d", - "light": "#d0d7de" - }, - "borderActive": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "borderSubtle": { - "dark": "#21262d", - "light": "#d8dee4" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "diffHunkHeader": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "diffHighlightAdded": { - "dark": "#3fb950", - "light": "#1a7f37" - }, - "diffHighlightRemoved": { - "dark": "#f85149", - "light": "#cf222e" - }, - "diffAddedBg": { - "dark": "#033a16", - "light": "#dafbe1" - }, - "diffRemovedBg": { - "dark": "#67060c", - "light": "#ffebe9" - }, - "diffContextBg": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "diffLineNumber": { - "dark": "#484f58", - "light": "#afb8c1" - }, - "diffAddedLineNumberBg": { - "dark": "#033a16", - "light": "#dafbe1" - }, - "diffRemovedLineNumberBg": { - "dark": "#67060c", - "light": "#ffebe9" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLink": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkPink", - "light": "lightPink" - }, - "markdownBlockQuote": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "#30363d", - "light": "#d0d7de" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "syntaxKeyword": { - "dark": "darkPink", - "light": "lightRed" - }, - "syntaxFunction": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxVariable": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxString": { - "dark": "darkCyan", - "light": "lightBlue" - }, - "syntaxNumber": { - "dark": "darkBlue", - "light": "lightCyan" - }, - "syntaxType": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxOperator": { - "dark": "darkPink", - "light": "lightRed" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } - }, - "gruvbox": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg0": "#282828", - "darkBg1": "#3c3836", - "darkBg2": "#504945", - "darkBg3": "#665c54", - "darkFg0": "#fbf1c7", - "darkFg1": "#ebdbb2", - "darkGray": "#928374", - "darkRed": "#cc241d", - "darkGreen": "#98971a", - "darkYellow": "#d79921", - "darkBlue": "#458588", - "darkPurple": "#b16286", - "darkAqua": "#689d6a", - "darkOrange": "#d65d0e", - "darkRedBright": "#fb4934", - "darkGreenBright": "#b8bb26", - "darkYellowBright": "#fabd2f", - "darkBlueBright": "#83a598", - "darkPurpleBright": "#d3869b", - "darkAquaBright": "#8ec07c", - "darkOrangeBright": "#fe8019", - "lightBg0": "#fbf1c7", - "lightBg1": "#ebdbb2", - "lightBg2": "#d5c4a1", - "lightBg3": "#bdae93", - "lightFg0": "#282828", - "lightFg1": "#3c3836", - "lightGray": "#7c6f64", - "lightRed": "#9d0006", - "lightGreen": "#79740e", - "lightYellow": "#b57614", - "lightBlue": "#076678", - "lightPurple": "#8f3f71", - "lightAqua": "#427b58", - "lightOrange": "#af3a03" - }, - "theme": { - "primary": { - "dark": "darkBlueBright", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurpleBright", - "light": "lightPurple" - }, - "accent": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "error": { - "dark": "darkRedBright", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrangeBright", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "info": { - "dark": "darkYellowBright", - "light": "lightYellow" - }, - "text": { - "dark": "darkFg1", - "light": "lightFg1" - }, - "textMuted": { - "dark": "darkGray", - "light": "lightGray" - }, - "background": { - "dark": "darkBg0", - "light": "lightBg0" - }, - "backgroundPanel": { - "dark": "darkBg1", - "light": "lightBg1" - }, - "backgroundElement": { - "dark": "darkBg2", - "light": "lightBg2" - }, - "border": { - "dark": "darkBg3", - "light": "lightBg3" - }, - "borderActive": { - "dark": "darkFg1", - "light": "lightFg1" - }, - "borderSubtle": { - "dark": "darkBg2", - "light": "lightBg2" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkGray", - "light": "lightGray" - }, - "diffHunkHeader": { - "dark": "darkAqua", - "light": "lightAqua" - }, - "diffHighlightAdded": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "diffHighlightRemoved": { - "dark": "darkRedBright", - "light": "lightRed" - }, - "diffAddedBg": { - "dark": "#32302f", - "light": "#dcd8a4" - }, - "diffRemovedBg": { - "dark": "#322929", - "light": "#e2c7c3" - }, - "diffContextBg": { - "dark": "darkBg1", - "light": "lightBg1" - }, - "diffLineNumber": { - "dark": "darkBg3", - "light": "lightBg3" - }, - "diffAddedLineNumberBg": { - "dark": "#2a2827", - "light": "#cec99e" - }, - "diffRemovedLineNumberBg": { - "dark": "#2a2222", - "light": "#d3bdb9" - }, - "markdownText": { - "dark": "darkFg1", - "light": "lightFg1" - }, - "markdownHeading": { - "dark": "darkBlueBright", - "light": "lightBlue" - }, - "markdownLink": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "markdownLinkText": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "markdownCode": { - "dark": "darkYellowBright", - "light": "lightYellow" - }, - "markdownBlockQuote": { - "dark": "darkGray", - "light": "lightGray" - }, - "markdownEmph": { - "dark": "darkPurpleBright", - "light": "lightPurple" - }, - "markdownStrong": { - "dark": "darkOrangeBright", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkGray", - "light": "lightGray" - }, - "markdownListItem": { - "dark": "darkBlueBright", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "markdownImage": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "markdownImageText": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "markdownCodeBlock": { - "dark": "darkFg1", - "light": "lightFg1" - }, - "syntaxComment": { - "dark": "darkGray", - "light": "lightGray" - }, - "syntaxKeyword": { - "dark": "darkRedBright", - "light": "lightRed" - }, - "syntaxFunction": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "syntaxVariable": { - "dark": "darkBlueBright", - "light": "lightBlue" - }, - "syntaxString": { - "dark": "darkYellowBright", - "light": "lightYellow" - }, - "syntaxNumber": { - "dark": "darkPurpleBright", - "light": "lightPurple" - }, - "syntaxType": { - "dark": "darkAquaBright", - "light": "lightAqua" - }, - "syntaxOperator": { - "dark": "darkOrangeBright", - "light": "lightOrange" - }, - "syntaxPunctuation": { - "dark": "darkFg1", - "light": "lightFg1" - } - } - }, - "kanagawa": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "sumiInk0": "#1F1F28", - "sumiInk1": "#2A2A37", - "sumiInk2": "#363646", - "sumiInk3": "#54546D", - "fujiWhite": "#DCD7BA", - "oldWhite": "#C8C093", - "fujiGray": "#727169", - "oniViolet": "#957FB8", - "crystalBlue": "#7E9CD8", - "carpYellow": "#C38D9D", - "sakuraPink": "#D27E99", - "waveAqua": "#76946A", - "roninYellow": "#D7A657", - "dragonRed": "#E82424", - "lotusGreen": "#98BB6C", - "waveBlue": "#2D4F67", - "lightBg": "#F2E9DE", - "lightPaper": "#EAE4D7", - "lightText": "#54433A", - "lightGray": "#9E9389" - }, - "theme": { - "primary": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "secondary": { - "dark": "oniViolet", - "light": "oniViolet" - }, - "accent": { - "dark": "sakuraPink", - "light": "sakuraPink" - }, - "error": { - "dark": "dragonRed", - "light": "dragonRed" - }, - "warning": { - "dark": "roninYellow", - "light": "roninYellow" - }, - "success": { - "dark": "lotusGreen", - "light": "lotusGreen" - }, - "info": { - "dark": "waveAqua", - "light": "waveAqua" - }, - "text": { - "dark": "fujiWhite", - "light": "lightText" - }, - "textMuted": { - "dark": "fujiGray", - "light": "lightGray" - }, - "background": { - "dark": "sumiInk0", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "sumiInk1", - "light": "lightPaper" - }, - "backgroundElement": { - "dark": "sumiInk2", - "light": "#E3DCD2" - }, - "border": { - "dark": "sumiInk3", - "light": "#D4CBBF" - }, - "borderActive": { - "dark": "carpYellow", - "light": "carpYellow" - }, - "borderSubtle": { - "dark": "sumiInk2", - "light": "#DCD4C9" - }, - "diffAdded": { - "dark": "lotusGreen", - "light": "lotusGreen" - }, - "diffRemoved": { - "dark": "dragonRed", - "light": "dragonRed" - }, - "diffContext": { - "dark": "fujiGray", - "light": "lightGray" - }, - "diffHunkHeader": { - "dark": "waveBlue", - "light": "waveBlue" - }, - "diffHighlightAdded": { - "dark": "#A9D977", - "light": "#89AF5B" - }, - "diffHighlightRemoved": { - "dark": "#F24A4A", - "light": "#D61F1F" - }, - "diffAddedBg": { - "dark": "#252E25", - "light": "#EAF3E4" - }, - "diffRemovedBg": { - "dark": "#362020", - "light": "#FBE6E6" - }, - "diffContextBg": { - "dark": "sumiInk1", - "light": "lightPaper" - }, - "diffLineNumber": { - "dark": "sumiInk3", - "light": "#C7BEB4" - }, - "diffAddedLineNumberBg": { - "dark": "#202820", - "light": "#DDE8D6" - }, - "diffRemovedLineNumberBg": { - "dark": "#2D1C1C", - "light": "#F2DADA" - }, - "markdownText": { - "dark": "fujiWhite", - "light": "lightText" - }, - "markdownHeading": { - "dark": "oniViolet", - "light": "oniViolet" - }, - "markdownLink": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "markdownLinkText": { - "dark": "waveAqua", - "light": "waveAqua" - }, - "markdownCode": { - "dark": "lotusGreen", - "light": "lotusGreen" - }, - "markdownBlockQuote": { - "dark": "fujiGray", - "light": "lightGray" - }, - "markdownEmph": { - "dark": "carpYellow", - "light": "carpYellow" - }, - "markdownStrong": { - "dark": "roninYellow", - "light": "roninYellow" - }, - "markdownHorizontalRule": { - "dark": "fujiGray", - "light": "lightGray" - }, - "markdownListItem": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "markdownListEnumeration": { - "dark": "waveAqua", - "light": "waveAqua" - }, - "markdownImage": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "markdownImageText": { - "dark": "waveAqua", - "light": "waveAqua" - }, - "markdownCodeBlock": { - "dark": "fujiWhite", - "light": "lightText" - }, - "syntaxComment": { - "dark": "fujiGray", - "light": "lightGray" - }, - "syntaxKeyword": { - "dark": "oniViolet", - "light": "oniViolet" - }, - "syntaxFunction": { - "dark": "crystalBlue", - "light": "waveBlue" - }, - "syntaxVariable": { - "dark": "fujiWhite", - "light": "lightText" - }, - "syntaxString": { - "dark": "lotusGreen", - "light": "lotusGreen" - }, - "syntaxNumber": { - "dark": "roninYellow", - "light": "roninYellow" - }, - "syntaxType": { - "dark": "carpYellow", - "light": "carpYellow" - }, - "syntaxOperator": { - "dark": "sakuraPink", - "light": "sakuraPink" - }, - "syntaxPunctuation": { - "dark": "fujiWhite", - "light": "lightText" - } - } - }, - "lucent-orng": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep6": "#3c3c3c", - "darkStep11": "#808080", - "darkStep12": "#eeeeee", - "darkSecondary": "#EE7948", - "darkAccent": "#FFF7F1", - "darkRed": "#e06c75", - "darkOrange": "#EC5B2B", - "darkBlue": "#6ba1e6", - "darkCyan": "#56b6c2", - "darkYellow": "#e5c07b", - "darkPanelBg": "#2a1a1599", - "lightStep6": "#d4d4d4", - "lightStep11": "#8a8a8a", - "lightStep12": "#1a1a1a", - "lightSecondary": "#EE7948", - "lightAccent": "#c94d24", - "lightRed": "#d1383d", - "lightOrange": "#EC5B2B", - "lightBlue": "#0062d1", - "lightCyan": "#318795", - "lightYellow": "#b0851f", - "lightPanelBg": "#fff5f099" - }, - "theme": { - "primary": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "secondary": { - "dark": "darkSecondary", - "light": "lightSecondary" - }, - "accent": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "selectedListItemText": { - "dark": "#0a0a0a", - "light": "#ffffff" - }, - "background": { - "dark": "transparent", - "light": "transparent" - }, - "backgroundPanel": { - "dark": "transparent", - "light": "transparent" - }, - "backgroundElement": { - "dark": "transparent", - "light": "transparent" - }, - "backgroundMenu": { - "dark": "darkPanelBg", - "light": "lightPanelBg" - }, - "border": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "borderActive": { - "dark": "darkSecondary", - "light": "lightAccent" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "transparent", - "light": "transparent" - }, - "diffRemovedBg": { - "dark": "transparent", - "light": "transparent" - }, - "diffContextBg": { - "dark": "transparent", - "light": "transparent" - }, - "diffLineNumber": { - "dark": "#666666", - "light": "#999999" - }, - "diffAddedLineNumberBg": { - "dark": "transparent", - "light": "transparent" - }, - "diffRemovedLineNumberBg": { - "dark": "transparent", - "light": "transparent" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownLink": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownBlockQuote": { - "dark": "darkAccent", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkSecondary", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxFunction": { - "dark": "darkSecondary", - "light": "lightAccent" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxNumber": { - "dark": "darkAccent", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "material": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#263238", - "darkBgAlt": "#1e272c", - "darkBgPanel": "#37474f", - "darkFg": "#eeffff", - "darkFgMuted": "#546e7a", - "darkRed": "#f07178", - "darkPink": "#f78c6c", - "darkOrange": "#ffcb6b", - "darkYellow": "#ffcb6b", - "darkGreen": "#c3e88d", - "darkCyan": "#89ddff", - "darkBlue": "#82aaff", - "darkPurple": "#c792ea", - "darkViolet": "#bb80b3", - "lightBg": "#fafafa", - "lightBgAlt": "#f5f5f5", - "lightBgPanel": "#e7e7e8", - "lightFg": "#263238", - "lightFgMuted": "#90a4ae", - "lightRed": "#e53935", - "lightPink": "#ec407a", - "lightOrange": "#f4511e", - "lightYellow": "#ffb300", - "lightGreen": "#91b859", - "lightCyan": "#39adb5", - "lightBlue": "#6182b8", - "lightPurple": "#7c4dff", - "lightViolet": "#945eb8" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "backgroundElement": { - "dark": "darkBgPanel", - "light": "lightBgPanel" - }, - "border": { - "dark": "#37474f", - "light": "#e0e0e0" - }, - "borderActive": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "borderSubtle": { - "dark": "#1e272c", - "light": "#eeeeee" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "diffHunkHeader": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "diffHighlightAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffHighlightRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffAddedBg": { - "dark": "#2e3c2b", - "light": "#e8f5e9" - }, - "diffRemovedBg": { - "dark": "#3c2b2b", - "light": "#ffebee" - }, - "diffContextBg": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "diffLineNumber": { - "dark": "#37474f", - "light": "#cfd8dc" - }, - "diffAddedLineNumberBg": { - "dark": "#2e3c2b", - "light": "#e8f5e9" - }, - "diffRemovedLineNumberBg": { - "dark": "#3c2b2b", - "light": "#ffebee" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLink": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownLinkText": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "#37474f", - "light": "#e0e0e0" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImageText": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "syntaxKeyword": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxFunction": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxVariable": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } - }, - "matrix": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "matrixInk0": "#0a0e0a", - "matrixInk1": "#0e130d", - "matrixInk2": "#141c12", - "matrixInk3": "#1e2a1b", - "rainGreen": "#2eff6a", - "rainGreenDim": "#1cc24b", - "rainGreenHi": "#62ff94", - "rainCyan": "#00efff", - "rainTeal": "#24f6d9", - "rainPurple": "#c770ff", - "rainOrange": "#ffa83d", - "alertRed": "#ff4b4b", - "alertYellow": "#e6ff57", - "alertBlue": "#30b3ff", - "rainGray": "#8ca391", - "lightBg": "#eef3ea", - "lightPaper": "#e4ebe1", - "lightInk1": "#dae1d7", - "lightText": "#203022", - "lightGray": "#748476" - }, - "theme": { - "primary": { - "dark": "rainGreen", - "light": "rainGreenDim" - }, - "secondary": { - "dark": "rainCyan", - "light": "rainTeal" - }, - "accent": { - "dark": "rainPurple", - "light": "rainPurple" - }, - "error": { - "dark": "alertRed", - "light": "alertRed" - }, - "warning": { - "dark": "alertYellow", - "light": "alertYellow" - }, - "success": { - "dark": "rainGreenHi", - "light": "rainGreenDim" - }, - "info": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "text": { - "dark": "rainGreenHi", - "light": "lightText" - }, - "textMuted": { - "dark": "rainGray", - "light": "lightGray" - }, - "background": { - "dark": "matrixInk0", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "matrixInk1", - "light": "lightPaper" - }, - "backgroundElement": { - "dark": "matrixInk2", - "light": "lightInk1" - }, - "border": { - "dark": "matrixInk3", - "light": "lightGray" - }, - "borderActive": { - "dark": "rainGreen", - "light": "rainGreenDim" - }, - "borderSubtle": { - "dark": "matrixInk2", - "light": "lightInk1" - }, - "diffAdded": { - "dark": "rainGreenDim", - "light": "rainGreenDim" - }, - "diffRemoved": { - "dark": "alertRed", - "light": "alertRed" - }, - "diffContext": { - "dark": "rainGray", - "light": "lightGray" - }, - "diffHunkHeader": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "diffHighlightAdded": { - "dark": "#77ffaf", - "light": "#5dac7e" - }, - "diffHighlightRemoved": { - "dark": "#ff7171", - "light": "#d53a3a" - }, - "diffAddedBg": { - "dark": "#132616", - "light": "#e0efde" - }, - "diffRemovedBg": { - "dark": "#261212", - "light": "#f9e5e5" - }, - "diffContextBg": { - "dark": "matrixInk1", - "light": "lightPaper" - }, - "diffLineNumber": { - "dark": "matrixInk3", - "light": "lightGray" - }, - "diffAddedLineNumberBg": { - "dark": "#0f1b11", - "light": "#d6e7d2" - }, - "diffRemovedLineNumberBg": { - "dark": "#1b1414", - "light": "#f2d2d2" - }, - "markdownText": { - "dark": "rainGreenHi", - "light": "lightText" - }, - "markdownHeading": { - "dark": "rainCyan", - "light": "rainTeal" - }, - "markdownLink": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "markdownLinkText": { - "dark": "rainTeal", - "light": "rainTeal" - }, - "markdownCode": { - "dark": "rainGreenDim", - "light": "rainGreenDim" - }, - "markdownBlockQuote": { - "dark": "rainGray", - "light": "lightGray" - }, - "markdownEmph": { - "dark": "rainOrange", - "light": "rainOrange" - }, - "markdownStrong": { - "dark": "alertYellow", - "light": "alertYellow" - }, - "markdownHorizontalRule": { - "dark": "rainGray", - "light": "lightGray" - }, - "markdownListItem": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "markdownListEnumeration": { - "dark": "rainTeal", - "light": "rainTeal" - }, - "markdownImage": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "markdownImageText": { - "dark": "rainTeal", - "light": "rainTeal" - }, - "markdownCodeBlock": { - "dark": "rainGreenHi", - "light": "lightText" - }, - "syntaxComment": { - "dark": "rainGray", - "light": "lightGray" - }, - "syntaxKeyword": { - "dark": "rainPurple", - "light": "rainPurple" - }, - "syntaxFunction": { - "dark": "alertBlue", - "light": "alertBlue" - }, - "syntaxVariable": { - "dark": "rainGreenHi", - "light": "lightText" - }, - "syntaxString": { - "dark": "rainGreenDim", - "light": "rainGreenDim" - }, - "syntaxNumber": { - "dark": "rainOrange", - "light": "rainOrange" - }, - "syntaxType": { - "dark": "alertYellow", - "light": "alertYellow" - }, - "syntaxOperator": { - "dark": "rainTeal", - "light": "rainTeal" - }, - "syntaxPunctuation": { - "dark": "rainGreenHi", - "light": "lightText" - } - } - }, - "mercury": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "purple-800": "#3442a6", - "purple-700": "#465bd1", - "purple-600": "#5266eb", - "purple-400": "#8da4f5", - "purple-300": "#a7b6f8", - "red-700": "#b0175f", - "red-600": "#d03275", - "red-400": "#fc92b4", - "green-700": "#036e43", - "green-600": "#188554", - "green-400": "#77c599", - "orange-700": "#a44200", - "orange-600": "#c45000", - "orange-400": "#fc9b6f", - "blue-600": "#007f95", - "blue-400": "#77becf", - "neutral-1000": "#10101a", - "neutral-950": "#171721", - "neutral-900": "#1e1e2a", - "neutral-800": "#272735", - "neutral-700": "#363644", - "neutral-600": "#535461", - "neutral-500": "#70707d", - "neutral-400": "#9d9da8", - "neutral-300": "#c3c3cc", - "neutral-200": "#dddde5", - "neutral-100": "#f4f5f9", - "neutral-050": "#fbfcfd", - "neutral-000": "#ffffff", - "neutral-150": "#ededf3", - "border-light": "#7073931a", - "border-light-subtle": "#7073930f", - "border-dark": "#b4b7c81f", - "border-dark-subtle": "#b4b7c814", - "diff-added-light": "#1885541a", - "diff-removed-light": "#d032751a", - "diff-added-dark": "#77c59933", - "diff-removed-dark": "#fc92b433" - }, - "theme": { - "primary": { - "light": "purple-600", - "dark": "purple-400" - }, - "secondary": { - "light": "purple-700", - "dark": "purple-300" - }, - "accent": { - "light": "purple-400", - "dark": "purple-400" - }, - "error": { - "light": "red-700", - "dark": "red-400" - }, - "warning": { - "light": "orange-700", - "dark": "orange-400" - }, - "success": { - "light": "green-700", - "dark": "green-400" - }, - "info": { - "light": "blue-600", - "dark": "blue-400" - }, - "text": { - "light": "neutral-700", - "dark": "neutral-200" - }, - "textMuted": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "background": { - "light": "neutral-000", - "dark": "neutral-950" - }, - "backgroundPanel": { - "light": "neutral-050", - "dark": "neutral-1000" - }, - "backgroundElement": { - "light": "neutral-100", - "dark": "neutral-800" - }, - "border": { - "light": "border-light", - "dark": "border-dark" - }, - "borderActive": { - "light": "purple-600", - "dark": "purple-400" - }, - "borderSubtle": { - "light": "border-light-subtle", - "dark": "border-dark-subtle" - }, - "diffAdded": { - "light": "green-700", - "dark": "green-400" - }, - "diffRemoved": { - "light": "red-700", - "dark": "red-400" - }, - "diffContext": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "diffHunkHeader": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "diffHighlightAdded": { - "light": "green-700", - "dark": "green-400" - }, - "diffHighlightRemoved": { - "light": "red-700", - "dark": "red-400" - }, - "diffAddedBg": { - "light": "diff-added-light", - "dark": "diff-added-dark" - }, - "diffRemovedBg": { - "light": "diff-removed-light", - "dark": "diff-removed-dark" - }, - "diffContextBg": { - "light": "neutral-050", - "dark": "neutral-900" - }, - "diffLineNumber": { - "light": "neutral-600", - "dark": "neutral-300" - }, - "diffAddedLineNumberBg": { - "light": "diff-added-light", - "dark": "diff-added-dark" - }, - "diffRemovedLineNumberBg": { - "light": "diff-removed-light", - "dark": "diff-removed-dark" - }, - "markdownText": { - "light": "neutral-700", - "dark": "neutral-200" - }, - "markdownHeading": { - "light": "neutral-900", - "dark": "neutral-000" - }, - "markdownLink": { - "light": "purple-700", - "dark": "purple-400" - }, - "markdownLinkText": { - "light": "purple-600", - "dark": "purple-300" - }, - "markdownCode": { - "light": "green-700", - "dark": "green-400" - }, - "markdownBlockQuote": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "markdownEmph": { - "light": "orange-700", - "dark": "orange-400" - }, - "markdownStrong": { - "light": "neutral-900", - "dark": "neutral-100" - }, - "markdownHorizontalRule": { - "light": "border-light", - "dark": "border-dark" - }, - "markdownListItem": { - "light": "neutral-900", - "dark": "neutral-000" - }, - "markdownListEnumeration": { - "light": "purple-600", - "dark": "purple-400" - }, - "markdownImage": { - "light": "purple-700", - "dark": "purple-400" - }, - "markdownImageText": { - "light": "purple-600", - "dark": "purple-300" - }, - "markdownCodeBlock": { - "light": "neutral-700", - "dark": "neutral-200" - }, - "syntaxComment": { - "light": "neutral-500", - "dark": "neutral-400" - }, - "syntaxKeyword": { - "light": "purple-700", - "dark": "purple-400" - }, - "syntaxFunction": { - "light": "purple-600", - "dark": "purple-400" - }, - "syntaxVariable": { - "light": "blue-600", - "dark": "blue-400" - }, - "syntaxString": { - "light": "green-700", - "dark": "green-400" - }, - "syntaxNumber": { - "light": "orange-700", - "dark": "orange-400" - }, - "syntaxType": { - "light": "blue-600", - "dark": "blue-400" - }, - "syntaxOperator": { - "light": "purple-700", - "dark": "purple-400" - }, - "syntaxPunctuation": { - "light": "neutral-700", - "dark": "neutral-200" - } - } - }, - "monokai": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#272822", - "backgroundAlt": "#1e1f1c", - "backgroundPanel": "#3e3d32", - "foreground": "#f8f8f2", - "comment": "#75715e", - "red": "#f92672", - "orange": "#fd971f", - "lightOrange": "#e69f66", - "yellow": "#e6db74", - "green": "#a6e22e", - "cyan": "#66d9ef", - "blue": "#66d9ef", - "purple": "#ae81ff", - "pink": "#f92672" - }, - "theme": { - "primary": { - "dark": "cyan", - "light": "blue" - }, - "secondary": { - "dark": "purple", - "light": "purple" - }, - "accent": { - "dark": "green", - "light": "green" - }, - "error": { - "dark": "red", - "light": "red" - }, - "warning": { - "dark": "yellow", - "light": "orange" - }, - "success": { - "dark": "green", - "light": "green" - }, - "info": { - "dark": "orange", - "light": "orange" - }, - "text": { - "dark": "foreground", - "light": "#272822" - }, - "textMuted": { - "dark": "comment", - "light": "#75715e" - }, - "background": { - "dark": "#272822", - "light": "#fafafa" - }, - "backgroundPanel": { - "dark": "#1e1f1c", - "light": "#f0f0f0" - }, - "backgroundElement": { - "dark": "#3e3d32", - "light": "#e0e0e0" - }, - "border": { - "dark": "#3e3d32", - "light": "#d0d0d0" - }, - "borderActive": { - "dark": "cyan", - "light": "blue" - }, - "borderSubtle": { - "dark": "#1e1f1c", - "light": "#e8e8e8" - }, - "diffAdded": { - "dark": "green", - "light": "green" - }, - "diffRemoved": { - "dark": "red", - "light": "red" - }, - "diffContext": { - "dark": "comment", - "light": "#75715e" - }, - "diffHunkHeader": { - "dark": "comment", - "light": "#75715e" - }, - "diffHighlightAdded": { - "dark": "green", - "light": "green" - }, - "diffHighlightRemoved": { - "dark": "red", - "light": "red" - }, - "diffAddedBg": { - "dark": "#1a3a1a", - "light": "#e0ffe0" - }, - "diffRemovedBg": { - "dark": "#3a1a1a", - "light": "#ffe0e0" - }, - "diffContextBg": { - "dark": "#1e1f1c", - "light": "#f0f0f0" - }, - "diffLineNumber": { - "dark": "#3e3d32", - "light": "#d0d0d0" - }, - "diffAddedLineNumberBg": { - "dark": "#1a3a1a", - "light": "#e0ffe0" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1a1a", - "light": "#ffe0e0" - }, - "markdownText": { - "dark": "foreground", - "light": "#272822" - }, - "markdownHeading": { - "dark": "pink", - "light": "pink" - }, - "markdownLink": { - "dark": "cyan", - "light": "blue" - }, - "markdownLinkText": { - "dark": "purple", - "light": "purple" - }, - "markdownCode": { - "dark": "green", - "light": "green" - }, - "markdownBlockQuote": { - "dark": "comment", - "light": "#75715e" - }, - "markdownEmph": { - "dark": "yellow", - "light": "orange" - }, - "markdownStrong": { - "dark": "orange", - "light": "orange" - }, - "markdownHorizontalRule": { - "dark": "comment", - "light": "#75715e" - }, - "markdownListItem": { - "dark": "cyan", - "light": "blue" - }, - "markdownListEnumeration": { - "dark": "purple", - "light": "purple" - }, - "markdownImage": { - "dark": "cyan", - "light": "blue" - }, - "markdownImageText": { - "dark": "purple", - "light": "purple" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#272822" - }, - "syntaxComment": { - "dark": "comment", - "light": "#75715e" - }, - "syntaxKeyword": { - "dark": "pink", - "light": "pink" - }, - "syntaxFunction": { - "dark": "green", - "light": "green" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#272822" - }, - "syntaxString": { - "dark": "yellow", - "light": "orange" - }, - "syntaxNumber": { - "dark": "purple", - "light": "purple" - }, - "syntaxType": { - "dark": "cyan", - "light": "blue" - }, - "syntaxOperator": { - "dark": "pink", - "light": "pink" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#272822" - } - } - }, - "nightowl": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "nightOwlBg": "#011627", - "nightOwlFg": "#d6deeb", - "nightOwlBlue": "#82AAFF", - "nightOwlCyan": "#7fdbca", - "nightOwlGreen": "#c5e478", - "nightOwlYellow": "#ecc48d", - "nightOwlOrange": "#F78C6C", - "nightOwlRed": "#EF5350", - "nightOwlPink": "#ff5874", - "nightOwlPurple": "#c792ea", - "nightOwlMuted": "#5f7e97", - "nightOwlGray": "#637777", - "nightOwlLightGray": "#89a4bb", - "nightOwlPanel": "#0b253a" - }, - "theme": { - "primary": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "secondary": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "accent": { - "dark": "nightOwlPurple", - "light": "nightOwlPurple" - }, - "error": { - "dark": "nightOwlRed", - "light": "nightOwlRed" - }, - "warning": { - "dark": "nightOwlYellow", - "light": "nightOwlYellow" - }, - "success": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "info": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "text": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - }, - "textMuted": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "background": { - "dark": "nightOwlBg", - "light": "nightOwlBg" - }, - "backgroundPanel": { - "dark": "nightOwlPanel", - "light": "nightOwlPanel" - }, - "backgroundElement": { - "dark": "nightOwlPanel", - "light": "nightOwlPanel" - }, - "border": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "borderActive": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "borderSubtle": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "diffAdded": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "diffRemoved": { - "dark": "nightOwlRed", - "light": "nightOwlRed" - }, - "diffContext": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "diffHunkHeader": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "diffHighlightAdded": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "diffHighlightRemoved": { - "dark": "nightOwlRed", - "light": "nightOwlRed" - }, - "diffAddedBg": { - "dark": "#0a2e1a", - "light": "#0a2e1a" - }, - "diffRemovedBg": { - "dark": "#2d1b1b", - "light": "#2d1b1b" - }, - "diffContextBg": { - "dark": "nightOwlPanel", - "light": "nightOwlPanel" - }, - "diffLineNumber": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "diffAddedLineNumberBg": { - "dark": "#0a2e1a", - "light": "#0a2e1a" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1b1b", - "light": "#2d1b1b" - }, - "markdownText": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - }, - "markdownHeading": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "markdownLink": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "markdownLinkText": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "markdownCode": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "markdownBlockQuote": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "markdownEmph": { - "dark": "nightOwlPurple", - "light": "nightOwlPurple" - }, - "markdownStrong": { - "dark": "nightOwlYellow", - "light": "nightOwlYellow" - }, - "markdownHorizontalRule": { - "dark": "nightOwlMuted", - "light": "nightOwlMuted" - }, - "markdownListItem": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "markdownListEnumeration": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "markdownImage": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "markdownImageText": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "markdownCodeBlock": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - }, - "syntaxComment": { - "dark": "nightOwlGray", - "light": "nightOwlGray" - }, - "syntaxKeyword": { - "dark": "nightOwlPurple", - "light": "nightOwlPurple" - }, - "syntaxFunction": { - "dark": "nightOwlBlue", - "light": "nightOwlBlue" - }, - "syntaxVariable": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - }, - "syntaxString": { - "dark": "nightOwlYellow", - "light": "nightOwlYellow" - }, - "syntaxNumber": { - "dark": "nightOwlOrange", - "light": "nightOwlOrange" - }, - "syntaxType": { - "dark": "nightOwlGreen", - "light": "nightOwlGreen" - }, - "syntaxOperator": { - "dark": "nightOwlCyan", - "light": "nightOwlCyan" - }, - "syntaxPunctuation": { - "dark": "nightOwlFg", - "light": "nightOwlFg" - } - } - }, - "nord": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "nord0": "#2E3440", - "nord1": "#3B4252", - "nord2": "#434C5E", - "nord3": "#4C566A", - "nord4": "#D8DEE9", - "nord5": "#E5E9F0", - "nord6": "#ECEFF4", - "nord7": "#8FBCBB", - "nord8": "#88C0D0", - "nord9": "#81A1C1", - "nord10": "#5E81AC", - "nord11": "#BF616A", - "nord12": "#D08770", - "nord13": "#EBCB8B", - "nord14": "#A3BE8C", - "nord15": "#B48EAD" - }, - "theme": { - "primary": { - "dark": "nord8", - "light": "nord10" - }, - "secondary": { - "dark": "nord9", - "light": "nord9" - }, - "accent": { - "dark": "nord7", - "light": "nord7" - }, - "error": { - "dark": "nord11", - "light": "nord11" - }, - "warning": { - "dark": "nord12", - "light": "nord12" - }, - "success": { - "dark": "nord14", - "light": "nord14" - }, - "info": { - "dark": "nord8", - "light": "nord10" - }, - "text": { - "dark": "nord6", - "light": "nord0" - }, - "textMuted": { - "dark": "#8B95A7", - "light": "nord1" - }, - "background": { - "dark": "nord0", - "light": "nord6" - }, - "backgroundPanel": { - "dark": "nord1", - "light": "nord5" - }, - "backgroundElement": { - "dark": "nord2", - "light": "nord4" - }, - "border": { - "dark": "nord2", - "light": "nord3" - }, - "borderActive": { - "dark": "nord3", - "light": "nord2" - }, - "borderSubtle": { - "dark": "nord2", - "light": "nord3" - }, - "diffAdded": { - "dark": "nord14", - "light": "nord14" - }, - "diffRemoved": { - "dark": "nord11", - "light": "nord11" - }, - "diffContext": { - "dark": "#8B95A7", - "light": "nord3" - }, - "diffHunkHeader": { - "dark": "#8B95A7", - "light": "nord3" - }, - "diffHighlightAdded": { - "dark": "nord14", - "light": "nord14" - }, - "diffHighlightRemoved": { - "dark": "nord11", - "light": "nord11" - }, - "diffAddedBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffRemovedBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffContextBg": { - "dark": "nord1", - "light": "nord5" - }, - "diffLineNumber": { - "dark": "nord2", - "light": "nord4" - }, - "diffAddedLineNumberBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "diffRemovedLineNumberBg": { - "dark": "#3B4252", - "light": "#E5E9F0" - }, - "markdownText": { - "dark": "nord4", - "light": "nord0" - }, - "markdownHeading": { - "dark": "nord8", - "light": "nord10" - }, - "markdownLink": { - "dark": "nord9", - "light": "nord9" - }, - "markdownLinkText": { - "dark": "nord7", - "light": "nord7" - }, - "markdownCode": { - "dark": "nord14", - "light": "nord14" - }, - "markdownBlockQuote": { - "dark": "#8B95A7", - "light": "nord3" - }, - "markdownEmph": { - "dark": "nord12", - "light": "nord12" - }, - "markdownStrong": { - "dark": "nord13", - "light": "nord13" - }, - "markdownHorizontalRule": { - "dark": "#8B95A7", - "light": "nord3" - }, - "markdownListItem": { - "dark": "nord8", - "light": "nord10" - }, - "markdownListEnumeration": { - "dark": "nord7", - "light": "nord7" - }, - "markdownImage": { - "dark": "nord9", - "light": "nord9" - }, - "markdownImageText": { - "dark": "nord7", - "light": "nord7" - }, - "markdownCodeBlock": { - "dark": "nord4", - "light": "nord0" - }, - "syntaxComment": { - "dark": "#8B95A7", - "light": "nord3" - }, - "syntaxKeyword": { - "dark": "nord9", - "light": "nord9" - }, - "syntaxFunction": { - "dark": "nord8", - "light": "nord8" - }, - "syntaxVariable": { - "dark": "nord7", - "light": "nord7" - }, - "syntaxString": { - "dark": "nord14", - "light": "nord14" - }, - "syntaxNumber": { - "dark": "nord15", - "light": "nord15" - }, - "syntaxType": { - "dark": "nord7", - "light": "nord7" - }, - "syntaxOperator": { - "dark": "nord9", - "light": "nord9" - }, - "syntaxPunctuation": { - "dark": "nord4", - "light": "nord0" - } - } - }, - "one-dark": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg": "#282c34", - "darkBgAlt": "#21252b", - "darkBgPanel": "#353b45", - "darkFg": "#abb2bf", - "darkFgMuted": "#5c6370", - "darkPurple": "#c678dd", - "darkBlue": "#61afef", - "darkRed": "#e06c75", - "darkGreen": "#98c379", - "darkYellow": "#e5c07b", - "darkOrange": "#d19a66", - "darkCyan": "#56b6c2", - "lightBg": "#fafafa", - "lightBgAlt": "#f0f0f1", - "lightBgPanel": "#eaeaeb", - "lightFg": "#383a42", - "lightFgMuted": "#a0a1a7", - "lightPurple": "#a626a4", - "lightBlue": "#4078f2", - "lightRed": "#e45649", - "lightGreen": "#50a14f", - "lightYellow": "#c18401", - "lightOrange": "#986801", - "lightCyan": "#0184bc" - }, - "theme": { - "primary": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "text": { - "dark": "darkFg", - "light": "lightFg" - }, - "textMuted": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "background": { - "dark": "darkBg", - "light": "lightBg" - }, - "backgroundPanel": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "backgroundElement": { - "dark": "darkBgPanel", - "light": "lightBgPanel" - }, - "border": { - "dark": "#393f4a", - "light": "#d1d1d2" - }, - "borderActive": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "borderSubtle": { - "dark": "#2c313a", - "light": "#e0e0e1" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "diffHunkHeader": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "diffHighlightAdded": { - "dark": "#aad482", - "light": "#489447" - }, - "diffHighlightRemoved": { - "dark": "#e8828b", - "light": "#d65145" - }, - "diffAddedBg": { - "dark": "#2c382b", - "light": "#eafbe9" - }, - "diffRemovedBg": { - "dark": "#3a2d2f", - "light": "#fce9e8" - }, - "diffContextBg": { - "dark": "darkBgAlt", - "light": "lightBgAlt" - }, - "diffLineNumber": { - "dark": "#495162", - "light": "#c9c9ca" - }, - "diffAddedLineNumberBg": { - "dark": "#283427", - "light": "#e1f3df" - }, - "diffRemovedLineNumberBg": { - "dark": "#36292b", - "light": "#f5e2e1" - }, - "markdownText": { - "dark": "darkFg", - "light": "lightFg" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownLink": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "markdownListItem": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkFg", - "light": "lightFg" - }, - "syntaxComment": { - "dark": "darkFgMuted", - "light": "lightFgMuted" - }, - "syntaxKeyword": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxFunction": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkFg", - "light": "lightFg" - } - } - }, - "opencode": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#0a0a0a", - "darkStep2": "#141414", - "darkStep3": "#1e1e1e", - "darkStep4": "#282828", - "darkStep5": "#323232", - "darkStep6": "#3c3c3c", - "darkStep7": "#484848", - "darkStep8": "#606060", - "darkStep9": "#fab283", - "darkStep10": "#ffc09f", - "darkStep11": "#808080", - "darkStep12": "#eeeeee", - "darkSecondary": "#5c9cf5", - "darkAccent": "#9d7cd8", - "darkRed": "#e06c75", - "darkOrange": "#f5a742", - "darkGreen": "#7fd88f", - "darkCyan": "#56b6c2", - "darkYellow": "#e5c07b", - "lightStep1": "#ffffff", - "lightStep2": "#fafafa", - "lightStep3": "#f5f5f5", - "lightStep4": "#ebebeb", - "lightStep5": "#e1e1e1", - "lightStep6": "#d4d4d4", - "lightStep7": "#b8b8b8", - "lightStep8": "#a0a0a0", - "lightStep9": "#3b7dd8", - "lightStep10": "#2968c3", - "lightStep11": "#8a8a8a", - "lightStep12": "#1a1a1a", - "lightSecondary": "#7b5bb6", - "lightAccent": "#d68c27", - "lightRed": "#d1383d", - "lightOrange": "#d68c27", - "lightGreen": "#3d9a57", - "lightCyan": "#318795", - "lightYellow": "#b0851f" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkSecondary", - "light": "lightSecondary" - }, - "accent": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "orng": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#0a0a0a", - "darkStep2": "#141414", - "darkStep3": "#1e1e1e", - "darkStep4": "#282828", - "darkStep5": "#323232", - "darkStep6": "#3c3c3c", - "darkStep7": "#484848", - "darkStep8": "#606060", - "darkStep9": "#EC5B2B", - "darkStep10": "#EE7948", - "darkStep11": "#808080", - "darkStep12": "#eeeeee", - "darkSecondary": "#EE7948", - "darkAccent": "#FFF7F1", - "darkRed": "#e06c75", - "darkOrange": "#EC5B2B", - "darkBlue": "#6ba1e6", - "darkCyan": "#56b6c2", - "darkYellow": "#e5c07b", - "lightStep1": "#ffffff", - "lightStep2": "#FFF7F1", - "lightStep3": "#f5f0eb", - "lightStep4": "#ebebeb", - "lightStep5": "#e1e1e1", - "lightStep6": "#d4d4d4", - "lightStep7": "#b8b8b8", - "lightStep8": "#a0a0a0", - "lightStep9": "#EC5B2B", - "lightStep10": "#c94d24", - "lightStep11": "#8a8a8a", - "lightStep12": "#1a1a1a", - "lightSecondary": "#EE7948", - "lightAccent": "#c94d24", - "lightRed": "#d1383d", - "lightOrange": "#EC5B2B", - "lightBlue": "#0062d1", - "lightCyan": "#318795", - "lightYellow": "#b0851f" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkSecondary", - "light": "lightSecondary" - }, - "accent": { - "dark": "darkAccent", - "light": "lightAccent" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "selectedListItemText": { - "dark": "#0a0a0a", - "light": "#ffffff" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "#EC5B2B", - "light": "#EC5B2B" - }, - "borderActive": { - "dark": "#EE7948", - "light": "#c94d24" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#6ba1e6", - "light": "#0062d1" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#6ba1e6", - "light": "#0062d1" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#1a2a3d", - "light": "#e0edfa" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#162535", - "light": "#d0e5f5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "#EC5B2B", - "light": "#EC5B2B" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "markdownBlockQuote": { - "dark": "#FFF7F1", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "#EE7948", - "light": "#EC5B2B" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "#EC5B2B", - "light": "#EC5B2B" - }, - "syntaxFunction": { - "dark": "#EE7948", - "light": "#c94d24" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxNumber": { - "dark": "#FFF7F1", - "light": "#EC5B2B" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "osaka-jade": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkBg0": "#111c18", - "darkBg1": "#1a2520", - "darkBg2": "#23372B", - "darkBg3": "#3d4a44", - "darkFg0": "#C1C497", - "darkFg1": "#9aa88a", - "darkGray": "#53685B", - "darkRed": "#FF5345", - "darkGreen": "#549e6a", - "darkYellow": "#459451", - "darkBlue": "#509475", - "darkMagenta": "#D2689C", - "darkCyan": "#2DD5B7", - "darkWhite": "#F6F5DD", - "darkRedBright": "#db9f9c", - "darkGreenBright": "#63b07a", - "darkYellowBright": "#E5C736", - "darkBlueBright": "#ACD4CF", - "darkMagentaBright": "#75bbb3", - "darkCyanBright": "#8CD3CB", - "lightBg0": "#F6F5DD", - "lightBg1": "#E8E7CC", - "lightBg2": "#D5D4B8", - "lightBg3": "#A8A78C", - "lightFg0": "#111c18", - "lightFg1": "#1a2520", - "lightGray": "#53685B", - "lightRed": "#c7392d", - "lightGreen": "#3d7a52", - "lightYellow": "#b5a020", - "lightBlue": "#3d7560", - "lightMagenta": "#a8527a", - "lightCyan": "#1faa90" - }, - "theme": { - "primary": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "secondary": { - "dark": "darkMagenta", - "light": "lightMagenta" - }, - "accent": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkYellowBright", - "light": "lightYellow" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "text": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "textMuted": { - "dark": "darkGray", - "light": "lightGray" - }, - "background": { - "dark": "darkBg0", - "light": "lightBg0" - }, - "backgroundPanel": { - "dark": "darkBg1", - "light": "lightBg1" - }, - "backgroundElement": { - "dark": "darkBg2", - "light": "lightBg2" - }, - "border": { - "dark": "darkBg3", - "light": "lightBg3" - }, - "borderActive": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "borderSubtle": { - "dark": "darkBg2", - "light": "lightBg2" - }, - "diffAdded": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "diffRemoved": { - "dark": "darkRed", - "light": "lightRed" - }, - "diffContext": { - "dark": "darkGray", - "light": "lightGray" - }, - "diffHunkHeader": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "diffHighlightAdded": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "diffHighlightRemoved": { - "dark": "darkRedBright", - "light": "lightRed" - }, - "diffAddedBg": { - "dark": "#15241c", - "light": "#e0eee5" - }, - "diffRemovedBg": { - "dark": "#241515", - "light": "#eee0e0" - }, - "diffContextBg": { - "dark": "darkBg1", - "light": "lightBg1" - }, - "diffLineNumber": { - "dark": "darkBg3", - "light": "lightBg3" - }, - "diffAddedLineNumberBg": { - "dark": "#121f18", - "light": "#d5e5da" - }, - "diffRemovedLineNumberBg": { - "dark": "#1f1212", - "light": "#e5d5d5" - }, - "markdownText": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "markdownHeading": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownLink": { - "dark": "darkCyanBright", - "light": "lightCyan" - }, - "markdownLinkText": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownCode": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkGray", - "light": "lightGray" - }, - "markdownEmph": { - "dark": "darkMagenta", - "light": "lightMagenta" - }, - "markdownStrong": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "markdownHorizontalRule": { - "dark": "darkGray", - "light": "lightGray" - }, - "markdownListItem": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownListEnumeration": { - "dark": "darkCyanBright", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkCyanBright", - "light": "lightCyan" - }, - "markdownImageText": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownCodeBlock": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "syntaxComment": { - "dark": "darkGray", - "light": "lightGray" - }, - "syntaxKeyword": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxFunction": { - "dark": "darkBlue", - "light": "lightBlue" - }, - "syntaxVariable": { - "dark": "darkFg0", - "light": "lightFg0" - }, - "syntaxString": { - "dark": "darkGreenBright", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkMagenta", - "light": "lightMagenta" - }, - "syntaxType": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxOperator": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxPunctuation": { - "dark": "darkFg0", - "light": "lightFg0" - } - } - }, - "palenight": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#292d3e", - "backgroundAlt": "#1e2132", - "backgroundPanel": "#32364a", - "foreground": "#a6accd", - "foregroundBright": "#bfc7d5", - "comment": "#676e95", - "red": "#f07178", - "orange": "#f78c6c", - "yellow": "#ffcb6b", - "green": "#c3e88d", - "cyan": "#89ddff", - "blue": "#82aaff", - "purple": "#c792ea", - "magenta": "#ff5370", - "pink": "#f07178" - }, - "theme": { - "primary": { - "dark": "blue", - "light": "#4976eb" - }, - "secondary": { - "dark": "purple", - "light": "#a854f2" - }, - "accent": { - "dark": "cyan", - "light": "#00acc1" - }, - "error": { - "dark": "red", - "light": "#e53935" - }, - "warning": { - "dark": "yellow", - "light": "#ffb300" - }, - "success": { - "dark": "green", - "light": "#91b859" - }, - "info": { - "dark": "orange", - "light": "#f4511e" - }, - "text": { - "dark": "foreground", - "light": "#292d3e" - }, - "textMuted": { - "dark": "comment", - "light": "#8796b0" - }, - "background": { - "dark": "#292d3e", - "light": "#fafafa" - }, - "backgroundPanel": { - "dark": "#1e2132", - "light": "#f5f5f5" - }, - "backgroundElement": { - "dark": "#32364a", - "light": "#e7e7e8" - }, - "border": { - "dark": "#32364a", - "light": "#e0e0e0" - }, - "borderActive": { - "dark": "blue", - "light": "#4976eb" - }, - "borderSubtle": { - "dark": "#1e2132", - "light": "#eeeeee" - }, - "diffAdded": { - "dark": "green", - "light": "#91b859" - }, - "diffRemoved": { - "dark": "red", - "light": "#e53935" - }, - "diffContext": { - "dark": "comment", - "light": "#8796b0" - }, - "diffHunkHeader": { - "dark": "cyan", - "light": "#00acc1" - }, - "diffHighlightAdded": { - "dark": "green", - "light": "#91b859" - }, - "diffHighlightRemoved": { - "dark": "red", - "light": "#e53935" - }, - "diffAddedBg": { - "dark": "#2e3c2b", - "light": "#e8f5e9" - }, - "diffRemovedBg": { - "dark": "#3c2b2b", - "light": "#ffebee" - }, - "diffContextBg": { - "dark": "#1e2132", - "light": "#f5f5f5" - }, - "diffLineNumber": { - "dark": "#444760", - "light": "#cfd8dc" - }, - "diffAddedLineNumberBg": { - "dark": "#2e3c2b", - "light": "#e8f5e9" - }, - "diffRemovedLineNumberBg": { - "dark": "#3c2b2b", - "light": "#ffebee" - }, - "markdownText": { - "dark": "foreground", - "light": "#292d3e" - }, - "markdownHeading": { - "dark": "purple", - "light": "#a854f2" - }, - "markdownLink": { - "dark": "blue", - "light": "#4976eb" - }, - "markdownLinkText": { - "dark": "cyan", - "light": "#00acc1" - }, - "markdownCode": { - "dark": "green", - "light": "#91b859" - }, - "markdownBlockQuote": { - "dark": "comment", - "light": "#8796b0" - }, - "markdownEmph": { - "dark": "yellow", - "light": "#ffb300" - }, - "markdownStrong": { - "dark": "orange", - "light": "#f4511e" - }, - "markdownHorizontalRule": { - "dark": "comment", - "light": "#8796b0" - }, - "markdownListItem": { - "dark": "blue", - "light": "#4976eb" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "#00acc1" - }, - "markdownImage": { - "dark": "blue", - "light": "#4976eb" - }, - "markdownImageText": { - "dark": "cyan", - "light": "#00acc1" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#292d3e" - }, - "syntaxComment": { - "dark": "comment", - "light": "#8796b0" - }, - "syntaxKeyword": { - "dark": "purple", - "light": "#a854f2" - }, - "syntaxFunction": { - "dark": "blue", - "light": "#4976eb" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#292d3e" - }, - "syntaxString": { - "dark": "green", - "light": "#91b859" - }, - "syntaxNumber": { - "dark": "orange", - "light": "#f4511e" - }, - "syntaxType": { - "dark": "yellow", - "light": "#ffb300" - }, - "syntaxOperator": { - "dark": "cyan", - "light": "#00acc1" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#292d3e" - } - } - }, - "rosepine": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "base": "#191724", - "surface": "#1f1d2e", - "overlay": "#26233a", - "muted": "#6e6a86", - "subtle": "#908caa", - "text": "#e0def4", - "love": "#eb6f92", - "gold": "#f6c177", - "rose": "#ebbcba", - "pine": "#31748f", - "foam": "#9ccfd8", - "iris": "#c4a7e7", - "highlightLow": "#21202e", - "highlightMed": "#403d52", - "highlightHigh": "#524f67", - "moonBase": "#232136", - "moonSurface": "#2a273f", - "moonOverlay": "#393552", - "moonMuted": "#6e6a86", - "moonSubtle": "#908caa", - "moonText": "#e0def4", - "dawnBase": "#faf4ed", - "dawnSurface": "#fffaf3", - "dawnOverlay": "#f2e9e1", - "dawnMuted": "#9893a5", - "dawnSubtle": "#797593", - "dawnText": "#575279" - }, - "theme": { - "primary": { - "dark": "foam", - "light": "pine" - }, - "secondary": { - "dark": "iris", - "light": "#907aa9" - }, - "accent": { - "dark": "rose", - "light": "#d7827e" - }, - "error": { - "dark": "love", - "light": "#b4637a" - }, - "warning": { - "dark": "gold", - "light": "#ea9d34" - }, - "success": { - "dark": "pine", - "light": "#286983" - }, - "info": { - "dark": "foam", - "light": "#56949f" - }, - "text": { - "dark": "#e0def4", - "light": "#575279" - }, - "textMuted": { - "dark": "muted", - "light": "dawnMuted" - }, - "background": { - "dark": "base", - "light": "dawnBase" - }, - "backgroundPanel": { - "dark": "surface", - "light": "dawnSurface" - }, - "backgroundElement": { - "dark": "overlay", - "light": "dawnOverlay" - }, - "border": { - "dark": "highlightMed", - "light": "#dfdad9" - }, - "borderActive": { - "dark": "foam", - "light": "pine" - }, - "borderSubtle": { - "dark": "highlightLow", - "light": "#f4ede8" - }, - "diffAdded": { - "dark": "pine", - "light": "#286983" - }, - "diffRemoved": { - "dark": "love", - "light": "#b4637a" - }, - "diffContext": { - "dark": "muted", - "light": "dawnMuted" - }, - "diffHunkHeader": { - "dark": "iris", - "light": "#907aa9" - }, - "diffHighlightAdded": { - "dark": "pine", - "light": "#286983" - }, - "diffHighlightRemoved": { - "dark": "love", - "light": "#b4637a" - }, - "diffAddedBg": { - "dark": "#1f2d3a", - "light": "#e5f2f3" - }, - "diffRemovedBg": { - "dark": "#3a1f2d", - "light": "#fce5e8" - }, - "diffContextBg": { - "dark": "surface", - "light": "dawnSurface" - }, - "diffLineNumber": { - "dark": "muted", - "light": "dawnMuted" - }, - "diffAddedLineNumberBg": { - "dark": "#1f2d3a", - "light": "#e5f2f3" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1f2d", - "light": "#fce5e8" - }, - "markdownText": { - "dark": "#e0def4", - "light": "#575279" - }, - "markdownHeading": { - "dark": "iris", - "light": "#907aa9" - }, - "markdownLink": { - "dark": "foam", - "light": "pine" - }, - "markdownLinkText": { - "dark": "rose", - "light": "#d7827e" - }, - "markdownCode": { - "dark": "pine", - "light": "#286983" - }, - "markdownBlockQuote": { - "dark": "muted", - "light": "dawnMuted" - }, - "markdownEmph": { - "dark": "gold", - "light": "#ea9d34" - }, - "markdownStrong": { - "dark": "love", - "light": "#b4637a" - }, - "markdownHorizontalRule": { - "dark": "highlightMed", - "light": "#dfdad9" - }, - "markdownListItem": { - "dark": "foam", - "light": "pine" - }, - "markdownListEnumeration": { - "dark": "rose", - "light": "#d7827e" - }, - "markdownImage": { - "dark": "foam", - "light": "pine" - }, - "markdownImageText": { - "dark": "rose", - "light": "#d7827e" - }, - "markdownCodeBlock": { - "dark": "#e0def4", - "light": "#575279" - }, - "syntaxComment": { - "dark": "muted", - "light": "dawnMuted" - }, - "syntaxKeyword": { - "dark": "pine", - "light": "#286983" - }, - "syntaxFunction": { - "dark": "rose", - "light": "#d7827e" - }, - "syntaxVariable": { - "dark": "#e0def4", - "light": "#575279" - }, - "syntaxString": { - "dark": "gold", - "light": "#ea9d34" - }, - "syntaxNumber": { - "dark": "iris", - "light": "#907aa9" - }, - "syntaxType": { - "dark": "foam", - "light": "#56949f" - }, - "syntaxOperator": { - "dark": "subtle", - "light": "dawnSubtle" - }, - "syntaxPunctuation": { - "dark": "subtle", - "light": "dawnSubtle" - } - } - }, - "solarized": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "base03": "#002b36", - "base02": "#073642", - "base01": "#586e75", - "base00": "#657b83", - "base0": "#839496", - "base1": "#93a1a1", - "base2": "#eee8d5", - "base3": "#fdf6e3", - "yellow": "#b58900", - "orange": "#cb4b16", - "red": "#dc322f", - "magenta": "#d33682", - "violet": "#6c71c4", - "blue": "#268bd2", - "cyan": "#2aa198", - "green": "#859900" - }, - "theme": { - "primary": { - "dark": "blue", - "light": "blue" - }, - "secondary": { - "dark": "violet", - "light": "violet" - }, - "accent": { - "dark": "cyan", - "light": "cyan" - }, - "error": { - "dark": "red", - "light": "red" - }, - "warning": { - "dark": "yellow", - "light": "yellow" - }, - "success": { - "dark": "green", - "light": "green" - }, - "info": { - "dark": "orange", - "light": "orange" - }, - "text": { - "dark": "base0", - "light": "base00" - }, - "textMuted": { - "dark": "base01", - "light": "base1" - }, - "background": { - "dark": "base03", - "light": "base3" - }, - "backgroundPanel": { - "dark": "base02", - "light": "base2" - }, - "backgroundElement": { - "dark": "#073642", - "light": "#eee8d5" - }, - "border": { - "dark": "base02", - "light": "base2" - }, - "borderActive": { - "dark": "base01", - "light": "base1" - }, - "borderSubtle": { - "dark": "#073642", - "light": "#eee8d5" - }, - "diffAdded": { - "dark": "green", - "light": "green" - }, - "diffRemoved": { - "dark": "red", - "light": "red" - }, - "diffContext": { - "dark": "base01", - "light": "base1" - }, - "diffHunkHeader": { - "dark": "base01", - "light": "base1" - }, - "diffHighlightAdded": { - "dark": "green", - "light": "green" - }, - "diffHighlightRemoved": { - "dark": "red", - "light": "red" - }, - "diffAddedBg": { - "dark": "#073642", - "light": "#eee8d5" - }, - "diffRemovedBg": { - "dark": "#073642", - "light": "#eee8d5" - }, - "diffContextBg": { - "dark": "base02", - "light": "base2" - }, - "diffLineNumber": { - "dark": "base01", - "light": "base1" - }, - "diffAddedLineNumberBg": { - "dark": "#073642", - "light": "#eee8d5" - }, - "diffRemovedLineNumberBg": { - "dark": "#073642", - "light": "#eee8d5" - }, - "markdownText": { - "dark": "base0", - "light": "base00" - }, - "markdownHeading": { - "dark": "blue", - "light": "blue" - }, - "markdownLink": { - "dark": "cyan", - "light": "cyan" - }, - "markdownLinkText": { - "dark": "violet", - "light": "violet" - }, - "markdownCode": { - "dark": "green", - "light": "green" - }, - "markdownBlockQuote": { - "dark": "base01", - "light": "base1" - }, - "markdownEmph": { - "dark": "yellow", - "light": "yellow" - }, - "markdownStrong": { - "dark": "orange", - "light": "orange" - }, - "markdownHorizontalRule": { - "dark": "base01", - "light": "base1" - }, - "markdownListItem": { - "dark": "blue", - "light": "blue" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "cyan" - }, - "markdownImage": { - "dark": "cyan", - "light": "cyan" - }, - "markdownImageText": { - "dark": "violet", - "light": "violet" - }, - "markdownCodeBlock": { - "dark": "base0", - "light": "base00" - }, - "syntaxComment": { - "dark": "base01", - "light": "base1" - }, - "syntaxKeyword": { - "dark": "green", - "light": "green" - }, - "syntaxFunction": { - "dark": "blue", - "light": "blue" - }, - "syntaxVariable": { - "dark": "cyan", - "light": "cyan" - }, - "syntaxString": { - "dark": "cyan", - "light": "cyan" - }, - "syntaxNumber": { - "dark": "magenta", - "light": "magenta" - }, - "syntaxType": { - "dark": "yellow", - "light": "yellow" - }, - "syntaxOperator": { - "dark": "green", - "light": "green" - }, - "syntaxPunctuation": { - "dark": "base0", - "light": "base00" - } - } - }, - "synthwave84": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background": "#262335", - "backgroundAlt": "#1e1a29", - "backgroundPanel": "#2a2139", - "foreground": "#ffffff", - "foregroundMuted": "#848bbd", - "pink": "#ff7edb", - "pinkBright": "#ff92df", - "cyan": "#36f9f6", - "cyanBright": "#72f1f8", - "yellow": "#fede5d", - "yellowBright": "#fff95d", - "orange": "#ff8b39", - "orangeBright": "#ff9f43", - "purple": "#b084eb", - "purpleBright": "#c792ea", - "red": "#fe4450", - "redBright": "#ff5e5b", - "green": "#72f1b8", - "greenBright": "#97f1d8" - }, - "theme": { - "primary": { - "dark": "cyan", - "light": "#00bcd4" - }, - "secondary": { - "dark": "pink", - "light": "#e91e63" - }, - "accent": { - "dark": "purple", - "light": "#9c27b0" - }, - "error": { - "dark": "red", - "light": "#f44336" - }, - "warning": { - "dark": "yellow", - "light": "#ff9800" - }, - "success": { - "dark": "green", - "light": "#4caf50" - }, - "info": { - "dark": "orange", - "light": "#ff5722" - }, - "text": { - "dark": "foreground", - "light": "#262335" - }, - "textMuted": { - "dark": "foregroundMuted", - "light": "#5c5c8a" - }, - "background": { - "dark": "#262335", - "light": "#fafafa" - }, - "backgroundPanel": { - "dark": "#1e1a29", - "light": "#f5f5f5" - }, - "backgroundElement": { - "dark": "#2a2139", - "light": "#eeeeee" - }, - "border": { - "dark": "#495495", - "light": "#e0e0e0" - }, - "borderActive": { - "dark": "cyan", - "light": "#00bcd4" - }, - "borderSubtle": { - "dark": "#241b2f", - "light": "#f0f0f0" - }, - "diffAdded": { - "dark": "green", - "light": "#4caf50" - }, - "diffRemoved": { - "dark": "red", - "light": "#f44336" - }, - "diffContext": { - "dark": "foregroundMuted", - "light": "#5c5c8a" - }, - "diffHunkHeader": { - "dark": "purple", - "light": "#9c27b0" - }, - "diffHighlightAdded": { - "dark": "greenBright", - "light": "#4caf50" - }, - "diffHighlightRemoved": { - "dark": "redBright", - "light": "#f44336" - }, - "diffAddedBg": { - "dark": "#1a3a2a", - "light": "#e8f5e9" - }, - "diffRemovedBg": { - "dark": "#3a1a2a", - "light": "#ffebee" - }, - "diffContextBg": { - "dark": "#1e1a29", - "light": "#f5f5f5" - }, - "diffLineNumber": { - "dark": "#495495", - "light": "#b0b0b0" - }, - "diffAddedLineNumberBg": { - "dark": "#1a3a2a", - "light": "#e8f5e9" - }, - "diffRemovedLineNumberBg": { - "dark": "#3a1a2a", - "light": "#ffebee" - }, - "markdownText": { - "dark": "foreground", - "light": "#262335" - }, - "markdownHeading": { - "dark": "pink", - "light": "#e91e63" - }, - "markdownLink": { - "dark": "cyan", - "light": "#00bcd4" - }, - "markdownLinkText": { - "dark": "purple", - "light": "#9c27b0" - }, - "markdownCode": { - "dark": "green", - "light": "#4caf50" - }, - "markdownBlockQuote": { - "dark": "foregroundMuted", - "light": "#5c5c8a" - }, - "markdownEmph": { - "dark": "yellow", - "light": "#ff9800" - }, - "markdownStrong": { - "dark": "orange", - "light": "#ff5722" - }, - "markdownHorizontalRule": { - "dark": "#495495", - "light": "#e0e0e0" - }, - "markdownListItem": { - "dark": "cyan", - "light": "#00bcd4" - }, - "markdownListEnumeration": { - "dark": "purple", - "light": "#9c27b0" - }, - "markdownImage": { - "dark": "cyan", - "light": "#00bcd4" - }, - "markdownImageText": { - "dark": "purple", - "light": "#9c27b0" - }, - "markdownCodeBlock": { - "dark": "foreground", - "light": "#262335" - }, - "syntaxComment": { - "dark": "foregroundMuted", - "light": "#5c5c8a" - }, - "syntaxKeyword": { - "dark": "pink", - "light": "#e91e63" - }, - "syntaxFunction": { - "dark": "orange", - "light": "#ff5722" - }, - "syntaxVariable": { - "dark": "foreground", - "light": "#262335" - }, - "syntaxString": { - "dark": "yellow", - "light": "#ff9800" - }, - "syntaxNumber": { - "dark": "purple", - "light": "#9c27b0" - }, - "syntaxType": { - "dark": "cyan", - "light": "#00bcd4" - }, - "syntaxOperator": { - "dark": "pink", - "light": "#e91e63" - }, - "syntaxPunctuation": { - "dark": "foreground", - "light": "#262335" - } - } - }, - "tokyonight": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "darkStep1": "#1a1b26", - "darkStep2": "#1e2030", - "darkStep3": "#222436", - "darkStep4": "#292e42", - "darkStep5": "#3b4261", - "darkStep6": "#545c7e", - "darkStep7": "#737aa2", - "darkStep8": "#9099b2", - "darkStep9": "#82aaff", - "darkStep10": "#89b4fa", - "darkStep11": "#828bb8", - "darkStep12": "#c8d3f5", - "darkRed": "#ff757f", - "darkOrange": "#ff966c", - "darkYellow": "#ffc777", - "darkGreen": "#c3e88d", - "darkCyan": "#86e1fc", - "darkPurple": "#c099ff", - "lightStep1": "#e1e2e7", - "lightStep2": "#d5d6db", - "lightStep3": "#c8c9ce", - "lightStep4": "#b9bac1", - "lightStep5": "#a8aecb", - "lightStep6": "#9699a8", - "lightStep7": "#737a8c", - "lightStep8": "#5a607d", - "lightStep9": "#2e7de9", - "lightStep10": "#1a6ce7", - "lightStep11": "#8990a3", - "lightStep12": "#3760bf", - "lightRed": "#f52a65", - "lightOrange": "#b15c00", - "lightYellow": "#8c6c3e", - "lightGreen": "#587539", - "lightCyan": "#007197", - "lightPurple": "#9854f1" - }, - "theme": { - "primary": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "secondary": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "accent": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "error": { - "dark": "darkRed", - "light": "lightRed" - }, - "warning": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "success": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "info": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "text": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "textMuted": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "background": { - "dark": "darkStep1", - "light": "lightStep1" - }, - "backgroundPanel": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "backgroundElement": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "border": { - "dark": "darkStep7", - "light": "lightStep7" - }, - "borderActive": { - "dark": "darkStep8", - "light": "lightStep8" - }, - "borderSubtle": { - "dark": "darkStep6", - "light": "lightStep6" - }, - "diffAdded": { - "dark": "#4fd6be", - "light": "#1e725c" - }, - "diffRemoved": { - "dark": "#c53b53", - "light": "#c53b53" - }, - "diffContext": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHunkHeader": { - "dark": "#828bb8", - "light": "#7086b5" - }, - "diffHighlightAdded": { - "dark": "#b8db87", - "light": "#4db380" - }, - "diffHighlightRemoved": { - "dark": "#e26a75", - "light": "#f52a65" - }, - "diffAddedBg": { - "dark": "#20303b", - "light": "#d5e5d5" - }, - "diffRemovedBg": { - "dark": "#37222c", - "light": "#f7d8db" - }, - "diffContextBg": { - "dark": "darkStep2", - "light": "lightStep2" - }, - "diffLineNumber": { - "dark": "darkStep3", - "light": "lightStep3" - }, - "diffAddedLineNumberBg": { - "dark": "#1b2b34", - "light": "#c5d5c5" - }, - "diffRemovedLineNumberBg": { - "dark": "#2d1f26", - "light": "#e7c8cb" - }, - "markdownText": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "markdownHeading": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "markdownLink": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownLinkText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCode": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "markdownBlockQuote": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownEmph": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "markdownStrong": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "markdownHorizontalRule": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "markdownListItem": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownListEnumeration": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownImage": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "markdownImageText": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "markdownCodeBlock": { - "dark": "darkStep12", - "light": "lightStep12" - }, - "syntaxComment": { - "dark": "darkStep11", - "light": "lightStep11" - }, - "syntaxKeyword": { - "dark": "darkPurple", - "light": "lightPurple" - }, - "syntaxFunction": { - "dark": "darkStep9", - "light": "lightStep9" - }, - "syntaxVariable": { - "dark": "darkRed", - "light": "lightRed" - }, - "syntaxString": { - "dark": "darkGreen", - "light": "lightGreen" - }, - "syntaxNumber": { - "dark": "darkOrange", - "light": "lightOrange" - }, - "syntaxType": { - "dark": "darkYellow", - "light": "lightYellow" - }, - "syntaxOperator": { - "dark": "darkCyan", - "light": "lightCyan" - }, - "syntaxPunctuation": { - "dark": "darkStep12", - "light": "lightStep12" - } - } - }, - "vercel": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "background100": "#0A0A0A", - "background200": "#000000", - "gray100": "#1A1A1A", - "gray200": "#1F1F1F", - "gray300": "#292929", - "gray400": "#2E2E2E", - "gray500": "#454545", - "gray600": "#878787", - "gray700": "#8F8F8F", - "gray900": "#A1A1A1", - "gray1000": "#EDEDED", - "blue600": "#0099FF", - "blue700": "#0070F3", - "blue900": "#52A8FF", - "blue1000": "#EBF8FF", - "red700": "#E5484D", - "red900": "#FF6166", - "red1000": "#FDECED", - "amber700": "#FFB224", - "amber900": "#F2A700", - "amber1000": "#FDF4DC", - "green700": "#46A758", - "green900": "#63C46D", - "green1000": "#E6F9E9", - "teal700": "#12A594", - "teal900": "#0AC7AC", - "purple700": "#8E4EC6", - "purple900": "#BF7AF0", - "pink700": "#E93D82", - "pink900": "#F75590", - "highlightPink": "#FF0080", - "highlightPurple": "#F81CE5", - "cyan": "#50E3C2", - "lightBackground": "#FFFFFF", - "lightGray100": "#FAFAFA", - "lightGray200": "#EAEAEA", - "lightGray600": "#666666", - "lightGray1000": "#171717" - }, - "theme": { - "primary": { - "dark": "blue700", - "light": "blue700" - }, - "secondary": { - "dark": "blue900", - "light": "#0062D1" - }, - "accent": { - "dark": "purple700", - "light": "purple700" - }, - "error": { - "dark": "red700", - "light": "#DC3545" - }, - "warning": { - "dark": "amber700", - "light": "#FF9500" - }, - "success": { - "dark": "green700", - "light": "#388E3C" - }, - "info": { - "dark": "blue900", - "light": "blue700" - }, - "text": { - "dark": "gray1000", - "light": "lightGray1000" - }, - "textMuted": { - "dark": "gray600", - "light": "lightGray600" - }, - "background": { - "dark": "background200", - "light": "lightBackground" - }, - "backgroundPanel": { - "dark": "gray100", - "light": "lightGray100" - }, - "backgroundElement": { - "dark": "gray300", - "light": "lightGray200" - }, - "border": { - "dark": "gray200", - "light": "lightGray200" - }, - "borderActive": { - "dark": "gray500", - "light": "#999999" - }, - "borderSubtle": { - "dark": "gray100", - "light": "#EAEAEA" - }, - "diffAdded": { - "dark": "green900", - "light": "green700" - }, - "diffRemoved": { - "dark": "red900", - "light": "red700" - }, - "diffContext": { - "dark": "gray600", - "light": "lightGray600" - }, - "diffHunkHeader": { - "dark": "gray600", - "light": "lightGray600" - }, - "diffHighlightAdded": { - "dark": "green900", - "light": "green700" - }, - "diffHighlightRemoved": { - "dark": "red900", - "light": "red700" - }, - "diffAddedBg": { - "dark": "#0B1D0F", - "light": "#E6F9E9" - }, - "diffRemovedBg": { - "dark": "#2A1314", - "light": "#FDECED" - }, - "diffContextBg": { - "dark": "background200", - "light": "lightBackground" - }, - "diffLineNumber": { - "dark": "gray600", - "light": "lightGray600" - }, - "diffAddedLineNumberBg": { - "dark": "#0F2613", - "light": "#D6F5D6" - }, - "diffRemovedLineNumberBg": { - "dark": "#3C1618", - "light": "#FFE5E5" - }, - "markdownText": { - "dark": "gray1000", - "light": "lightGray1000" - }, - "markdownHeading": { - "dark": "purple900", - "light": "purple700" - }, - "markdownLink": { - "dark": "blue900", - "light": "blue700" - }, - "markdownLinkText": { - "dark": "teal900", - "light": "teal700" - }, - "markdownCode": { - "dark": "green900", - "light": "green700" - }, - "markdownBlockQuote": { - "dark": "gray600", - "light": "lightGray600" - }, - "markdownEmph": { - "dark": "amber900", - "light": "amber700" - }, - "markdownStrong": { - "dark": "pink900", - "light": "pink700" - }, - "markdownHorizontalRule": { - "dark": "gray500", - "light": "#999999" - }, - "markdownListItem": { - "dark": "gray1000", - "light": "lightGray1000" - }, - "markdownListEnumeration": { - "dark": "blue900", - "light": "blue700" - }, - "markdownImage": { - "dark": "teal900", - "light": "teal700" - }, - "markdownImageText": { - "dark": "cyan", - "light": "teal700" - }, - "markdownCodeBlock": { - "dark": "gray1000", - "light": "lightGray1000" - }, - "syntaxComment": { - "dark": "gray600", - "light": "#888888" - }, - "syntaxKeyword": { - "dark": "pink900", - "light": "pink700" - }, - "syntaxFunction": { - "dark": "purple900", - "light": "purple700" - }, - "syntaxVariable": { - "dark": "blue900", - "light": "blue700" - }, - "syntaxString": { - "dark": "green900", - "light": "green700" - }, - "syntaxNumber": { - "dark": "amber900", - "light": "amber700" - }, - "syntaxType": { - "dark": "teal900", - "light": "teal700" - }, - "syntaxOperator": { - "dark": "pink900", - "light": "pink700" - }, - "syntaxPunctuation": { - "dark": "gray1000", - "light": "lightGray1000" - } - } - }, - "vesper": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "vesperBg": "#101010", - "vesperFg": "#FFF", - "vesperComment": "#8b8b8b", - "vesperKeyword": "#A0A0A0", - "vesperFunction": "#FFC799", - "vesperString": "#99FFE4", - "vesperNumber": "#FFC799", - "vesperError": "#FF8080", - "vesperWarning": "#FFC799", - "vesperSuccess": "#99FFE4", - "vesperMuted": "#A0A0A0" - }, - "theme": { - "primary": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "secondary": { - "dark": "#99FFE4", - "light": "#99FFE4" - }, - "accent": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "error": { - "dark": "vesperError", - "light": "vesperError" - }, - "warning": { - "dark": "vesperWarning", - "light": "vesperWarning" - }, - "success": { - "dark": "vesperSuccess", - "light": "vesperSuccess" - }, - "info": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "text": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "textMuted": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "background": { - "dark": "vesperBg", - "light": "#FFF" - }, - "backgroundPanel": { - "dark": "vesperBg", - "light": "#F0F0F0" - }, - "backgroundElement": { - "dark": "vesperBg", - "light": "#E0E0E0" - }, - "border": { - "dark": "#282828", - "light": "#D0D0D0" - }, - "borderActive": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "borderSubtle": { - "dark": "#1C1C1C", - "light": "#E8E8E8" - }, - "diffAdded": { - "dark": "vesperSuccess", - "light": "vesperSuccess" - }, - "diffRemoved": { - "dark": "vesperError", - "light": "vesperError" - }, - "diffContext": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "diffHunkHeader": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "diffHighlightAdded": { - "dark": "vesperSuccess", - "light": "vesperSuccess" - }, - "diffHighlightRemoved": { - "dark": "vesperError", - "light": "vesperError" - }, - "diffAddedBg": { - "dark": "#0d2818", - "light": "#e8f5e8" - }, - "diffRemovedBg": { - "dark": "#281a1a", - "light": "#f5e8e8" - }, - "diffContextBg": { - "dark": "vesperBg", - "light": "#F8F8F8" - }, - "diffLineNumber": { - "dark": "#505050", - "light": "#808080" - }, - "diffAddedLineNumberBg": { - "dark": "#0d2818", - "light": "#e8f5e8" - }, - "diffRemovedLineNumberBg": { - "dark": "#281a1a", - "light": "#f5e8e8" - }, - "markdownText": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownHeading": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "markdownLink": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "markdownLinkText": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "markdownCode": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "markdownBlockQuote": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownEmph": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownStrong": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownHorizontalRule": { - "dark": "#65737E", - "light": "#65737E" - }, - "markdownListItem": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownListEnumeration": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "markdownImage": { - "dark": "#FFC799", - "light": "#FFC799" - }, - "markdownImageText": { - "dark": "vesperMuted", - "light": "vesperMuted" - }, - "markdownCodeBlock": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "syntaxComment": { - "dark": "vesperComment", - "light": "vesperComment" - }, - "syntaxKeyword": { - "dark": "vesperKeyword", - "light": "vesperKeyword" - }, - "syntaxFunction": { - "dark": "vesperFunction", - "light": "vesperFunction" - }, - "syntaxVariable": { - "dark": "vesperFg", - "light": "vesperBg" - }, - "syntaxString": { - "dark": "vesperString", - "light": "vesperString" - }, - "syntaxNumber": { - "dark": "vesperNumber", - "light": "vesperNumber" - }, - "syntaxType": { - "dark": "vesperFunction", - "light": "vesperFunction" - }, - "syntaxOperator": { - "dark": "vesperKeyword", - "light": "vesperKeyword" - }, - "syntaxPunctuation": { - "dark": "vesperFg", - "light": "vesperBg" - } - } - }, - "zenburn": { - "$schema": "https://opencode.ai/theme.json", - "defs": { - "bg": "#3f3f3f", - "bgAlt": "#4f4f4f", - "bgPanel": "#5f5f5f", - "fg": "#dcdccc", - "fgMuted": "#9f9f9f", - "red": "#cc9393", - "redBright": "#dca3a3", - "green": "#7f9f7f", - "greenBright": "#8fb28f", - "yellow": "#f0dfaf", - "yellowDim": "#e0cf9f", - "blue": "#8cd0d3", - "blueDim": "#7cb8bb", - "magenta": "#dc8cc3", - "cyan": "#93e0e3", - "orange": "#dfaf8f" - }, - "theme": { - "primary": { - "dark": "blue", - "light": "#5f7f8f" - }, - "secondary": { - "dark": "magenta", - "light": "#8f5f8f" - }, - "accent": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "error": { - "dark": "red", - "light": "#8f5f5f" - }, - "warning": { - "dark": "yellow", - "light": "#8f8f5f" - }, - "success": { - "dark": "green", - "light": "#5f8f5f" - }, - "info": { - "dark": "orange", - "light": "#8f7f5f" - }, - "text": { - "dark": "fg", - "light": "#3f3f3f" - }, - "textMuted": { - "dark": "fgMuted", - "light": "#6f6f6f" - }, - "background": { - "dark": "bg", - "light": "#ffffef" - }, - "backgroundPanel": { - "dark": "bgAlt", - "light": "#f5f5e5" - }, - "backgroundElement": { - "dark": "bgPanel", - "light": "#ebebdb" - }, - "border": { - "dark": "#5f5f5f", - "light": "#d0d0c0" - }, - "borderActive": { - "dark": "blue", - "light": "#5f7f8f" - }, - "borderSubtle": { - "dark": "#4f4f4f", - "light": "#e0e0d0" - }, - "diffAdded": { - "dark": "green", - "light": "#5f8f5f" - }, - "diffRemoved": { - "dark": "red", - "light": "#8f5f5f" - }, - "diffContext": { - "dark": "fgMuted", - "light": "#6f6f6f" - }, - "diffHunkHeader": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "diffHighlightAdded": { - "dark": "greenBright", - "light": "#5f8f5f" - }, - "diffHighlightRemoved": { - "dark": "redBright", - "light": "#8f5f5f" - }, - "diffAddedBg": { - "dark": "#4f5f4f", - "light": "#efffef" - }, - "diffRemovedBg": { - "dark": "#5f4f4f", - "light": "#ffefef" - }, - "diffContextBg": { - "dark": "bgAlt", - "light": "#f5f5e5" - }, - "diffLineNumber": { - "dark": "#6f6f6f", - "light": "#b0b0a0" - }, - "diffAddedLineNumberBg": { - "dark": "#4f5f4f", - "light": "#efffef" - }, - "diffRemovedLineNumberBg": { - "dark": "#5f4f4f", - "light": "#ffefef" - }, - "markdownText": { - "dark": "fg", - "light": "#3f3f3f" - }, - "markdownHeading": { - "dark": "yellow", - "light": "#8f8f5f" - }, - "markdownLink": { - "dark": "blue", - "light": "#5f7f8f" - }, - "markdownLinkText": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "markdownCode": { - "dark": "green", - "light": "#5f8f5f" - }, - "markdownBlockQuote": { - "dark": "fgMuted", - "light": "#6f6f6f" - }, - "markdownEmph": { - "dark": "yellowDim", - "light": "#8f8f5f" - }, - "markdownStrong": { - "dark": "orange", - "light": "#8f7f5f" - }, - "markdownHorizontalRule": { - "dark": "fgMuted", - "light": "#6f6f6f" - }, - "markdownListItem": { - "dark": "blue", - "light": "#5f7f8f" - }, - "markdownListEnumeration": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "markdownImage": { - "dark": "blue", - "light": "#5f7f8f" - }, - "markdownImageText": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "markdownCodeBlock": { - "dark": "fg", - "light": "#3f3f3f" - }, - "syntaxComment": { - "dark": "#7f9f7f", - "light": "#5f7f5f" - }, - "syntaxKeyword": { - "dark": "yellow", - "light": "#8f8f5f" - }, - "syntaxFunction": { - "dark": "blue", - "light": "#5f7f8f" - }, - "syntaxVariable": { - "dark": "fg", - "light": "#3f3f3f" - }, - "syntaxString": { - "dark": "red", - "light": "#8f5f5f" - }, - "syntaxNumber": { - "dark": "greenBright", - "light": "#5f8f5f" - }, - "syntaxType": { - "dark": "cyan", - "light": "#5f8f8f" - }, - "syntaxOperator": { - "dark": "yellow", - "light": "#8f8f5f" - }, - "syntaxPunctuation": { - "dark": "fg", - "light": "#3f3f3f" - } - } - } -} diff --git a/factory/packages/cli/src/tmux.ts b/factory/packages/cli/src/tmux.ts deleted file mode 100644 index 16441339..00000000 --- a/factory/packages/cli/src/tmux.ts +++ /dev/null @@ -1,220 +0,0 @@ -import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync } from "node:fs"; -import { homedir } from "node:os"; - -const SYMBOL_RUNNING = "▶"; -const SYMBOL_IDLE = "✓"; -const DEFAULT_OPENCODE_ENDPOINT = "http://127.0.0.1:4097/opencode"; - -export interface TmuxWindowMatch { - target: string; - windowName: string; -} - -export interface SpawnCreateTmuxWindowInput { - branchName: string; - targetPath: string; - sessionId?: string | null; - opencodeEndpoint?: string; -} - -export interface SpawnCreateTmuxWindowResult { - created: boolean; - reason: - | "created" - | "not-in-tmux" - | "not-local-path" - | "window-exists" - | "tmux-new-window-failed"; -} - -function isTmuxSession(): boolean { - return Boolean(process.env.TMUX); -} - -function isAbsoluteLocalPath(path: string): boolean { - return path.startsWith("/"); -} - -function runTmux(args: string[]): boolean { - const result = spawnSync("tmux", args, { stdio: "ignore" }); - return !result.error && result.status === 0; -} - -function shellEscape(value: string): string { - if (value.length === 0) { - return "''"; - } - return `'${value.replace(/'/g, `'\\''`)}'`; -} - -function opencodeExistsOnPath(): boolean { - const probe = spawnSync("which", ["opencode"], { stdio: "ignore" }); - return !probe.error && probe.status === 0; -} - -function resolveOpencodeBinary(): string { - const envOverride = process.env.HF_OPENCODE_BIN?.trim(); - if (envOverride) { - return envOverride; - } - - if (opencodeExistsOnPath()) { - return "opencode"; - } - - const bundledCandidates = [ - `${homedir()}/.local/share/sandbox-agent/bin/opencode`, - `${homedir()}/.opencode/bin/opencode` - ]; - - for (const candidate of bundledCandidates) { - if (existsSync(candidate)) { - return candidate; - } - } - - return "opencode"; -} - -function attachCommand(sessionId: string, targetPath: string, endpoint: string): string { - const opencode = resolveOpencodeBinary(); - return [ - shellEscape(opencode), - "attach", - shellEscape(endpoint), - "--session", - shellEscape(sessionId), - "--dir", - shellEscape(targetPath) - ].join(" "); -} - -export function stripStatusPrefix(windowName: string): string { - return windowName - .trimStart() - .replace(new RegExp(`^${SYMBOL_RUNNING}\\s+`), "") - .replace(new RegExp(`^${SYMBOL_IDLE}\\s+`), "") - .trim(); -} - -export function findTmuxWindowsByBranch(branchName: string): TmuxWindowMatch[] { - const output = spawnSync( - "tmux", - ["list-windows", "-a", "-F", "#{session_name}:#{window_id}:#{window_name}"], - { encoding: "utf8" } - ); - - if (output.error || output.status !== 0 || !output.stdout) { - return []; - } - - const lines = output.stdout.split(/\r?\n/).filter((line) => line.trim().length > 0); - const matches: TmuxWindowMatch[] = []; - - for (const line of lines) { - const parts = line.split(":", 3); - if (parts.length !== 3) { - continue; - } - - const sessionName = parts[0] ?? ""; - const windowId = parts[1] ?? ""; - const windowName = parts[2] ?? ""; - const clean = stripStatusPrefix(windowName); - if (clean !== branchName) { - continue; - } - - matches.push({ - target: `${sessionName}:${windowId}`, - windowName - }); - } - - return matches; -} - -export function spawnCreateTmuxWindow( - input: SpawnCreateTmuxWindowInput -): SpawnCreateTmuxWindowResult { - if (!isTmuxSession()) { - return { created: false, reason: "not-in-tmux" }; - } - - if (!isAbsoluteLocalPath(input.targetPath)) { - return { created: false, reason: "not-local-path" }; - } - - if (findTmuxWindowsByBranch(input.branchName).length > 0) { - return { created: false, reason: "window-exists" }; - } - - const windowName = input.sessionId ? `${SYMBOL_RUNNING} ${input.branchName}` : input.branchName; - const endpoint = input.opencodeEndpoint ?? DEFAULT_OPENCODE_ENDPOINT; - let output = ""; - try { - output = execFileSync( - "tmux", - [ - "new-window", - "-d", - "-P", - "-F", - "#{window_id}", - "-n", - windowName, - "-c", - input.targetPath - ], - { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } - ); - } catch { - return { created: false, reason: "tmux-new-window-failed" }; - } - - const windowId = output.trim(); - if (!windowId) { - return { created: false, reason: "tmux-new-window-failed" }; - } - - if (input.sessionId) { - const leftPane = `${windowId}.0`; - - // Split left pane horizontally → creates right pane; capture its pane ID - let rightPane: string; - try { - rightPane = execFileSync( - "tmux", - ["split-window", "-h", "-P", "-F", "#{pane_id}", "-t", leftPane, "-c", input.targetPath], - { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] } - ).trim(); - } catch { - return { created: true, reason: "created" }; - } - - if (!rightPane) { - return { created: true, reason: "created" }; - } - - // Split right pane vertically → top-right (rightPane) + bottom-right (new) - runTmux(["split-window", "-v", "-t", rightPane, "-c", input.targetPath]); - - // Left pane 60% width, top-right pane 70% height - runTmux(["resize-pane", "-t", leftPane, "-x", "60%"]); - runTmux(["resize-pane", "-t", rightPane, "-y", "70%"]); - - // Editor in left pane, agent attach in top-right pane - runTmux(["send-keys", "-t", leftPane, "nvim .", "Enter"]); - runTmux([ - "send-keys", - "-t", - rightPane, - attachCommand(input.sessionId, input.targetPath, endpoint), - "Enter" - ]); - runTmux(["select-pane", "-t", rightPane]); - } - - return { created: true, reason: "created" }; -} diff --git a/factory/packages/cli/src/tui.ts b/factory/packages/cli/src/tui.ts deleted file mode 100644 index 458ede31..00000000 --- a/factory/packages/cli/src/tui.ts +++ /dev/null @@ -1,640 +0,0 @@ -import type { AppConfig, HandoffRecord } from "@openhandoff/shared"; -import { spawnSync } from "node:child_process"; -import { - createBackendClientFromConfig, - filterHandoffs, - formatRelativeAge, - groupHandoffStatus -} from "@openhandoff/client"; -import { CLI_BUILD_ID } from "./build-id.js"; -import { resolveTuiTheme, type TuiTheme } from "./theme.js"; - -interface KeyEventLike { - name?: string; - ctrl?: boolean; - meta?: boolean; -} - -const HELP_LINES = [ - "Shortcuts", - "Ctrl-H toggle cheatsheet", - "Enter switch to branch", - "Ctrl-A attach to session", - "Ctrl-O open PR in browser", - "Ctrl-X archive branch / close PR", - "Ctrl-Y merge highlighted PR", - "Ctrl-S sync handoff with remote", - "Ctrl-N / Down next row", - "Ctrl-P / Up previous row", - "Backspace delete filter", - "Type filter by branch/PR/author", - "Esc / Ctrl-C cancel", - "", - "Legend", - "Agent: \u{1F916} running \u{1F4AC} idle \u25CC queued" -]; - -const COLUMN_WIDTHS = { - diff: 10, - agent: 5, - pr: 6, - author: 10, - ci: 7, - review: 8, - age: 5 -} as const; - -interface DisplayRow { - name: string; - diff: string; - agent: string; - pr: string; - author: string; - ci: string; - review: string; - age: string; -} - -interface RenderOptions { - width?: number; - height?: number; -} - -function pad(input: string, width: number): string { - if (width <= 0) { - return ""; - } - const chars = Array.from(input); - const text = chars.length > width ? `${chars.slice(0, Math.max(1, width - 1)).join("")}…` : input; - return text.padEnd(width, " "); -} - -function truncateToLen(input: string, maxLen: number): string { - if (maxLen <= 0) { - return ""; - } - return Array.from(input).slice(0, maxLen).join(""); -} - -function fitLine(input: string, width: number): string { - if (width <= 0) { - return ""; - } - const clipped = truncateToLen(input, width); - const len = Array.from(clipped).length; - if (len >= width) { - return clipped; - } - return `${clipped}${" ".repeat(width - len)}`; -} - -function overlayLine(base: string, overlay: string, startCol: number, width: number): string { - const out = Array.from(fitLine(base, width)); - const src = Array.from(truncateToLen(overlay, Math.max(0, width - startCol))); - for (let i = 0; i < src.length; i += 1) { - const col = startCol + i; - if (col >= 0 && col < out.length) { - out[col] = src[i] ?? " "; - } - } - return out.join(""); -} - -function buildFooterLine(width: number, segments: string[], right: string): string { - if (width <= 0) { - return ""; - } - - const rightLen = Array.from(right).length; - if (width <= rightLen + 1) { - return truncateToLen(right, width); - } - - const leftMax = width - rightLen - 1; - let used = 0; - let left = ""; - let first = true; - - for (const segment of segments) { - const chunk = first ? segment : ` | ${segment}`; - const clipped = truncateToLen(chunk, leftMax - used); - if (!clipped) { - break; - } - left += clipped; - used += Array.from(clipped).length; - first = false; - if (used >= leftMax) { - break; - } - } - - const padding = " ".repeat(Math.max(0, leftMax - used) + 1); - return `${left}${padding}${right}`; -} - -function agentSymbol(status: HandoffRecord["status"]): string { - const group = groupHandoffStatus(status); - if (group === "running") return "🤖"; - if (group === "idle") return "💬"; - if (group === "error") return "⚠"; - if (group === "queued") return "◌"; - return "-"; -} - -function toDisplayRow(row: HandoffRecord): DisplayRow { - const conflictPrefix = row.conflictsWithMain === "true" ? "\u26A0 " : ""; - - const prLabel = row.prUrl - ? `#${row.prUrl.match(/\/pull\/(\d+)/)?.[1] ?? "?"}` - : row.prSubmitted ? "sub" : "-"; - - const ciLabel = row.ciStatus ?? "-"; - const reviewLabel = row.reviewStatus - ? row.reviewStatus === "approved" ? "ok" - : row.reviewStatus === "changes_requested" ? "chg" - : row.reviewStatus === "pending" ? "..." : row.reviewStatus - : "-"; - - return { - name: `${conflictPrefix}${row.title || row.branchName}`, - diff: row.diffStat ?? "-", - agent: agentSymbol(row.status), - pr: prLabel, - author: row.prAuthor ?? "-", - ci: ciLabel, - review: reviewLabel, - age: formatRelativeAge(row.updatedAt) - }; -} - -function helpLines(width: number): string[] { - const popupWidth = Math.max(40, Math.min(width - 2, 100)); - const innerWidth = Math.max(2, popupWidth - 2); - const borderTop = `┌${"─".repeat(innerWidth)}┐`; - const borderBottom = `└${"─".repeat(innerWidth)}┘`; - - const lines = [borderTop]; - for (const line of HELP_LINES) { - lines.push(`│${pad(line, innerWidth)}│`); - } - lines.push(borderBottom); - return lines; -} - -export function formatRows( - rows: HandoffRecord[], - selected: number, - workspaceId: string, - status: string, - searchQuery = "", - showHelp = false, - options: RenderOptions = {} -): string { - const totalWidth = options.width ?? process.stdout.columns ?? 120; - const totalHeight = Math.max(6, options.height ?? process.stdout.rows ?? 24); - const fixedWidth = - COLUMN_WIDTHS.diff + - COLUMN_WIDTHS.agent + - COLUMN_WIDTHS.pr + - COLUMN_WIDTHS.author + - COLUMN_WIDTHS.ci + - COLUMN_WIDTHS.review + - COLUMN_WIDTHS.age; - const separators = 7; - const prefixWidth = 2; - const branchWidth = Math.max(20, totalWidth - (fixedWidth + separators + prefixWidth)); - - const branchHeader = searchQuery ? `Branch/PR: ${searchQuery}_` : "Branch/PR (type to filter)"; - const header = [ - ` ${pad(branchHeader, branchWidth)} ${pad("Diff", COLUMN_WIDTHS.diff)} ${pad("Agent", COLUMN_WIDTHS.agent)} ${pad("PR", COLUMN_WIDTHS.pr)} ${pad("Author", COLUMN_WIDTHS.author)} ${pad("CI", COLUMN_WIDTHS.ci)} ${pad("Review", COLUMN_WIDTHS.review)} ${pad("Age", COLUMN_WIDTHS.age)}`, - "-".repeat(Math.max(24, Math.min(totalWidth, 180))) - ]; - - const body = - rows.length === 0 - ? ["No branches found."] - : rows.map((row, index) => { - const marker = index === selected ? "┃ " : " "; - const display = toDisplayRow(row); - return `${marker}${pad(display.name, branchWidth)} ${pad(display.diff, COLUMN_WIDTHS.diff)} ${pad(display.agent, COLUMN_WIDTHS.agent)} ${pad(display.pr, COLUMN_WIDTHS.pr)} ${pad(display.author, COLUMN_WIDTHS.author)} ${pad(display.ci, COLUMN_WIDTHS.ci)} ${pad(display.review, COLUMN_WIDTHS.review)} ${pad(display.age, COLUMN_WIDTHS.age)}`; - }); - - const footer = fitLine( - buildFooterLine( - totalWidth, - ["Ctrl-H:cheatsheet", `workspace:${workspaceId}`, status], - `v${CLI_BUILD_ID}` - ), - totalWidth, - ); - - const contentHeight = totalHeight - 1; - const lines = [...header, ...body].map((line) => fitLine(line, totalWidth)); - const page = lines.slice(0, contentHeight); - while (page.length < contentHeight) { - page.push(" ".repeat(totalWidth)); - } - - if (showHelp) { - const popup = helpLines(totalWidth); - const startRow = Math.max(0, Math.floor((contentHeight - popup.length) / 2)); - for (let i = 0; i < popup.length; i += 1) { - const target = startRow + i; - if (target >= page.length) { - break; - } - const popupLine = popup[i] ?? ""; - const popupLen = Array.from(popupLine).length; - const startCol = Math.max(0, Math.floor((totalWidth - popupLen) / 2)); - page[target] = overlayLine(page[target] ?? "", popupLine, startCol, totalWidth); - } - } - - return [...page, footer].join("\n"); -} - -interface OpenTuiLike { - createCliRenderer?: (options?: Record) => Promise; - TextRenderable?: new (ctx: any, options: { id: string; content: string }) => { - content: unknown; - fg?: string; - bg?: string; - }; - fg?: (color: string) => (input: unknown) => unknown; - bg?: (color: string) => (input: unknown) => unknown; - StyledText?: new (chunks: unknown[]) => unknown; -} - -interface StyledTextApi { - fg: (color: string) => (input: unknown) => unknown; - bg: (color: string) => (input: unknown) => unknown; - StyledText: new (chunks: unknown[]) => unknown; -} - -function buildStyledContent(content: string, theme: TuiTheme, api: StyledTextApi): unknown { - const lines = content.split("\n"); - const chunks: unknown[] = []; - const footerIndex = Math.max(0, lines.length - 1); - - for (let i = 0; i < lines.length; i += 1) { - const line = lines[i] ?? ""; - - let fgColor = theme.text; - let bgColor: string | undefined; - - if (line.startsWith("┃ ")) { - const marker = "┃ "; - const rest = line.slice(marker.length); - bgColor = theme.highlightBg; - const markerChunk = api.bg(bgColor)(api.fg(theme.selectionBorder)(marker)); - const restChunk = api.bg(bgColor)(api.fg(theme.highlightFg)(rest)); - chunks.push(markerChunk); - chunks.push(restChunk); - if (i < lines.length - 1) { - chunks.push(api.fg(theme.text)("\n")); - } - continue; - } - - if (i === 0) { - fgColor = theme.header; - } else if (i === 1) { - fgColor = theme.muted; - } else if (i === footerIndex) { - fgColor = theme.status; - } else if (line.startsWith("┌") || line.startsWith("│") || line.startsWith("└")) { - fgColor = theme.info; - } - - let chunk: unknown = api.fg(fgColor)(line); - if (bgColor) { - chunk = api.bg(bgColor)(chunk); - } - chunks.push(chunk); - - if (i < lines.length - 1) { - chunks.push(api.fg(theme.text)("\n")); - } - } - - return new api.StyledText(chunks); -} - -export async function runTui(config: AppConfig, workspaceId: string): Promise { - const core = (await import("@opentui/core")) as OpenTuiLike; - const createCliRenderer = core.createCliRenderer; - const TextRenderable = core.TextRenderable; - const styleApi = - core.fg && core.bg && core.StyledText - ? { fg: core.fg, bg: core.bg, StyledText: core.StyledText } - : null; - - if (!createCliRenderer || !TextRenderable) { - throw new Error("OpenTUI runtime missing createCliRenderer/TextRenderable exports"); - } - - const themeResolution = resolveTuiTheme(config); - const client = createBackendClientFromConfig(config); - const renderer = await createCliRenderer({ exitOnCtrlC: false }); - const text = new TextRenderable(renderer, { - id: "openhandoff-switch", - content: "Loading..." - }); - text.fg = themeResolution.theme.text; - text.bg = themeResolution.theme.background; - renderer.root.add(text); - renderer.start(); - - let allRows: HandoffRecord[] = []; - let filteredRows: HandoffRecord[] = []; - let selected = 0; - let searchQuery = ""; - let showHelp = false; - let status = "loading..."; - let busy = false; - let closed = false; - let timer: ReturnType | null = null; - - const clampSelected = (): void => { - if (filteredRows.length === 0) { - selected = 0; - return; - } - if (selected < 0) { - selected = 0; - return; - } - if (selected >= filteredRows.length) { - selected = filteredRows.length - 1; - } - }; - - const render = (): void => { - if (closed) { - return; - } - const output = formatRows(filteredRows, selected, workspaceId, status, searchQuery, showHelp, { - width: renderer.width ?? process.stdout.columns, - height: renderer.height ?? process.stdout.rows - }); - text.content = styleApi - ? buildStyledContent(output, themeResolution.theme, styleApi) - : output; - renderer.requestRender(); - }; - - const refresh = async (): Promise => { - if (closed) { - return; - } - try { - allRows = await client.listHandoffs(workspaceId); - if (closed) { - return; - } - filteredRows = filterHandoffs(allRows, searchQuery); - clampSelected(); - status = `handoffs=${allRows.length} filtered=${filteredRows.length}`; - } catch (err) { - if (closed) { - return; - } - status = err instanceof Error ? err.message : String(err); - } - render(); - }; - - const selectedRow = (): HandoffRecord | null => { - if (filteredRows.length === 0) { - return null; - } - return filteredRows[selected] ?? null; - }; - - let resolveDone: () => void = () => {}; - const done = new Promise((resolve) => { - resolveDone = () => resolve(); - }); - - const close = (output?: string): void => { - if (closed) { - return; - } - closed = true; - if (timer) { - clearInterval(timer); - timer = null; - } - process.off("SIGINT", handleSignal); - process.off("SIGTERM", handleSignal); - renderer.destroy(); - if (output) { - console.log(output); - } - resolveDone(); - }; - - const handleSignal = (): void => { - close(); - }; - - const runActionWithRefresh = async ( - label: string, - fn: () => Promise, - success: string - ): Promise => { - if (busy) { - return; - } - busy = true; - status = `${label}...`; - render(); - try { - await fn(); - status = success; - await refresh(); - } catch (err) { - status = err instanceof Error ? err.message : String(err); - render(); - } finally { - busy = false; - } - }; - - await refresh(); - timer = setInterval(() => { - void refresh(); - }, 10_000); - process.once("SIGINT", handleSignal); - process.once("SIGTERM", handleSignal); - - const keyInput = (renderer.keyInput ?? renderer.keyHandler) as - | { on: (name: string, cb: (event: KeyEventLike) => void) => void } - | undefined; - - if (!keyInput) { - clearInterval(timer); - renderer.destroy(); - throw new Error("OpenTUI key input handler is unavailable"); - } - - keyInput.on("keypress", (event: KeyEventLike) => { - if (closed) { - return; - } - - const name = event.name ?? ""; - const ctrl = Boolean(event.ctrl); - - if (ctrl && name === "h") { - showHelp = !showHelp; - render(); - return; - } - - if (showHelp) { - if (name === "escape") { - showHelp = false; - render(); - } - return; - } - - if (name === "q" || name === "escape" || (ctrl && name === "c")) { - close(); - return; - } - - if ((ctrl && name === "n") || name === "down") { - if (filteredRows.length > 0) { - selected = selected >= filteredRows.length - 1 ? 0 : selected + 1; - render(); - } - return; - } - - if ((ctrl && name === "p") || name === "up") { - if (filteredRows.length > 0) { - selected = selected <= 0 ? filteredRows.length - 1 : selected - 1; - render(); - } - return; - } - - if (name === "backspace") { - searchQuery = searchQuery.slice(0, -1); - filteredRows = filterHandoffs(allRows, searchQuery); - selected = 0; - render(); - return; - } - - if (name === "return" || name === "enter") { - const row = selectedRow(); - if (!row || busy) { - return; - } - busy = true; - status = `switching ${row.handoffId}...`; - render(); - void (async () => { - try { - const result = await client.switchHandoff(workspaceId, row.handoffId); - close(`cd ${result.switchTarget}`); - } catch (err) { - busy = false; - status = err instanceof Error ? err.message : String(err); - render(); - } - })(); - return; - } - - if (ctrl && name === "a") { - const row = selectedRow(); - if (!row || busy) { - return; - } - busy = true; - status = `attaching ${row.handoffId}...`; - render(); - void (async () => { - try { - const result = await client.attachHandoff(workspaceId, row.handoffId); - close(`target=${result.target} session=${result.sessionId ?? "none"}`); - } catch (err) { - busy = false; - status = err instanceof Error ? err.message : String(err); - render(); - } - })(); - return; - } - - if (ctrl && name === "x") { - const row = selectedRow(); - if (!row) { - return; - } - void runActionWithRefresh( - `archiving ${row.handoffId}`, - async () => client.runAction(workspaceId, row.handoffId, "archive"), - `archived ${row.handoffId}` - ); - return; - } - - if (ctrl && name === "s") { - const row = selectedRow(); - if (!row) { - return; - } - void runActionWithRefresh( - `syncing ${row.handoffId}`, - async () => client.runAction(workspaceId, row.handoffId, "sync"), - `synced ${row.handoffId}` - ); - return; - } - - if (ctrl && name === "y") { - const row = selectedRow(); - if (!row) { - return; - } - void runActionWithRefresh( - `merging ${row.handoffId}`, - async () => { - await client.runAction(workspaceId, row.handoffId, "merge"); - await client.runAction(workspaceId, row.handoffId, "archive"); - }, - `merged+archived ${row.handoffId}` - ); - return; - } - - if (ctrl && name === "o") { - const row = selectedRow(); - if (!row?.prUrl) { - status = "no PR URL available for this handoff"; - render(); - return; - } - const openCmd = process.platform === "darwin" ? "open" : "xdg-open"; - spawnSync(openCmd, [row.prUrl], { stdio: "ignore" }); - status = `opened ${row.prUrl}`; - render(); - return; - } - - if (!ctrl && !event.meta && name.length === 1) { - searchQuery += name; - filteredRows = filterHandoffs(allRows, searchQuery); - selected = 0; - render(); - } - }); - - await done; -} diff --git a/factory/packages/cli/src/workspace/config.ts b/factory/packages/cli/src/workspace/config.ts deleted file mode 100644 index 81a92bd4..00000000 --- a/factory/packages/cli/src/workspace/config.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { dirname } from "node:path"; -import { homedir } from "node:os"; -import * as toml from "@iarna/toml"; -import { ConfigSchema, resolveWorkspaceId, type AppConfig } from "@openhandoff/shared"; - -export const CONFIG_PATH = `${homedir()}/.config/openhandoff/config.toml`; - -export function loadConfig(path = CONFIG_PATH): AppConfig { - if (!existsSync(path)) { - return ConfigSchema.parse({}); - } - - const raw = readFileSync(path, "utf8"); - return ConfigSchema.parse(toml.parse(raw)); -} - -export function saveConfig(config: AppConfig, path = CONFIG_PATH): void { - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, toml.stringify(config), "utf8"); -} - -export function resolveWorkspace(flagWorkspace: string | undefined, config: AppConfig): string { - return resolveWorkspaceId(flagWorkspace, config); -} diff --git a/factory/packages/cli/test/backend-manager.test.ts b/factory/packages/cli/test/backend-manager.test.ts deleted file mode 100644 index 06d1cb67..00000000 --- a/factory/packages/cli/test/backend-manager.test.ts +++ /dev/null @@ -1,167 +0,0 @@ -import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { EventEmitter } from "node:events"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { ChildProcess } from "node:child_process"; - -const { spawnMock, execFileSyncMock } = vi.hoisted(() => ({ - spawnMock: vi.fn(), - execFileSyncMock: vi.fn() -})); - -vi.mock("node:child_process", async () => { - const actual = await vi.importActual("node:child_process"); - return { - ...actual, - spawn: spawnMock, - execFileSync: execFileSyncMock - }; -}); - -import { ensureBackendRunning, parseBackendPort } from "../src/backend/manager.js"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; - -function backendStateFile(baseDir: string, host: string, port: number, suffix: string): string { - const sanitized = host - .split("") - .map((ch) => (/[a-zA-Z0-9]/.test(ch) ? ch : "-")) - .join(""); - - return join(baseDir, `backend-${sanitized}-${port}.${suffix}`); -} - -function healthyMetadataResponse(): { ok: boolean; json: () => Promise } { - return { - ok: true, - json: async () => ({ - runtime: "rivetkit", - actorNames: { - workspace: {} - } - }) - }; -} - -function unhealthyMetadataResponse(): { ok: boolean; json: () => Promise } { - return { - ok: false, - json: async () => ({}) - }; -} - -describe("backend manager", () => { - const originalFetch = globalThis.fetch; - const originalStateDir = process.env.HF_BACKEND_STATE_DIR; - const originalBuildId = process.env.HF_BUILD_ID; - - const config: AppConfig = ConfigSchema.parse({ - auto_submit: true, - notify: ["terminal"], - workspace: { default: "default" }, - backend: { - host: "127.0.0.1", - port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", - opencode_poll_interval: 2, - github_poll_interval: 30, - backup_interval_secs: 3600, - backup_retention_days: 7 - }, - providers: { - daytona: { image: "ubuntu:24.04" } - } - }); - - beforeEach(() => { - process.env.HF_BUILD_ID = "test-build"; - }); - - afterEach(() => { - vi.restoreAllMocks(); - spawnMock.mockReset(); - execFileSyncMock.mockReset(); - globalThis.fetch = originalFetch; - - if (originalStateDir === undefined) { - delete process.env.HF_BACKEND_STATE_DIR; - } else { - process.env.HF_BACKEND_STATE_DIR = originalStateDir; - } - - if (originalBuildId === undefined) { - delete process.env.HF_BUILD_ID; - } else { - process.env.HF_BUILD_ID = originalBuildId; - } - }); - - it("restarts backend when healthy but build is outdated", async () => { - const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-")); - process.env.HF_BACKEND_STATE_DIR = stateDir; - - const pidPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "pid"); - const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version"); - - mkdirSync(stateDir, { recursive: true }); - writeFileSync(pidPath, "999999", "utf8"); - writeFileSync(versionPath, "old-build", "utf8"); - - const fetchMock = vi - .fn<() => Promise<{ ok: boolean; json: () => Promise }>>() - .mockResolvedValueOnce(healthyMetadataResponse()) - .mockResolvedValueOnce(unhealthyMetadataResponse()) - .mockResolvedValue(healthyMetadataResponse()); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - const fakeChild = Object.assign(new EventEmitter(), { - pid: process.pid, - unref: vi.fn() - }) as unknown as ChildProcess; - spawnMock.mockReturnValue(fakeChild); - - await ensureBackendRunning(config); - - expect(spawnMock).toHaveBeenCalledTimes(1); - const launchCommand = spawnMock.mock.calls[0]?.[0]; - const launchArgs = spawnMock.mock.calls[0]?.[1] as string[] | undefined; - expect( - launchCommand === "pnpm" || - launchCommand === "bun" || - (typeof launchCommand === "string" && launchCommand.endsWith("/bun")) - ).toBe(true); - expect(launchArgs).toEqual( - expect.arrayContaining(["start", "--host", config.backend.host, "--port", String(config.backend.port)]) - ); - if (launchCommand === "pnpm") { - expect(launchArgs).toEqual(expect.arrayContaining(["exec", "bun", "src/index.ts"])); - } - expect(readFileSync(pidPath, "utf8").trim()).toBe(String(process.pid)); - expect(readFileSync(versionPath, "utf8").trim()).toBe("test-build"); - }); - - it("does not restart when backend is healthy and build is current", async () => { - const stateDir = mkdtempSync(join(tmpdir(), "hf-backend-test-")); - process.env.HF_BACKEND_STATE_DIR = stateDir; - - const versionPath = backendStateFile(stateDir, config.backend.host, config.backend.port, "version"); - mkdirSync(stateDir, { recursive: true }); - writeFileSync(versionPath, "test-build", "utf8"); - - const fetchMock = vi - .fn<() => Promise<{ ok: boolean; json: () => Promise }>>() - .mockResolvedValue(healthyMetadataResponse()); - globalThis.fetch = fetchMock as unknown as typeof fetch; - - await ensureBackendRunning(config); - - expect(spawnMock).not.toHaveBeenCalled(); - }); - - it("validates backend port parsing", () => { - expect(parseBackendPort(undefined, 7741)).toBe(7741); - expect(parseBackendPort("8080", 7741)).toBe(8080); - expect(() => parseBackendPort("0", 7741)).toThrow("Invalid backend port"); - expect(() => parseBackendPort("abc", 7741)).toThrow("Invalid backend port"); - }); -}); diff --git a/factory/packages/cli/test/task-editor.test.ts b/factory/packages/cli/test/task-editor.test.ts deleted file mode 100644 index 32321b70..00000000 --- a/factory/packages/cli/test/task-editor.test.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { sanitizeEditorTask } from "../src/task-editor.js"; - -describe("task editor helpers", () => { - it("strips comment lines and trims whitespace", () => { - const value = sanitizeEditorTask(` -# comment -Implement feature - -# another comment -with more detail -`); - - expect(value).toBe("Implement feature\n\nwith more detail"); - }); - - it("returns empty string when only comments are present", () => { - const value = sanitizeEditorTask(` -# hello -# world -`); - - expect(value).toBe(""); - }); -}); - diff --git a/factory/packages/cli/test/theme.test.ts b/factory/packages/cli/test/theme.test.ts deleted file mode 100644 index 6ccd9024..00000000 --- a/factory/packages/cli/test/theme.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { afterEach, describe, expect, it } from "vitest"; -import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; -import { ConfigSchema, type AppConfig } from "@openhandoff/shared"; -import { resolveTuiTheme } from "../src/theme.js"; - -function withEnv(key: string, value: string | undefined): void { - if (value === undefined) { - delete process.env[key]; - return; - } - process.env[key] = value; -} - -describe("resolveTuiTheme", () => { - let tempDir: string | null = null; - const originalState = process.env.XDG_STATE_HOME; - const originalConfig = process.env.XDG_CONFIG_HOME; - - const baseConfig: AppConfig = ConfigSchema.parse({ - auto_submit: true, - notify: ["terminal"], - workspace: { default: "default" }, - backend: { - host: "127.0.0.1", - port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", - opencode_poll_interval: 2, - github_poll_interval: 30, - backup_interval_secs: 3600, - backup_retention_days: 7 - }, - providers: { - daytona: { image: "ubuntu:24.04" } - } - }); - - afterEach(() => { - withEnv("XDG_STATE_HOME", originalState); - withEnv("XDG_CONFIG_HOME", originalConfig); - if (tempDir) { - rmSync(tempDir, { recursive: true, force: true }); - tempDir = null; - } - }); - - it("falls back to default theme when no theme sources are present", () => { - tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-")); - withEnv("XDG_STATE_HOME", join(tempDir, "state")); - withEnv("XDG_CONFIG_HOME", join(tempDir, "config")); - - const resolution = resolveTuiTheme(baseConfig, tempDir); - - expect(resolution.name).toBe("opencode-default"); - expect(resolution.source).toBe("default"); - expect(resolution.theme.text).toBe("#ffffff"); - }); - - it("loads theme from opencode state when configured", () => { - tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-")); - const stateHome = join(tempDir, "state"); - const configHome = join(tempDir, "config"); - withEnv("XDG_STATE_HOME", stateHome); - withEnv("XDG_CONFIG_HOME", configHome); - mkdirSync(join(stateHome, "opencode"), { recursive: true }); - writeFileSync( - join(stateHome, "opencode", "kv.json"), - JSON.stringify({ theme: "gruvbox", theme_mode: "dark" }), - "utf8" - ); - - const resolution = resolveTuiTheme(baseConfig, tempDir); - - expect(resolution.name).toBe("gruvbox"); - expect(resolution.source).toContain("opencode state"); - expect(resolution.mode).toBe("dark"); - expect(resolution.theme.selectionBorder.toLowerCase()).not.toContain("dark"); - }); - - it("resolves OpenCode token references in theme defs", () => { - tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-")); - const stateHome = join(tempDir, "state"); - const configHome = join(tempDir, "config"); - withEnv("XDG_STATE_HOME", stateHome); - withEnv("XDG_CONFIG_HOME", configHome); - mkdirSync(join(stateHome, "opencode"), { recursive: true }); - writeFileSync( - join(stateHome, "opencode", "kv.json"), - JSON.stringify({ theme: "orng", theme_mode: "dark" }), - "utf8" - ); - - const resolution = resolveTuiTheme(baseConfig, tempDir); - - expect(resolution.name).toBe("orng"); - expect(resolution.theme.selectionBorder).toBe("#EE7948"); - expect(resolution.theme.background).toBe("#0a0a0a"); - }); - - it("prefers explicit openhandoff theme override from config", () => { - tempDir = mkdtempSync(join(tmpdir(), "hf-theme-test-")); - withEnv("XDG_STATE_HOME", join(tempDir, "state")); - withEnv("XDG_CONFIG_HOME", join(tempDir, "config")); - - const config = { ...baseConfig, theme: "default" } as AppConfig & { theme: string }; - const resolution = resolveTuiTheme(config, tempDir); - - expect(resolution.name).toBe("opencode-default"); - expect(resolution.source).toBe("openhandoff config"); - }); -}); diff --git a/factory/packages/cli/test/tmux.test.ts b/factory/packages/cli/test/tmux.test.ts deleted file mode 100644 index 29b78015..00000000 --- a/factory/packages/cli/test/tmux.test.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { stripStatusPrefix } from "../src/tmux.js"; - -describe("tmux helpers", () => { - it("strips running and idle markers from window names", () => { - expect(stripStatusPrefix("▶ feature/auth")).toBe("feature/auth"); - expect(stripStatusPrefix("✓ feature/auth")).toBe("feature/auth"); - expect(stripStatusPrefix("feature/auth")).toBe("feature/auth"); - }); -}); diff --git a/factory/packages/cli/test/tui-format.test.ts b/factory/packages/cli/test/tui-format.test.ts deleted file mode 100644 index e7821f9f..00000000 --- a/factory/packages/cli/test/tui-format.test.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { HandoffRecord } from "@openhandoff/shared"; -import { filterHandoffs, fuzzyMatch } from "@openhandoff/client"; -import { formatRows } from "../src/tui.js"; - -const sample: HandoffRecord = { - workspaceId: "default", - repoId: "repo-a", - repoRemote: "https://example.com/repo-a.git", - handoffId: "handoff-1", - branchName: "feature/test", - title: "Test Title", - task: "Do test", - providerId: "daytona", - status: "running", - statusMessage: null, - activeSandboxId: "sandbox-1", - activeSessionId: "session-1", - sandboxes: [ - { - sandboxId: "sandbox-1", - providerId: "daytona", - switchTarget: "daytona://sandbox-1", - cwd: null, - createdAt: 1, - updatedAt: 1 - } - ], - agentType: null, - prSubmitted: false, - diffStat: null, - prUrl: null, - prAuthor: null, - ciStatus: null, - reviewStatus: null, - reviewer: null, - conflictsWithMain: null, - hasUnpushed: null, - parentBranch: null, - createdAt: 1, - updatedAt: 1 -}; - -describe("formatRows", () => { - it("renders rust-style table header and empty state", () => { - const output = formatRows([], 0, "default", "ok"); - expect(output).toContain("Branch/PR (type to filter)"); - expect(output).toContain("No branches found."); - expect(output).toContain("Ctrl-H:cheatsheet"); - expect(output).toContain("ok"); - }); - - it("marks selected row with highlight", () => { - const output = formatRows([sample], 0, "default", "ready"); - expect(output).toContain("┃ "); - expect(output).toContain("Test Title"); - expect(output).toContain("Ctrl-H:cheatsheet"); - }); - - it("pins footer to the last terminal row", () => { - const output = formatRows([sample], 0, "default", "ready", "", false, { - width: 80, - height: 12 - }); - const lines = output.split("\n"); - expect(lines).toHaveLength(12); - expect(lines[11]).toContain("Ctrl-H:cheatsheet"); - expect(lines[11]).toContain("v"); - }); -}); - -describe("search", () => { - it("supports ordered fuzzy matching", () => { - expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true); - expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false); - }); - - it("filters rows across branch and title", () => { - const rows: HandoffRecord[] = [ - sample, - { - ...sample, - handoffId: "handoff-2", - branchName: "docs/update-intro", - title: "Docs Intro Refresh", - status: "idle" - } - ]; - expect(filterHandoffs(rows, "doc")).toHaveLength(1); - expect(filterHandoffs(rows, "h2")).toHaveLength(1); - expect(filterHandoffs(rows, "test")).toHaveLength(2); - }); -}); diff --git a/factory/packages/cli/test/workspace-config.test.ts b/factory/packages/cli/test/workspace-config.test.ts deleted file mode 100644 index 06669842..00000000 --- a/factory/packages/cli/test/workspace-config.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { ConfigSchema } from "@openhandoff/shared"; -import { resolveWorkspace } from "../src/workspace/config.js"; - -describe("cli workspace resolution", () => { - it("uses default workspace when no flag", () => { - const config = ConfigSchema.parse({ - auto_submit: true as const, - notify: ["terminal" as const], - workspace: { default: "team" }, - backend: { - host: "127.0.0.1", - port: 7741, - dbPath: "~/.local/share/openhandoff/handoff.db", - opencode_poll_interval: 2, - github_poll_interval: 30, - backup_interval_secs: 3600, - backup_retention_days: 7 - }, - providers: { - daytona: { image: "ubuntu:24.04" } - } - }); - - expect(resolveWorkspace(undefined, config)).toBe("team"); - expect(resolveWorkspace("alpha", config)).toBe("alpha"); - }); -}); diff --git a/factory/packages/cli/tsconfig.json b/factory/packages/cli/tsconfig.json deleted file mode 100644 index ae5ba214..00000000 --- a/factory/packages/cli/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src", "test"] -} diff --git a/factory/packages/cli/tsup.config.ts b/factory/packages/cli/tsup.config.ts deleted file mode 100644 index 70b7806f..00000000 --- a/factory/packages/cli/tsup.config.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { execSync } from "node:child_process"; -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { defineConfig } from "tsup"; - -function packageVersion(): string { - try { - const packageJsonPath = resolve(process.cwd(), "package.json"); - const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { version?: unknown }; - if (typeof parsed.version === "string" && parsed.version.trim()) { - return parsed.version.trim(); - } - } catch { - // Fall through. - } - return "dev"; -} - -function sourceId(): string { - try { - const raw = execSync("git rev-parse --short HEAD", { - encoding: "utf8", - stdio: ["ignore", "pipe", "ignore"] - }).trim(); - if (raw.length > 0) { - return raw; - } - } catch { - // Fall through. - } - return packageVersion(); -} - -function resolveBuildId(): string { - const override = process.env.HF_BUILD_ID?.trim(); - if (override) { - return override; - } - - // Match sandbox-agent semantics: source id + unique build timestamp. - return `${sourceId()}-${Date.now().toString()}`; -} - -const buildId = resolveBuildId(); - -export default defineConfig({ - entry: ["src/index.ts"], - format: ["esm"], - dts: true, - define: { - __HF_BUILD_ID__: JSON.stringify(buildId) - } -}); - diff --git a/factory/packages/client/package.json b/factory/packages/client/package.json deleted file mode 100644 index ebb82f83..00000000 --- a/factory/packages/client/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "@openhandoff/client", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "scripts": { - "build": "tsup src/index.ts --format esm --dts", - "typecheck": "tsc --noEmit", - "test": "vitest run", - "test:e2e:full": "HF_ENABLE_DAEMON_FULL_E2E=1 vitest run test/e2e/full-integration-e2e.test.ts", - "test:e2e:workbench": "HF_ENABLE_DAEMON_WORKBENCH_E2E=1 vitest run test/e2e/workbench-e2e.test.ts", - "test:e2e:workbench-load": "HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E=1 vitest run test/e2e/workbench-load-e2e.test.ts" - }, - "dependencies": { - "@openhandoff/shared": "workspace:*", - "rivetkit": "link:../../../../../handoff/rivet-checkout/rivetkit-typescript/packages/rivetkit" - }, - "devDependencies": { - "tsup": "^8.5.0" - } -} diff --git a/factory/packages/client/src/backend-client.ts b/factory/packages/client/src/backend-client.ts deleted file mode 100644 index 86288fb9..00000000 --- a/factory/packages/client/src/backend-client.ts +++ /dev/null @@ -1,821 +0,0 @@ -import { createClient } from "rivetkit/client"; -import type { - AgentType, - AddRepoInput, - AppConfig, - CreateHandoffInput, - HandoffRecord, - HandoffSummary, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, - HistoryEvent, - HistoryQueryInput, - ProviderId, - RepoOverview, - RepoStackActionInput, - RepoStackActionResult, - RepoRecord, - SwitchResult -} from "@openhandoff/shared"; -import { sandboxInstanceKey, workspaceKey } from "./keys.js"; - -export type HandoffAction = "push" | "sync" | "merge" | "archive" | "kill"; - -type RivetMetadataResponse = { - runtime?: string; - actorNames?: Record; - clientEndpoint?: string; - clientNamespace?: string; - clientToken?: string; -}; - -export interface SandboxSessionRecord { - id: string; - agent: string; - agentSessionId: string; - lastConnectionId: string; - createdAt: number; - destroyedAt?: number; - status?: "running" | "idle" | "error"; -} - -export interface SandboxSessionEventRecord { - id: string; - eventIndex: number; - sessionId: string; - createdAt: number; - connectionId: string; - sender: "client" | "agent"; - payload: unknown; -} - -interface WorkspaceHandle { - addRepo(input: AddRepoInput): Promise; - listRepos(input: { workspaceId: string }): Promise; - createHandoff(input: CreateHandoffInput): Promise; - listHandoffs(input: { workspaceId: string; repoId?: string }): Promise; - getRepoOverview(input: { workspaceId: string; repoId: string }): Promise; - runRepoStackAction(input: RepoStackActionInput): Promise; - history(input: HistoryQueryInput): Promise; - switchHandoff(handoffId: string): Promise; - getHandoff(input: { workspaceId: string; handoffId: string }): Promise; - attachHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise<{ target: string; sessionId: string | null }>; - pushHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - syncHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - mergeHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - archiveHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - killHandoff(input: { workspaceId: string; handoffId: string; reason?: string }): Promise; - useWorkspace(input: { workspaceId: string }): Promise<{ workspaceId: string }>; - getWorkbench(input: { workspaceId: string }): Promise; - createWorkbenchHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise; - markWorkbenchUnread(input: HandoffWorkbenchSelectInput): Promise; - renameWorkbenchHandoff(input: HandoffWorkbenchRenameInput): Promise; - renameWorkbenchBranch(input: HandoffWorkbenchRenameInput): Promise; - createWorkbenchSession(input: HandoffWorkbenchSelectInput & { model?: string }): Promise<{ tabId: string }>; - renameWorkbenchSession(input: HandoffWorkbenchRenameSessionInput): Promise; - setWorkbenchSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise; - updateWorkbenchDraft(input: HandoffWorkbenchUpdateDraftInput): Promise; - changeWorkbenchModel(input: HandoffWorkbenchChangeModelInput): Promise; - sendWorkbenchMessage(input: HandoffWorkbenchSendMessageInput): Promise; - stopWorkbenchSession(input: HandoffWorkbenchTabInput): Promise; - closeWorkbenchSession(input: HandoffWorkbenchTabInput): Promise; - publishWorkbenchPr(input: HandoffWorkbenchSelectInput): Promise; - revertWorkbenchFile(input: HandoffWorkbenchDiffInput): Promise; -} - -interface SandboxInstanceHandle { - createSession(input: { prompt: string; cwd?: string; agent?: AgentType | "opencode" }): Promise<{ id: string | null; status: "running" | "idle" | "error"; error?: string }>; - listSessions(input?: { cursor?: string; limit?: number }): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>; - listSessionEvents(input: { sessionId: string; cursor?: string; limit?: number }): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>; - sendPrompt(input: { sessionId: string; prompt: string; notification?: boolean }): Promise; - sessionStatus(input: { sessionId: string }): Promise<{ id: string; status: "running" | "idle" | "error" }>; - providerState(): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>; -} - -interface RivetClient { - workspace: { - getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): WorkspaceHandle; - }; - sandboxInstance: { - getOrCreate(key?: string | string[], opts?: { createWithInput?: unknown }): SandboxInstanceHandle; - }; -} - -export interface BackendClientOptions { - endpoint: string; - defaultWorkspaceId?: string; -} - -export interface BackendMetadata { - runtime?: string; - actorNames?: Record; - clientEndpoint?: string; - clientNamespace?: string; - clientToken?: string; -} - -export interface BackendClient { - addRepo(workspaceId: string, remoteUrl: string): Promise; - listRepos(workspaceId: string): Promise; - createHandoff(input: CreateHandoffInput): Promise; - listHandoffs(workspaceId: string, repoId?: string): Promise; - getRepoOverview(workspaceId: string, repoId: string): Promise; - runRepoStackAction(input: RepoStackActionInput): Promise; - getHandoff(workspaceId: string, handoffId: string): Promise; - listHistory(input: HistoryQueryInput): Promise; - switchHandoff(workspaceId: string, handoffId: string): Promise; - attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }>; - runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise; - createSandboxSession(input: { - workspaceId: string; - providerId: ProviderId; - sandboxId: string; - prompt: string; - cwd?: string; - agent?: AgentType | "opencode"; - }): Promise<{ id: string; status: "running" | "idle" | "error" }>; - listSandboxSessions( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - input?: { cursor?: string; limit?: number } - ): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }>; - listSandboxSessionEvents( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - input: { sessionId: string; cursor?: string; limit?: number } - ): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }>; - sendSandboxPrompt(input: { - workspaceId: string; - providerId: ProviderId; - sandboxId: string; - sessionId: string; - prompt: string; - notification?: boolean; - }): Promise; - sandboxSessionStatus( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - sessionId: string - ): Promise<{ id: string; status: "running" | "idle" | "error" }>; - sandboxProviderState( - workspaceId: string, - providerId: ProviderId, - sandboxId: string - ): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }>; - getWorkbench(workspaceId: string): Promise; - subscribeWorkbench(workspaceId: string, listener: () => void): () => void; - createWorkbenchHandoff( - workspaceId: string, - input: HandoffWorkbenchCreateHandoffInput - ): Promise; - markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise; - renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise; - renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise; - createWorkbenchSession( - workspaceId: string, - input: HandoffWorkbenchSelectInput & { model?: string } - ): Promise<{ tabId: string }>; - renameWorkbenchSession(workspaceId: string, input: HandoffWorkbenchRenameSessionInput): Promise; - setWorkbenchSessionUnread( - workspaceId: string, - input: HandoffWorkbenchSetSessionUnreadInput - ): Promise; - updateWorkbenchDraft(workspaceId: string, input: HandoffWorkbenchUpdateDraftInput): Promise; - changeWorkbenchModel(workspaceId: string, input: HandoffWorkbenchChangeModelInput): Promise; - sendWorkbenchMessage(workspaceId: string, input: HandoffWorkbenchSendMessageInput): Promise; - stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise; - closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise; - publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise; - revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise; - health(): Promise<{ ok: true }>; - useWorkspace(workspaceId: string): Promise<{ workspaceId: string }>; -} - -export function rivetEndpoint(config: AppConfig): string { - return `http://${config.backend.host}:${config.backend.port}/api/rivet`; -} - -export function createBackendClientFromConfig(config: AppConfig): BackendClient { - return createBackendClient({ - endpoint: rivetEndpoint(config), - defaultWorkspaceId: config.workspace.default - }); -} - -function isLoopbackHost(hostname: string): boolean { - const h = hostname.toLowerCase(); - return h === "127.0.0.1" || h === "localhost" || h === "0.0.0.0" || h === "::1"; -} - -function rewriteLoopbackClientEndpoint(clientEndpoint: string, fallbackOrigin: string): string { - const clientUrl = new URL(clientEndpoint); - if (!isLoopbackHost(clientUrl.hostname)) { - return clientUrl.toString().replace(/\/$/, ""); - } - - const originUrl = new URL(fallbackOrigin); - // Keep the manager port from clientEndpoint; only rewrite host/protocol to match the origin. - clientUrl.hostname = originUrl.hostname; - clientUrl.protocol = originUrl.protocol; - return clientUrl.toString().replace(/\/$/, ""); -} - -async function fetchJsonWithTimeout(url: string, timeoutMs: number): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - const res = await fetch(url, { signal: controller.signal }); - if (!res.ok) { - throw new Error(`request failed: ${res.status} ${res.statusText}`); - } - return (await res.json()) as unknown; - } finally { - clearTimeout(timeout); - } -} - -async function fetchMetadataWithRetry( - endpoint: string, - namespace: string | undefined, - opts: { timeoutMs: number; requestTimeoutMs: number } -): Promise { - const base = new URL(endpoint); - base.pathname = base.pathname.replace(/\/$/, "") + "/metadata"; - if (namespace) { - base.searchParams.set("namespace", namespace); - } - - const start = Date.now(); - let delayMs = 250; - // Keep this bounded: callers (UI/CLI) should not hang forever if the backend is down. - for (;;) { - try { - const json = await fetchJsonWithTimeout(base.toString(), opts.requestTimeoutMs); - if (!json || typeof json !== "object") return {}; - const data = json as Record; - return { - runtime: typeof data.runtime === "string" ? data.runtime : undefined, - actorNames: - data.actorNames && typeof data.actorNames === "object" - ? (data.actorNames as Record) - : undefined, - clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined, - clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined, - clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined, - }; - } catch (err) { - if (Date.now() - start > opts.timeoutMs) { - throw err; - } - await new Promise((r) => setTimeout(r, delayMs)); - delayMs = Math.min(delayMs * 2, 2_000); - } - } -} - -export async function readBackendMetadata(input: { - endpoint: string; - namespace?: string; - timeoutMs?: number; -}): Promise { - const base = new URL(input.endpoint); - base.pathname = base.pathname.replace(/\/$/, "") + "/metadata"; - if (input.namespace) { - base.searchParams.set("namespace", input.namespace); - } - - const json = await fetchJsonWithTimeout(base.toString(), input.timeoutMs ?? 4_000); - if (!json || typeof json !== "object") { - return {}; - } - const data = json as Record; - return { - runtime: typeof data.runtime === "string" ? data.runtime : undefined, - actorNames: - data.actorNames && typeof data.actorNames === "object" - ? (data.actorNames as Record) - : undefined, - clientEndpoint: typeof data.clientEndpoint === "string" ? data.clientEndpoint : undefined, - clientNamespace: typeof data.clientNamespace === "string" ? data.clientNamespace : undefined, - clientToken: typeof data.clientToken === "string" ? data.clientToken : undefined, - }; -} - -export async function checkBackendHealth(input: { - endpoint: string; - namespace?: string; - timeoutMs?: number; -}): Promise { - try { - const metadata = await readBackendMetadata(input); - return metadata.runtime === "rivetkit" && Boolean(metadata.actorNames); - } catch { - return false; - } -} - -async function probeMetadataEndpoint( - endpoint: string, - namespace: string | undefined, - timeoutMs: number -): Promise { - try { - const base = new URL(endpoint); - base.pathname = base.pathname.replace(/\/$/, "") + "/metadata"; - if (namespace) { - base.searchParams.set("namespace", namespace); - } - await fetchJsonWithTimeout(base.toString(), timeoutMs); - return true; - } catch { - return false; - } -} - -export function createBackendClient(options: BackendClientOptions): BackendClient { - let clientPromise: Promise | null = null; - const workbenchSubscriptions = new Map< - string, - { - listeners: Set<() => void>; - disposeConnPromise: Promise<(() => Promise) | null> | null; - } - >(); - - const getClient = async (): Promise => { - if (clientPromise) { - return clientPromise; - } - - clientPromise = (async () => { - // Use the serverless /metadata endpoint to discover the manager endpoint. - // If the server reports a loopback clientEndpoint (127.0.0.1), rewrite to the same host - // as the configured endpoint so remote browsers/clients can connect. - const configured = new URL(options.endpoint); - const configuredOrigin = `${configured.protocol}//${configured.host}`; - - const initialNamespace = undefined; - const metadata = await fetchMetadataWithRetry(options.endpoint, initialNamespace, { - timeoutMs: 30_000, - requestTimeoutMs: 8_000 - }); - - // Candidate endpoint: manager endpoint if provided, otherwise stick to the configured endpoint. - const candidateEndpoint = metadata.clientEndpoint - ? rewriteLoopbackClientEndpoint(metadata.clientEndpoint, configuredOrigin) - : options.endpoint; - - // If the manager port isn't reachable from this client (common behind reverse proxies), - // fall back to the configured serverless endpoint to avoid hanging requests. - const shouldUseCandidate = metadata.clientEndpoint - ? await probeMetadataEndpoint(candidateEndpoint, metadata.clientNamespace, 1_500) - : true; - const resolvedEndpoint = shouldUseCandidate ? candidateEndpoint : options.endpoint; - - return createClient({ - endpoint: resolvedEndpoint, - namespace: metadata.clientNamespace, - token: metadata.clientToken, - // Prevent rivetkit from overriding back to a loopback endpoint (or to an unreachable manager). - disableMetadataLookup: true, - }) as unknown as RivetClient; - })(); - - return clientPromise; - }; - - const workspace = async (workspaceId: string): Promise => - (await getClient()).workspace.getOrCreate(workspaceKey(workspaceId), { - createWithInput: workspaceId - }); - - const sandboxByKey = async ( - workspaceId: string, - providerId: ProviderId, - sandboxId: string - ): Promise => { - const client = await getClient(); - return (client as any).sandboxInstance.get(sandboxInstanceKey(workspaceId, providerId, sandboxId)); - }; - - function isActorNotFoundError(error: unknown): boolean { - const message = error instanceof Error ? error.message : String(error); - return message.includes("Actor not found"); - } - - const sandboxByActorIdFromHandoff = async ( - workspaceId: string, - providerId: ProviderId, - sandboxId: string - ): Promise => { - const ws = await workspace(workspaceId); - const rows = await ws.listHandoffs({ workspaceId }); - const candidates = [...rows].sort((a, b) => b.updatedAt - a.updatedAt); - - for (const row of candidates) { - try { - const detail = await ws.getHandoff({ workspaceId, handoffId: row.handoffId }); - if (detail.providerId !== providerId) { - continue; - } - const sandbox = detail.sandboxes.find((sb) => - sb.sandboxId === sandboxId && - sb.providerId === providerId && - typeof (sb as any).sandboxActorId === "string" && - (sb as any).sandboxActorId.length > 0 - ) as ({ sandboxActorId?: string } | undefined); - if (sandbox?.sandboxActorId) { - const client = await getClient(); - return (client as any).sandboxInstance.getForId(sandbox.sandboxActorId); - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!isActorNotFoundError(error) && !message.includes("Unknown handoff")) { - throw error; - } - // Best effort fallback path; ignore missing handoff actors here. - } - } - - return null; - }; - - const withSandboxHandle = async ( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - run: (handle: SandboxInstanceHandle) => Promise - ): Promise => { - const handle = await sandboxByKey(workspaceId, providerId, sandboxId); - try { - return await run(handle); - } catch (error) { - if (!isActorNotFoundError(error)) { - throw error; - } - const fallback = await sandboxByActorIdFromHandoff(workspaceId, providerId, sandboxId); - if (!fallback) { - throw error; - } - return await run(fallback); - } - }; - - const subscribeWorkbench = (workspaceId: string, listener: () => void): (() => void) => { - let entry = workbenchSubscriptions.get(workspaceId); - if (!entry) { - entry = { - listeners: new Set(), - disposeConnPromise: null, - }; - workbenchSubscriptions.set(workspaceId, entry); - } - - entry.listeners.add(listener); - - if (!entry.disposeConnPromise) { - entry.disposeConnPromise = (async () => { - const handle = await workspace(workspaceId); - const conn = (handle as any).connect(); - const unsubscribeEvent = conn.on("workbenchUpdated", () => { - const current = workbenchSubscriptions.get(workspaceId); - if (!current) { - return; - } - for (const currentListener of [...current.listeners]) { - currentListener(); - } - }); - const unsubscribeError = conn.onError(() => {}); - return async () => { - unsubscribeEvent(); - unsubscribeError(); - await conn.dispose(); - }; - })().catch(() => null); - } - - return () => { - const current = workbenchSubscriptions.get(workspaceId); - if (!current) { - return; - } - current.listeners.delete(listener); - if (current.listeners.size > 0) { - return; - } - - workbenchSubscriptions.delete(workspaceId); - void current.disposeConnPromise?.then(async (disposeConn) => { - await disposeConn?.(); - }); - }; - }; - - return { - async addRepo(workspaceId: string, remoteUrl: string): Promise { - return (await workspace(workspaceId)).addRepo({ workspaceId, remoteUrl }); - }, - - async listRepos(workspaceId: string): Promise { - return (await workspace(workspaceId)).listRepos({ workspaceId }); - }, - - async createHandoff(input: CreateHandoffInput): Promise { - return (await workspace(input.workspaceId)).createHandoff(input); - }, - - async listHandoffs(workspaceId: string, repoId?: string): Promise { - return (await workspace(workspaceId)).listHandoffs({ workspaceId, repoId }); - }, - - async getRepoOverview(workspaceId: string, repoId: string): Promise { - return (await workspace(workspaceId)).getRepoOverview({ workspaceId, repoId }); - }, - - async runRepoStackAction(input: RepoStackActionInput): Promise { - return (await workspace(input.workspaceId)).runRepoStackAction(input); - }, - - async getHandoff(workspaceId: string, handoffId: string): Promise { - return (await workspace(workspaceId)).getHandoff({ - workspaceId, - handoffId - }); - }, - - async listHistory(input: HistoryQueryInput): Promise { - return (await workspace(input.workspaceId)).history(input); - }, - - async switchHandoff(workspaceId: string, handoffId: string): Promise { - return (await workspace(workspaceId)).switchHandoff(handoffId); - }, - - async attachHandoff(workspaceId: string, handoffId: string): Promise<{ target: string; sessionId: string | null }> { - return (await workspace(workspaceId)).attachHandoff({ - workspaceId, - handoffId, - reason: "cli.attach" - }); - }, - - async runAction(workspaceId: string, handoffId: string, action: HandoffAction): Promise { - if (action === "push") { - await (await workspace(workspaceId)).pushHandoff({ - workspaceId, - handoffId, - reason: "cli.push" - }); - return; - } - if (action === "sync") { - await (await workspace(workspaceId)).syncHandoff({ - workspaceId, - handoffId, - reason: "cli.sync" - }); - return; - } - if (action === "merge") { - await (await workspace(workspaceId)).mergeHandoff({ - workspaceId, - handoffId, - reason: "cli.merge" - }); - return; - } - if (action === "archive") { - await (await workspace(workspaceId)).archiveHandoff({ - workspaceId, - handoffId, - reason: "cli.archive" - }); - return; - } - await (await workspace(workspaceId)).killHandoff({ - workspaceId, - handoffId, - reason: "cli.kill" - }); - }, - - async createSandboxSession(input: { - workspaceId: string; - providerId: ProviderId; - sandboxId: string; - prompt: string; - cwd?: string; - agent?: AgentType | "opencode"; - }): Promise<{ id: string; status: "running" | "idle" | "error" }> { - const created = await withSandboxHandle( - input.workspaceId, - input.providerId, - input.sandboxId, - async (handle) => - handle.createSession({ - prompt: input.prompt, - cwd: input.cwd, - agent: input.agent - }) - ); - if (!created.id) { - throw new Error(created.error ?? "sandbox session creation failed"); - } - return { - id: created.id, - status: created.status - }; - }, - - async listSandboxSessions( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - input?: { cursor?: string; limit?: number } - ): Promise<{ items: SandboxSessionRecord[]; nextCursor?: string }> { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.listSessions(input ?? {}) - ); - }, - - async listSandboxSessionEvents( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - input: { sessionId: string; cursor?: string; limit?: number } - ): Promise<{ items: SandboxSessionEventRecord[]; nextCursor?: string }> { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.listSessionEvents(input) - ); - }, - - async sendSandboxPrompt(input: { - workspaceId: string; - providerId: ProviderId; - sandboxId: string; - sessionId: string; - prompt: string; - notification?: boolean; - }): Promise { - await withSandboxHandle( - input.workspaceId, - input.providerId, - input.sandboxId, - async (handle) => - handle.sendPrompt({ - sessionId: input.sessionId, - prompt: input.prompt, - notification: input.notification - }) - ); - }, - - async sandboxSessionStatus( - workspaceId: string, - providerId: ProviderId, - sandboxId: string, - sessionId: string - ): Promise<{ id: string; status: "running" | "idle" | "error" }> { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.sessionStatus({ sessionId }) - ); - }, - - async sandboxProviderState( - workspaceId: string, - providerId: ProviderId, - sandboxId: string - ): Promise<{ providerId: ProviderId; sandboxId: string; state: string; at: number }> { - return await withSandboxHandle( - workspaceId, - providerId, - sandboxId, - async (handle) => handle.providerState() - ); - }, - - async getWorkbench(workspaceId: string): Promise { - return (await workspace(workspaceId)).getWorkbench({ workspaceId }); - }, - - subscribeWorkbench(workspaceId: string, listener: () => void): () => void { - return subscribeWorkbench(workspaceId, listener); - }, - - async createWorkbenchHandoff( - workspaceId: string, - input: HandoffWorkbenchCreateHandoffInput - ): Promise { - return (await workspace(workspaceId)).createWorkbenchHandoff(input); - }, - - async markWorkbenchUnread(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { - await (await workspace(workspaceId)).markWorkbenchUnread(input); - }, - - async renameWorkbenchHandoff(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { - await (await workspace(workspaceId)).renameWorkbenchHandoff(input); - }, - - async renameWorkbenchBranch(workspaceId: string, input: HandoffWorkbenchRenameInput): Promise { - await (await workspace(workspaceId)).renameWorkbenchBranch(input); - }, - - async createWorkbenchSession( - workspaceId: string, - input: HandoffWorkbenchSelectInput & { model?: string } - ): Promise<{ tabId: string }> { - return await (await workspace(workspaceId)).createWorkbenchSession(input); - }, - - async renameWorkbenchSession( - workspaceId: string, - input: HandoffWorkbenchRenameSessionInput - ): Promise { - await (await workspace(workspaceId)).renameWorkbenchSession(input); - }, - - async setWorkbenchSessionUnread( - workspaceId: string, - input: HandoffWorkbenchSetSessionUnreadInput - ): Promise { - await (await workspace(workspaceId)).setWorkbenchSessionUnread(input); - }, - - async updateWorkbenchDraft( - workspaceId: string, - input: HandoffWorkbenchUpdateDraftInput - ): Promise { - await (await workspace(workspaceId)).updateWorkbenchDraft(input); - }, - - async changeWorkbenchModel( - workspaceId: string, - input: HandoffWorkbenchChangeModelInput - ): Promise { - await (await workspace(workspaceId)).changeWorkbenchModel(input); - }, - - async sendWorkbenchMessage( - workspaceId: string, - input: HandoffWorkbenchSendMessageInput - ): Promise { - await (await workspace(workspaceId)).sendWorkbenchMessage(input); - }, - - async stopWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise { - await (await workspace(workspaceId)).stopWorkbenchSession(input); - }, - - async closeWorkbenchSession(workspaceId: string, input: HandoffWorkbenchTabInput): Promise { - await (await workspace(workspaceId)).closeWorkbenchSession(input); - }, - - async publishWorkbenchPr(workspaceId: string, input: HandoffWorkbenchSelectInput): Promise { - await (await workspace(workspaceId)).publishWorkbenchPr(input); - }, - - async revertWorkbenchFile(workspaceId: string, input: HandoffWorkbenchDiffInput): Promise { - await (await workspace(workspaceId)).revertWorkbenchFile(input); - }, - - async health(): Promise<{ ok: true }> { - const workspaceId = options.defaultWorkspaceId; - if (!workspaceId) { - throw new Error("Backend client default workspace is required for health checks"); - } - - await (await workspace(workspaceId)).useWorkspace({ - workspaceId - }); - return { ok: true }; - }, - - async useWorkspace(workspaceId: string): Promise<{ workspaceId: string }> { - return (await workspace(workspaceId)).useWorkspace({ workspaceId }); - } - }; -} diff --git a/factory/packages/client/src/index.ts b/factory/packages/client/src/index.ts deleted file mode 100644 index 11621602..00000000 --- a/factory/packages/client/src/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from "./backend-client.js"; -export * from "./keys.js"; -export * from "./view-model.js"; -export * from "./workbench-client.js"; diff --git a/factory/packages/client/src/keys.ts b/factory/packages/client/src/keys.ts deleted file mode 100644 index 5c1eae9a..00000000 --- a/factory/packages/client/src/keys.ts +++ /dev/null @@ -1,44 +0,0 @@ -export type ActorKey = string[]; - -export function workspaceKey(workspaceId: string): ActorKey { - return ["ws", workspaceId]; -} - -export function projectKey(workspaceId: string, repoId: string): ActorKey { - return ["ws", workspaceId, "project", repoId]; -} - -export function handoffKey(workspaceId: string, repoId: string, handoffId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "handoff", handoffId]; -} - -export function sandboxInstanceKey( - workspaceId: string, - providerId: string, - sandboxId: string -): ActorKey { - return ["ws", workspaceId, "provider", providerId, "sandbox", sandboxId]; -} - -export function historyKey(workspaceId: string, repoId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "history"]; -} - -export function projectPrSyncKey(workspaceId: string, repoId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "pr-sync"]; -} - -export function projectBranchSyncKey(workspaceId: string, repoId: string): ActorKey { - return ["ws", workspaceId, "project", repoId, "branch-sync"]; -} - -export function handoffStatusSyncKey( - workspaceId: string, - repoId: string, - handoffId: string, - sandboxId: string, - sessionId: string -): ActorKey { - // Include sandbox + session so multiple sandboxes/sessions can be tracked per handoff. - return ["ws", workspaceId, "project", repoId, "handoff", handoffId, "status-sync", sandboxId, sessionId]; -} diff --git a/factory/packages/client/src/mock/workbench-client.ts b/factory/packages/client/src/mock/workbench-client.ts deleted file mode 100644 index b2738dda..00000000 --- a/factory/packages/client/src/mock/workbench-client.ts +++ /dev/null @@ -1,445 +0,0 @@ -import { - MODEL_GROUPS, - buildInitialMockLayoutViewModel, - groupWorkbenchProjects, - nowMs, - providerAgent, - randomReply, - removeFileTreePath, - slugify, - uid, -} from "../workbench-model.js"; -import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, - WorkbenchAgentTab as AgentTab, - WorkbenchHandoff as Handoff, - WorkbenchTranscriptEvent as TranscriptEvent, -} from "@openhandoff/shared"; -import type { HandoffWorkbenchClient } from "../workbench-client.js"; - -function buildTranscriptEvent(params: { - sessionId: string; - sender: "client" | "agent"; - createdAt: number; - payload: unknown; - eventIndex: number; -}): TranscriptEvent { - return { - id: uid(), - sessionId: params.sessionId, - sender: params.sender, - createdAt: params.createdAt, - payload: params.payload, - connectionId: "mock-connection", - eventIndex: params.eventIndex, - }; -} - -class MockWorkbenchStore implements HandoffWorkbenchClient { - private snapshot = buildInitialMockLayoutViewModel(); - private listeners = new Set<() => void>(); - private pendingTimers = new Map>(); - - getSnapshot(): HandoffWorkbenchSnapshot { - return this.snapshot; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - return () => { - this.listeners.delete(listener); - }; - } - - async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise { - const id = uid(); - const tabId = `session-${id}`; - const repo = this.snapshot.repos.find((candidate) => candidate.id === input.repoId); - if (!repo) { - throw new Error(`Cannot create mock handoff for unknown repo ${input.repoId}`); - } - const nextHandoff: Handoff = { - id, - repoId: repo.id, - title: input.title?.trim() || "New Handoff", - status: "new", - repoName: repo.label, - updatedAtMs: nowMs(), - branch: input.branch?.trim() || null, - pullRequest: null, - tabs: [ - { - id: tabId, - sessionId: tabId, - sessionName: "Session 1", - agent: providerAgent(MODEL_GROUPS.find((group) => group.models.some((model) => model.id === (input.model ?? "claude-sonnet-4")))?.provider ?? "Claude"), - model: input.model ?? "claude-sonnet-4", - status: "idle", - thinkingSinceMs: null, - unread: false, - created: false, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: [], - }, - ], - fileChanges: [], - diffs: {}, - fileTree: [], - }; - - this.updateState((current) => ({ - ...current, - handoffs: [nextHandoff, ...current.handoffs], - })); - return { handoffId: id, tabId }; - } - - async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise { - this.updateHandoff(input.handoffId, (handoff) => { - const targetTab = handoff.tabs[handoff.tabs.length - 1] ?? null; - if (!targetTab) { - return handoff; - } - - return { - ...handoff, - tabs: handoff.tabs.map((tab) => (tab.id === targetTab.id ? { ...tab, unread: true } : tab)), - }; - }); - } - - async renameHandoff(input: HandoffWorkbenchRenameInput): Promise { - const value = input.value.trim(); - if (!value) { - throw new Error(`Cannot rename handoff ${input.handoffId} to an empty title`); - } - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, title: value, updatedAtMs: nowMs() })); - } - - async renameBranch(input: HandoffWorkbenchRenameInput): Promise { - const value = input.value.trim(); - if (!value) { - throw new Error(`Cannot rename branch for handoff ${input.handoffId} to an empty value`); - } - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, branch: value, updatedAtMs: nowMs() })); - } - - async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise { - this.updateHandoff(input.handoffId, (handoff) => ({ ...handoff, status: "archived", updatedAtMs: nowMs() })); - } - - async publishPr(input: HandoffWorkbenchSelectInput): Promise { - const nextPrNumber = Math.max(0, ...this.snapshot.handoffs.map((handoff) => handoff.pullRequest?.number ?? 0)) + 1; - this.updateHandoff(input.handoffId, (handoff) => ({ - ...handoff, - updatedAtMs: nowMs(), - pullRequest: { number: nextPrNumber, status: "ready" }, - })); - } - - async revertFile(input: HandoffWorkbenchDiffInput): Promise { - this.updateHandoff(input.handoffId, (handoff) => { - const file = handoff.fileChanges.find((entry) => entry.path === input.path); - const nextDiffs = { ...handoff.diffs }; - delete nextDiffs[input.path]; - - return { - ...handoff, - fileChanges: handoff.fileChanges.filter((entry) => entry.path !== input.path), - diffs: nextDiffs, - fileTree: file?.type === "A" ? removeFileTreePath(handoff.fileTree, input.path) : handoff.fileTree, - }; - }); - } - - async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise { - this.assertTab(input.handoffId, input.tabId); - this.updateHandoff(input.handoffId, (handoff) => ({ - ...handoff, - updatedAtMs: nowMs(), - tabs: handoff.tabs.map((tab) => - tab.id === input.tabId - ? { - ...tab, - draft: { - text: input.text, - attachments: input.attachments, - updatedAtMs: nowMs(), - }, - } - : tab, - ), - })); - } - - async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise { - const text = input.text.trim(); - if (!text) { - throw new Error(`Cannot send an empty mock prompt for handoff ${input.handoffId}`); - } - - this.assertTab(input.handoffId, input.tabId); - const startedAtMs = nowMs(); - - this.updateHandoff(input.handoffId, (currentHandoff) => { - const isFirstOnHandoff = currentHandoff.status === "new"; - const newTitle = isFirstOnHandoff ? (text.length > 50 ? `${text.slice(0, 47)}...` : text) : currentHandoff.title; - const newBranch = isFirstOnHandoff ? `feat/${slugify(newTitle)}` : currentHandoff.branch; - const userMessageLines = [text, ...input.attachments.map((attachment) => `@ ${attachment.filePath}:${attachment.lineNumber}`)]; - const userEvent = buildTranscriptEvent({ - sessionId: input.tabId, - sender: "client", - createdAt: startedAtMs, - eventIndex: candidateEventIndex(currentHandoff, input.tabId), - payload: { - method: "session/prompt", - params: { - prompt: userMessageLines.map((line) => ({ type: "text", text: line })), - }, - }, - }); - - return { - ...currentHandoff, - title: newTitle, - branch: newBranch, - status: "running", - updatedAtMs: startedAtMs, - tabs: currentHandoff.tabs.map((candidate) => - candidate.id === input.tabId - ? { - ...candidate, - created: true, - status: "running", - unread: false, - thinkingSinceMs: startedAtMs, - draft: { text: "", attachments: [], updatedAtMs: startedAtMs }, - transcript: [...candidate.transcript, userEvent], - } - : candidate, - ), - }; - }); - - const existingTimer = this.pendingTimers.get(input.tabId); - if (existingTimer) { - clearTimeout(existingTimer); - } - - const timer = setTimeout(() => { - const handoff = this.requireHandoff(input.handoffId); - const replyTab = this.requireTab(handoff, input.tabId); - const completedAtMs = nowMs(); - const replyEvent = buildTranscriptEvent({ - sessionId: input.tabId, - sender: "agent", - createdAt: completedAtMs, - eventIndex: candidateEventIndex(handoff, input.tabId), - payload: { - result: { - text: randomReply(), - durationMs: completedAtMs - startedAtMs, - }, - }, - }); - - this.updateHandoff(input.handoffId, (currentHandoff) => { - const updatedTabs = currentHandoff.tabs.map((candidate) => { - if (candidate.id !== input.tabId) { - return candidate; - } - - return { - ...candidate, - status: "idle" as const, - thinkingSinceMs: null, - unread: true, - transcript: [...candidate.transcript, replyEvent], - }; - }); - const anyRunning = updatedTabs.some((candidate) => candidate.status === "running"); - - return { - ...currentHandoff, - updatedAtMs: completedAtMs, - tabs: updatedTabs, - status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle", - }; - }); - - this.pendingTimers.delete(input.tabId); - }, 2_500); - - this.pendingTimers.set(input.tabId, timer); - } - - async stopAgent(input: HandoffWorkbenchTabInput): Promise { - this.assertTab(input.handoffId, input.tabId); - const existing = this.pendingTimers.get(input.tabId); - if (existing) { - clearTimeout(existing); - this.pendingTimers.delete(input.tabId); - } - - this.updateHandoff(input.handoffId, (currentHandoff) => { - const updatedTabs = currentHandoff.tabs.map((candidate) => - candidate.id === input.tabId ? { ...candidate, status: "idle" as const, thinkingSinceMs: null } : candidate, - ); - const anyRunning = updatedTabs.some((candidate) => candidate.status === "running"); - - return { - ...currentHandoff, - updatedAtMs: nowMs(), - tabs: updatedTabs, - status: currentHandoff.status === "archived" ? "archived" : anyRunning ? "running" : "idle", - }; - }); - } - - async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise { - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => - candidate.id === input.tabId ? { ...candidate, unread: input.unread } : candidate, - ), - })); - } - - async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise { - const title = input.title.trim(); - if (!title) { - throw new Error(`Cannot rename session ${input.tabId} to an empty title`); - } - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => - candidate.id === input.tabId ? { ...candidate, sessionName: title } : candidate, - ), - })); - } - - async closeTab(input: HandoffWorkbenchTabInput): Promise { - this.updateHandoff(input.handoffId, (currentHandoff) => { - if (currentHandoff.tabs.length <= 1) { - return currentHandoff; - } - - return { - ...currentHandoff, - tabs: currentHandoff.tabs.filter((candidate) => candidate.id !== input.tabId), - }; - }); - } - - async addTab(input: HandoffWorkbenchSelectInput): Promise { - this.assertHandoff(input.handoffId); - const nextTab: AgentTab = { - id: uid(), - sessionId: null, - sessionName: `Session ${this.requireHandoff(input.handoffId).tabs.length + 1}`, - agent: "Claude", - model: "claude-sonnet-4", - status: "idle", - thinkingSinceMs: null, - unread: false, - created: false, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: [], - }; - - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - updatedAtMs: nowMs(), - tabs: [...currentHandoff.tabs, nextTab], - })); - return { tabId: nextTab.id }; - } - - async changeModel(input: HandoffWorkbenchChangeModelInput): Promise { - const group = MODEL_GROUPS.find((candidate) => candidate.models.some((entry) => entry.id === input.model)); - if (!group) { - throw new Error(`Unable to resolve model provider for ${input.model}`); - } - - this.updateHandoff(input.handoffId, (currentHandoff) => ({ - ...currentHandoff, - tabs: currentHandoff.tabs.map((candidate) => - candidate.id === input.tabId ? { ...candidate, model: input.model, agent: providerAgent(group.provider) } : candidate, - ), - })); - } - - private updateState(updater: (current: HandoffWorkbenchSnapshot) => HandoffWorkbenchSnapshot): void { - const nextSnapshot = updater(this.snapshot); - this.snapshot = { - ...nextSnapshot, - projects: groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs), - }; - this.notify(); - } - - private updateHandoff(handoffId: string, updater: (handoff: Handoff) => Handoff): void { - this.assertHandoff(handoffId); - this.updateState((current) => ({ - ...current, - handoffs: current.handoffs.map((handoff) => (handoff.id === handoffId ? updater(handoff) : handoff)), - })); - } - - private notify(): void { - for (const listener of this.listeners) { - listener(); - } - } - - private assertHandoff(handoffId: string): void { - this.requireHandoff(handoffId); - } - - private assertTab(handoffId: string, tabId: string): void { - const handoff = this.requireHandoff(handoffId); - this.requireTab(handoff, tabId); - } - - private requireHandoff(handoffId: string): Handoff { - const handoff = this.snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`Unable to find mock handoff ${handoffId}`); - } - return handoff; - } - - private requireTab(handoff: Handoff, tabId: string): AgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); - if (!tab) { - throw new Error(`Unable to find mock tab ${tabId} in handoff ${handoff.id}`); - } - return tab; - } -} - -function candidateEventIndex(handoff: Handoff, tabId: string): number { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); - return (tab?.transcript.length ?? 0) + 1; -} - -let sharedMockWorkbenchClient: HandoffWorkbenchClient | null = null; - -export function getSharedMockWorkbenchClient(): HandoffWorkbenchClient { - if (!sharedMockWorkbenchClient) { - sharedMockWorkbenchClient = new MockWorkbenchStore(); - } - return sharedMockWorkbenchClient; -} diff --git a/factory/packages/client/src/remote/workbench-client.ts b/factory/packages/client/src/remote/workbench-client.ts deleted file mode 100644 index 720613be..00000000 --- a/factory/packages/client/src/remote/workbench-client.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, -} from "@openhandoff/shared"; -import type { BackendClient } from "../backend-client.js"; -import { groupWorkbenchProjects } from "../workbench-model.js"; -import type { HandoffWorkbenchClient } from "../workbench-client.js"; - -export interface RemoteWorkbenchClientOptions { - backend: BackendClient; - workspaceId: string; -} - -class RemoteWorkbenchStore implements HandoffWorkbenchClient { - private readonly backend: BackendClient; - private readonly workspaceId: string; - private snapshot: HandoffWorkbenchSnapshot; - private readonly listeners = new Set<() => void>(); - private unsubscribeWorkbench: (() => void) | null = null; - private refreshPromise: Promise | null = null; - private refreshRetryTimeout: ReturnType | null = null; - - constructor(options: RemoteWorkbenchClientOptions) { - this.backend = options.backend; - this.workspaceId = options.workspaceId; - this.snapshot = { - workspaceId: options.workspaceId, - repos: [], - projects: [], - handoffs: [], - }; - } - - getSnapshot(): HandoffWorkbenchSnapshot { - return this.snapshot; - } - - subscribe(listener: () => void): () => void { - this.listeners.add(listener); - this.ensureStarted(); - return () => { - this.listeners.delete(listener); - if (this.listeners.size === 0 && this.refreshRetryTimeout) { - clearTimeout(this.refreshRetryTimeout); - this.refreshRetryTimeout = null; - } - if (this.listeners.size === 0 && this.unsubscribeWorkbench) { - this.unsubscribeWorkbench(); - this.unsubscribeWorkbench = null; - } - }; - } - - async createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise { - const created = await this.backend.createWorkbenchHandoff(this.workspaceId, input); - await this.refresh(); - return created; - } - - async markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise { - await this.backend.markWorkbenchUnread(this.workspaceId, input); - await this.refresh(); - } - - async renameHandoff(input: HandoffWorkbenchRenameInput): Promise { - await this.backend.renameWorkbenchHandoff(this.workspaceId, input); - await this.refresh(); - } - - async renameBranch(input: HandoffWorkbenchRenameInput): Promise { - await this.backend.renameWorkbenchBranch(this.workspaceId, input); - await this.refresh(); - } - - async archiveHandoff(input: HandoffWorkbenchSelectInput): Promise { - await this.backend.runAction(this.workspaceId, input.handoffId, "archive"); - await this.refresh(); - } - - async publishPr(input: HandoffWorkbenchSelectInput): Promise { - await this.backend.publishWorkbenchPr(this.workspaceId, input); - await this.refresh(); - } - - async revertFile(input: HandoffWorkbenchDiffInput): Promise { - await this.backend.revertWorkbenchFile(this.workspaceId, input); - await this.refresh(); - } - - async updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise { - await this.backend.updateWorkbenchDraft(this.workspaceId, input); - await this.refresh(); - } - - async sendMessage(input: HandoffWorkbenchSendMessageInput): Promise { - await this.backend.sendWorkbenchMessage(this.workspaceId, input); - await this.refresh(); - } - - async stopAgent(input: HandoffWorkbenchTabInput): Promise { - await this.backend.stopWorkbenchSession(this.workspaceId, input); - await this.refresh(); - } - - async setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise { - await this.backend.setWorkbenchSessionUnread(this.workspaceId, input); - await this.refresh(); - } - - async renameSession(input: HandoffWorkbenchRenameSessionInput): Promise { - await this.backend.renameWorkbenchSession(this.workspaceId, input); - await this.refresh(); - } - - async closeTab(input: HandoffWorkbenchTabInput): Promise { - await this.backend.closeWorkbenchSession(this.workspaceId, input); - await this.refresh(); - } - - async addTab(input: HandoffWorkbenchSelectInput): Promise { - const created = await this.backend.createWorkbenchSession(this.workspaceId, input); - await this.refresh(); - return created; - } - - async changeModel(input: HandoffWorkbenchChangeModelInput): Promise { - await this.backend.changeWorkbenchModel(this.workspaceId, input); - await this.refresh(); - } - - private ensureStarted(): void { - if (!this.unsubscribeWorkbench) { - this.unsubscribeWorkbench = this.backend.subscribeWorkbench(this.workspaceId, () => { - void this.refresh().catch(() => { - this.scheduleRefreshRetry(); - }); - }); - } - void this.refresh().catch(() => { - this.scheduleRefreshRetry(); - }); - } - - private scheduleRefreshRetry(): void { - if (this.refreshRetryTimeout || this.listeners.size === 0) { - return; - } - - this.refreshRetryTimeout = setTimeout(() => { - this.refreshRetryTimeout = null; - void this.refresh().catch(() => { - this.scheduleRefreshRetry(); - }); - }, 1_000); - } - - private async refresh(): Promise { - if (this.refreshPromise) { - await this.refreshPromise; - return; - } - - this.refreshPromise = (async () => { - const nextSnapshot = await this.backend.getWorkbench(this.workspaceId); - if (this.refreshRetryTimeout) { - clearTimeout(this.refreshRetryTimeout); - this.refreshRetryTimeout = null; - } - this.snapshot = { - ...nextSnapshot, - projects: nextSnapshot.projects ?? groupWorkbenchProjects(nextSnapshot.repos, nextSnapshot.handoffs), - }; - for (const listener of [...this.listeners]) { - listener(); - } - })().finally(() => { - this.refreshPromise = null; - }); - - await this.refreshPromise; - } -} - -export function createRemoteWorkbenchClient( - options: RemoteWorkbenchClientOptions, -): HandoffWorkbenchClient { - return new RemoteWorkbenchStore(options); -} diff --git a/factory/packages/client/src/view-model.ts b/factory/packages/client/src/view-model.ts deleted file mode 100644 index 344f8a52..00000000 --- a/factory/packages/client/src/view-model.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { HandoffRecord, HandoffStatus } from "@openhandoff/shared"; - -export const HANDOFF_STATUS_GROUPS = [ - "queued", - "running", - "idle", - "archived", - "killed", - "error" -] as const; - -export type HandoffStatusGroup = (typeof HANDOFF_STATUS_GROUPS)[number]; - -const QUEUED_STATUSES = new Set([ - "init_bootstrap_db", - "init_enqueue_provision", - "init_ensure_name", - "init_assert_name", - "init_create_sandbox", - "init_ensure_agent", - "init_start_sandbox_instance", - "init_create_session", - "init_write_db", - "init_start_status_sync", - "init_complete", - "archive_stop_status_sync", - "archive_release_sandbox", - "archive_finalize", - "kill_destroy_sandbox", - "kill_finalize" -]); - -export function groupHandoffStatus(status: HandoffStatus): HandoffStatusGroup { - if (status === "running") return "running"; - if (status === "idle") return "idle"; - if (status === "archived") return "archived"; - if (status === "killed") return "killed"; - if (status === "error") return "error"; - if (QUEUED_STATUSES.has(status)) return "queued"; - return "queued"; -} - -function emptyStatusCounts(): Record { - return { - queued: 0, - running: 0, - idle: 0, - archived: 0, - killed: 0, - error: 0 - }; -} - -export interface HandoffSummary { - total: number; - byStatus: Record; - byProvider: Record; -} - -export function fuzzyMatch(target: string, query: string): boolean { - const haystack = target.toLowerCase(); - const needle = query.toLowerCase(); - let i = 0; - for (const ch of needle) { - i = haystack.indexOf(ch, i); - if (i < 0) { - return false; - } - i += 1; - } - return true; -} - -export function filterHandoffs(rows: HandoffRecord[], query: string): HandoffRecord[] { - const q = query.trim(); - if (!q) { - return rows; - } - - return rows.filter((row) => { - const fields = [ - row.branchName ?? "", - row.title ?? "", - row.handoffId, - row.task, - row.prAuthor ?? "", - row.reviewer ?? "" - ]; - return fields.some((field) => fuzzyMatch(field, q)); - }); -} - -export function formatRelativeAge(updatedAt: number, now = Date.now()): string { - const deltaSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000)); - if (deltaSeconds < 60) return `${deltaSeconds}s`; - const minutes = Math.floor(deltaSeconds / 60); - if (minutes < 60) return `${minutes}m`; - const hours = Math.floor(minutes / 60); - if (hours < 24) return `${hours}h`; - const days = Math.floor(hours / 24); - return `${days}d`; -} - -export function summarizeHandoffs(rows: HandoffRecord[]): HandoffSummary { - const byStatus = emptyStatusCounts(); - const byProvider: Record = {}; - - for (const row of rows) { - byStatus[groupHandoffStatus(row.status)] += 1; - byProvider[row.providerId] = (byProvider[row.providerId] ?? 0) + 1; - } - - return { - total: rows.length, - byStatus, - byProvider - }; -} diff --git a/factory/packages/client/src/workbench-client.ts b/factory/packages/client/src/workbench-client.ts deleted file mode 100644 index 1738c19f..00000000 --- a/factory/packages/client/src/workbench-client.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { - HandoffWorkbenchAddTabResponse, - HandoffWorkbenchChangeModelInput, - HandoffWorkbenchCreateHandoffInput, - HandoffWorkbenchCreateHandoffResponse, - HandoffWorkbenchDiffInput, - HandoffWorkbenchRenameInput, - HandoffWorkbenchRenameSessionInput, - HandoffWorkbenchSelectInput, - HandoffWorkbenchSetSessionUnreadInput, - HandoffWorkbenchSendMessageInput, - HandoffWorkbenchSnapshot, - HandoffWorkbenchTabInput, - HandoffWorkbenchUpdateDraftInput, -} from "@openhandoff/shared"; -import type { BackendClient } from "./backend-client.js"; -import { getSharedMockWorkbenchClient } from "./mock/workbench-client.js"; -import { createRemoteWorkbenchClient } from "./remote/workbench-client.js"; - -export type HandoffWorkbenchClientMode = "mock" | "remote"; - -export interface CreateHandoffWorkbenchClientOptions { - mode: HandoffWorkbenchClientMode; - backend?: BackendClient; - workspaceId?: string; -} - -export interface HandoffWorkbenchClient { - getSnapshot(): HandoffWorkbenchSnapshot; - subscribe(listener: () => void): () => void; - createHandoff(input: HandoffWorkbenchCreateHandoffInput): Promise; - markHandoffUnread(input: HandoffWorkbenchSelectInput): Promise; - renameHandoff(input: HandoffWorkbenchRenameInput): Promise; - renameBranch(input: HandoffWorkbenchRenameInput): Promise; - archiveHandoff(input: HandoffWorkbenchSelectInput): Promise; - publishPr(input: HandoffWorkbenchSelectInput): Promise; - revertFile(input: HandoffWorkbenchDiffInput): Promise; - updateDraft(input: HandoffWorkbenchUpdateDraftInput): Promise; - sendMessage(input: HandoffWorkbenchSendMessageInput): Promise; - stopAgent(input: HandoffWorkbenchTabInput): Promise; - setSessionUnread(input: HandoffWorkbenchSetSessionUnreadInput): Promise; - renameSession(input: HandoffWorkbenchRenameSessionInput): Promise; - closeTab(input: HandoffWorkbenchTabInput): Promise; - addTab(input: HandoffWorkbenchSelectInput): Promise; - changeModel(input: HandoffWorkbenchChangeModelInput): Promise; -} - -export function createHandoffWorkbenchClient( - options: CreateHandoffWorkbenchClientOptions, -): HandoffWorkbenchClient { - if (options.mode === "mock") { - return getSharedMockWorkbenchClient(); - } - - if (!options.backend) { - throw new Error("Remote handoff workbench client requires a backend client"); - } - if (!options.workspaceId) { - throw new Error("Remote handoff workbench client requires a workspace id"); - } - - return createRemoteWorkbenchClient({ - backend: options.backend, - workspaceId: options.workspaceId, - }); -} diff --git a/factory/packages/client/src/workbench-model.ts b/factory/packages/client/src/workbench-model.ts deleted file mode 100644 index 51dd4f58..00000000 --- a/factory/packages/client/src/workbench-model.ts +++ /dev/null @@ -1,965 +0,0 @@ -import type { - WorkbenchAgentKind as AgentKind, - WorkbenchAgentTab as AgentTab, - WorkbenchDiffLineKind as DiffLineKind, - WorkbenchFileTreeNode as FileTreeNode, - WorkbenchHandoff as Handoff, - HandoffWorkbenchSnapshot, - WorkbenchHistoryEvent as HistoryEvent, - WorkbenchModelGroup as ModelGroup, - WorkbenchModelId as ModelId, - WorkbenchParsedDiffLine as ParsedDiffLine, - WorkbenchProjectSection, - WorkbenchRepo, - WorkbenchTranscriptEvent as TranscriptEvent, -} from "@openhandoff/shared"; - -export const MODEL_GROUPS: ModelGroup[] = [ - { - provider: "Claude", - models: [ - { id: "claude-sonnet-4", label: "Sonnet 4" }, - { id: "claude-opus-4", label: "Opus 4" }, - ], - }, - { - provider: "OpenAI", - models: [ - { id: "gpt-4o", label: "GPT-4o" }, - { id: "o3", label: "o3" }, - ], - }, -]; - -const MOCK_REPLIES = [ - "Got it. I'll work on that now. Let me start by examining the relevant files...", - "I've analyzed the codebase and found the relevant code. Making the changes now...", - "Working on it. I'll update you once I have the implementation ready.", - "Let me look into that. I'll trace through the code to understand the current behavior...", - "Starting on this now. I'll need to modify a few files to implement this properly.", -]; - -let nextId = 100; - -export function uid(): string { - return String(++nextId); -} - -export function nowMs(): number { - return Date.now(); -} - -export function formatThinkingDuration(durationMs: number): string { - const totalSeconds = Math.max(0, Math.floor(durationMs / 1000)); - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}:${String(seconds).padStart(2, "0")}`; -} - -export function formatMessageDuration(durationMs: number): string { - const totalSeconds = Math.max(1, Math.round(durationMs / 1000)); - if (totalSeconds < 60) { - return `${totalSeconds}s`; - } - - const minutes = Math.floor(totalSeconds / 60); - const seconds = totalSeconds % 60; - return `${minutes}m ${String(seconds).padStart(2, "0")}s`; -} - -export function modelLabel(id: ModelId): string { - const group = MODEL_GROUPS.find((candidate) => candidate.models.some((model) => model.id === id)); - const model = group?.models.find((candidate) => candidate.id === id); - return model && group ? `${group.provider} ${model.label}` : id; -} - -export function providerAgent(provider: string): AgentKind { - if (provider === "Claude") return "Claude"; - if (provider === "OpenAI") return "Codex"; - return "Cursor"; -} - -export function slugify(text: string): string { - return text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40); -} - -export function randomReply(): string { - return MOCK_REPLIES[Math.floor(Math.random() * MOCK_REPLIES.length)]!; -} - -const DIFF_PREFIX = "diff:"; - -export function isDiffTab(id: string): boolean { - return id.startsWith(DIFF_PREFIX); -} - -export function diffPath(id: string): string { - return id.slice(DIFF_PREFIX.length); -} - -export function diffTabId(path: string): string { - return `${DIFF_PREFIX}${path}`; -} - -export function fileName(path: string): string { - return path.split("/").pop() ?? path; -} - -function messageOrder(id: string): number { - const match = id.match(/\d+/); - return match ? Number(match[0]) : 0; -} - -interface LegacyMessage { - id: string; - role: "agent" | "user"; - agent: string | null; - createdAtMs: number; - lines: string[]; - durationMs?: number; -} - -function transcriptText(payload: unknown): string { - if (!payload || typeof payload !== "object") { - return String(payload ?? ""); - } - - const envelope = payload as { - method?: unknown; - params?: unknown; - result?: unknown; - error?: unknown; - }; - - if (envelope.params && typeof envelope.params === "object") { - const prompt = (envelope.params as { prompt?: unknown }).prompt; - if (Array.isArray(prompt)) { - const text = prompt - .map((item) => (item && typeof item === "object" ? (item as { text?: unknown }).text : null)) - .filter((value): value is string => typeof value === "string" && value.trim().length > 0) - .join("\n"); - if (text) { - return text; - } - } - - const paramsText = (envelope.params as { text?: unknown }).text; - if (typeof paramsText === "string" && paramsText.trim().length > 0) { - return paramsText.trim(); - } - } - - if (envelope.result && typeof envelope.result === "object") { - const resultText = (envelope.result as { text?: unknown }).text; - if (typeof resultText === "string" && resultText.trim().length > 0) { - return resultText.trim(); - } - } - - if (envelope.error) { - return JSON.stringify(envelope.error); - } - - if (typeof envelope.method === "string") { - return envelope.method; - } - - return JSON.stringify(payload); -} - -function historyPreview(event: TranscriptEvent): string { - const content = transcriptText(event.payload).trim() || "Untitled event"; - return content.length > 42 ? `${content.slice(0, 39)}...` : content; -} - -function historyDetail(event: TranscriptEvent): string { - const content = transcriptText(event.payload).trim(); - return content || "Untitled event"; -} - -export function buildHistoryEvents(tabs: AgentTab[]): HistoryEvent[] { - return tabs - .flatMap((tab) => - tab.transcript - .filter((event) => event.sender === "client") - .map((event) => ({ - id: `history-${tab.id}-${event.id}`, - messageId: event.id, - preview: historyPreview(event), - sessionName: tab.sessionName, - tabId: tab.id, - createdAtMs: event.createdAt, - detail: historyDetail(event), - })), - ) - .sort((left, right) => messageOrder(left.messageId) - messageOrder(right.messageId)); -} - -function transcriptFromLegacyMessages(sessionId: string, messages: LegacyMessage[]): TranscriptEvent[] { - return messages.map((message, index) => ({ - id: message.id, - eventIndex: index + 1, - sessionId, - createdAt: message.createdAtMs, - connectionId: "mock-connection", - sender: message.role === "user" ? "client" : "agent", - payload: - message.role === "user" - ? { - method: "session/prompt", - params: { - prompt: message.lines.map((line) => ({ type: "text", text: line })), - }, - } - : { - result: { - text: message.lines.join("\n"), - durationMs: message.durationMs, - }, - }, - })); -} - -const NOW_MS = Date.now(); - -function minutesAgo(minutes: number): number { - return NOW_MS - minutes * 60_000; -} - -export function parseDiffLines(diff: string): ParsedDiffLine[] { - return diff.split("\n").map((text, index) => { - if (text.startsWith("@@")) { - return { kind: "hunk", lineNumber: index + 1, text }; - } - if (text.startsWith("+")) { - return { kind: "add", lineNumber: index + 1, text }; - } - if (text.startsWith("-")) { - return { kind: "remove", lineNumber: index + 1, text }; - } - return { kind: "context", lineNumber: index + 1, text }; - }); -} - -export function removeFileTreePath(nodes: FileTreeNode[], targetPath: string): FileTreeNode[] { - return nodes.flatMap((node) => { - if (node.path === targetPath) { - return []; - } - - if (!node.children) { - return [node]; - } - - const nextChildren = removeFileTreePath(node.children, targetPath); - if (node.isDir && nextChildren.length === 0) { - return []; - } - - return [{ ...node, children: nextChildren }]; - }); -} - -export function buildInitialHandoffs(): Handoff[] { - return [ - { - id: "h1", - repoId: "acme-backend", - title: "Fix auth token refresh", - status: "idle", - repoName: "acme/backend", - updatedAtMs: minutesAgo(2), - branch: "fix/auth-token-refresh", - pullRequest: { number: 47, status: "draft" }, - tabs: [ - { - id: "t1", - sessionId: "t1", - sessionName: "Auth token fix", - agent: "Claude", - model: "claude-sonnet-4", - status: "idle", - thinkingSinceMs: null, - unread: false, - created: true, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: transcriptFromLegacyMessages("t1", [ - { - id: "m1", - role: "agent", - agent: "claude", - createdAtMs: minutesAgo(12), - lines: [ - "I'll fix the auth token refresh logic. Let me start by examining the current implementation in `src/auth/token-manager.ts`.", - "", - "Found the issue - the refresh interval is set to 1 hour but the token expires in 5 minutes. Updating now.", - ], - durationMs: 12_000, - }, - { - id: "m2", - role: "agent", - agent: "claude", - createdAtMs: minutesAgo(11), - lines: [ - "Fixed token refresh in `src/auth/token-manager.ts`. Also updated the retry logic in `src/api/client.ts` to handle 401 responses gracefully.", - ], - durationMs: 18_000, - }, - { - id: "m3", - role: "user", - agent: null, - createdAtMs: minutesAgo(10), - lines: ["Can you also add unit tests for the refresh logic?"], - }, - { - id: "m4", - role: "agent", - agent: "claude", - createdAtMs: minutesAgo(9), - lines: ["Writing tests now in `src/auth/__tests__/token-manager.test.ts`..."], - durationMs: 9_000, - }, - ]), - }, - { - id: "t2", - sessionId: "t2", - sessionName: "Code analysis", - agent: "Codex", - model: "gpt-4o", - status: "idle", - thinkingSinceMs: null, - unread: true, - created: true, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: transcriptFromLegacyMessages("t2", [ - { - id: "m5", - role: "agent", - agent: "codex", - createdAtMs: minutesAgo(15), - lines: ["Analyzed the codebase. The auth module uses a simple in-memory token cache with no refresh mechanism."], - durationMs: 21_000, - }, - { - id: "m6", - role: "agent", - agent: "codex", - createdAtMs: minutesAgo(14), - lines: ["Suggested approach: add a refresh timer that fires before token expiry. I'll wait for instructions."], - durationMs: 7_000, - }, - ]), - }, - ], - fileChanges: [ - { path: "src/auth/token-manager.ts", added: 18, removed: 5, type: "M" }, - { path: "src/api/client.ts", added: 8, removed: 3, type: "M" }, - { path: "src/auth/__tests__/token-manager.test.ts", added: 21, removed: 0, type: "A" }, - { path: "src/types/auth.ts", added: 0, removed: 4, type: "M" }, - ], - diffs: { - "src/auth/token-manager.ts": [ - "@@ -21,10 +21,15 @@ import { TokenCache } from './cache';", - " export class TokenManager {", - " private refreshInterval: number;", - " ", - "- const REFRESH_MS = 3_600_000; // 1 hour", - "+ const REFRESH_MS = 300_000; // 5 minutes", - " ", - "+ async refreshToken(): Promise {", - "+ const newToken = await this.fetchNewToken();", - "+ this.cache.set(newToken);", - "+ return newToken;", - "+ }", - " ", - "- private async onExpiry() {", - "- console.log('token expired');", - "- this.logout();", - "- }", - "+ private async onExpiry() {", - "+ try {", - "+ await this.refreshToken();", - "+ } catch { this.logout(); }", - "+ }", - ].join("\n"), - "src/api/client.ts": [ - "@@ -45,8 +45,16 @@ export class ApiClient {", - " private async request(url: string, opts?: RequestInit): Promise {", - " const token = await this.tokenManager.getToken();", - " const res = await fetch(url, {", - "- ...opts,", - "- headers: { Authorization: `Bearer ${token}` },", - "+ ...opts, headers: {", - "+ ...opts?.headers,", - "+ Authorization: `Bearer ${token}`,", - "+ },", - " });", - "- return res.json();", - "+ if (res.status === 401) {", - "+ const freshToken = await this.tokenManager.refreshToken();", - "+ const retry = await fetch(url, {", - "+ ...opts, headers: { ...opts?.headers, Authorization: `Bearer ${freshToken}` },", - "+ });", - "+ return retry.json();", - "+ }", - "+ return res.json() as T;", - " }", - ].join("\n"), - "src/auth/__tests__/token-manager.test.ts": [ - "@@ -0,0 +1,21 @@", - "+import { describe, it, expect, vi } from 'vitest';", - "+import { TokenManager } from '../token-manager';", - "+", - "+describe('TokenManager', () => {", - "+ it('refreshes token before expiry', async () => {", - "+ const mgr = new TokenManager({ expiresIn: 100 });", - "+ const first = await mgr.getToken();", - "+ await new Promise(r => setTimeout(r, 150));", - "+ const second = await mgr.getToken();", - "+ expect(second).not.toBe(first);", - "+ });", - "+", - "+ it('retries on 401', async () => {", - "+ const mgr = new TokenManager();", - "+ const spy = vi.spyOn(mgr, 'refreshToken');", - "+ await mgr.handleUnauthorized();", - "+ expect(spy).toHaveBeenCalledOnce();", - "+ });", - "+", - "+ it('logs out after max retries', async () => {", - "+ const mgr = new TokenManager({ maxRetries: 0 });", - "+ await expect(mgr.handleUnauthorized()).rejects.toThrow();", - "+ });", - "+});", - ].join("\n"), - "src/types/auth.ts": [ - "@@ -8,10 +8,6 @@ export interface AuthConfig {", - " clientId: string;", - " clientSecret: string;", - " redirectUri: string;", - "- /** @deprecated Use refreshInterval instead */", - "- legacyTimeout?: number;", - "- /** @deprecated */", - "- useOldRefresh?: boolean;", - " }", - " ", - " export interface TokenPayload {", - ].join("\n"), - }, - fileTree: [ - { - name: "src", - path: "src", - isDir: true, - children: [ - { - name: "api", - path: "src/api", - isDir: true, - children: [{ name: "client.ts", path: "src/api/client.ts", isDir: false }], - }, - { - name: "auth", - path: "src/auth", - isDir: true, - children: [ - { - name: "__tests__", - path: "src/auth/__tests__", - isDir: true, - children: [{ name: "token-manager.test.ts", path: "src/auth/__tests__/token-manager.test.ts", isDir: false }], - }, - { name: "token-manager.ts", path: "src/auth/token-manager.ts", isDir: false }, - ], - }, - { - name: "types", - path: "src/types", - isDir: true, - children: [{ name: "auth.ts", path: "src/types/auth.ts", isDir: false }], - }, - ], - }, - ], - }, - { - id: "h3", - repoId: "acme-backend", - title: "Refactor user service", - status: "idle", - repoName: "acme/backend", - updatedAtMs: minutesAgo(5), - branch: "refactor/user-service", - pullRequest: { number: 52, status: "ready" }, - tabs: [ - { - id: "t4", - sessionId: "t4", - sessionName: "DI refactor", - agent: "Claude", - model: "claude-opus-4", - status: "idle", - thinkingSinceMs: null, - unread: false, - created: true, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: transcriptFromLegacyMessages("t4", [ - { - id: "m20", - role: "user", - agent: null, - createdAtMs: minutesAgo(35), - lines: ["Refactor the user service to use dependency injection."], - }, - { - id: "m21", - role: "agent", - agent: "claude", - createdAtMs: minutesAgo(34), - lines: ["Starting refactor. I'll extract interfaces and set up a DI container..."], - durationMs: 14_000, - }, - ]), - }, - ], - fileChanges: [ - { path: "src/services/user-service.ts", added: 45, removed: 30, type: "M" }, - { path: "src/services/interfaces.ts", added: 22, removed: 0, type: "A" }, - { path: "src/container.ts", added: 15, removed: 0, type: "A" }, - ], - diffs: { - "src/services/user-service.ts": [ - "@@ -1,35 +1,50 @@", - "-import { db } from '../db';", - "-import { hashPassword, verifyPassword } from '../utils/crypto';", - "-import { sendEmail } from '../utils/email';", - "+import type { IUserRepository, IEmailService, IHashService } from './interfaces';", - " ", - "-export class UserService {", - "- async createUser(email: string, password: string) {", - "- const hash = await hashPassword(password);", - "- const user = await db.users.create({", - "- email,", - "- passwordHash: hash,", - "- createdAt: new Date(),", - "- });", - "- await sendEmail(email, 'Welcome!', 'Thanks for signing up.');", - "- return user;", - "+export class UserService {", - "+ constructor(", - "+ private readonly users: IUserRepository,", - "+ private readonly email: IEmailService,", - "+ private readonly hash: IHashService,", - "+ ) {}", - "+", - "+ async createUser(email: string, password: string) {", - "+ const passwordHash = await this.hash.hash(password);", - "+ const user = await this.users.create({ email, passwordHash });", - "+ await this.email.send(email, 'Welcome!', 'Thanks for signing up.');", - "+ return user;", - " }", - " ", - "- async authenticate(email: string, password: string) {", - "- const user = await db.users.findByEmail(email);", - "+ async authenticate(email: string, password: string) {", - "+ const user = await this.users.findByEmail(email);", - " if (!user) throw new Error('User not found');", - "- const valid = await verifyPassword(password, user.passwordHash);", - "+ const valid = await this.hash.verify(password, user.passwordHash);", - " if (!valid) throw new Error('Invalid password');", - " return user;", - " }", - " ", - "- async deleteUser(id: string) {", - "- await db.users.delete(id);", - "+ async deleteUser(id: string) {", - "+ const user = await this.users.findById(id);", - "+ if (!user) throw new Error('User not found');", - "+ await this.users.delete(id);", - "+ await this.email.send(user.email, 'Account deleted', 'Your account has been removed.');", - " }", - " }", - ].join("\n"), - "src/services/interfaces.ts": [ - "@@ -0,0 +1,22 @@", - "+export interface IUserRepository {", - "+ create(data: { email: string; passwordHash: string }): Promise;", - "+ findByEmail(email: string): Promise;", - "+ findById(id: string): Promise;", - "+ delete(id: string): Promise;", - "+}", - "+", - "+export interface IEmailService {", - "+ send(to: string, subject: string, body: string): Promise;", - "+}", - "+", - "+export interface IHashService {", - "+ hash(password: string): Promise;", - "+ verify(password: string, hash: string): Promise;", - "+}", - "+", - "+export interface User {", - "+ id: string;", - "+ email: string;", - "+ passwordHash: string;", - "+ createdAt: Date;", - "+}", - ].join("\n"), - "src/container.ts": [ - "@@ -0,0 +1,15 @@", - "+import { UserService } from './services/user-service';", - "+import { DrizzleUserRepository } from './repos/user-repo';", - "+import { ResendEmailService } from './providers/email';", - "+import { Argon2HashService } from './providers/hash';", - "+import { db } from './db';", - "+", - "+const userRepo = new DrizzleUserRepository(db);", - "+const emailService = new ResendEmailService();", - "+const hashService = new Argon2HashService();", - "+", - "+export const userService = new UserService(", - "+ userRepo,", - "+ emailService,", - "+ hashService,", - "+);", - ].join("\n"), - }, - fileTree: [ - { - name: "src", - path: "src", - isDir: true, - children: [ - { name: "container.ts", path: "src/container.ts", isDir: false }, - { - name: "services", - path: "src/services", - isDir: true, - children: [ - { name: "interfaces.ts", path: "src/services/interfaces.ts", isDir: false }, - { name: "user-service.ts", path: "src/services/user-service.ts", isDir: false }, - ], - }, - ], - }, - ], - }, - { - id: "h2", - repoId: "acme-frontend", - title: "Add dark mode toggle", - status: "idle", - repoName: "acme/frontend", - updatedAtMs: minutesAgo(15), - branch: "feat/dark-mode", - pullRequest: null, - tabs: [ - { - id: "t3", - sessionId: "t3", - sessionName: "Dark mode", - agent: "Claude", - model: "claude-sonnet-4", - status: "idle", - thinkingSinceMs: null, - unread: true, - created: true, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: transcriptFromLegacyMessages("t3", [ - { - id: "m10", - role: "user", - agent: null, - createdAtMs: minutesAgo(75), - lines: ["Add a dark mode toggle to the settings page."], - }, - { - id: "m11", - role: "agent", - agent: "claude", - createdAtMs: minutesAgo(74), - lines: [ - "I've added a dark mode toggle to the settings page. The implementation uses CSS custom properties for theming and persists the user's preference to localStorage.", - ], - durationMs: 26_000, - }, - ]), - }, - ], - fileChanges: [ - { path: "src/components/settings.tsx", added: 32, removed: 2, type: "M" }, - { path: "src/styles/theme.css", added: 45, removed: 0, type: "A" }, - ], - diffs: { - "src/components/settings.tsx": [ - "@@ -1,5 +1,6 @@", - " import React, { useState, useEffect } from 'react';", - "+import { useTheme } from '../hooks/use-theme';", - " import { Card } from './ui/card';", - " import { Toggle } from './ui/toggle';", - " import { Label } from './ui/label';", - "@@ -15,8 +16,38 @@ export function Settings() {", - " const [notifications, setNotifications] = useState(true);", - "+ const { theme, setTheme } = useTheme();", - "+ const [isDark, setIsDark] = useState(theme === 'dark');", - " ", - " return (", - "
", - "+ ", - "+

Appearance

", - "+
", - "+ ", - "+ {", - "+ setIsDark(checked);", - "+ setTheme(checked ? 'dark' : 'light');", - "+ document.documentElement.setAttribute(", - "+ 'data-theme',", - "+ checked ? 'dark' : 'light'", - "+ );", - "+ }}", - "+ />", - "+
", - "+

", - "+ Toggle between light and dark color schemes.", - "+ Your preference is saved to localStorage.", - "+

", - "+
", - "+", - " ", - "

Notifications

", - "
", - "- ", - "- ", - "+ ", - "+ ", - "
", - "
", - ].join("\n"), - "src/styles/theme.css": [ - "@@ -0,0 +1,45 @@", - "+:root {", - "+ --bg-primary: #ffffff;", - "+ --bg-secondary: #f8f9fa;", - "+ --bg-tertiary: #e9ecef;", - "+ --text-primary: #212529;", - "+ --text-secondary: #495057;", - "+ --text-muted: #868e96;", - "+ --border-color: #dee2e6;", - "+ --accent: #228be6;", - "+ --accent-hover: #1c7ed6;", - "+}", - "+", - "+[data-theme='dark'] {", - "+ --bg-primary: #09090b;", - "+ --bg-secondary: #18181b;", - "+ --bg-tertiary: #27272a;", - "+ --text-primary: #fafafa;", - "+ --text-secondary: #a1a1aa;", - "+ --text-muted: #71717a;", - "+ --border-color: #3f3f46;", - "+ --accent: #ff4f00;", - "+ --accent-hover: #ff6a00;", - "+}", - "+", - "+body {", - "+ background: var(--bg-primary);", - "+ color: var(--text-primary);", - "+ transition: background 0.2s ease, color 0.2s ease;", - "+}", - "+", - "+.card {", - "+ background: var(--bg-secondary);", - "+ border: 1px solid var(--border-color);", - "+ border-radius: 8px;", - "+ padding: 16px 20px;", - "+}", - "+", - "+.setting-row {", - "+ display: flex;", - "+ align-items: center;", - "+ justify-content: space-between;", - "+ padding: 8px 0;", - "+}", - "+", - "+.setting-description {", - "+ color: var(--text-muted);", - "+ font-size: 13px;", - "+ margin-top: 4px;", - "+}", - ].join("\n"), - }, - fileTree: [ - { - name: "src", - path: "src", - isDir: true, - children: [ - { - name: "components", - path: "src/components", - isDir: true, - children: [{ name: "settings.tsx", path: "src/components/settings.tsx", isDir: false }], - }, - { - name: "styles", - path: "src/styles", - isDir: true, - children: [{ name: "theme.css", path: "src/styles/theme.css", isDir: false }], - }, - ], - }, - ], - }, - { - id: "h5", - repoId: "acme-infra", - title: "Update CI pipeline", - status: "archived", - repoName: "acme/infra", - updatedAtMs: minutesAgo(2 * 24 * 60 + 10), - branch: "chore/ci-pipeline", - pullRequest: { number: 38, status: "ready" }, - tabs: [ - { - id: "t6", - sessionId: "t6", - sessionName: "CI pipeline", - agent: "Claude", - model: "claude-sonnet-4", - status: "idle", - thinkingSinceMs: null, - unread: false, - created: true, - draft: { text: "", attachments: [], updatedAtMs: null }, - transcript: transcriptFromLegacyMessages("t6", [ - { - id: "m30", - role: "agent", - agent: "claude", - createdAtMs: minutesAgo(2 * 24 * 60 + 60), - lines: ["CI pipeline updated. Added caching for node_modules and parallel test execution."], - durationMs: 33_000, - }, - ]), - }, - ], - fileChanges: [{ path: ".github/workflows/ci.yml", added: 20, removed: 8, type: "M" }], - diffs: { - ".github/workflows/ci.yml": [ - "@@ -12,14 +12,26 @@ jobs:", - " build:", - " runs-on: ubuntu-latest", - " steps:", - "- - uses: actions/checkout@v3", - "- - uses: actions/setup-node@v3", - "+ - uses: actions/checkout@v4", - "+ - uses: actions/setup-node@v4", - " with:", - " node-version: 20", - "- - run: npm ci", - "- - run: npm run build", - "- - run: npm test", - "+ cache: 'pnpm'", - "+ - uses: pnpm/action-setup@v4", - "+ - run: pnpm install --frozen-lockfile", - "+ - run: pnpm build", - "+", - "+ test:", - "+ runs-on: ubuntu-latest", - "+ needs: build", - "+ strategy:", - "+ matrix:", - "+ shard: [1, 2, 3]", - "+ steps:", - "+ - uses: actions/checkout@v4", - "+ - uses: actions/setup-node@v4", - "+ with:", - "+ node-version: 20", - "+ cache: 'pnpm'", - "+ - uses: pnpm/action-setup@v4", - "+ - run: pnpm install --frozen-lockfile", - "+ - run: pnpm test -- --shard=${{ matrix.shard }}/3", - " ", - "- deploy:", - "- needs: build", - "- if: github.ref == 'refs/heads/main'", - "+ deploy:", - "+ needs: [build, test]", - "+ if: github.ref == 'refs/heads/main'", - ].join("\n"), - }, - fileTree: [ - { - name: ".github", - path: ".github", - isDir: true, - children: [ - { - name: "workflows", - path: ".github/workflows", - isDir: true, - children: [{ name: "ci.yml", path: ".github/workflows/ci.yml", isDir: false }], - }, - ], - }, - ], - }, - ]; -} - -export function buildInitialMockLayoutViewModel(): HandoffWorkbenchSnapshot { - const repos: WorkbenchRepo[] = [ - { id: "acme-backend", label: "acme/backend" }, - { id: "acme-frontend", label: "acme/frontend" }, - { id: "acme-infra", label: "acme/infra" }, - ]; - const handoffs = buildInitialHandoffs(); - return { - workspaceId: "default", - repos, - projects: groupWorkbenchProjects(repos, handoffs), - handoffs, - }; -} - -export function groupWorkbenchProjects(repos: WorkbenchRepo[], handoffs: Handoff[]): WorkbenchProjectSection[] { - const grouped = new Map(); - - for (const repo of repos) { - grouped.set(repo.id, { - id: repo.id, - label: repo.label, - updatedAtMs: 0, - handoffs: [], - }); - } - - for (const handoff of handoffs) { - const existing = grouped.get(handoff.repoId) ?? { - id: handoff.repoId, - label: handoff.repoName, - updatedAtMs: 0, - handoffs: [], - }; - - existing.handoffs.push(handoff); - existing.updatedAtMs = Math.max(existing.updatedAtMs, handoff.updatedAtMs); - grouped.set(handoff.repoId, existing); - } - - return [...grouped.values()] - .map((project) => ({ - ...project, - handoffs: [...project.handoffs].sort((a, b) => b.updatedAtMs - a.updatedAtMs), - updatedAtMs: - project.handoffs.length > 0 ? Math.max(...project.handoffs.map((handoff) => handoff.updatedAtMs)) : project.updatedAtMs, - })) - .filter((project) => project.handoffs.length > 0) - .sort((a, b) => b.updatedAtMs - a.updatedAtMs); -} diff --git a/factory/packages/client/test/e2e/full-integration-e2e.test.ts b/factory/packages/client/test/e2e/full-integration-e2e.test.ts deleted file mode 100644 index 74f29d43..00000000 --- a/factory/packages/client/test/e2e/full-integration-e2e.test.ts +++ /dev/null @@ -1,197 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { describe, expect, it } from "vitest"; -import type { HistoryEvent, RepoOverview } from "@openhandoff/shared"; -import { createBackendClient } from "../../src/backend-client.js"; - -const RUN_FULL_E2E = process.env.HF_ENABLE_DAEMON_FULL_E2E === "1"; - -function requiredEnv(name: string): string { - const value = process.env[name]?.trim(); - if (!value) { - throw new Error(`Missing required env var: ${name}`); - } - return value; -} - -function parseGithubRepo(input: string): { fullName: string } { - const trimmed = input.trim(); - const shorthand = trimmed.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/); - if (shorthand) { - return { fullName: `${shorthand[1]}/${shorthand[2]}` }; - } - - const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`); - const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean); - if (url.hostname.toLowerCase().includes("github.com") && parts.length >= 2) { - return { fullName: `${parts[0]}/${(parts[1] ?? "").replace(/\.git$/, "")}` }; - } - - throw new Error(`Unable to parse GitHub repo from: ${input}`); -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function poll( - label: string, - timeoutMs: number, - intervalMs: number, - fn: () => Promise, - isDone: (value: T) => boolean -): Promise { - const start = Date.now(); - let last: T; - for (;;) { - last = await fn(); - if (isDone(last)) { - return last; - } - if (Date.now() - start > timeoutMs) { - throw new Error(`timed out waiting for ${label}`); - } - await sleep(intervalMs); - } -} - -function parseHistoryPayload(event: HistoryEvent): Record { - try { - return JSON.parse(event.payloadJson) as Record; - } catch { - return {}; - } -} - -async function githubApi(token: string, path: string, init?: RequestInit): Promise { - const url = `https://api.github.com/${path.replace(/^\/+/, "")}`; - return await fetch(url, { - ...init, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - ...(init?.headers ?? {}), - }, - }); -} - -async function ensureRemoteBranchExists( - token: string, - fullName: string, - branchName: string -): Promise { - const repoRes = await githubApi(token, `repos/${fullName}`, { method: "GET" }); - if (!repoRes.ok) { - throw new Error(`GitHub repo lookup failed: ${repoRes.status} ${await repoRes.text()}`); - } - const repo = (await repoRes.json()) as { default_branch?: string }; - const defaultBranch = repo.default_branch; - if (!defaultBranch) { - throw new Error(`GitHub repo default branch is missing for ${fullName}`); - } - - const defaultRefRes = await githubApi( - token, - `repos/${fullName}/git/ref/heads/${encodeURIComponent(defaultBranch)}`, - { method: "GET" } - ); - if (!defaultRefRes.ok) { - throw new Error(`GitHub default ref lookup failed: ${defaultRefRes.status} ${await defaultRefRes.text()}`); - } - const defaultRef = (await defaultRefRes.json()) as { object?: { sha?: string } }; - const sha = defaultRef.object?.sha; - if (!sha) { - throw new Error(`GitHub default ref sha missing for ${fullName}:${defaultBranch}`); - } - - const createRefRes = await githubApi(token, `repos/${fullName}/git/refs`, { - method: "POST", - body: JSON.stringify({ - ref: `refs/heads/${branchName}`, - sha, - }), - headers: { "Content-Type": "application/json" }, - }); - if (createRefRes.ok || createRefRes.status === 422) { - return; - } - - throw new Error(`GitHub create ref failed: ${createRefRes.status} ${await createRefRes.text()}`); -} - -describe("e2e(client): full integration stack workflow", () => { - it.skipIf(!RUN_FULL_E2E)( - "adds repo, loads branch graph, and executes a stack restack action", - { timeout: 8 * 60_000 }, - async () => { - const endpoint = - process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; - const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; - const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); - const githubToken = requiredEnv("GITHUB_TOKEN"); - const { fullName } = parseGithubRepo(repoRemote); - const normalizedRepoRemote = `https://github.com/${fullName}.git`; - const seededBranch = `e2e/full-seed-${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`; - - const client = createBackendClient({ - endpoint, - defaultWorkspaceId: workspaceId, - }); - - try { - await ensureRemoteBranchExists(githubToken, fullName, seededBranch); - - const repo = await client.addRepo(workspaceId, repoRemote); - expect(repo.remoteUrl).toBe(normalizedRepoRemote); - - const overview = await poll( - "repo overview includes seeded branch", - 90_000, - 1_000, - async () => client.getRepoOverview(workspaceId, repo.repoId), - (value) => value.branches.some((row) => row.branchName === seededBranch) - ); - - if (!overview.stackAvailable) { - throw new Error( - "git-spice is unavailable for this repo during full integration e2e; set HF_GIT_SPICE_BIN or install git-spice in the backend container" - ); - } - - const stackResult = await client.runRepoStackAction({ - workspaceId, - repoId: repo.repoId, - action: "restack_repo", - }); - expect(stackResult.executed).toBe(true); - expect(stackResult.action).toBe("restack_repo"); - - await poll( - "repo stack action history event", - 60_000, - 1_000, - async () => client.listHistory({ workspaceId, limit: 200 }), - (events) => - events.some((event) => { - if (event.kind !== "repo.stack_action") { - return false; - } - const payload = parseHistoryPayload(event); - return payload.action === "restack_repo"; - }) - ); - - const postActionOverview = await client.getRepoOverview(workspaceId, repo.repoId); - const seededRow = postActionOverview.branches.find((row) => row.branchName === seededBranch); - expect(Boolean(seededRow)).toBe(true); - expect(postActionOverview.fetchedAt).toBeGreaterThan(overview.fetchedAt); - } finally { - await githubApi( - githubToken, - `repos/${fullName}/git/refs/heads/${encodeURIComponent(seededBranch)}`, - { method: "DELETE" } - ).catch(() => {}); - } - } - ); -}); diff --git a/factory/packages/client/test/e2e/github-pr-e2e.test.ts b/factory/packages/client/test/e2e/github-pr-e2e.test.ts deleted file mode 100644 index bd489fa2..00000000 --- a/factory/packages/client/test/e2e/github-pr-e2e.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { HandoffRecord, HistoryEvent } from "@openhandoff/shared"; -import { createBackendClient } from "../../src/backend-client.js"; - -const RUN_E2E = process.env.HF_ENABLE_DAEMON_E2E === "1"; - -function requiredEnv(name: string): string { - const value = process.env[name]?.trim(); - if (!value) { - throw new Error(`Missing required env var: ${name}`); - } - return value; -} - -function parseGithubRepo(input: string): { owner: string; repo: string; fullName: string } { - const trimmed = input.trim(); - if (!trimmed) { - throw new Error("HF_E2E_GITHUB_REPO is empty"); - } - - // owner/repo shorthand - const shorthand = trimmed.match(/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/); - if (shorthand) { - const owner = shorthand[1]!; - const repo = shorthand[2]!; - return { owner, repo, fullName: `${owner}/${repo}` }; - } - - // https://github.com/owner/repo(.git)?(/...)? - try { - const url = new URL(trimmed.startsWith("http") ? trimmed : `https://${trimmed}`); - const parts = url.pathname.replace(/^\/+/, "").split("/").filter(Boolean); - if (url.hostname.toLowerCase().includes("github.com") && parts.length >= 2) { - const owner = parts[0]!; - const repo = (parts[1] ?? "").replace(/\.git$/, ""); - if (owner && repo) { - return { owner, repo, fullName: `${owner}/${repo}` }; - } - } - } catch { - // fall through - } - - throw new Error(`Unable to parse GitHub repo from: ${input}`); -} - -async function sleep(ms: number): Promise { - await new Promise((r) => setTimeout(r, ms)); -} - -async function poll( - label: string, - timeoutMs: number, - intervalMs: number, - fn: () => Promise, - isDone: (value: T) => boolean, - onTick?: (value: T) => void -): Promise { - const start = Date.now(); - let last: T; - for (;;) { - last = await fn(); - onTick?.(last); - if (isDone(last)) { - return last; - } - if (Date.now() - start > timeoutMs) { - throw new Error(`timed out waiting for ${label}`); - } - await sleep(intervalMs); - } -} - -function parseHistoryPayload(event: HistoryEvent): Record { - try { - return JSON.parse(event.payloadJson) as Record; - } catch { - return {}; - } -} - -async function debugDump(client: ReturnType, workspaceId: string, handoffId: string): Promise { - try { - const handoff = await client.getHandoff(workspaceId, handoffId); - const history = await client.listHistory({ workspaceId, handoffId, limit: 80 }).catch(() => []); - const historySummary = history - .slice(0, 20) - .map((e) => `${new Date(e.createdAt).toISOString()} ${e.kind}`) - .join("\n"); - - let sessionEventsSummary = ""; - if (handoff.activeSandboxId && handoff.activeSessionId) { - const events = await client - .listSandboxSessionEvents(workspaceId, handoff.providerId, handoff.activeSandboxId, { - sessionId: handoff.activeSessionId, - limit: 50, - }) - .then((r) => r.items) - .catch(() => []); - sessionEventsSummary = events - .slice(-12) - .map((e) => `${new Date(e.createdAt).toISOString()} ${e.sender}`) - .join("\n"); - } - - return [ - "=== handoff ===", - JSON.stringify( - { - status: handoff.status, - statusMessage: handoff.statusMessage, - title: handoff.title, - branchName: handoff.branchName, - activeSandboxId: handoff.activeSandboxId, - activeSessionId: handoff.activeSessionId, - prUrl: handoff.prUrl, - prSubmitted: handoff.prSubmitted, - }, - null, - 2 - ), - "=== history (most recent first) ===", - historySummary || "(none)", - "=== session events (tail) ===", - sessionEventsSummary || "(none)", - ].join("\n"); - } catch (err) { - return `debug dump failed: ${err instanceof Error ? err.message : String(err)}`; - } -} - -async function githubApi(token: string, path: string, init?: RequestInit): Promise { - const url = `https://api.github.com/${path.replace(/^\/+/, "")}`; - return await fetch(url, { - ...init, - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${token}`, - "X-GitHub-Api-Version": "2022-11-28", - ...(init?.headers ?? {}), - }, - }); -} - -describe("e2e: backend -> sandbox-agent -> git -> PR", () => { - it.skipIf(!RUN_E2E)( - "creates a handoff, waits for agent to implement, and opens a PR", - { timeout: 15 * 60_000 }, - async () => { - const endpoint = - process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; - const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; - const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); - const githubToken = requiredEnv("GITHUB_TOKEN"); - - const { fullName } = parseGithubRepo(repoRemote); - const runId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`; - const expectedFile = `e2e/${runId}.txt`; - - const client = createBackendClient({ - endpoint, - defaultWorkspaceId: workspaceId, - }); - - const repo = await client.addRepo(workspaceId, repoRemote); - - const created = await client.createHandoff({ - workspaceId, - repoId: repo.repoId, - task: [ - "E2E test task:", - `1. Create a new file at ${expectedFile} containing the single line: ${runId}`, - "2. git add the file", - `3. git commit -m \"test(e2e): ${runId}\"`, - "4. git push the branch to origin", - "5. Stop when done (agent should go idle).", - ].join("\n"), - providerId: "daytona", - explicitTitle: `test(e2e): ${runId}`, - explicitBranchName: `e2e/${runId}`, - }); - - let prNumber: number | null = null; - let branchName: string | null = null; - let sandboxId: string | null = null; - let sessionId: string | null = null; - let lastStatus: string | null = null; - - try { - const namedAndProvisioned = await poll( - "handoff naming + sandbox provisioning", - // Cold Daytona snapshot/image preparation can exceed 5 minutes on first run. - 8 * 60_000, - 1_000, - async () => client.getHandoff(workspaceId, created.handoffId), - (h) => Boolean(h.title && h.branchName && h.activeSandboxId), - (h) => { - if (h.status !== lastStatus) { - lastStatus = h.status; - } - if (h.status === "error") { - throw new Error("handoff entered error state during provisioning"); - } - } - ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); - throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); - }); - - branchName = namedAndProvisioned.branchName!; - sandboxId = namedAndProvisioned.activeSandboxId!; - - const withSession = await poll( - "handoff to create active session", - 3 * 60_000, - 1_500, - async () => client.getHandoff(workspaceId, created.handoffId), - (h) => Boolean(h.activeSessionId), - (h) => { - if (h.status === "error") { - throw new Error("handoff entered error state while waiting for active session"); - } - } - ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); - throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); - }); - - sessionId = withSession.activeSessionId!; - - await poll<{ id: string }[]>( - "session transcript bootstrap events", - 2 * 60_000, - 2_000, - async () => - ( - await client.listSandboxSessionEvents(workspaceId, withSession.providerId, sandboxId!, { - sessionId: sessionId!, - limit: 40, - }) - ).items, - (events) => events.length > 0 - ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); - throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); - }); - - await poll( - "handoff to reach idle state", - 8 * 60_000, - 2_000, - async () => client.getHandoff(workspaceId, created.handoffId), - (h) => h.status === "idle", - (h) => { - if (h.status === "error") { - throw new Error("handoff entered error state while waiting for idle"); - } - } - ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); - throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); - }); - - const prCreatedEvent = await poll( - "PR creation history event", - 3 * 60_000, - 2_000, - async () => client.listHistory({ workspaceId, handoffId: created.handoffId, limit: 200 }), - (events) => events.some((e) => e.kind === "handoff.pr_created") - ) - .catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); - throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); - }) - .then((events) => events.find((e) => e.kind === "handoff.pr_created")!); - - const payload = parseHistoryPayload(prCreatedEvent); - prNumber = Number(payload.prNumber); - const prUrl = String(payload.prUrl ?? ""); - - expect(prNumber).toBeGreaterThan(0); - expect(prUrl).toContain("/pull/"); - - const prFilesRes = await githubApi( - githubToken, - `repos/${fullName}/pulls/${prNumber}/files?per_page=100`, - { method: "GET" } - ); - if (!prFilesRes.ok) { - const body = await prFilesRes.text(); - throw new Error(`GitHub PR files request failed: ${prFilesRes.status} ${body}`); - } - const prFiles = (await prFilesRes.json()) as Array<{ filename: string }>; - expect(prFiles.some((f) => f.filename === expectedFile)).toBe(true); - - // Close the handoff and assert the sandbox is released (stopped). - await client.runAction(workspaceId, created.handoffId, "archive"); - - await poll( - "handoff to become archived (session released)", - 60_000, - 1_000, - async () => client.getHandoff(workspaceId, created.handoffId), - (h) => h.status === "archived" && h.activeSessionId === null - ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); - throw new Error(`${err instanceof Error ? err.message : String(err)}\n${dump}`); - }); - - if (sandboxId) { - await poll<{ providerId: string; sandboxId: string; state: string; at: number }>( - "daytona sandbox to stop", - 2 * 60_000, - 2_000, - async () => client.sandboxProviderState(workspaceId, "daytona", sandboxId!), - (s) => { - const st = String(s.state).toLowerCase(); - return st.includes("stopped") || st.includes("suspended") || st.includes("paused"); - } - ).catch(async (err) => { - const dump = await debugDump(client, workspaceId, created.handoffId); - const state = await client - .sandboxProviderState(workspaceId, "daytona", sandboxId!) - .catch(() => null); - throw new Error( - `${err instanceof Error ? err.message : String(err)}\n` + - `sandbox state: ${state ? state.state : "unknown"}\n` + - `${dump}` - ); - }); - } - } finally { - if (prNumber && Number.isFinite(prNumber)) { - await githubApi(githubToken, `repos/${fullName}/pulls/${prNumber}`, { - method: "PATCH", - body: JSON.stringify({ state: "closed" }), - headers: { "Content-Type": "application/json" }, - }).catch(() => {}); - } - - if (branchName) { - await githubApi( - githubToken, - `repos/${fullName}/git/refs/heads/${encodeURIComponent(branchName)}`, - { method: "DELETE" } - ).catch(() => {}); - } - } - } - ); -}); diff --git a/factory/packages/client/test/e2e/workbench-e2e.test.ts b/factory/packages/client/test/e2e/workbench-e2e.test.ts deleted file mode 100644 index 00e8167a..00000000 --- a/factory/packages/client/test/e2e/workbench-e2e.test.ts +++ /dev/null @@ -1,334 +0,0 @@ -import { execFile } from "node:child_process"; -import { promisify } from "node:util"; -import { describe, expect, it } from "vitest"; -import type { - HandoffWorkbenchSnapshot, - WorkbenchAgentTab, - WorkbenchHandoff, - WorkbenchModelId, - WorkbenchTranscriptEvent, -} from "@openhandoff/shared"; -import { createBackendClient } from "../../src/backend-client.js"; - -const RUN_WORKBENCH_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_E2E === "1"; -const execFileAsync = promisify(execFile); - -function requiredEnv(name: string): string { - const value = process.env[name]?.trim(); - if (!value) { - throw new Error(`Missing required env var: ${name}`); - } - return value; -} - -function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId { - const value = process.env[name]?.trim(); - switch (value) { - case "claude-sonnet-4": - case "claude-opus-4": - case "gpt-4o": - case "o3": - return value; - default: - return fallback; - } -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function seedSandboxFile(workspaceId: string, handoffId: string, filePath: string, content: string): Promise { - const repoPath = `/root/.local/share/openhandoff/local-sandboxes/${workspaceId}/${handoffId}/repo`; - const script = [ - `cd ${JSON.stringify(repoPath)}`, - `mkdir -p ${JSON.stringify(filePath.includes("/") ? filePath.slice(0, filePath.lastIndexOf("/")) : ".")}`, - `printf '%s\\n' ${JSON.stringify(content)} > ${JSON.stringify(filePath)}`, - ].join(" && "); - await execFileAsync("docker", ["exec", "openhandoff-backend-1", "bash", "-lc", script]); -} - -async function poll( - label: string, - timeoutMs: number, - intervalMs: number, - fn: () => Promise, - isDone: (value: T) => boolean, -): Promise { - const startedAt = Date.now(); - let lastValue: T; - - for (;;) { - lastValue = await fn(); - if (isDone(lastValue)) { - return lastValue; - } - if (Date.now() - startedAt > timeoutMs) { - throw new Error(`timed out waiting for ${label}`); - } - await sleep(intervalMs); - } -} - -function findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff { - const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`handoff ${handoffId} missing from snapshot`); - } - return handoff; -} - -function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); - if (!tab) { - throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`); - } - return tab; -} - -function extractEventText(event: WorkbenchTranscriptEvent): string { - const payload = event.payload; - if (!payload || typeof payload !== "object") { - return String(payload ?? ""); - } - - const envelope = payload as { - method?: unknown; - params?: unknown; - result?: unknown; - error?: unknown; - }; - - const params = envelope.params; - if (params && typeof params === "object") { - const update = (params as { update?: unknown }).update; - if (update && typeof update === "object") { - const content = (update as { content?: unknown }).content; - if (content && typeof content === "object") { - const chunkText = (content as { text?: unknown }).text; - if (typeof chunkText === "string") { - return chunkText; - } - } - } - - const text = (params as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - return text.trim(); - } - const prompt = (params as { prompt?: Array<{ text?: unknown }> }).prompt; - if (Array.isArray(prompt)) { - const value = prompt - .map((item) => (typeof item?.text === "string" ? item.text.trim() : "")) - .filter(Boolean) - .join("\n"); - if (value) { - return value; - } - } - } - - const result = envelope.result; - if (result && typeof result === "object") { - const text = (result as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - return text.trim(); - } - } - - if (envelope.error) { - return JSON.stringify(envelope.error); - } - - if (typeof envelope.method === "string") { - return envelope.method; - } - - return JSON.stringify(payload); -} - -function transcriptIncludesAgentText( - transcript: WorkbenchTranscriptEvent[], - expectedText: string, -): boolean { - return transcript - .filter((event) => event.sender === "agent") - .map((event) => extractEventText(event)) - .join("") - .includes(expectedText); -} - -describe("e2e(client): workbench flows", () => { - it.skipIf(!RUN_WORKBENCH_E2E)( - "creates a handoff, adds sessions, exchanges messages, and manages workbench state", - { timeout: 20 * 60_000 }, - async () => { - const endpoint = - process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; - const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; - const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); - const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o"); - const runId = `wb-${Date.now().toString(36)}`; - const expectedFile = `${runId}.txt`; - const expectedInitialReply = `WORKBENCH_READY_${runId}`; - const expectedReply = `WORKBENCH_ACK_${runId}`; - - const client = createBackendClient({ - endpoint, - defaultWorkspaceId: workspaceId, - }); - - const repo = await client.addRepo(workspaceId, repoRemote); - const created = await client.createWorkbenchHandoff(workspaceId, { - repoId: repo.repoId, - title: `Workbench E2E ${runId}`, - branch: `e2e/${runId}`, - model, - task: `Reply with exactly: ${expectedInitialReply}`, - }); - - const provisioned = await poll( - "handoff provisioning", - 12 * 60_000, - 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => handoff.branch === `e2e/${runId}` && handoff.tabs.length > 0, - ); - - const primaryTab = provisioned.tabs[0]!; - - const initialCompleted = await poll( - "initial agent response", - 12 * 60_000, - 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, primaryTab.id); - return ( - handoff.status === "idle" && - tab.status === "idle" && - transcriptIncludesAgentText(tab.transcript, expectedInitialReply) - ); - }, - ); - - expect(findTab(initialCompleted, primaryTab.id).sessionId).toBeTruthy(); - expect(transcriptIncludesAgentText(findTab(initialCompleted, primaryTab.id).transcript, expectedInitialReply)).toBe(true); - - await seedSandboxFile(workspaceId, created.handoffId, expectedFile, runId); - - const fileSeeded = await poll( - "seeded sandbox file reflected in workbench", - 30_000, - 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => handoff.fileChanges.some((file) => file.path === expectedFile), - ); - expect(fileSeeded.fileChanges.some((file) => file.path === expectedFile)).toBe(true); - - await client.renameWorkbenchHandoff(workspaceId, { - handoffId: created.handoffId, - value: `Workbench E2E ${runId} Renamed`, - }); - await client.renameWorkbenchSession(workspaceId, { - handoffId: created.handoffId, - tabId: primaryTab.id, - title: "Primary Session", - }); - - const secondTab = await client.createWorkbenchSession(workspaceId, { - handoffId: created.handoffId, - model, - }); - - await client.renameWorkbenchSession(workspaceId, { - handoffId: created.handoffId, - tabId: secondTab.tabId, - title: "Follow-up Session", - }); - - await client.updateWorkbenchDraft(workspaceId, { - handoffId: created.handoffId, - tabId: secondTab.tabId, - text: `Reply with exactly: ${expectedReply}`, - attachments: [ - { - id: `${expectedFile}:1`, - filePath: expectedFile, - lineNumber: 1, - lineContent: runId, - }, - ], - }); - - const drafted = findHandoff(await client.getWorkbench(workspaceId), created.handoffId); - expect(findTab(drafted, secondTab.tabId).draft.text).toContain(expectedReply); - expect(findTab(drafted, secondTab.tabId).draft.attachments).toHaveLength(1); - - await client.sendWorkbenchMessage(workspaceId, { - handoffId: created.handoffId, - tabId: secondTab.tabId, - text: `Reply with exactly: ${expectedReply}`, - attachments: [], - }); - - const withSecondReply = await poll( - "follow-up session response", - 10 * 60_000, - 2_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, secondTab.tabId); - return ( - tab.status === "idle" && - transcriptIncludesAgentText(tab.transcript, expectedReply) - ); - }, - ); - - const secondTranscript = findTab(withSecondReply, secondTab.tabId).transcript; - expect(transcriptIncludesAgentText(secondTranscript, expectedReply)).toBe(true); - - await client.setWorkbenchSessionUnread(workspaceId, { - handoffId: created.handoffId, - tabId: secondTab.tabId, - unread: false, - }); - await client.markWorkbenchUnread(workspaceId, { handoffId: created.handoffId }); - - const unreadSnapshot = findHandoff(await client.getWorkbench(workspaceId), created.handoffId); - expect(unreadSnapshot.tabs.some((tab) => tab.unread)).toBe(true); - - await client.closeWorkbenchSession(workspaceId, { - handoffId: created.handoffId, - tabId: secondTab.tabId, - }); - - const closedSnapshot = await poll( - "secondary session closed", - 30_000, - 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => !handoff.tabs.some((tab) => tab.id === secondTab.tabId), - ); - expect(closedSnapshot.tabs).toHaveLength(1); - - await client.revertWorkbenchFile(workspaceId, { - handoffId: created.handoffId, - path: expectedFile, - }); - - const revertedSnapshot = await poll( - "file revert reflected in workbench", - 30_000, - 1_000, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => !handoff.fileChanges.some((file) => file.path === expectedFile), - ); - - expect(revertedSnapshot.fileChanges.some((file) => file.path === expectedFile)).toBe(false); - expect(revertedSnapshot.title).toBe(`Workbench E2E ${runId} Renamed`); - expect(findTab(revertedSnapshot, primaryTab.id).sessionName).toBe("Primary Session"); - }, - ); -}); diff --git a/factory/packages/client/test/e2e/workbench-load-e2e.test.ts b/factory/packages/client/test/e2e/workbench-load-e2e.test.ts deleted file mode 100644 index 230ae494..00000000 --- a/factory/packages/client/test/e2e/workbench-load-e2e.test.ts +++ /dev/null @@ -1,331 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { - HandoffWorkbenchSnapshot, - WorkbenchAgentTab, - WorkbenchHandoff, - WorkbenchModelId, - WorkbenchTranscriptEvent, -} from "@openhandoff/shared"; -import { createBackendClient } from "../../src/backend-client.js"; - -const RUN_WORKBENCH_LOAD_E2E = process.env.HF_ENABLE_DAEMON_WORKBENCH_LOAD_E2E === "1"; - -function requiredEnv(name: string): string { - const value = process.env[name]?.trim(); - if (!value) { - throw new Error(`Missing required env var: ${name}`); - } - return value; -} - -function workbenchModelEnv(name: string, fallback: WorkbenchModelId): WorkbenchModelId { - const value = process.env[name]?.trim(); - switch (value) { - case "claude-sonnet-4": - case "claude-opus-4": - case "gpt-4o": - case "o3": - return value; - default: - return fallback; - } -} - -function intEnv(name: string, fallback: number): number { - const raw = process.env[name]?.trim(); - if (!raw) { - return fallback; - } - const value = Number.parseInt(raw, 10); - return Number.isFinite(value) && value > 0 ? value : fallback; -} - -async function sleep(ms: number): Promise { - await new Promise((resolve) => setTimeout(resolve, ms)); -} - -async function poll( - label: string, - timeoutMs: number, - intervalMs: number, - fn: () => Promise, - isDone: (value: T) => boolean, -): Promise { - const startedAt = Date.now(); - let lastValue: T; - - for (;;) { - lastValue = await fn(); - if (isDone(lastValue)) { - return lastValue; - } - if (Date.now() - startedAt > timeoutMs) { - throw new Error(`timed out waiting for ${label}`); - } - await sleep(intervalMs); - } -} - -function findHandoff(snapshot: HandoffWorkbenchSnapshot, handoffId: string): WorkbenchHandoff { - const handoff = snapshot.handoffs.find((candidate) => candidate.id === handoffId); - if (!handoff) { - throw new Error(`handoff ${handoffId} missing from snapshot`); - } - return handoff; -} - -function findTab(handoff: WorkbenchHandoff, tabId: string): WorkbenchAgentTab { - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); - if (!tab) { - throw new Error(`tab ${tabId} missing from handoff ${handoff.id}`); - } - return tab; -} - -function extractEventText(event: WorkbenchTranscriptEvent): string { - const payload = event.payload; - if (!payload || typeof payload !== "object") { - return String(payload ?? ""); - } - - const envelope = payload as { - method?: unknown; - params?: unknown; - result?: unknown; - }; - - const params = envelope.params; - if (params && typeof params === "object") { - const update = (params as { update?: unknown }).update; - if (update && typeof update === "object") { - const content = (update as { content?: unknown }).content; - if (content && typeof content === "object") { - const chunkText = (content as { text?: unknown }).text; - if (typeof chunkText === "string") { - return chunkText; - } - } - } - - const text = (params as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - return text.trim(); - } - - const prompt = (params as { prompt?: Array<{ text?: unknown }> }).prompt; - if (Array.isArray(prompt)) { - return prompt - .map((item) => (typeof item?.text === "string" ? item.text.trim() : "")) - .filter(Boolean) - .join("\n"); - } - } - - const result = envelope.result; - if (result && typeof result === "object") { - const text = (result as { text?: unknown }).text; - if (typeof text === "string" && text.trim()) { - return text.trim(); - } - } - - return typeof envelope.method === "string" ? envelope.method : JSON.stringify(payload); -} - -function transcriptIncludesAgentText(transcript: WorkbenchTranscriptEvent[], expectedText: string): boolean { - return transcript - .filter((event) => event.sender === "agent") - .map((event) => extractEventText(event)) - .join("") - .includes(expectedText); -} - -function average(values: number[]): number { - return values.reduce((sum, value) => sum + value, 0) / Math.max(values.length, 1); -} - -async function measureWorkbenchSnapshot( - client: ReturnType, - workspaceId: string, - iterations: number, -): Promise<{ - avgMs: number; - maxMs: number; - payloadBytes: number; - handoffCount: number; - tabCount: number; - transcriptEventCount: number; -}> { - const durations: number[] = []; - let snapshot: HandoffWorkbenchSnapshot | null = null; - - for (let index = 0; index < iterations; index += 1) { - const startedAt = performance.now(); - snapshot = await client.getWorkbench(workspaceId); - durations.push(performance.now() - startedAt); - } - - const finalSnapshot = snapshot ?? { - workspaceId, - repos: [], - projects: [], - handoffs: [], - }; - const payloadBytes = Buffer.byteLength(JSON.stringify(finalSnapshot), "utf8"); - const tabCount = finalSnapshot.handoffs.reduce((sum, handoff) => sum + handoff.tabs.length, 0); - const transcriptEventCount = finalSnapshot.handoffs.reduce( - (sum, handoff) => - sum + handoff.tabs.reduce((tabSum, tab) => tabSum + tab.transcript.length, 0), - 0, - ); - - return { - avgMs: Math.round(average(durations)), - maxMs: Math.round(Math.max(...durations, 0)), - payloadBytes, - handoffCount: finalSnapshot.handoffs.length, - tabCount, - transcriptEventCount, - }; -} - -describe("e2e(client): workbench load", () => { - it.skipIf(!RUN_WORKBENCH_LOAD_E2E)( - "runs a simple sequential load profile against the real backend", - { timeout: 30 * 60_000 }, - async () => { - const endpoint = process.env.HF_E2E_BACKEND_ENDPOINT?.trim() || "http://127.0.0.1:7741/api/rivet"; - const workspaceId = process.env.HF_E2E_WORKSPACE?.trim() || "default"; - const repoRemote = requiredEnv("HF_E2E_GITHUB_REPO"); - const model = workbenchModelEnv("HF_E2E_MODEL", "gpt-4o"); - const handoffCount = intEnv("HF_LOAD_HANDOFF_COUNT", 3); - const extraSessionCount = intEnv("HF_LOAD_EXTRA_SESSION_COUNT", 2); - const pollIntervalMs = intEnv("HF_LOAD_POLL_INTERVAL_MS", 2_000); - - const client = createBackendClient({ - endpoint, - defaultWorkspaceId: workspaceId, - }); - - const repo = await client.addRepo(workspaceId, repoRemote); - const createHandoffLatencies: number[] = []; - const provisionLatencies: number[] = []; - const createSessionLatencies: number[] = []; - const messageRoundTripLatencies: number[] = []; - const snapshotSeries: Array<{ - handoffCount: number; - avgMs: number; - maxMs: number; - payloadBytes: number; - tabCount: number; - transcriptEventCount: number; - }> = []; - - snapshotSeries.push(await measureWorkbenchSnapshot(client, workspaceId, 2)); - - for (let handoffIndex = 0; handoffIndex < handoffCount; handoffIndex += 1) { - const runId = `load-${handoffIndex}-${Date.now().toString(36)}`; - const initialReply = `LOAD_INIT_${runId}`; - - const createStartedAt = performance.now(); - const created = await client.createWorkbenchHandoff(workspaceId, { - repoId: repo.repoId, - title: `Workbench Load ${runId}`, - branch: `load/${runId}`, - model, - task: `Reply with exactly: ${initialReply}`, - }); - createHandoffLatencies.push(performance.now() - createStartedAt); - - const provisionStartedAt = performance.now(); - const provisioned = await poll( - `handoff ${runId} provisioning`, - 12 * 60_000, - pollIntervalMs, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = handoff.tabs[0]; - return Boolean( - tab && - handoff.status === "idle" && - tab.status === "idle" && - transcriptIncludesAgentText(tab.transcript, initialReply), - ); - }, - ); - provisionLatencies.push(performance.now() - provisionStartedAt); - - expect(provisioned.tabs.length).toBeGreaterThan(0); - const primaryTab = provisioned.tabs[0]!; - expect(transcriptIncludesAgentText(primaryTab.transcript, initialReply)).toBe(true); - - for (let sessionIndex = 0; sessionIndex < extraSessionCount; sessionIndex += 1) { - const expectedReply = `LOAD_REPLY_${runId}_${sessionIndex}`; - const createSessionStartedAt = performance.now(); - const createdSession = await client.createWorkbenchSession(workspaceId, { - handoffId: created.handoffId, - model, - }); - createSessionLatencies.push(performance.now() - createSessionStartedAt); - - await client.sendWorkbenchMessage(workspaceId, { - handoffId: created.handoffId, - tabId: createdSession.tabId, - text: `Run pwd in the repo, then reply with exactly: ${expectedReply}`, - attachments: [], - }); - - const messageStartedAt = performance.now(); - const withReply = await poll( - `handoff ${runId} session ${sessionIndex} reply`, - 10 * 60_000, - pollIntervalMs, - async () => findHandoff(await client.getWorkbench(workspaceId), created.handoffId), - (handoff) => { - const tab = findTab(handoff, createdSession.tabId); - return tab.status === "idle" && transcriptIncludesAgentText(tab.transcript, expectedReply); - }, - ); - messageRoundTripLatencies.push(performance.now() - messageStartedAt); - - expect(transcriptIncludesAgentText(findTab(withReply, createdSession.tabId).transcript, expectedReply)).toBe(true); - } - - const snapshotMetrics = await measureWorkbenchSnapshot(client, workspaceId, 3); - snapshotSeries.push(snapshotMetrics); - console.info( - "[workbench-load-snapshot]", - JSON.stringify({ - handoffIndex: handoffIndex + 1, - ...snapshotMetrics, - }), - ); - } - - const firstSnapshot = snapshotSeries[0]!; - const lastSnapshot = snapshotSeries[snapshotSeries.length - 1]!; - const summary = { - handoffCount, - extraSessionCount, - createHandoffAvgMs: Math.round(average(createHandoffLatencies)), - provisionAvgMs: Math.round(average(provisionLatencies)), - createSessionAvgMs: Math.round(average(createSessionLatencies)), - messageRoundTripAvgMs: Math.round(average(messageRoundTripLatencies)), - snapshotReadBaselineAvgMs: firstSnapshot.avgMs, - snapshotReadFinalAvgMs: lastSnapshot.avgMs, - snapshotReadFinalMaxMs: lastSnapshot.maxMs, - snapshotPayloadBaselineBytes: firstSnapshot.payloadBytes, - snapshotPayloadFinalBytes: lastSnapshot.payloadBytes, - snapshotTabFinalCount: lastSnapshot.tabCount, - snapshotTranscriptFinalCount: lastSnapshot.transcriptEventCount, - }; - - console.info("[workbench-load-summary]", JSON.stringify(summary)); - - expect(createHandoffLatencies.length).toBe(handoffCount); - expect(provisionLatencies.length).toBe(handoffCount); - expect(createSessionLatencies.length).toBe(handoffCount * extraSessionCount); - expect(messageRoundTripLatencies.length).toBe(handoffCount * extraSessionCount); - }, - ); -}); diff --git a/factory/packages/client/test/keys.test.ts b/factory/packages/client/test/keys.test.ts deleted file mode 100644 index 61320f8d..00000000 --- a/factory/packages/client/test/keys.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - handoffKey, - handoffStatusSyncKey, - historyKey, - projectBranchSyncKey, - projectKey, - projectPrSyncKey, - sandboxInstanceKey, - workspaceKey -} from "../src/keys.js"; - -describe("actor keys", () => { - it("prefixes every key with workspace namespace", () => { - const keys = [ - workspaceKey("default"), - projectKey("default", "repo"), - handoffKey("default", "repo", "handoff"), - sandboxInstanceKey("default", "daytona", "sbx"), - historyKey("default", "repo"), - projectPrSyncKey("default", "repo"), - projectBranchSyncKey("default", "repo"), - handoffStatusSyncKey("default", "repo", "handoff", "sandbox-1", "session-1") - ]; - - for (const key of keys) { - expect(key[0]).toBe("ws"); - expect(key[1]).toBe("default"); - } - }); -}); diff --git a/factory/packages/client/test/view-model.test.ts b/factory/packages/client/test/view-model.test.ts deleted file mode 100644 index 823ab7d9..00000000 --- a/factory/packages/client/test/view-model.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { HandoffRecord } from "@openhandoff/shared"; -import { - filterHandoffs, - formatRelativeAge, - fuzzyMatch, - summarizeHandoffs -} from "../src/view-model.js"; - -const sample: HandoffRecord = { - workspaceId: "default", - repoId: "repo-a", - repoRemote: "https://example.com/repo-a.git", - handoffId: "handoff-1", - branchName: "feature/test", - title: "Test Title", - task: "Do test", - providerId: "daytona", - status: "running", - statusMessage: null, - activeSandboxId: "sandbox-1", - activeSessionId: "session-1", - sandboxes: [ - { - sandboxId: "sandbox-1", - providerId: "daytona", - sandboxActorId: null, - switchTarget: "daytona://sandbox-1", - cwd: null, - createdAt: 1, - updatedAt: 1 - } - ], - agentType: null, - prSubmitted: false, - diffStat: null, - prUrl: null, - prAuthor: null, - ciStatus: null, - reviewStatus: null, - reviewer: null, - conflictsWithMain: null, - hasUnpushed: null, - parentBranch: null, - createdAt: 1, - updatedAt: 1 -}; - -describe("search helpers", () => { - it("supports ordered fuzzy matching", () => { - expect(fuzzyMatch("feature/test-branch", "ftb")).toBe(true); - expect(fuzzyMatch("feature/test-branch", "fbt")).toBe(false); - }); - - it("filters rows across branch and title", () => { - const rows: HandoffRecord[] = [ - sample, - { - ...sample, - handoffId: "handoff-2", - branchName: "docs/update-intro", - title: "Docs Intro Refresh", - status: "idle" - } - ]; - expect(filterHandoffs(rows, "doc")).toHaveLength(1); - expect(filterHandoffs(rows, "h2")).toHaveLength(1); - expect(filterHandoffs(rows, "test")).toHaveLength(2); - }); -}); - -describe("summary helpers", () => { - it("formats relative age", () => { - expect(formatRelativeAge(9_000, 10_000)).toBe("1s"); - expect(formatRelativeAge(0, 120_000)).toBe("2m"); - }); - - it("summarizes by status and provider", () => { - const rows: HandoffRecord[] = [ - sample, - { ...sample, handoffId: "handoff-2", status: "idle", providerId: "daytona" }, - { ...sample, handoffId: "handoff-3", status: "error", providerId: "daytona" } - ]; - - const summary = summarizeHandoffs(rows); - expect(summary.total).toBe(3); - expect(summary.byStatus.running).toBe(1); - expect(summary.byStatus.idle).toBe(1); - expect(summary.byStatus.error).toBe(1); - expect(summary.byProvider.daytona).toBe(3); - }); -}); diff --git a/factory/packages/client/tsconfig.json b/factory/packages/client/tsconfig.json deleted file mode 100644 index ae5ba214..00000000 --- a/factory/packages/client/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src", "test"] -} diff --git a/factory/packages/frontend-errors/package.json b/factory/packages/frontend-errors/package.json deleted file mode 100644 index c30f7963..00000000 --- a/factory/packages/frontend-errors/package.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "name": "@openhandoff/frontend-errors", - "version": "0.1.0", - "private": true, - "type": "module", - "main": "dist/index.js", - "types": "dist/index.d.ts", - "exports": { - ".": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" - }, - "./client": { - "types": "./dist/client.d.ts", - "default": "./dist/client.js" - }, - "./vite": { - "types": "./dist/vite.d.ts", - "default": "./dist/vite.js" - } - }, - "scripts": { - "build": "tsup src/index.ts src/client.ts src/vite.ts --format esm --dts", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@hono/node-server": "^1.19.9", - "hono": "^4.11.9" - }, - "devDependencies": { - "tsup": "^8.5.0", - "vite": "^7.1.3" - } -} diff --git a/factory/packages/frontend-errors/src/client.ts b/factory/packages/frontend-errors/src/client.ts deleted file mode 100644 index f0557042..00000000 --- a/factory/packages/frontend-errors/src/client.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { FrontendErrorContext } from "./types.js"; - -interface FrontendErrorCollectorGlobal { - setContext: (context: FrontendErrorContext) => void; -} - -declare global { - interface Window { - __OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__?: FrontendErrorCollectorGlobal; - __OPENHANDOFF_FRONTEND_ERROR_CONTEXT__?: FrontendErrorContext; - } -} - -export function setFrontendErrorContext(context: FrontendErrorContext): void { - if (typeof window === "undefined") { - return; - } - - const nextContext = sanitizeContext(context); - window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ = { - ...(window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ ?? {}), - ...nextContext, - }; - window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__?.setContext(nextContext); -} - -function sanitizeContext(input: FrontendErrorContext): FrontendErrorContext { - const output: FrontendErrorContext = {}; - for (const [key, value] of Object.entries(input)) { - if ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ) { - output[key] = value; - } - } - return output; -} diff --git a/factory/packages/frontend-errors/src/index.ts b/factory/packages/frontend-errors/src/index.ts deleted file mode 100644 index a07981a6..00000000 --- a/factory/packages/frontend-errors/src/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from "./router.js"; -export * from "./script.js"; -export * from "./types.js"; diff --git a/factory/packages/frontend-errors/src/router.ts b/factory/packages/frontend-errors/src/router.ts deleted file mode 100644 index aa0bbe72..00000000 --- a/factory/packages/frontend-errors/src/router.ts +++ /dev/null @@ -1,277 +0,0 @@ -import { existsSync } from "node:fs"; -import { appendFile, mkdir } from "node:fs/promises"; -import { dirname, join, resolve } from "node:path"; -import { Hono } from "hono"; -import type { FrontendErrorContext, FrontendErrorKind, FrontendErrorLogEvent } from "./types.js"; - -const DEFAULT_RELATIVE_LOG_PATH = ".openhandoff/logs/frontend-errors.ndjson"; -const DEFAULT_REPORTER = "openhandoff-frontend"; -const MAX_FIELD_LENGTH = 12_000; - -export interface FrontendErrorCollectorRouterOptions { - logFilePath?: string; - reporter?: string; -} - -export function findProjectRoot(startDirectory: string = process.cwd()): string { - let currentDirectory = resolve(startDirectory); - while (true) { - if (existsSync(join(currentDirectory, ".git"))) { - return currentDirectory; - } - const parentDirectory = dirname(currentDirectory); - if (parentDirectory === currentDirectory) { - return resolve(startDirectory); - } - currentDirectory = parentDirectory; - } -} - -export function defaultFrontendErrorLogPath(startDirectory: string = process.cwd()): string { - const root = findProjectRoot(startDirectory); - return resolve(root, DEFAULT_RELATIVE_LOG_PATH); -} - -export function createFrontendErrorCollectorRouter( - options: FrontendErrorCollectorRouterOptions = {} -): Hono { - const logFilePath = options.logFilePath ?? defaultFrontendErrorLogPath(); - const reporter = trimText(options.reporter, 128) ?? DEFAULT_REPORTER; - let ensureLogPathPromise: Promise | null = null; - - const app = new Hono(); - - app.get("/healthz", (c) => - c.json({ - ok: true, - logFilePath, - reporter, - }) - ); - - app.post("/events", async (c) => { - let parsedBody: unknown; - try { - parsedBody = await c.req.json(); - } catch { - return c.json({ ok: false, error: "Expected JSON body" }, 400); - } - - const inputEvents = Array.isArray(parsedBody) ? parsedBody : [parsedBody]; - if (inputEvents.length === 0) { - return c.json({ ok: false, error: "Expected at least one event" }, 400); - } - - const receivedAt = Date.now(); - const userAgent = trimText(c.req.header("user-agent"), 512); - const clientIp = readClientIp(c.req.header("x-forwarded-for")); - const normalizedEvents: FrontendErrorLogEvent[] = []; - - for (const candidate of inputEvents) { - if (!isObject(candidate)) { - continue; - } - normalizedEvents.push( - normalizeEvent({ - candidate, - reporter, - userAgent: userAgent ?? null, - clientIp: clientIp ?? null, - receivedAt, - }) - ); - } - - if (normalizedEvents.length === 0) { - return c.json({ ok: false, error: "No valid events found in request" }, 400); - } - - await ensureLogPath(); - - const payload = `${normalizedEvents.map((event) => JSON.stringify(event)).join("\n")}\n`; - await appendFile(logFilePath, payload, "utf8"); - - return c.json( - { - ok: true, - accepted: normalizedEvents.length, - }, - 202 - ); - }); - - return app; - - async function ensureLogPath(): Promise { - ensureLogPathPromise ??= mkdir(dirname(logFilePath), { recursive: true }).then(() => undefined); - await ensureLogPathPromise; - } -} - -interface NormalizeEventInput { - candidate: Record; - reporter: string; - userAgent: string | null; - clientIp: string | null; - receivedAt: number; -} - -function normalizeEvent(input: NormalizeEventInput): FrontendErrorLogEvent { - const kind = normalizeKind(input.candidate.kind); - return { - id: createEventId(), - kind, - message: trimText(input.candidate.message, MAX_FIELD_LENGTH) ?? "(no message)", - stack: trimText(input.candidate.stack, MAX_FIELD_LENGTH) ?? null, - source: trimText(input.candidate.source, 1024) ?? null, - line: normalizeNumber(input.candidate.line), - column: normalizeNumber(input.candidate.column), - url: trimText(input.candidate.url, 2048) ?? null, - timestamp: normalizeTimestamp(input.candidate.timestamp), - receivedAt: input.receivedAt, - userAgent: input.userAgent, - clientIp: input.clientIp, - reporter: input.reporter, - context: normalizeContext(input.candidate.context), - extra: normalizeExtra(input.candidate.extra), - }; -} - -function normalizeKind(value: unknown): FrontendErrorKind { - switch (value) { - case "window-error": - case "resource-error": - case "unhandled-rejection": - case "console-error": - case "fetch-error": - case "fetch-response-error": - return value; - default: - return "window-error"; - } -} - -function normalizeTimestamp(value: unknown): number { - const parsed = normalizeNumber(value); - if (parsed === null) { - return Date.now(); - } - return parsed; -} - -function normalizeNumber(value: unknown): number | null { - if (typeof value !== "number" || !Number.isFinite(value)) { - return null; - } - return value; -} - -function normalizeContext(value: unknown): FrontendErrorContext { - if (!isObject(value)) { - return {}; - } - - const context: FrontendErrorContext = {}; - for (const [key, candidate] of Object.entries(value)) { - if (!isAllowedContextValue(candidate)) { - continue; - } - const safeKey = trimText(key, 128); - if (!safeKey) { - continue; - } - if (typeof candidate === "string") { - context[safeKey] = trimText(candidate, 1024); - continue; - } - context[safeKey] = candidate; - } - - return context; -} - -function normalizeExtra(value: unknown): Record { - if (!isObject(value)) { - return {}; - } - - const normalized: Record = {}; - for (const [key, candidate] of Object.entries(value)) { - const safeKey = trimText(key, 128); - if (!safeKey) { - continue; - } - normalized[safeKey] = normalizeUnknown(candidate); - } - return normalized; -} - -function normalizeUnknown(value: unknown): unknown { - if (typeof value === "string") { - return trimText(value, 1024) ?? ""; - } - if (typeof value === "number" || typeof value === "boolean" || value === null) { - return value; - } - if (Array.isArray(value)) { - return value.slice(0, 25).map((item) => normalizeUnknown(item)); - } - if (isObject(value)) { - const output: Record = {}; - const entries = Object.entries(value).slice(0, 25); - for (const [key, candidate] of entries) { - const safeKey = trimText(key, 128); - if (!safeKey) { - continue; - } - output[safeKey] = normalizeUnknown(candidate); - } - return output; - } - return String(value); -} - -function trimText(value: unknown, maxLength: number): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - if (trimmed.length <= maxLength) { - return trimmed; - } - return `${trimmed.slice(0, maxLength)}...(truncated)`; -} - -function createEventId(): string { - if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { - return crypto.randomUUID(); - } - return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; -} - -function readClientIp(forwardedFor: string | undefined): string | null { - if (!forwardedFor) { - return null; - } - const [first] = forwardedFor.split(","); - return trimText(first, 64) ?? null; -} - -function isAllowedContextValue( - value: unknown -): value is string | number | boolean | null | undefined { - return ( - value === null || - value === undefined || - typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" - ); -} - -function isObject(value: unknown): value is Record { - return typeof value === "object" && value !== null; -} diff --git a/factory/packages/frontend-errors/src/script.ts b/factory/packages/frontend-errors/src/script.ts deleted file mode 100644 index 66101bfd..00000000 --- a/factory/packages/frontend-errors/src/script.ts +++ /dev/null @@ -1,248 +0,0 @@ -import type { FrontendErrorCollectorScriptOptions } from "./types.js"; - -const DEFAULT_REPORTER = "openhandoff-frontend"; - -export function createFrontendErrorCollectorScript( - options: FrontendErrorCollectorScriptOptions -): string { - const config = { - endpoint: options.endpoint, - reporter: options.reporter ?? DEFAULT_REPORTER, - includeConsoleErrors: options.includeConsoleErrors ?? true, - includeFetchErrors: options.includeFetchErrors ?? true, - }; - - return `(function () { - if (typeof window === "undefined") { - return; - } - - if (window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__) { - return; - } - - var config = ${JSON.stringify(config)}; - var sharedContext = window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ || {}; - window.__OPENHANDOFF_FRONTEND_ERROR_CONTEXT__ = sharedContext; - - function now() { - return Date.now(); - } - - function clampText(input, maxLength) { - if (typeof input !== "string") { - return null; - } - var value = input.trim(); - if (!value) { - return null; - } - if (value.length <= maxLength) { - return value; - } - return value.slice(0, maxLength) + "...(truncated)"; - } - - function currentRoute() { - return location.pathname + location.search + location.hash; - } - - function safeContext() { - var copy = {}; - for (var key in sharedContext) { - if (!Object.prototype.hasOwnProperty.call(sharedContext, key)) { - continue; - } - var candidate = sharedContext[key]; - if ( - candidate === null || - candidate === undefined || - typeof candidate === "string" || - typeof candidate === "number" || - typeof candidate === "boolean" - ) { - copy[key] = candidate; - } - } - copy.route = currentRoute(); - return copy; - } - - function stringifyUnknown(input) { - if (typeof input === "string") { - return input; - } - if (input instanceof Error) { - return input.stack || input.message || String(input); - } - try { - return JSON.stringify(input); - } catch { - return String(input); - } - } - - var internalSendInFlight = false; - - function send(eventPayload) { - var payload = { - kind: eventPayload.kind || "window-error", - message: clampText(eventPayload.message || "(no message)", 12000), - stack: clampText(eventPayload.stack, 12000), - source: clampText(eventPayload.source, 1024), - line: typeof eventPayload.line === "number" ? eventPayload.line : null, - column: typeof eventPayload.column === "number" ? eventPayload.column : null, - url: clampText(eventPayload.url || location.href, 2048), - timestamp: typeof eventPayload.timestamp === "number" ? eventPayload.timestamp : now(), - context: safeContext(), - extra: eventPayload.extra || {}, - }; - - var body = JSON.stringify(payload); - - if (navigator.sendBeacon && body.length < 60000) { - var blob = new Blob([body], { type: "application/json" }); - navigator.sendBeacon(config.endpoint, blob); - return; - } - - if (internalSendInFlight) { - return; - } - - internalSendInFlight = true; - fetch(config.endpoint, { - method: "POST", - headers: { "content-type": "application/json" }, - credentials: "same-origin", - keepalive: true, - body: body, - }).catch(function () { - return; - }).finally(function () { - internalSendInFlight = false; - }); - } - - window.__OPENHANDOFF_FRONTEND_ERROR_COLLECTOR__ = { - setContext: function (nextContext) { - if (!nextContext || typeof nextContext !== "object") { - return; - } - for (var key in nextContext) { - if (!Object.prototype.hasOwnProperty.call(nextContext, key)) { - continue; - } - sharedContext[key] = nextContext[key]; - } - }, - }; - - if (config.includeConsoleErrors) { - var originalConsoleError = console.error.bind(console); - console.error = function () { - var message = ""; - var values = []; - for (var index = 0; index < arguments.length; index += 1) { - values.push(stringifyUnknown(arguments[index])); - } - message = values.join(" "); - send({ - kind: "console-error", - message: message || "console.error called", - timestamp: now(), - extra: { args: values.slice(0, 10) }, - }); - return originalConsoleError.apply(console, arguments); - }; - } - - window.addEventListener("error", function (event) { - var target = event.target; - var hasResourceTarget = target && target !== window && typeof target === "object"; - if (hasResourceTarget) { - var url = null; - if ("src" in target && typeof target.src === "string") { - url = target.src; - } else if ("href" in target && typeof target.href === "string") { - url = target.href; - } - send({ - kind: "resource-error", - message: "Resource failed to load", - source: event.filename || null, - line: typeof event.lineno === "number" ? event.lineno : null, - column: typeof event.colno === "number" ? event.colno : null, - url: url || location.href, - stack: null, - timestamp: now(), - }); - return; - } - - var message = clampText(event.message, 12000) || "Unhandled window error"; - var stack = event.error && event.error.stack ? String(event.error.stack) : null; - send({ - kind: "window-error", - message: message, - stack: stack, - source: event.filename || null, - line: typeof event.lineno === "number" ? event.lineno : null, - column: typeof event.colno === "number" ? event.colno : null, - url: location.href, - timestamp: now(), - }); - }, true); - - window.addEventListener("unhandledrejection", function (event) { - var reason = event.reason; - var stack = reason && reason.stack ? String(reason.stack) : null; - send({ - kind: "unhandled-rejection", - message: stringifyUnknown(reason), - stack: stack, - url: location.href, - timestamp: now(), - }); - }); - - if (config.includeFetchErrors && typeof window.fetch === "function") { - var originalFetch = window.fetch.bind(window); - window.fetch = function () { - var args = arguments; - var requestUrl = null; - if (typeof args[0] === "string") { - requestUrl = args[0]; - } else if (args[0] && typeof args[0].url === "string") { - requestUrl = args[0].url; - } - - return originalFetch.apply(window, args).then(function (response) { - if (!response.ok && response.status >= 500) { - send({ - kind: "fetch-response-error", - message: "Fetch returned HTTP " + response.status, - url: requestUrl || location.href, - timestamp: now(), - extra: { - status: response.status, - statusText: response.statusText, - }, - }); - } - return response; - }).catch(function (error) { - send({ - kind: "fetch-error", - message: stringifyUnknown(error), - stack: error && error.stack ? String(error.stack) : null, - url: requestUrl || location.href, - timestamp: now(), - }); - throw error; - }); - }; - } - -})();`; -} diff --git a/factory/packages/frontend-errors/src/types.ts b/factory/packages/frontend-errors/src/types.ts deleted file mode 100644 index 33b88baf..00000000 --- a/factory/packages/frontend-errors/src/types.ts +++ /dev/null @@ -1,52 +0,0 @@ -export type FrontendErrorKind = - | "window-error" - | "resource-error" - | "unhandled-rejection" - | "console-error" - | "fetch-error" - | "fetch-response-error"; - -export interface FrontendErrorContext { - route?: string; - workspaceId?: string; - handoffId?: string; - [key: string]: string | number | boolean | null | undefined; -} - -export interface FrontendErrorEventInput { - kind?: string; - message?: string; - stack?: string | null; - source?: string | null; - line?: number | null; - column?: number | null; - url?: string | null; - timestamp?: number; - context?: FrontendErrorContext | null; - extra?: Record | null; -} - -export interface FrontendErrorLogEvent { - id: string; - kind: FrontendErrorKind; - message: string; - stack: string | null; - source: string | null; - line: number | null; - column: number | null; - url: string | null; - timestamp: number; - receivedAt: number; - userAgent: string | null; - clientIp: string | null; - reporter: string; - context: FrontendErrorContext; - extra: Record; -} - -export interface FrontendErrorCollectorScriptOptions { - endpoint: string; - reporter?: string; - includeConsoleErrors?: boolean; - includeFetchErrors?: boolean; -} diff --git a/factory/packages/frontend-errors/src/vite.ts b/factory/packages/frontend-errors/src/vite.ts deleted file mode 100644 index f52eccbc..00000000 --- a/factory/packages/frontend-errors/src/vite.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { getRequestListener } from "@hono/node-server"; -import { Hono } from "hono"; -import type { Plugin } from "vite"; -import { createFrontendErrorCollectorRouter, defaultFrontendErrorLogPath } from "./router.js"; -import { createFrontendErrorCollectorScript } from "./script.js"; - -const DEFAULT_MOUNT_PATH = "/__openhandoff/frontend-errors"; -const DEFAULT_EVENT_PATH = "/events"; - -export interface FrontendErrorCollectorVitePluginOptions { - mountPath?: string; - logFilePath?: string; - reporter?: string; - includeConsoleErrors?: boolean; - includeFetchErrors?: boolean; -} - -export function frontendErrorCollectorVitePlugin( - options: FrontendErrorCollectorVitePluginOptions = {} -): Plugin { - const mountPath = normalizePath(options.mountPath ?? DEFAULT_MOUNT_PATH); - const logFilePath = options.logFilePath ?? defaultFrontendErrorLogPath(process.cwd()); - const reporter = options.reporter ?? "openhandoff-vite"; - const endpoint = `${mountPath}${DEFAULT_EVENT_PATH}`; - - const router = createFrontendErrorCollectorRouter({ - logFilePath, - reporter, - }); - const mountApp = new Hono().route(mountPath, router); - const listener = getRequestListener(mountApp.fetch); - - return { - name: "openhandoff:frontend-error-collector", - apply: "serve", - transformIndexHtml(html) { - return { - html, - tags: [ - { - tag: "script", - attrs: { type: "module" }, - children: createFrontendErrorCollectorScript({ - endpoint, - reporter, - includeConsoleErrors: options.includeConsoleErrors, - includeFetchErrors: options.includeFetchErrors, - }), - injectTo: "head-prepend", - }, - ], - }; - }, - configureServer(server) { - server.middlewares.use((req, res, next) => { - if (!req.url?.startsWith(mountPath)) { - return next(); - } - void listener(req, res).catch((error) => next(error)); - }); - }, - configurePreviewServer(server) { - server.middlewares.use((req, res, next) => { - if (!req.url?.startsWith(mountPath)) { - return next(); - } - void listener(req, res).catch((error) => next(error)); - }); - }, - }; -} - -function normalizePath(path: string): string { - if (!path.startsWith("/")) { - return `/${path}`; - } - return path.replace(/\/+$/, ""); -} diff --git a/factory/packages/frontend-errors/test/router.test.ts b/factory/packages/frontend-errors/test/router.test.ts deleted file mode 100644 index bed1d13e..00000000 --- a/factory/packages/frontend-errors/test/router.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { mkdtemp, readFile, rm } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { describe, expect, test } from "vitest"; -import { createFrontendErrorCollectorRouter } from "../src/router.js"; -import { createFrontendErrorCollectorScript } from "../src/script.js"; - -describe("frontend error collector router", () => { - test("writes accepted event payloads to NDJSON", async () => { - const directory = await mkdtemp(join(tmpdir(), "hf-frontend-errors-")); - const logFilePath = join(directory, "events.ndjson"); - const app = createFrontendErrorCollectorRouter({ logFilePath, reporter: "test-suite" }); - - try { - const response = await app.request("/events", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - kind: "window-error", - message: "Boom", - stack: "at app.tsx:1:1", - context: { route: "/workspaces/default" }, - }), - }); - - expect(response.status).toBe(202); - - const written = await readFile(logFilePath, "utf8"); - const [firstLine] = written.trim().split("\n"); - expect(firstLine).toBeTruthy(); - const parsed = JSON.parse(firstLine ?? "{}") as { - kind?: string; - message?: string; - reporter?: string; - context?: { route?: string }; - }; - expect(parsed.kind).toBe("window-error"); - expect(parsed.message).toBe("Boom"); - expect(parsed.reporter).toBe("test-suite"); - expect(parsed.context?.route).toBe("/workspaces/default"); - } finally { - await rm(directory, { recursive: true, force: true }); - } - }); -}); - -describe("frontend error collector script", () => { - test("embeds configured endpoint", () => { - const script = createFrontendErrorCollectorScript({ - endpoint: "/__openhandoff/frontend-errors/events", - }); - expect(script).toContain("/__openhandoff/frontend-errors/events"); - expect(script).toContain("window.addEventListener(\"error\""); - }); -}); diff --git a/factory/packages/frontend-errors/tsconfig.json b/factory/packages/frontend-errors/tsconfig.json deleted file mode 100644 index 6bb5dcd8..00000000 --- a/factory/packages/frontend-errors/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.base.json", - "compilerOptions": { - "outDir": "dist" - }, - "include": ["src", "test", "vitest.config.ts"] -} diff --git a/factory/packages/frontend-errors/vitest.config.ts b/factory/packages/frontend-errors/vitest.config.ts deleted file mode 100644 index ed078a33..00000000 --- a/factory/packages/frontend-errors/vitest.config.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { defineConfig } from "vitest/config"; - -export default defineConfig({ - test: { - environment: "node", - include: ["test/**/*.test.ts"] - } -}); diff --git a/factory/packages/frontend/index.html b/factory/packages/frontend/index.html deleted file mode 100644 index f4d55ce2..00000000 --- a/factory/packages/frontend/index.html +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - OpenHandoff - - -
- - - diff --git a/factory/packages/frontend/package.json b/factory/packages/frontend/package.json deleted file mode 100644 index 4d00b9fd..00000000 --- a/factory/packages/frontend/package.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "@openhandoff/frontend", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite", - "build": "vite build", - "typecheck": "tsc --noEmit", - "test": "vitest run" - }, - "dependencies": { - "@openhandoff/client": "workspace:*", - "@openhandoff/frontend-errors": "workspace:*", - "@openhandoff/shared": "workspace:*", - "@tanstack/react-query": "^5.85.5", - "@tanstack/react-router": "^1.132.23", - "baseui": "^16.1.1", - "lucide-react": "^0.542.0", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "styletron-engine-atomic": "^1.6.2", - "styletron-react": "^6.1.1" - }, - "devDependencies": { - "@react-grab/mcp": "^0.1.13", - "@types/react": "^19.1.12", - "@types/react-dom": "^19.1.9", - "@vitejs/plugin-react": "^5.0.3", - "react-grab": "^0.1.13", - "tsup": "^8.5.0", - "vite": "^7.1.3" - } -} diff --git a/factory/packages/frontend/src/app/router.tsx b/factory/packages/frontend/src/app/router.tsx deleted file mode 100644 index 27197360..00000000 --- a/factory/packages/frontend/src/app/router.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { useEffect } from "react"; -import { setFrontendErrorContext } from "@openhandoff/frontend-errors/client"; -import { - Navigate, - Outlet, - createRootRoute, - createRoute, - createRouter, - useRouterState, -} from "@tanstack/react-router"; -import { MockLayout } from "../components/mock-layout"; -import { defaultWorkspaceId } from "../lib/env"; -import { handoffWorkbenchClient } from "../lib/workbench"; - -const rootRoute = createRootRoute({ - component: RootLayout, -}); - -const indexRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/", - component: () => ( - - ), -}); - -const workspaceRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/workspaces/$workspaceId", - component: WorkspaceLayoutRoute, -}); - -const workspaceIndexRoute = createRoute({ - getParentRoute: () => workspaceRoute, - path: "/", - component: WorkspaceRoute, -}); - -const handoffRoute = createRoute({ - getParentRoute: () => workspaceRoute, - path: "handoffs/$handoffId", - validateSearch: (search: Record) => ({ - sessionId: typeof search.sessionId === "string" && search.sessionId.trim().length > 0 ? search.sessionId : undefined, - }), - component: HandoffRoute, -}); - -const repoRoute = createRoute({ - getParentRoute: () => workspaceRoute, - path: "repos/$repoId", - component: RepoRoute, -}); - -const routeTree = rootRoute.addChildren([ - indexRoute, - workspaceRoute.addChildren([workspaceIndexRoute, handoffRoute, repoRoute]), -]); - -export const router = createRouter({ routeTree }); - -declare module "@tanstack/react-router" { - interface Register { - router: typeof router; - } -} - -function WorkspaceLayoutRoute() { - return ; -} - -function WorkspaceRoute() { - const { workspaceId } = workspaceRoute.useParams(); - useEffect(() => { - setFrontendErrorContext({ - workspaceId, - handoffId: undefined, - }); - }, [workspaceId]); - return ; -} - -function HandoffRoute() { - const { workspaceId, handoffId } = handoffRoute.useParams(); - const { sessionId } = handoffRoute.useSearch(); - useEffect(() => { - setFrontendErrorContext({ - workspaceId, - handoffId, - repoId: undefined, - }); - }, [handoffId, workspaceId]); - return ; -} - -function RepoRoute() { - const { workspaceId, repoId } = repoRoute.useParams(); - useEffect(() => { - setFrontendErrorContext({ - workspaceId, - handoffId: undefined, - repoId, - }); - }, [repoId, workspaceId]); - const activeHandoffId = handoffWorkbenchClient.getSnapshot().handoffs.find( - (handoff) => handoff.repoId === repoId, - )?.id; - if (!activeHandoffId) { - return ( - - ); - } - return ( - - ); -} - -function RootLayout() { - return ( - <> - - - - ); -} - -function RouteContextSync() { - const location = useRouterState({ - select: (state) => state.location, - }); - - useEffect(() => { - setFrontendErrorContext({ - route: `${location.pathname}${location.search}${location.hash}`, - }); - }, [location.hash, location.pathname, location.search]); - - return null; -} diff --git a/factory/packages/frontend/src/app/theme.ts b/factory/packages/frontend/src/app/theme.ts deleted file mode 100644 index 16ef64f9..00000000 --- a/factory/packages/frontend/src/app/theme.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createDarkTheme, type Theme } from "baseui"; - -export const appTheme: Theme = createDarkTheme({ - colors: { - primary: "#e4e4e7", // zinc-200 - accent: "#ff4f00", // orange accent (inspector) - backgroundPrimary: "#000000", // pure black (inspector --bg) - backgroundSecondary: "#0a0a0b", // near-black panels (inspector --bg-panel) - backgroundTertiary: "#0a0a0b", // same as panel (border provides separation) - backgroundInversePrimary: "#fafafa", - contentPrimary: "#ffffff", // white (inspector --text) - contentSecondary: "#a1a1aa", // zinc-400 (inspector --muted) - contentTertiary: "#71717a", // zinc-500 - contentInversePrimary: "#000000", - borderOpaque: "rgba(255, 255, 255, 0.18)", // inspector --border - borderTransparent: "rgba(255, 255, 255, 0.14)", // inspector --border-2 - }, -}); diff --git a/factory/packages/frontend/src/components/mock-layout.tsx b/factory/packages/frontend/src/components/mock-layout.tsx deleted file mode 100644 index 68dd14f7..00000000 --- a/factory/packages/frontend/src/components/mock-layout.tsx +++ /dev/null @@ -1,916 +0,0 @@ -import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { useNavigate } from "@tanstack/react-router"; - -import { DiffContent } from "./mock-layout/diff-content"; -import { MessageList } from "./mock-layout/message-list"; -import { PromptComposer } from "./mock-layout/prompt-composer"; -import { RightSidebar } from "./mock-layout/right-sidebar"; -import { Sidebar } from "./mock-layout/sidebar"; -import { TabStrip } from "./mock-layout/tab-strip"; -import { TranscriptHeader } from "./mock-layout/transcript-header"; -import { PROMPT_TEXTAREA_MAX_HEIGHT, PROMPT_TEXTAREA_MIN_HEIGHT, SPanel, ScrollBody, Shell } from "./mock-layout/ui"; -import { - buildDisplayMessages, - buildHistoryEvents, - diffPath, - diffTabId, - formatThinkingDuration, - isDiffTab, - type Handoff, - type HistoryEvent, - type LineAttachment, - type Message, - type ModelId, -} from "./mock-layout/view-model"; -import { handoffWorkbenchClient } from "../lib/workbench"; - -function firstAgentTabId(handoff: Handoff): string | null { - return handoff.tabs[0]?.id ?? null; -} - -function sanitizeOpenDiffs(handoff: Handoff, paths: string[] | undefined): string[] { - if (!paths) { - return []; - } - - return paths.filter((path) => handoff.diffs[path] != null); -} - -function sanitizeLastAgentTabId(handoff: Handoff, tabId: string | null | undefined): string | null { - if (tabId && handoff.tabs.some((tab) => tab.id === tabId)) { - return tabId; - } - - return firstAgentTabId(handoff); -} - -function sanitizeActiveTabId( - handoff: Handoff, - tabId: string | null | undefined, - openDiffs: string[], - lastAgentTabId: string | null, -): string | null { - if (tabId) { - if (handoff.tabs.some((tab) => tab.id === tabId)) { - return tabId; - } - if (isDiffTab(tabId) && openDiffs.includes(diffPath(tabId))) { - return tabId; - } - } - - return openDiffs.length > 0 ? diffTabId(openDiffs[openDiffs.length - 1]!) : lastAgentTabId; -} - -const TranscriptPanel = memo(function TranscriptPanel({ - handoff, - activeTabId, - lastAgentTabId, - openDiffs, - onSyncRouteSession, - onSetActiveTabId, - onSetLastAgentTabId, - onSetOpenDiffs, -}: { - handoff: Handoff; - activeTabId: string | null; - lastAgentTabId: string | null; - openDiffs: string[]; - onSyncRouteSession: (handoffId: string, sessionId: string | null, replace?: boolean) => void; - onSetActiveTabId: (tabId: string | null) => void; - onSetLastAgentTabId: (tabId: string | null) => void; - onSetOpenDiffs: (paths: string[]) => void; -}) { - const [defaultModel, setDefaultModel] = useState("claude-sonnet-4"); - const [editingField, setEditingField] = useState<"title" | "branch" | null>(null); - const [editValue, setEditValue] = useState(""); - const [editingSessionTabId, setEditingSessionTabId] = useState(null); - const [editingSessionName, setEditingSessionName] = useState(""); - const [pendingHistoryTarget, setPendingHistoryTarget] = useState<{ messageId: string; tabId: string } | null>(null); - const [copiedMessageId, setCopiedMessageId] = useState(null); - const [timerNowMs, setTimerNowMs] = useState(() => Date.now()); - const scrollRef = useRef(null); - const textareaRef = useRef(null); - const messageRefs = useRef(new Map()); - const activeDiff = activeTabId && isDiffTab(activeTabId) ? diffPath(activeTabId) : null; - const activeAgentTab = activeDiff ? null : (handoff.tabs.find((candidate) => candidate.id === activeTabId) ?? handoff.tabs[0] ?? null); - const promptTab = handoff.tabs.find((candidate) => candidate.id === lastAgentTabId) ?? handoff.tabs[0] ?? null; - const isTerminal = handoff.status === "archived"; - const historyEvents = useMemo(() => buildHistoryEvents(handoff.tabs), [handoff.tabs]); - const activeMessages = useMemo(() => buildDisplayMessages(activeAgentTab), [activeAgentTab]); - const draft = promptTab?.draft.text ?? ""; - const attachments = promptTab?.draft.attachments ?? []; - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, [activeMessages.length]); - - useEffect(() => { - textareaRef.current?.focus(); - }, [activeTabId, handoff.id]); - - useEffect(() => { - setEditingSessionTabId(null); - setEditingSessionName(""); - }, [handoff.id]); - - useLayoutEffect(() => { - const textarea = textareaRef.current; - if (!textarea) { - return; - } - - textarea.style.height = `${PROMPT_TEXTAREA_MIN_HEIGHT}px`; - const nextHeight = Math.min(textarea.scrollHeight, PROMPT_TEXTAREA_MAX_HEIGHT); - textarea.style.height = `${Math.max(PROMPT_TEXTAREA_MIN_HEIGHT, nextHeight)}px`; - textarea.style.overflowY = textarea.scrollHeight > PROMPT_TEXTAREA_MAX_HEIGHT ? "auto" : "hidden"; - }, [draft, activeTabId, handoff.id]); - - useEffect(() => { - if (!pendingHistoryTarget || activeTabId !== pendingHistoryTarget.tabId) { - return; - } - - const targetNode = messageRefs.current.get(pendingHistoryTarget.messageId); - if (!targetNode) { - return; - } - - targetNode.scrollIntoView({ behavior: "smooth", block: "center" }); - setPendingHistoryTarget(null); - }, [activeMessages.length, activeTabId, pendingHistoryTarget]); - - useEffect(() => { - if (!copiedMessageId) { - return; - } - - const timer = setTimeout(() => { - setCopiedMessageId(null); - }, 1_200); - - return () => clearTimeout(timer); - }, [copiedMessageId]); - - useEffect(() => { - if (!activeAgentTab || activeAgentTab.status !== "running" || activeAgentTab.thinkingSinceMs === null) { - return; - } - - setTimerNowMs(Date.now()); - const timer = window.setInterval(() => { - setTimerNowMs(Date.now()); - }, 1_000); - - return () => window.clearInterval(timer); - }, [activeAgentTab?.id, activeAgentTab?.status, activeAgentTab?.thinkingSinceMs]); - - useEffect(() => { - if (!activeAgentTab?.unread) { - return; - } - - void handoffWorkbenchClient.setSessionUnread({ - handoffId: handoff.id, - tabId: activeAgentTab.id, - unread: false, - }); - }, [activeAgentTab?.id, activeAgentTab?.unread, handoff.id]); - - const startEditingField = useCallback((field: "title" | "branch", value: string) => { - setEditingField(field); - setEditValue(value); - }, []); - - const cancelEditingField = useCallback(() => { - setEditingField(null); - }, []); - - const commitEditingField = useCallback( - (field: "title" | "branch") => { - const value = editValue.trim(); - if (!value) { - setEditingField(null); - return; - } - - if (field === "title") { - void handoffWorkbenchClient.renameHandoff({ handoffId: handoff.id, value }); - } else { - void handoffWorkbenchClient.renameBranch({ handoffId: handoff.id, value }); - } - setEditingField(null); - }, - [editValue, handoff.id], - ); - - const updateDraft = useCallback( - (nextText: string, nextAttachments: LineAttachment[]) => { - if (!promptTab) { - return; - } - - void handoffWorkbenchClient.updateDraft({ - handoffId: handoff.id, - tabId: promptTab.id, - text: nextText, - attachments: nextAttachments, - }); - }, - [handoff.id, promptTab], - ); - - const sendMessage = useCallback(() => { - const text = draft.trim(); - if (!text || !promptTab) { - return; - } - - onSetActiveTabId(promptTab.id); - onSetLastAgentTabId(promptTab.id); - void handoffWorkbenchClient.sendMessage({ - handoffId: handoff.id, - tabId: promptTab.id, - text, - attachments, - }); - }, [attachments, draft, handoff.id, onSetActiveTabId, onSetLastAgentTabId, promptTab]); - - const stopAgent = useCallback(() => { - if (!promptTab) { - return; - } - - void handoffWorkbenchClient.stopAgent({ - handoffId: handoff.id, - tabId: promptTab.id, - }); - }, [handoff.id, promptTab]); - - const switchTab = useCallback( - (tabId: string) => { - onSetActiveTabId(tabId); - - if (!isDiffTab(tabId)) { - onSetLastAgentTabId(tabId); - const tab = handoff.tabs.find((candidate) => candidate.id === tabId); - if (tab?.unread) { - void handoffWorkbenchClient.setSessionUnread({ - handoffId: handoff.id, - tabId, - unread: false, - }); - } - onSyncRouteSession(handoff.id, tabId); - } - }, - [handoff.id, handoff.tabs, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], - ); - - const setTabUnread = useCallback( - (tabId: string, unread: boolean) => { - void handoffWorkbenchClient.setSessionUnread({ handoffId: handoff.id, tabId, unread }); - }, - [handoff.id], - ); - - const startRenamingTab = useCallback( - (tabId: string) => { - const targetTab = handoff.tabs.find((candidate) => candidate.id === tabId); - if (!targetTab) { - throw new Error(`Unable to rename missing session tab ${tabId}`); - } - - setEditingSessionTabId(tabId); - setEditingSessionName(targetTab.sessionName); - }, - [handoff.tabs], - ); - - const cancelTabRename = useCallback(() => { - setEditingSessionTabId(null); - setEditingSessionName(""); - }, []); - - const commitTabRename = useCallback(() => { - if (!editingSessionTabId) { - return; - } - - const trimmedName = editingSessionName.trim(); - if (!trimmedName) { - cancelTabRename(); - return; - } - - void handoffWorkbenchClient.renameSession({ - handoffId: handoff.id, - tabId: editingSessionTabId, - title: trimmedName, - }); - cancelTabRename(); - }, [cancelTabRename, editingSessionName, editingSessionTabId, handoff.id]); - - const closeTab = useCallback( - (tabId: string) => { - const remainingTabs = handoff.tabs.filter((candidate) => candidate.id !== tabId); - const nextTabId = remainingTabs[0]?.id ?? null; - - if (activeTabId === tabId) { - onSetActiveTabId(nextTabId); - } - if (lastAgentTabId === tabId) { - onSetLastAgentTabId(nextTabId); - } - - onSyncRouteSession(handoff.id, nextTabId); - void handoffWorkbenchClient.closeTab({ handoffId: handoff.id, tabId }); - }, - [activeTabId, handoff.id, handoff.tabs, lastAgentTabId, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession], - ); - - const closeDiffTab = useCallback( - (path: string) => { - const nextOpenDiffs = openDiffs.filter((candidate) => candidate !== path); - onSetOpenDiffs(nextOpenDiffs); - if (activeTabId === diffTabId(path)) { - onSetActiveTabId( - nextOpenDiffs.length > 0 ? diffTabId(nextOpenDiffs[nextOpenDiffs.length - 1]!) : (lastAgentTabId ?? firstAgentTabId(handoff)), - ); - } - }, - [activeTabId, handoff, lastAgentTabId, onSetActiveTabId, onSetOpenDiffs, openDiffs], - ); - - const addTab = useCallback(() => { - void (async () => { - const { tabId } = await handoffWorkbenchClient.addTab({ handoffId: handoff.id }); - onSetLastAgentTabId(tabId); - onSetActiveTabId(tabId); - onSyncRouteSession(handoff.id, tabId); - })(); - }, [handoff.id, onSetActiveTabId, onSetLastAgentTabId, onSyncRouteSession]); - - const changeModel = useCallback( - (model: ModelId) => { - if (!promptTab) { - throw new Error(`Unable to change model for handoff ${handoff.id} without an active prompt tab`); - } - - void handoffWorkbenchClient.changeModel({ - handoffId: handoff.id, - tabId: promptTab.id, - model, - }); - }, - [handoff.id, promptTab], - ); - - const addAttachment = useCallback( - (filePath: string, lineNumber: number, lineContent: string) => { - if (!promptTab) { - return; - } - - const nextAttachment = { id: `${filePath}:${lineNumber}`, filePath, lineNumber, lineContent }; - if (attachments.some((attachment) => attachment.filePath === filePath && attachment.lineNumber === lineNumber)) { - return; - } - - updateDraft(draft, [...attachments, nextAttachment]); - }, - [attachments, draft, promptTab, updateDraft], - ); - - const removeAttachment = useCallback( - (id: string) => { - updateDraft( - draft, - attachments.filter((attachment) => attachment.id !== id), - ); - }, - [attachments, draft, updateDraft], - ); - - const jumpToHistoryEvent = useCallback( - (event: HistoryEvent) => { - setPendingHistoryTarget({ messageId: event.messageId, tabId: event.tabId }); - - if (activeTabId !== event.tabId) { - switchTab(event.tabId); - return; - } - - const targetNode = messageRefs.current.get(event.messageId); - if (targetNode) { - targetNode.scrollIntoView({ behavior: "smooth", block: "center" }); - setPendingHistoryTarget(null); - } - }, - [activeTabId, switchTab], - ); - - const copyMessage = useCallback(async (message: Message) => { - try { - if (!window.navigator.clipboard) { - throw new Error("Clipboard API unavailable in mock layout"); - } - - await window.navigator.clipboard.writeText(message.text); - setCopiedMessageId(message.id); - } catch (error) { - console.error("Failed to copy transcript message", error); - } - }, []); - - const thinkingTimerLabel = - activeAgentTab?.status === "running" && activeAgentTab.thinkingSinceMs !== null - ? formatThinkingDuration(timerNowMs - activeAgentTab.thinkingSinceMs) - : null; - - return ( - - { - if (activeAgentTab) { - setTabUnread(activeAgentTab.id, unread); - } - }} - /> - - {activeDiff ? ( - file.path === activeDiff)} - diff={handoff.diffs[activeDiff]} - onAddAttachment={addAttachment} - /> - ) : handoff.tabs.length === 0 ? ( - -
-
-

Create the first session

-

- Sessions are where you chat with the agent. Start one now to send the first prompt on this handoff. -

- -
-
-
- ) : ( - - { - void copyMessage(message); - }} - thinkingTimerLabel={thinkingTimerLabel} - /> - - )} - {!isTerminal && promptTab ? ( - updateDraft(value, attachments)} - onSend={sendMessage} - onStop={stopAgent} - onRemoveAttachment={removeAttachment} - onChangeModel={changeModel} - onSetDefaultModel={setDefaultModel} - /> - ) : null} -
- ); -}); - -interface MockLayoutProps { - workspaceId: string; - selectedHandoffId?: string | null; - selectedSessionId?: string | null; -} - -export function MockLayout({ workspaceId, selectedHandoffId, selectedSessionId }: MockLayoutProps) { - const navigate = useNavigate(); - const viewModel = useSyncExternalStore( - handoffWorkbenchClient.subscribe.bind(handoffWorkbenchClient), - handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient), - handoffWorkbenchClient.getSnapshot.bind(handoffWorkbenchClient), - ); - const handoffs = viewModel.handoffs ?? []; - const projects = viewModel.projects ?? []; - const [activeTabIdByHandoff, setActiveTabIdByHandoff] = useState>({}); - const [lastAgentTabIdByHandoff, setLastAgentTabIdByHandoff] = useState>({}); - const [openDiffsByHandoff, setOpenDiffsByHandoff] = useState>({}); - - const activeHandoff = useMemo( - () => handoffs.find((handoff) => handoff.id === selectedHandoffId) ?? handoffs[0] ?? null, - [handoffs, selectedHandoffId], - ); - - useEffect(() => { - if (activeHandoff) { - return; - } - - const fallbackHandoffId = handoffs[0]?.id; - if (!fallbackHandoffId) { - return; - } - - const fallbackHandoff = handoffs.find((handoff) => handoff.id === fallbackHandoffId) ?? null; - - void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", - params: { - workspaceId, - handoffId: fallbackHandoffId, - }, - search: { sessionId: fallbackHandoff?.tabs[0]?.id ?? undefined }, - replace: true, - }); - }, [activeHandoff, handoffs, navigate, workspaceId]); - - const openDiffs = activeHandoff ? sanitizeOpenDiffs(activeHandoff, openDiffsByHandoff[activeHandoff.id]) : []; - const lastAgentTabId = activeHandoff ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) : null; - const activeTabId = activeHandoff - ? sanitizeActiveTabId(activeHandoff, activeTabIdByHandoff[activeHandoff.id], openDiffs, lastAgentTabId) - : null; - - const syncRouteSession = useCallback( - (handoffId: string, sessionId: string | null, replace = false) => { - void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", - params: { - workspaceId, - handoffId, - }, - search: { sessionId: sessionId ?? undefined }, - ...(replace ? { replace: true } : {}), - }); - }, - [navigate, workspaceId], - ); - - useEffect(() => { - if (!activeHandoff) { - return; - } - - const resolvedRouteSessionId = sanitizeLastAgentTabId(activeHandoff, selectedSessionId); - if (!resolvedRouteSessionId) { - return; - } - - if (selectedSessionId !== resolvedRouteSessionId) { - syncRouteSession(activeHandoff.id, resolvedRouteSessionId, true); - return; - } - - if (lastAgentTabIdByHandoff[activeHandoff.id] === resolvedRouteSessionId) { - return; - } - - setLastAgentTabIdByHandoff((current) => ({ - ...current, - [activeHandoff.id]: resolvedRouteSessionId, - })); - setActiveTabIdByHandoff((current) => { - const currentActive = current[activeHandoff.id]; - if (currentActive && isDiffTab(currentActive)) { - return current; - } - - return { - ...current, - [activeHandoff.id]: resolvedRouteSessionId, - }; - }); - }, [activeHandoff, lastAgentTabIdByHandoff, selectedSessionId, syncRouteSession]); - - const createHandoff = useCallback(() => { - void (async () => { - const repoId = activeHandoff?.repoId ?? viewModel.repos[0]?.id ?? ""; - if (!repoId) { - throw new Error("Cannot create a handoff without an available repo"); - } - - const task = window.prompt("Describe the handoff task", "Investigate and implement the requested change"); - if (!task) { - return; - } - - const title = window.prompt("Optional handoff title", "")?.trim() || undefined; - const branch = window.prompt("Optional branch name", "")?.trim() || undefined; - const { handoffId, tabId } = await handoffWorkbenchClient.createHandoff({ - repoId, - task, - model: "gpt-4o", - ...(title ? { title } : {}), - ...(branch ? { branch } : {}), - }); - await navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", - params: { - workspaceId, - handoffId, - }, - search: { sessionId: tabId ?? undefined }, - }); - })(); - }, [activeHandoff?.repoId, navigate, viewModel.repos, workspaceId]); - - const openDiffTab = useCallback( - (path: string) => { - if (!activeHandoff) { - throw new Error("Cannot open a diff tab without an active handoff"); - } - setOpenDiffsByHandoff((current) => { - const existing = sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]); - if (existing.includes(path)) { - return current; - } - - return { - ...current, - [activeHandoff.id]: [...existing, path], - }; - }); - setActiveTabIdByHandoff((current) => ({ - ...current, - [activeHandoff.id]: diffTabId(path), - })); - }, - [activeHandoff], - ); - - const selectHandoff = useCallback( - (id: string) => { - const handoff = handoffs.find((candidate) => candidate.id === id) ?? null; - void navigate({ - to: "/workspaces/$workspaceId/handoffs/$handoffId", - params: { - workspaceId, - handoffId: id, - }, - search: { sessionId: handoff?.tabs[0]?.id ?? undefined }, - }); - }, - [handoffs, navigate, workspaceId], - ); - - const markHandoffUnread = useCallback((id: string) => { - void handoffWorkbenchClient.markHandoffUnread({ handoffId: id }); - }, []); - - const renameHandoff = useCallback( - (id: string) => { - const currentHandoff = handoffs.find((handoff) => handoff.id === id); - if (!currentHandoff) { - throw new Error(`Unable to rename missing handoff ${id}`); - } - - const nextTitle = window.prompt("Rename handoff", currentHandoff.title); - if (nextTitle === null) { - return; - } - - const trimmedTitle = nextTitle.trim(); - if (!trimmedTitle) { - return; - } - - void handoffWorkbenchClient.renameHandoff({ handoffId: id, value: trimmedTitle }); - }, - [handoffs], - ); - - const renameBranch = useCallback( - (id: string) => { - const currentHandoff = handoffs.find((handoff) => handoff.id === id); - if (!currentHandoff) { - throw new Error(`Unable to rename missing handoff ${id}`); - } - - const nextBranch = window.prompt("Rename branch", currentHandoff.branch ?? ""); - if (nextBranch === null) { - return; - } - - const trimmedBranch = nextBranch.trim(); - if (!trimmedBranch) { - return; - } - - void handoffWorkbenchClient.renameBranch({ handoffId: id, value: trimmedBranch }); - }, - [handoffs], - ); - - const archiveHandoff = useCallback(() => { - if (!activeHandoff) { - throw new Error("Cannot archive without an active handoff"); - } - void handoffWorkbenchClient.archiveHandoff({ handoffId: activeHandoff.id }); - }, [activeHandoff]); - - const publishPr = useCallback(() => { - if (!activeHandoff) { - throw new Error("Cannot publish PR without an active handoff"); - } - void handoffWorkbenchClient.publishPr({ handoffId: activeHandoff.id }); - }, [activeHandoff]); - - const revertFile = useCallback( - (path: string) => { - if (!activeHandoff) { - throw new Error("Cannot revert a file without an active handoff"); - } - setOpenDiffsByHandoff((current) => ({ - ...current, - [activeHandoff.id]: sanitizeOpenDiffs(activeHandoff, current[activeHandoff.id]).filter((candidate) => candidate !== path), - })); - setActiveTabIdByHandoff((current) => ({ - ...current, - [activeHandoff.id]: - current[activeHandoff.id] === diffTabId(path) - ? sanitizeLastAgentTabId(activeHandoff, lastAgentTabIdByHandoff[activeHandoff.id]) - : current[activeHandoff.id] ?? null, - })); - - void handoffWorkbenchClient.revertFile({ - handoffId: activeHandoff.id, - path, - }); - }, - [activeHandoff, lastAgentTabIdByHandoff], - ); - - if (!activeHandoff) { - return ( - - - - -
-
-

Create your first handoff

-

- {viewModel.repos.length > 0 - ? "Start from the sidebar to create a handoff on the first available repo." - : "No repos are available in this workspace yet."} -

- -
-
-
-
- -
- ); - } - - return ( - - - { - setActiveTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetLastAgentTabId={(tabId) => { - setLastAgentTabIdByHandoff((current) => ({ ...current, [activeHandoff.id]: tabId })); - }} - onSetOpenDiffs={(paths) => { - setOpenDiffsByHandoff((current) => ({ ...current, [activeHandoff.id]: paths })); - }} - /> - - - ); -} diff --git a/factory/packages/frontend/src/components/mock-layout/diff-content.tsx b/factory/packages/frontend/src/components/mock-layout/diff-content.tsx deleted file mode 100644 index 8665e971..00000000 --- a/factory/packages/frontend/src/components/mock-layout/diff-content.tsx +++ /dev/null @@ -1,77 +0,0 @@ -import { memo, useMemo } from "react"; -import { FileCode, Plus } from "lucide-react"; - -import { ScrollBody } from "./ui"; -import { parseDiffLines, type FileChange } from "./view-model"; - -export const DiffContent = memo(function DiffContent({ - filePath, - file, - diff, - onAddAttachment, -}: { - filePath: string; - file?: FileChange; - diff?: string; - onAddAttachment?: (filePath: string, lineNumber: number, lineContent: string) => void; -}) { - const diffLines = useMemo(() => (diff ? parseDiffLines(diff) : []), [diff]); - - return ( - <> -
- -
{filePath}
- {file ? ( -
- +{file.added} - −{file.removed} -
- ) : null} -
- - {diff ? ( -
- {diffLines.map((line) => { - const isHunk = line.kind === "hunk"; - return ( -
onAddAttachment(filePath, line.lineNumber, line.text) : undefined} - > -
- {!isHunk && onAddAttachment ? ( - - ) : null} - {isHunk ? "" : line.lineNumber} -
-
- {line.text} -
-
- ); - })} -
- ) : ( -
-
No diff data available for this file
-
- )} -
- - ); -}); diff --git a/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx b/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx deleted file mode 100644 index 83c89044..00000000 --- a/factory/packages/frontend/src/components/mock-layout/history-minimap.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { memo, useEffect, useState } from "react"; -import { useStyletron } from "baseui"; -import { LabelXSmall } from "baseui/typography"; - -import { formatMessageTimestamp, type HistoryEvent } from "./view-model"; - -export const HistoryMinimap = memo(function HistoryMinimap({ - events, - onSelect, -}: { - events: HistoryEvent[]; - onSelect: (event: HistoryEvent) => void; -}) { - const [css, theme] = useStyletron(); - const [open, setOpen] = useState(false); - const [activeEventId, setActiveEventId] = useState(events[events.length - 1]?.id ?? null); - - useEffect(() => { - if (!events.some((event) => event.id === activeEventId)) { - setActiveEventId(events[events.length - 1]?.id ?? null); - } - }, [activeEventId, events]); - - if (events.length === 0) { - return null; - } - - return ( -
setOpen(true)} - onMouseLeave={() => setOpen(false)} - > - {open ? ( -
-
- - Handoff Events - - {events.length} -
-
- {events.map((event) => { - const isActive = event.id === activeEventId; - return ( - - ); - })} -
-
- ) : null} - -
- {events.map((event) => { - const isActive = event.id === activeEventId; - return ( -
- ); - })} -
-
- ); -}); diff --git a/factory/packages/frontend/src/components/mock-layout/message-list.tsx b/factory/packages/frontend/src/components/mock-layout/message-list.tsx deleted file mode 100644 index baf758f5..00000000 --- a/factory/packages/frontend/src/components/mock-layout/message-list.tsx +++ /dev/null @@ -1,197 +0,0 @@ -import { memo, type MutableRefObject, type Ref } from "react"; -import { useStyletron } from "baseui"; -import { LabelSmall, LabelXSmall } from "baseui/typography"; -import { Copy } from "lucide-react"; - -import { HistoryMinimap } from "./history-minimap"; -import { SpinnerDot } from "./ui"; -import { buildDisplayMessages, formatMessageDuration, formatMessageTimestamp, type AgentTab, type HistoryEvent, type Message } from "./view-model"; - -export const MessageList = memo(function MessageList({ - tab, - scrollRef, - messageRefs, - historyEvents, - onSelectHistoryEvent, - copiedMessageId, - onCopyMessage, - thinkingTimerLabel, -}: { - tab: AgentTab | null | undefined; - scrollRef: Ref; - messageRefs: MutableRefObject>; - historyEvents: HistoryEvent[]; - onSelectHistoryEvent: (event: HistoryEvent) => void; - copiedMessageId: string | null; - onCopyMessage: (message: Message) => void; - thinkingTimerLabel: string | null; -}) { - const [css, theme] = useStyletron(); - const messages = buildDisplayMessages(tab); - - return ( - <> - {historyEvents.length > 0 ? : null} -
- {tab && messages.length === 0 ? ( -
- - {!tab.created ? "Choose an agent and model, then send your first message" : "No messages yet in this session"} - -
- ) : null} - {messages.map((message) => { - const isUser = message.sender === "client"; - const isCopied = copiedMessageId === message.id; - const messageTimestamp = formatMessageTimestamp(message.createdAtMs); - const displayFooter = isUser - ? messageTimestamp - : message.durationMs - ? `${messageTimestamp} • Took ${formatMessageDuration(message.durationMs)}` - : null; - - return ( -
{ - if (node) { - messageRefs.current.set(message.id, node); - } else { - messageRefs.current.delete(message.id); - } - }} - className={css({ display: "flex", justifyContent: isUser ? "flex-end" : "flex-start" })} - > -
-
-
- {message.text} -
-
-
- {displayFooter ? ( - - {displayFooter} - - ) : null} - -
-
-
- ); - })} - {tab && tab.status === "running" && messages.length > 0 ? ( -
- - - Agent is thinking - {thinkingTimerLabel ? ( - - {thinkingTimerLabel} - - ) : null} - -
- ) : null} -
- - ); -}); diff --git a/factory/packages/frontend/src/components/mock-layout/model-picker.tsx b/factory/packages/frontend/src/components/mock-layout/model-picker.tsx deleted file mode 100644 index 743023ae..00000000 --- a/factory/packages/frontend/src/components/mock-layout/model-picker.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { memo, useState } from "react"; -import { useStyletron } from "baseui"; -import { StatefulPopover, PLACEMENT } from "baseui/popover"; -import { ChevronDown, Star } from "lucide-react"; - -import { AgentIcon } from "./ui"; -import { MODEL_GROUPS, modelLabel, providerAgent, type ModelId } from "./view-model"; - -const ModelPickerContent = memo(function ModelPickerContent({ - value, - defaultModel, - onChange, - onSetDefault, - close, -}: { - value: ModelId; - defaultModel: ModelId; - onChange: (id: ModelId) => void; - onSetDefault: (id: ModelId) => void; - close: () => void; -}) { - const [css, theme] = useStyletron(); - const [hoveredId, setHoveredId] = useState(null); - - return ( -
- {MODEL_GROUPS.map((group) => ( -
-
- {group.provider} -
- {group.models.map((model) => { - const isActive = model.id === value; - const isDefault = model.id === defaultModel; - const isHovered = model.id === hoveredId; - const agent = providerAgent(group.provider); - - return ( -
setHoveredId(model.id)} - onMouseLeave={() => setHoveredId(null)} - onClick={() => { - onChange(model.id); - close(); - }} - className={css({ - display: "flex", - alignItems: "center", - gap: "8px", - padding: "6px 12px", - cursor: "pointer", - fontSize: "12px", - fontWeight: isActive ? 600 : 400, - color: isActive ? theme.colors.contentPrimary : theme.colors.contentSecondary, - ":hover": { backgroundColor: "rgba(255, 255, 255, 0.06)" }, - })} - > - - {model.label} - {isDefault ? : null} - {!isDefault && isHovered ? ( - { - event.stopPropagation(); - onSetDefault(model.id); - }} - /> - ) : null} -
- ); - })} -
- ))} -
- ); -}); - -export const ModelPicker = memo(function ModelPicker({ - value, - defaultModel, - onChange, - onSetDefault, -}: { - value: ModelId; - defaultModel: ModelId; - onChange: (id: ModelId) => void; - onSetDefault: (id: ModelId) => void; -}) { - const [css, theme] = useStyletron(); - - return ( - ( - - )} - > -
- -
-
- ); -}); diff --git a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx b/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx deleted file mode 100644 index f3cdcd26..00000000 --- a/factory/packages/frontend/src/components/mock-layout/prompt-composer.tsx +++ /dev/null @@ -1,181 +0,0 @@ -import { memo, type Ref } from "react"; -import { useStyletron } from "baseui"; -import { ArrowUpFromLine, FileCode, Square, X } from "lucide-react"; - -import { ModelPicker } from "./model-picker"; -import { PROMPT_TEXTAREA_MIN_HEIGHT, PROMPT_TEXTAREA_MAX_HEIGHT } from "./ui"; -import { fileName, type LineAttachment, type ModelId } from "./view-model"; - -export const PromptComposer = memo(function PromptComposer({ - draft, - textareaRef, - placeholder, - attachments, - defaultModel, - model, - isRunning, - onDraftChange, - onSend, - onStop, - onRemoveAttachment, - onChangeModel, - onSetDefaultModel, -}: { - draft: string; - textareaRef: Ref; - placeholder: string; - attachments: LineAttachment[]; - defaultModel: ModelId; - model: ModelId; - isRunning: boolean; - onDraftChange: (value: string) => void; - onSend: () => void; - onStop: () => void; - onRemoveAttachment: (id: string) => void; - onChangeModel: (model: ModelId) => void; - onSetDefaultModel: (model: ModelId) => void; -}) { - const [css, theme] = useStyletron(); - - return ( -
- {attachments.length > 0 ? ( -
- {attachments.map((attachment) => ( -
- - - {fileName(attachment.filePath)}:{attachment.lineNumber} - - onRemoveAttachment(attachment.id)} - /> -
- ))} -
- ) : null} -
-