Compare commits
41 Commits
v1.13.2-co
...
v2.1.0-pro
| Author | SHA1 | Date | |
|---|---|---|---|
| d8ffee1950 | |||
| e423579e99 | |||
| 06116f31b3 | |||
| 47abbb6e3c | |||
| f53c6d6cb9 | |||
| 3d6055518b | |||
| 752ea74f43 | |||
| 73b53089b0 | |||
| 457c59fb06 | |||
| 78455b7efc | |||
| d2108b2f8d | |||
| ce31577d1e | |||
| 006226cce5 | |||
| 62d818af23 | |||
| 531d39ace9 | |||
| f2974d6887 | |||
| 29c7d051b6 | |||
| d27a977d59 | |||
| 5692e99a5d | |||
| f4a97808ad | |||
| 211e903620 | |||
| ad45b28250 | |||
| 1a889dcde3 | |||
| b52c5df705 | |||
| 2e1a81de72 | |||
| 61308cf17c | |||
| 3992a9fcb7 | |||
| 0fa46cd06c | |||
| bc376c878d | |||
| 8b568b36d3 | |||
| 34cbecf975 | |||
| 5a3f357ce9 | |||
| fc11e8dc91 | |||
| 9ce638c916 | |||
| 8126d78b34 | |||
| b06a4a8e55 | |||
| a0c8d212cb | |||
| 0ce6115976 | |||
| ff29b48e3a | |||
| 81d837c04e | |||
| f8fc5db929 |
33
.codecontextignore
Normal file
33
.codecontextignore
Normal file
@@ -0,0 +1,33 @@
|
||||
# .codecontextignore — paths codecontext skips during analysis
|
||||
# Copy to your project root and customize. Same syntax as .gitignore.
|
||||
|
||||
# Dependencies / vendored code
|
||||
node_modules/
|
||||
vendor/
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
target/
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
out/
|
||||
.next/
|
||||
.nuxt/
|
||||
.svelte-kit/
|
||||
|
||||
# IDE / tooling
|
||||
.opencode/
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Test artifacts / coverage
|
||||
coverage/
|
||||
.nyc_output/
|
||||
.pytest_cache/
|
||||
|
||||
# Lock files (rarely have meaningful symbols)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
pnpm-lock.yaml
|
||||
@@ -10,3 +10,12 @@ POSTGRES_PASSWORD=CHANGE_ME
|
||||
# Internal Tailscale address that bypasses Authelia. Override if you
|
||||
# point BooCode at a different SearXNG instance.
|
||||
SEARXNG_URL=http://100.114.205.53:8888
|
||||
|
||||
# v1.13.15-tools: BOOCODE_TOOLS narrows the tool whitelist sent to the LLM.
|
||||
# Unset (default) → all tools (~21k schema). Useful primarily for single-purpose
|
||||
# sessions where the model only needs read-only filesystem access.
|
||||
#
|
||||
# core → view_file, list_dir, grep, find_files (~2k)
|
||||
# standard → core + web_*, git_status, all 8 codecontext_* tools (~10k)
|
||||
# all → every tool in ALL_TOOLS (~21k)
|
||||
# BOOCODE_TOOLS=all
|
||||
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
@@ -1,9 +1,13 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
CLAUDE.local.md
|
||||
*.log
|
||||
.DS_Store
|
||||
.vite
|
||||
coverage
|
||||
secrets/
|
||||
data/
|
||||
data/*
|
||||
!data/AGENTS.md
|
||||
!data/skills/
|
||||
!data/mcp.json
|
||||
|
||||
21
BOOCHAT.md
21
BOOCHAT.md
@@ -1,7 +1,5 @@
|
||||
# BooChat
|
||||
|
||||
You are the assistant running inside BooChat — a self-hosted developer chat app.
|
||||
|
||||
## Capabilities
|
||||
|
||||
- Read-only file tools: `view_file`, `list_dir`, `grep`, `find_files`
|
||||
@@ -28,6 +26,25 @@ You are the assistant running inside BooChat — a self-hosted developer chat ap
|
||||
- Cite file paths + line numbers for any claim about the codebase
|
||||
- When uncertain about scope or intent, surface options via `ask_user_input` rather than guessing
|
||||
- Prefer codecontext (`search_symbols`, `get_symbol_info`, `get_dependencies`) over `grep` for symbol-level questions. Fall back to `grep` / `view_file` when codecontext returns degraded or empty results — that signals an unsupported language or parse failure.
|
||||
- Verify before reporting work complete: run the relevant test/build/smoke command and confirm output matches the claim. Evidence first, assertion second.
|
||||
|
||||
## Output format
|
||||
|
||||
- Stay in Markdown by default for every reply, short or long.
|
||||
- Switch to a self-contained `<!DOCTYPE html>...</html>` artifact only when the user explicitly asks (e.g. "render this as HTML", "make me a dashboard", "build an interactive diagram"). Detection is opportunistic — the BooChat backend tags the assistant message as an HTML artifact, opens it in a sandboxed pane, and offers Download. Do not emit HTML unprompted; long Markdown is the right answer for most explanatory output.
|
||||
- When asked to produce HTML, avoid generic AI aesthetics: no excessive centered layouts, no purple gradients, no uniform rounded corners, no Inter font. Prefer interactive controls (sliders / knobs / SVG / side-by-side diffs) over passive prose-in-HTML. Pattern reference: claude.com/blog/using-claude-code-the-unreasonable-effectiveness-of-html (Thariq Shihipar, May 2026).
|
||||
- The HTML artifact is rendered in a sandboxed iframe with `connect-src 'none'` — `fetch()`, WebSockets, and tracking pixels do not work. All logic must be client-side.
|
||||
|
||||
## Convention: rules vs recipes
|
||||
|
||||
Always-true rules (process discipline, refusals, behavior contracts) live here in `BOOCHAT.md` — and in `BOOCODER.md` / `CLAUDE.md` per their scopes — where they are 100% present in every turn. On-demand recipes (specific procedures, scaffolds, checklists) live in `/data/skills/` and invoke roughly 6% of the time in clean multi-turn flow (Codeminer42 measurement, 2026). Don't file workflow rules as skills — they silently misfire. See Anthropic agent-skills best-practices (platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for the canonical conventions.
|
||||
|
||||
## Verification discipline
|
||||
|
||||
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
|
||||
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
|
||||
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
||||
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||
|
||||
## Known limitations
|
||||
|
||||
|
||||
45
BOOCODER.md
45
BOOCODER.md
@@ -1,24 +1,39 @@
|
||||
# BooCoder
|
||||
# BooCoder — Container Guidance
|
||||
|
||||
> (Stub. v2.0 implementation pending. This file documents the intended contract.)
|
||||
You are BooCoder, a write-capable coding agent. You can read AND modify files within the project scope.
|
||||
|
||||
You are the assistant running inside BooCoder — the write-capable companion to BooChat.
|
||||
## You can
|
||||
|
||||
## Capabilities
|
||||
- Read files (view_file, list_dir, grep, find_files)
|
||||
- Edit files (edit_file, create_file, delete_file) — all changes queue in pending_changes
|
||||
- Apply pending changes to disk (apply_pending)
|
||||
- Revert applied changes (rewind)
|
||||
- Dispatch tasks to external agents (dispatch_external_agent)
|
||||
- Use MCP tools from configured servers
|
||||
|
||||
- Everything in `BOOCHAT.md`
|
||||
- Write tools (pending): `write_file`, `edit_file`, `delete_file` (all gated through pending-changes sandbox)
|
||||
- Shell (pending): `run_command` (Docker-isolated per-session)
|
||||
## You cannot
|
||||
|
||||
## Constraints
|
||||
- Write outside the project root (path-guard enforced)
|
||||
- Write to secret files (.env, *.pem, id_rsa*, credentials.json)
|
||||
- Apply changes without explicit user approval (unless auto-apply is enabled per task)
|
||||
- Push to git remotes
|
||||
- Access the internet except via configured MCP servers
|
||||
|
||||
- All writes land in a pending-changes virtual layer; nothing touches the real filesystem until `/apply`
|
||||
- `run_command` executes inside the session sandbox, not the host
|
||||
- No git commits, pushes, or pulls — Sam owns those
|
||||
- Stop and ask before destructive operations (delete, overwrite, recreate)
|
||||
## Pending changes discipline
|
||||
|
||||
Every file modification queues in `pending_changes` before touching disk. The user sees a diff preview and approves/rejects each change. Never bypass this queue — it is the safety boundary between inference and the filesystem.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Show a diff preview before any write
|
||||
- Group related edits into a single `/apply` batch
|
||||
- If a tool fails, surface the error verbatim — don't paper over it
|
||||
- Show diffs clearly. Explain what you're changing and why.
|
||||
- For multi-file changes, organize as a logical unit (one task = one coherent change set).
|
||||
- If uncertain about scope, use smaller edits and verify between steps.
|
||||
- Cite file paths + line numbers for context.
|
||||
- Verify before reporting work complete: run the relevant test/build/smoke and confirm output matches the claim. Evidence first, assertion second.
|
||||
|
||||
## Verification discipline
|
||||
|
||||
- When assessing implementation status, verify against the running container (`curl /api/health`) and latest git commit (`git log --oneline -3`), not just source file contents. Source files can be mid-edit. The deployed state is the truth.
|
||||
- Never count `dist/` directory sizes as source lines. Only count `src/**/*.ts` files. Compiled output is inflated by inlined types and transpilation artifacts.
|
||||
- Before claiming a feature works, run the actual command and show the output. "Should work" is not verification. Acceptable evidence: test output (`pnpm test`), build output (`pnpm build`), curl response, docker logs, `\d tablename` output. If you can't run it, say so explicitly — don't assert success without evidence.
|
||||
- When reporting counts (tools, tests, files, routes, lines), derive the number from a command (`grep -c`, `wc -l`, test runner output) — not from memory or approximation.
|
||||
|
||||
207
CHANGELOG.md
Normal file
207
CHANGELOG.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Changelog
|
||||
|
||||
All notable changes per release tag. Most recent on top, ordered by tag creation date (which matches the git history). Tag names follow `vMAJOR.MINOR.PATCH-slug` — the slug describes what shipped, so the tag name alone is enough to recall the batch.
|
||||
|
||||
## v2.1.0-provider-picker — 2026-05-25
|
||||
|
||||
Provider picker: BooCoder moves from Docker container to host systemd service (`boocoder.service`). All agent dispatch (ACP + PTY) switches from SSH tunnel to direct `spawn`/`exec` — no more `sshSpawn`/`sshExec`/`sshSpawnWithStdin` (marked `@deprecated`). New provider registry (`provider-registry.ts`) with 5 providers (boocode, opencode, goose, claude, qwen), per-provider model discovery (llama-swap for ACP agents, `~/.qwen/settings.json` for qwen, static for claude), and `agent-probe.ts` runs direct `which`/`exec` instead of SSH. `GET /api/providers` route assembles the provider list with installed status, models, and transport (ACP→PTY fallback if `supports_acp` is false). Frontend `ProviderPicker` component in CoderPane header lets users pick provider/model per message; messages route through `tasks` row for external providers instead of inference enqueue. Smart scroll: `MessageList` only auto-scrolls when user is near bottom (150px threshold). DB schema adds `models`, `label`, `transport` columns to `available_agents`. Bug fixes: `loadContext` SELECT now includes `allowed_read_paths` (cross-repo read grants were silently failing), cap hit sentinel insertion moved before `buildMessagesPayload` call.
|
||||
|
||||
## v2.0.4-hardening — 2026-05-25
|
||||
|
||||
Path-guard fuzz suite: 25+ traversal-attack tests covering ../ sequences (all depths), encoded traversal (%2e%2e), null byte injection, absolute path escape, prefix-without-separator, backslash traversal, and the full secret-file deny list (.env, *.pem, id_rsa*, *.key, credentials.json, *.kdbx, .netrc). Plus 5 valid-path positive tests confirming normal writes aren't blocked and 5 edge-case tests (empty, whitespace-only, very long path, triple-dot, multiple slashes). Null-byte and whitespace-only guards added to `resolveWritePath` (previously only checked empty string). DB-integration test skeleton for pending_changes full-cycle (queue create/edit/delete, apply, rewind) gated on DATABASE_URL via `describe.runIf`. Production readiness verified: all services healthy, all builds clean, 57 tests passing (23 existing + 34 new).
|
||||
|
||||
## v1.16.0-codesight-merge — 2026-05-24
|
||||
|
||||
Ports codesight's highest-value analysis capabilities into the codecontext sidecar as 4 new MCP tools. Tier 1 (graph queries on existing edges, no re-parsing): `get_blast_radius` (BFS reverse-edge traversal — "what breaks if I change this file?", with depth tracking) and `get_hot_files` (most-imported files ranked by incoming edge count — change-risk indicators). Tier 2 (tree-sitter AST re-parsing on demand): `get_routes` (Fastify/Express HTTP route extraction with method, path, file, line, inferred tags for db/auth/cache) and `get_middleware` (middleware registration detection via import-name heuristics and app.register/addHook/setErrorHandler patterns, classifying as auth/cors/rate-limit/security/error-handler/logging/validation). All 4 tools use `defer s.graphMu.RUnlock()` for consistent mutex discipline (reviewer caught that the initial implementation released the lock early on the Tier 2 tools). Route object-property extraction delegates to `extractStringValue` for template-literal handling (reviewer catch). codecontext sidecar rebuilt from `/opt/forks/codecontext` commit `b19e646`, tagged `v1.16.0-codesight-merge`. BooCode wrapper tools follow the existing codecontext pattern — 4 new files in `apps/server/src/services/tools/codecontext/`, registered in ALL_TOOLS. 29 new Go tests + 363/363 BooCode server tests passing. No schema changes, no frontend changes.
|
||||
|
||||
## v1.15.0-mcp-multi — 2026-05-24
|
||||
|
||||
Multi-server MCP client with stdio + Streamable HTTP transports, JSON config file, and per-agent tool glob patterns. Generalizes the v1.14.1 single-server Context7 PoC into a registry of named MCP servers with per-server graceful degradation. JSON config at `/data/mcp.json` (bind-mounted alongside `AGENTS.md`) matches opencode's `mcpServers` schema shape so server entries are copy-pasteable. Config file missing = no MCP (opt-in by file presence). Stdio transport spawns a persistent subprocess via the SDK's `StdioClientTransport` with NDJSON framing; Streamable HTTP reuses the v1.14.1 pattern via `StreamableHTTPClientTransport`. Tool prefix generalized from `context7_<name>` to `<serverName>_<toolName>` with a reverse `toolToServer` map for dispatch routing. Per-agent AGENTS.md `tools:` field now supports glob patterns (`context7_*`, `!web_*`) via `matchToolGlob` (last-match-wins, `!` prefix denies); replaces the exact-match `.includes()` in `stream-phase.ts`. Glob patterns bypass `ALL_TOOL_NAMES` validation in the parser since MCP tool names aren't known at parse time. `refreshToolNames()` in `agents.ts` rebuilds the `DEFAULT_TOOLS` snapshot after `appendMcpTools` so agents without explicit `tools:` lists see MCP tools — reviewer caught that the module-load-time snapshot would permanently exclude late-registered tools. Read-only invariant preserved: all MCP tools with `readOnlyHint: false` rejected at discovery. Result size capped at 5MB. Shutdown hook closes all transports. v1.14.1 env vars (`MCP_CONTEXT7_URL`, `MCP_CONTEXT7_API_KEY`) removed — superseded by the config file. Default `data/mcp.json` ships with Context7 disabled; flip `"enabled": true` to activate. 363/363 server tests passing (27 new: multi-server wrapping, glob matching, routing, degradation). No schema changes, no frontend changes.
|
||||
|
||||
## v1.14.1-mcp-poc — 2026-05-23
|
||||
|
||||
Single-server MCP client PoC against Context7. New `apps/server/src/services/mcp-client.ts` (~200 lines) wraps `@modelcontextprotocol/sdk` v1.29.0 with Streamable HTTP transport. On startup (when `MCP_CONTEXT7_URL` is set), connects to Context7, discovers tools via `tools/list`, wraps each as a `ToolDef` prefixed `context7_<name>`, and appends to `ALL_TOOLS` (alpha-sorted for prompt-cache stability). `appendMcpTools()` in `tools.ts` handles the late-registration; `ALL_TOOLS` changed from `ReadonlyArray` to mutable to support it. Read-only invariant guard rejects any MCP tool with `readOnlyHint: false` (MCP SDK v1.29.0 uses `readOnlyHint`, not `readOnly`). Tool dispatch is transparent — `executeToolCall` routes MCP tool calls through the `ToolDef.execute` wrapper, which strips the `context7_` prefix before calling the MCP server. Graceful degradation: MCP server down at startup → zero tools, warn log; MCP server down mid-session → error-shaped result, model self-corrects. Result size capped at 5MB with truncation (matches native `view_file`'s `MAX_FILE_BYTES`). Adversarial review caught that the Zod `.default('https://...')` on the URL config made MCP effectively always-on instead of opt-in — fixed by removing the default. 348/348 server tests passing (16 new mcp-client tests covering tool wrapping, read-only guard, name prefixing, content extraction). No schema changes, no frontend changes. Proves the MCP tool-discovery → tool-call → result-render loop end-to-end before the full v1.15 port.
|
||||
|
||||
## v1.14.0-outer-loop — 2026-05-23
|
||||
|
||||
Converts the inference engine's ad-hoc `executeToolPhase → runAssistantTurn` recursion into an explicit `while` loop with a configurable step cap. A step is one stream-and-tool-execute iteration; the loop terminates on non-tool finish, step-cap hit, doom-loop, budget exhaustion, abort, or synthesis success. `MAX_STEPS = 200` is the hard ceiling (4x the old effective limit from budget); per-agent `steps:` field in AGENTS.md frontmatter sets tighter caps (Refactorer: 5, Architect: 20, others: unset = bounded only by MAX_STEPS). `executeToolPhase` no longer recurses — returns a `ToolPhaseResult` struct (`action: 'continue' | 'paused' | 'synthesis_done'`) so the caller (the while loop) decides whether to continue or break. `steps: 0` is handled as "no tool calls allowed" — one text-only stream phase, tool calls ignored with a warn log. Step-cap hits produce a sentinel summary (reuses `cap_hit` kind so `CapHitSentinel.tsx` renders it without frontend changes; text distinguishes "Step limit reached" from "Tool budget exhausted"). Doom-loop check migrated from pre-recursion position to top of loop body — same predicate (`detectDoomLoop`), same threshold (3 identical calls), `break` instead of `return`. `step_start` parts are in the schema CHECK but not emitted as message_parts in v1.14 — writing to the assistant message before the stream phase creates a sequence-0 collision with `partsFromAssistantMessage`; a structured log line is emitted instead. Adversarial review caught the collision pre-deploy. 332/332 server tests passing; no frontend changes. Pairs with `v1.13.20-drop-legacy-cols` (parts is now the sole source of truth, and this batch's loop operates entirely through parts).
|
||||
|
||||
## v1.13.20-drop-legacy-cols — 2026-05-23
|
||||
|
||||
Final phase of the v1.13.0 strangler-fig migration. Removes the dual-write into `messages.tool_calls` / `messages.tool_results` JSON columns and drops the columns themselves; `message_parts` is now the only source of truth for tool-call and tool-result data. 10 dual-write sites stripped (5 in `tool-phase.ts`, 2 in `routes/skills.ts`, 2 in `routes/messages.ts`, 1 in `routes/chats.ts` fork-clone) — recon's grep-driven inventory caught 2 sites beyond the original v1.13.2 roadmap count. `messages_with_parts` view simplified to parts-only subselects (COALESCE fallbacks gone) and rewritten via `CREATE OR REPLACE VIEW` BEFORE the column DROP since Postgres rejects column-drop on view-referenced cols. Adversarial review caught a runtime bug the green test suite missed: `chats.ts:/api/chats/:id/discard_stale` had a `RETURNING ... tool_calls, tool_results, ...` clause referencing the dropped columns; would have crashed on every 60s-no-token-activity recovery in production. Fixed by switching to two-step UPDATE-then-SELECT-from-view so the response keeps the parts-synthesized fields. `Message` API type retains `tool_calls?` / `tool_results?` fields (override on the original v1.13.2 plan) — the view continues to populate them from parts, so the wire shape is unchanged and the frontend needs no updates. v1.12.1 cleanup block (`DROP CONSTRAINT messages_status_check`/`messages_role_check`) removed — those one-shots have done their work. `tool_cost_stats.test.ts` had a direct `INSERT INTO messages` touching the legacy columns that wasn't in the roadmap's inventory; rewritten to parts-table inserts and confirmed semantically faithful. 339/339 server tests passing including the 7 DB-integration tests (live-DB applied the schema migration and ran the parts-only view end-to-end). Pairs with `v1.13.0-ai-sdk-v6` (which introduced the dual-write) and `v1.13.1-B` (which moved the read path to `messages_with_parts`); umbrella `v1.13` tag ships on the same commit.
|
||||
|
||||
## v1.13.19-html-artifact-panes — 2026-05-23
|
||||
|
||||
Pane-based artifact viewer with on-request HTML support. Every assistant message gets an "Open in pane" icon button (`PanelRightOpen`, mobile 44px tap-target) in `MessageBubble`'s ActionRow; click opens the message in the workspace splitter as either a Markdown pane (Copy raw source + Download `.md`) or an HTML pane (Download `.html` only, no Copy). The HTML path triggers when the model emits a self-contained `<!DOCTYPE html>` or fenced ` ```html` artifact (opt-in only — `BOOCHAT.md` rule says Markdown is default at every length; HTML only on explicit user request like "render this as HTML"). Backend detection in `finalizeCompletion` (`error-handler.ts`) writes a new `message_parts.kind='html_artifact'` row with payload `{html_content, char_count, title}` (`<title>` → first `<h1>` → first 80 chars of inner text). Schema CHECK extended via the v1.13.13 drop-and-re-add pattern. 1MB cap is graceful — over-cap artifacts skip the part write and plain content lands; decision factored into a pure `decideHtmlArtifactWrite` helper so the warn-and-skip branch is unit-testable without mocking the full InferenceContext. Pane state is reference-only (`{chat_id, message_id, title}`) — content is fetched on mount, keeping `sessions.workspace_panes` jsonb small and avoiding 1MB blobs riding the `session_workspace_updated` WS frame. New `services/artifacts.ts` ships slug derivation (Markdown: first `#` heading → first 6 words; HTML: `<title>` → `<h1>` → inner text) and write helpers that realpath the artifacts directory after `mkdir` to close a symlink-escape gap (`assertArtifactsDirSafe`). `routes/artifacts.ts` exposes POST `/api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html` (writes to `<projectRoot>/.boocode/artifacts/<slug>-<ts>.<ext>`) plus GET `/api/projects/:project_id/artifacts/:filename` with `Content-Disposition: attachment`, `X-Content-Type-Options: nosniff`, and `Content-Security-Policy: sandbox` defense-in-depth on LLM-served HTML. iframe sandbox locks to `allow-scripts allow-clipboard-write allow-downloads` with no `allow-same-origin` and uses `srcDoc` (not `src`) for opaque-origin isolation. Frontend extracts `MarkdownRenderer.tsx` from `MessageBubble`'s inline `MarkdownBody` for reuse; `MarkdownArtifactPane.tsx` / `HtmlArtifactPane.tsx` render with loading + error states. 404-vs-real-error discrimination in `openInPane`: a real network/500 failure toasts and bails instead of silently masquerading as a Markdown pane. 31 new server unit tests (slug derivation, detection positive/negative, write helpers, symlink-escape, 1MB cap, real-symlink filesystem test); 332/332 server tests passing; `tsc -p apps/web/tsconfig.app.json --noEmit` clean; `pnpm -C apps/web build` green. Smoke deferred to first deploy.
|
||||
|
||||
## v1.13.18-codecontext-file-path — 2026-05-22
|
||||
|
||||
Fix: four codecontext wrappers (`get_file_analysis`, `get_symbol_info`, `get_dependencies`, `get_semantic_neighborhoods`) forwarded `file_path` to the sidecar unchanged, but the sidecar's index is keyed on absolute paths — every relative path from the model returned "File not found in graph" (three back-to-back failures in one chat at 17:56 UTC, ~48 s of wasted tool budget). New `resolveProjectPath` helper in `codecontext_client.ts:64-89` realpath-resolves the candidate, applies the same escape check as the existing `target_dir` resolver (matching the error template byte-for-byte except the field name), and falls through with the normalised absolute on ENOENT so the sidecar issues its own self-correctable "File not found" error. Wired into `callCodecontext` once at the args-spread site — all four wrappers benefit without per-wrapper edits. `.trim()` added to all four `file_path` Zod schemas to absorb trailing newlines from model output. Adversarial review caught a P2 escape-bypass: an absolute path with `..` (e.g. `<projectRoot>/../etc/passwd`) that ENOENTs at realpath would slip through the literal prefix-check, fixed by `resolve()`-normalising the absolute branch too. 9 new test cases in `codecontext_client.test.ts` (7 spec scenarios + symlink-out-of-root + absolute-with-`..` ENOENT) plus a 1-line update in `codecontext_tools.test.ts` asserting the new resolved-absolute contract. Pairs with `v1.13.17-cross-repo-reads` — both harden path traversal, but v1.13.18 stays inside the project root while v1.13.17 widens access outside it.
|
||||
|
||||
## v1.13.17-cross-repo-reads — 2026-05-22
|
||||
|
||||
On-demand read access to paths outside the session's primary project root. Closes the dead-end where `pathGuard` rejected every cross-repo read with no recovery path. New `request_read_access(path, reason)` tool emits an `ask_user_input`-style pause; user picks Allow/Deny via inline chips in `RequestReadAccessCard.tsx`; on Allow, the new `POST /api/chats/:id/grant_read_access` endpoint re-resolves the grant root and appends to `sessions.allowed_read_paths` (new `TEXT[]` column, default empty). Grant unit per design D1 = nearest registered `projects.path` ancestor → else nearest repo-shaped ancestor (`.git/` / `package.json` / `go.mod` / `Cargo.toml`) under `PROJECT_ROOT_WHITELIST` → else refuse without prompting. `pathGuard` extended with an optional `extraRoots` argument threaded from `session.allowed_read_paths` through `executeToolCall` to the four filesystem tools (view_file, list_dir, grep, find_files); `view_file` re-anchors the secret-guard check on `basename(real)` whenever the path resolved via a grant root so `.env` / `id_rsa*` deny still fires across grants. `grant_resolver.ts`'s ancestor walk checks the whitelist invariant on every iteration (not just final parent) so a symlinked input can't escape mid-walk. PATCH `/api/sessions/:id` exposes `allowed_read_paths` only for revocation: zod refines paths to absolute + no traversal markers, and a runtime subset guard (`findUnauthorizedAdditions`) rejects any entry not already present in the row, so a malicious `curl -X PATCH -d '{"allowed_read_paths":["/etc"]}'` 400s instead of bypassing the grant flow. Settings pane gains a per-session revoke list; archiving the session clears grants implicitly. 11 grant_resolver tests pin the symlink-escape-mid-walk guard (Sam's checkpoint-1 ask) and the nearest-project disambiguation; 8 path_guard tests cover extraRoots traversal; 8 sessions PATCH tests cover the subset guard including the `/etc` bypass attempt. Pairs with `v1.13.16-xml-parser` (model now both self-recovers from a wrong tool name AND from a refused path).
|
||||
|
||||
## v1.13.16-xml-parser — 2026-05-22
|
||||
|
||||
Two-part fix for the model-emitted XML drift the v1.13.15 investigation surfaced. **Parser extension:** `xml-parser.ts` now recognizes the Anthropic `<invoke name="…"><parameter name="…">…</parameter></invoke>` shape alongside the existing Qwen/Hermes `<tool_call><function=…>…</function></tool_call>` shape. qwen3.6-35b-a3b-mxfp4 drifts to the Anthropic format when prompted as an Architect-style agent (Claude Code documentation in its pre-training corpus). Both formats route through the same synthetic-id `xml_call_${idx}` ToolCall path. The existing Qwen parser was tightened to tolerate whitespace around `=` (`<function = name>` shape) so a stray space doesn't get absorbed into the function name. **Unknown-tool recovery hint:** new `tool-suggestions.ts` exports `levenshtein()` + `suggestToolName()` + `formatUnknownToolError()`. When the dispatcher (`tool-phase.ts:executeToolCall`) receives an unknown tool name, the error returned to the model includes a "Did you mean: X?" hint based on Levenshtein distance ≤3 or substring match against `Object.keys(TOOLS_BY_NAME)`. Targets the qwen3.6 drift to `read_file` → suggest `view_file`. Test coverage in `xml-parser.test.ts` (46 tests, all green) covers both parsers, the partial-opener detector for both flavors, the unified extraction helper, and the new error formatter.
|
||||
|
||||
## v1.13.15-codecontext-synth — 2026-05-22
|
||||
|
||||
Forced second-inference synthesis pass for codecontext overview-class tools (`get_codebase_overview`, `get_framework_analysis`, `get_semantic_neighborhoods`). After the tool result lands, the pipeline expands the truncated head via in-process `readTruncation`, extracts referenced file paths from the full content, auto-fetches top-N files + project docs (BOOCHAT.md, AGENTS.md, *roadmap*.md, CONTEXT.md) under a 32k-token budget with explicit drop-priority order, then streams a synthesis turn that replaces the recursive `runAssistantTurn`. The 32k truncated head still ships to the synth model (token-budget contract preserved); the expansion is reference-extraction-only. Falls through to recursion on timeout (90s), model error, or non-2xx; user-abort marks the synth message `status='failed'` and re-throws (the outer abort handler operates on the parent turn's message, not the new synth row — without explicit marking, the row would sit `streaming` until the 5-min sweeper, tripping the 60s stale-stream banner). Adds `'synthesis'` to `message_parts.kind` CHECK constraint via `DROP CONSTRAINT IF EXISTS` + `DO $$ pg_constraint` idempotency-guarded re-add. Smokes #1, #2, #6 all clean; smokes #3–#5 are content-quality checks for UI review.
|
||||
|
||||
## v1.13.14-skills-audit — 2026-05-22
|
||||
|
||||
Multi-topic batch. **Skills audit (headline):** vendored all 26 skills from `/home/samkintop/opt/skills/` into repo-local `data/skills/` (the `/opt/skills:/data/skills` override mount removed from `docker-compose.yml` so skills are auditable per-batch in git). Audited via 5 parallel Claude Code agent-teams running mgechev's 4-step protocol per skill — 14 survive with gerund-form names + refined triggers; 11 dropped (duplicates, BooCode-irrelevant patterns, Claude-already-does-natively); 1 (`verification-before-completion`) migrated to `BOOCHAT.md`/`BOOCODER.md` as an always-true rule. The Codeminer42 "rules vs recipes" split codified in those files. **Token tracking + stale-stream banner fix:** same root cause — `IsoTimestamp = z.string()` in `ws-frames.ts` was failing on postgres `Date` objects, silently dropping every `message_complete` / `session_updated` / `chat_updated` frame through the `v1.13.13-ws-publish` Zod gate; `z.preprocess(v => v instanceof Date ? v.toISOString() : v, ...)` applied to the primitive on both server + web (parity test still passes). **Codecontext ignore:** `codecontext_client.ts` auto-installs `.codecontextignore.template` into any project's root on first call (stops the upstream empty-source-file parser crash on foreign projects' `node_modules`). **Budget bump:** `BUDGET_READ_ONLY` + `BUDGET_NO_AGENT` 30 → 50 (real recon need ~27 + headroom for codecontext failure-retry turns; doom-loop guard catches the loop class anyway). **UI:** queued-message dropdown → edit / force-send / cancel buttons in `ChatPane.tsx`; `ChatThroughput` removed from desktop tab strip (mobile tab switcher keeps it). Audit decisions in `openspec/changes/v1.13.12-skills-audit/audit-notes.md`.
|
||||
|
||||
## v1.13.13-ws-publish — 2026-05-22
|
||||
|
||||
Second half of the WebSocket-frame-typing batch. Converts the existing ~50 inference + auto_name publish sites (via the `index.ts` adapter) plus ~30 direct `broker.publish*` call sites in routes + compaction, so every server-emitted frame now goes through Zod validation at the broker boundary. Pairs with `v1.13.12-ws-schemas`.
|
||||
|
||||
## v1.13.12-ws-schemas — 2026-05-22
|
||||
|
||||
First half of the WebSocket-frame-typing batch. Adds `apps/server/src/types/ws-frames.ts` with Zod schemas for all 27 wire-format frame types (discriminated union `WsFrameSchema` + `KNOWN_FRAME_TYPES` diagnostic lookup), duplicated byte-identical at `apps/web/src/api/ws-frames.ts` with a parity test. Introduces the `publishFrame` / `publishUserFrame` wrappers that fail-closed on schema mismatch.
|
||||
|
||||
## v1.13.11-tools — 2026-05-22
|
||||
|
||||
Tiered tool loading via `BOOCODE_TOOLS` env var (`core` | `standard` | `all`). Core = 4 read-only fs tools (~2k token schema cost). Standard = +web + git + codecontext (~10k). All (default) = every tool in `ALL_TOOLS` (~21k). The var is a ceiling — narrows agent whitelists, never expands. Pattern lifted from `eyaltoledano/claude-task-master`.
|
||||
|
||||
## v1.13.10-openspec — 2026-05-22
|
||||
|
||||
Adopt `Fission-AI/OpenSpec`'s `openspec/changes/<slug>/{proposal,tasks,design}.md` shape for BooCode's own batch docs. Existing batch docs (`boocode_batch10.md`, `handoff_v1.13.8_prefix_verify.md`, `handoff_v1.13.10_per_tool_cost.md`) moved into `openspec/changes/archived/` via `git mv` to preserve history. Zero-dep documentation reformat.
|
||||
|
||||
## v1.13.9-agentlint — 2026-05-22
|
||||
|
||||
Manual audit of instruction files against `0xmariowu/AgentLint`'s 31-check standard. Removed identity-opener sections from `BOOCHAT.md` and `BOOCODER.md` (emphatic decoration the model doesn't need). Added `CLAUDE.local.md` to `.gitignore` — Claude Code's Glob ignores `.gitignore` by default, so local overrides were otherwise readable by any agent walking the workspace. `CLAUDE.md` passed all 10 checks unchanged.
|
||||
|
||||
## v1.13.8-tool-cost — 2026-05-22
|
||||
|
||||
Per-tool prompt/completion-token rolling averages surfaced in AgentPicker as at-a-glance cost hints. Implementation is the `tool_cost_stats` SQL view over `messages_with_parts` (`LATERAL jsonb_array_elements` on `tool_calls`), plus a read endpoint and a tooltip extension. Equal-split attribution — multi-tool turn divides tokens N-ways; the 100-call rolling mean absorbs split noise. Filters out `cap_hit` / `doom_loop` sentinels. Source data already lands via existing UPDATEs that `v1.13.5-stability-bundle`'s `includeUsage: true` fix made non-NULL.
|
||||
|
||||
## v1.13.7-compaction-trigger — 2026-05-22
|
||||
|
||||
Compaction overflow trigger lowered to `floor(0.85 × ctx_max)`, replacing the v1.11.0-era `ctx_max − 20_000` formula. Old formula gave only 7.6% headroom at 262k context and 0 budget for ≤20k contexts (never fired). New formula gives consistent 15% summarizer headroom across all model sizes. Opencode pattern lift from `session/overflow.ts`.
|
||||
|
||||
## v1.13.6-prefix-stability — 2026-05-22
|
||||
|
||||
System-prompt prefix stability verify-and-measure. Recon during planning disproved the original DB-cache premise: `buildSystemPrompt` already runs over inputs mtime-cached at the file layer (BOOCHAT.md, AGENTS.md global+per-project), and DB scalars are byte-stable until edited. This batch closes the verification gap with instrumentation, not implementation — `buildSystemPromptWithFingerprint` computes SHA-256 over the assembled prefix and a per-session `Map` observer fires `prefix-drift` (warn) on hash change with field-level `changed_inputs` diff.
|
||||
|
||||
## v1.13.5-stability-bundle — 2026-05-22
|
||||
|
||||
Five fixes for latent regressions surfaced during the cosmetic-revert investigation. (1) `provider.ts` — `includeUsage: true` on `createOpenAICompatible` (default false omitted `stream_options.include_usage`; llama-swap never emitted usage; tokens_used / ctx_used were NULL on every assistant row since `v1.13.0-ai-sdk-v6`). (2) `MessageList.tsx` — `hasText = m.content.trim().length > 0` to skip whitespace-only tool-call-only turns rendering empty bubbles. (3) `BUDGET_NO_AGENT` raised 15 → 30 to match read-only agent cap. (4) `payload.ts` skips status='failed' + complete-but-empty assistant rows so cap-hit + Continue doesn't upstream-reject. (5) Misc UI sanitization.
|
||||
|
||||
## v1.13.4-reasoning-fix — 2026-05-22
|
||||
|
||||
Compaction head-assembly audit caught one fix: reasoning was omitted from the summarizer's view of tool-bearing turns, silently degrading summary quality for reasoning-channel models (qwen3.6). `v1.13.0-ai-sdk-v6` had wired reasoning end-to-end into inference but missed this one read site. `CompactionMessage` extended with `reasoning_parts`; `buildHeadPayload` embeds it as a `<reasoning>...</reasoning>` prose prefix on the assistant content (OpenAI wire shape has no structured reasoning field).
|
||||
|
||||
## v1.13.3-truncate — 2026-05-22
|
||||
|
||||
Port of opencode's `truncate.ts`. Full tool output retrievable via opaque `tr_<12 base32 chars>` id (~60 bits entropy) and a new `view_truncated_output(id)` tool. Tmpfs storage at `/tmp/boocode-truncations/` (overridable via `BOOCODE_TRUNCATION_DIR`), 5MB cap, 7-day TTL, orphan-reap on the periodic 60s sweeper. Wired through four tools: `view_file`, `list_dir`, `web_fetch`, `codecontext_client`. Each returns the existing sliced view plus an `outputPath` field when truncation fires.
|
||||
|
||||
## v1.13.2-compaction-prune — 2026-05-22
|
||||
|
||||
Two-tier compaction prune — opencode pattern that was half-shipped in v1.11.0. New `message_parts.hidden_at` column with partial index on `WHERE hidden_at IS NULL`. `messages_with_parts` view changed from `COALESCE(parts, legacy)` to a CASE that distinguishes "no parts at all → fall back to legacy column for pre-v1.13.0 history" from "all parts hidden → drop the row from the model payload" (smoke caught the `COALESCE` leaking hidden parts back via legacy fallback). `prune.ts` scans `tool_result` parts newest-first, protects the last 40k tokens, marks older candidates hidden once the combined estimate clears 20k.
|
||||
|
||||
## v1.13.1-cleanup-bundle — 2026-05-22
|
||||
|
||||
Four independent items owed from prior dispatches. (1) `statement_timeout = '30s'` at the database level (documented in `schema.sql` but applied operationally — `ALTER DATABASE` can't run inside a `DO` block). (2) Tool registry alpha-sorted at module load — llama.cpp's prompt cache hits on byte-identical prefixes; reordering tools near the top of the system prompt would invalidate every cached turn. (3) Periodic 60s stuck-row sweeper. (4) `experimental_repairToolCall` to keep streams alive on malformed qwen3.6 tool args (pass-through implementation — logs and forwards unmodified; existing zod-reject path routes back to the model).
|
||||
|
||||
## v1.13.0-ai-sdk-v6 — 2026-05-22
|
||||
|
||||
Major migration to AI SDK v6. Introduces the `streamCompletion` adapter (`services/inference/stream-phase.ts`) over `streamText`, with five known gotchas the LSP can't catch — abort signals swallowed by `fullStream` (post-iteration throw required), usage lands only at stream end via `await result.usage`, tools have no `execute` field (BooCode dispatches in `tool-phase.ts`), and tool-call-only turns may emit a leading `\n` text-delta. Also ships the `messages_with_parts` view (parts-merge read path) and wires `reasoning_parts` end-to-end via a `ReasoningPart` in the v6 ModelMessage. Ports `ask_user_input` correlation queries from JSON columns to `message_parts` JOINs.
|
||||
|
||||
## v1.12.4-inference-split — 2026-05-21
|
||||
|
||||
Complete `inference.ts` split into `services/inference/`. Pieces: `turn.ts` (orchestration — `runAssistantTurn` / `runInference` / `createInferenceRunner`), `sentinel-summaries.ts` (`runCapHitSummary`, `runDoomLoopSummary`), `stream-phase.ts`, `tool-phase.ts`, `provider.ts`, `payload.ts`, `prune.ts`, `budget.ts`, `xml-parser.ts`, `error-handler.ts`, `sentinels.ts`, `parts.ts`, `types.ts`. Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution).
|
||||
|
||||
## v1.12.3-stale-banner — 2026-05-21
|
||||
|
||||
Stale-stream banner with Retry/Discard. When an assistant message sits `status='streaming'` with no token activity for 60+ seconds, the chat shows a banner above the input. Both actions clear the stale row via new `POST /api/chats/:id/discard_stale` (updates `status='failed'`, publishes `chat_status='idle'`). Closes the UX gap from the 2026-05-21 debugging spiral — slow streams and dead streams now look different.
|
||||
|
||||
## v1.12.2-live-toks — 2026-05-21
|
||||
|
||||
Live tok/s + ctx display next to the status indicator. `ChatThroughput` renders inline beside `StatusDot` while streaming or tool_running. Subscribes to existing `'usage'` WS frames (500ms-throttled, carrying `completion_tokens` + `ctx_used` + `ctx_max`) via `sessionEvents`. Hides when status drops to idle/error or data is older than 10s. Addresses the same UX gap as `v1.12.3-stale-banner` — gives users a live token velocity readout that immediately distinguishes slow from dead.
|
||||
|
||||
## v1.12.1-stop-handler — 2026-05-21
|
||||
|
||||
`handleAbortOrError` now writes `status='cancelled'` on user stop; rows no longer stuck `streaming` forever. Drops stale `messages_status_check` constraint (only `messages_status_chk` remains, allowing 'cancelled' via TS `MESSAGE_STATUSES`). Removes `detectSameNameLoop` and `DOOM_LOOP_SAME_NAME_THRESHOLD` (added during the 2026-05-21 debugging spike, never fired in any real run) plus 12 verbose `ctx.log.info` diagnostic markers from the same spike. Bundles workspace pane sync + status indicator overhaul + startup hung-row sweep that landed earlier in v1.12.1 work.
|
||||
|
||||
## v1.12.0-codecontext — 2026-05-21
|
||||
|
||||
Adds the `codecontext` sidecar (Go-based code-graph indexer at `codecontext:8080/v1/<tool_name>` over `boocode_net`) plus container guidance and skills runtime updates. Introduces the `chat_status` WS frame (`streaming | tool_running | waiting_for_input | idle | error`, widened from `working|idle|error`). Drops the deprecated `session_panes` table — workspace pane state moves to `sessions.workspace_panes jsonb` for cross-device sync via `PATCH /api/sessions/:id/workspace`.
|
||||
|
||||
## v1.11.1-consolidation — 2026-05-21
|
||||
|
||||
Rollup of v1.11.0–v1.11.10 work that was shipped piecemeal. Covers anchored rolling compaction (single `summary=true` row per chat that supersedes itself), doom-loop guard via `detectDoomLoop`, `path_guard` secret-filename deny list, web tools (`web_search` against SearXNG + `web_fetch` with SSRF/private-IP block), and the 5MB stream-cap on response bodies with abort-on-overflow.
|
||||
|
||||
## v1.11.0-context-bar — 2026-05-20
|
||||
|
||||
Persistent context-window tracker in `ChatPane` + `ctx_max` capture via `${LLAMA_SWAP_URL}/upstream/<model>/props`. First inferences after a boocode boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model yet — 60s negative cache TTL recovers on next turn. Replaced an earlier dead read of `parsed.timings.n_ctx` which never carried n_ctx.
|
||||
|
||||
## v1.10.1-booterm-user — 2026-05-19
|
||||
|
||||
Per-user shell privilege drop in the booterm container via `gosu` in `tmux.conf` default-command. Shells launched in browser terminal panes drop privs to `samkintop` rather than running as root inside the container.
|
||||
|
||||
## v1.10.0-booterm — 2026-05-18
|
||||
|
||||
Second container (`apps/booterm`, port 9501, bookworm-slim+glibc). Fastify + node-pty + tmux. Browser terminal panes connect via WS to `/ws/term/sessions/:sid/panes/:pid`; per-session tmux session `bc-<sid>`, per-pane window `term-<pid>`. xterm-addon-webgl with `document.fonts.load(...)`-gated init (Canvas2D doesn't honor `font-display: block`) and iOS-friendly visibility-change context recreation.
|
||||
|
||||
## v1.9.2-ask-user-input — 2026-05-18
|
||||
|
||||
`ask_user_input` elicitation tool. Pauses the inference loop and surfaces a prompt to the user; their response routes back as the tool result. Correlation initially via `messages.tool_calls` / `tool_results` JSON columns (later ported to `message_parts` in `v1.13.0-ai-sdk-v6`).
|
||||
|
||||
## v1.9.1-skills — 2026-05-18
|
||||
|
||||
Skills runtime + `/skill` slash command with autocomplete. Server-side parser, tools, `/api/skills`, and mount. Hardens `.dockerignore` to exclude `secrets/` and `data/`. Drops the type-to-confirm gate on chat delete (plain Cancel/Confirm only — per workspace convention).
|
||||
|
||||
## v1.9.0-themes-settings — 2026-05-17
|
||||
|
||||
Settings pane + per-project defaults + bulk archive + themes lift. `themes-v1` (18 preset palettes) ships in the same batch with a Settings picker for live theme switching.
|
||||
|
||||
## v1.8.2-cap-hit — 2026-05-17
|
||||
|
||||
Tool-loop cap-hit summary — when an assistant exceeds the per-turn tool budget, a sentinel `role='system'` row with `metadata.kind='cap_hit'` is inserted and a summary turn runs to give the user a coherent endpoint. Also compacts the tool-call UI rendering.
|
||||
|
||||
## v1.8.1-agents-global — 2026-05-16
|
||||
|
||||
Global agents (`data/AGENTS.md` bind-mounted at `/data/AGENTS.md`) + parser robustness + WS reconnect toast. Per-project `AGENTS.md` mechanism (`getAgentsForProject`) remains for *other* projects; the BooCode repo itself uses global-only to eliminate two-files-must-stay-in-sync drift.
|
||||
|
||||
## v1.8.0-agents — 2026-05-16
|
||||
|
||||
Tier 2 agents — `AGENTS.md` registry + per-session agent picker. Also lands mobile tab switcher, branch indicator, and the `git_status` tool.
|
||||
|
||||
## v1.7.0-drag-drop — 2026-05-16
|
||||
|
||||
Drag-drop + paste-as-attachment for long text in the chat input.
|
||||
|
||||
## v1.6.0-mobile — 2026-05-16
|
||||
|
||||
Full mobile suite. Adds `useViewport` (matchMedia breakpoints mobile <768 / tablet 768–1023 / desktop ≥1024), `useSidebarDrawer` / `useRightRailDrawer` (Context + auto-close on `useLocation().pathname` change), `useLongPress` (500ms timer, synthetic `contextmenu`), `usePullToRefresh` (80px threshold, 600ms hold), `SwipeablePaneTab` (60px close, 30px vertical bail). Mobile headers with safe-area padding, hamburger left, FolderTree right. Tap targets at `max-md:min-h-[44px] max-md:min-w-[44px]`. Raises `MAX_TOOL_LOOP_DEPTH` 5 → 15. Right-rail becomes a drawer on mobile.
|
||||
|
||||
## v1.5.1-bootstrap — 2026-05-16
|
||||
|
||||
Bootstrap fixes — git + ssh installed in the boocode container, Tailscale host rewrite, `/opt/projects` label correction for the create-new-project bootstrap flow.
|
||||
|
||||
## v1.5.0-refactor-tests — 2026-05-16
|
||||
|
||||
Refactor split (FileBrowserPane / Workspace / `runAssistantTurn`) + vitest harness + unit tests for security-critical pure functions. Scopes the `/opt` mount to `/opt/projects` (writable) plus `PROJECT_ROOT_WHITELIST=/opt` (read-only resolution for add-existing). Surfaces swallowed errors and removes dead `session_renamed` paths.
|
||||
|
||||
## v1.4.0-fork-header — 2026-05-16
|
||||
|
||||
Fork from message + delete message + header polish + general housekeeping.
|
||||
|
||||
## v1.3.0-chats-projects — 2026-05-16
|
||||
|
||||
Chats-in-sessions era. Adds force-send, `/compact`, right-rail file browser, archive/rename/Open-in-Gitea sidebar context menu, archived projects landing page, create-project bootstrap with Gitea remote setup, landing-card buttons, 1000px content cap. Dedup audit and chat archive/delete from the sidebar.
|
||||
|
||||
## v1.2.0-multi-pane — 2026-05-15
|
||||
|
||||
Multi-pane workspace (batch 3, T1–T8). `session_panes` schema (later replaced by `sessions.workspace_panes jsonb` in v1.12.0), `Pane` discriminated union, broker user channel + `/api/ws/user`, `file_ops` + `file_index` services, `PaneShell` / `ChatPane` / `FileBrowserPane` / `PaneTab` / `Workspace` components, `usePanes` hook, Shiki integration in `CodeBlock`. Up to 5 panes per session; default chat pane created on `POST /api/sessions`.
|
||||
|
||||
## v1.1.0-markdown-sidebar — 2026-05-15
|
||||
|
||||
Markdown rendering, message actions, tok/s + ctx display, AI session naming. Sidebar restructure — chats nested under projects (max 5 + view-all), live updates via WS.
|
||||
|
||||
## v1.0.0-initial — 2026-05-14
|
||||
|
||||
Initial commit. Skeleton of the monorepo: `apps/server` (Fastify + postgres), `apps/web` (React + Vite), basic chat loop against llama-swap.
|
||||
56
CLAUDE.md
56
CLAUDE.md
@@ -46,17 +46,45 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
|
||||
- **Zod** for request validation and config parsing.
|
||||
|
||||
Key services:
|
||||
- **`services/inference/`** (v1.12.4 split — was a single `inference.ts` file). Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js`. Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner orchestration, plus `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult` exported), `stream-phase.ts` (streamCompletion + executeStreamPhase + SSE parsing), `tool-phase.ts` (executeToolPhase; back-edges into turn.ts for the runAssistantTurn recursion — cycle is safe because dereferenced at call time, not module top-level), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + their sentinel inserters; two near-clones kept side-by-side until a third sentinel justifies factoring out runWrapUpSummary), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (Qwen-coder XML tool-call fallback), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS` shared between stream-phase and sentinel-summaries). **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion (`toolsUsed`, `recentToolCalls`, `assistantMessageId`, `signal`); reset to defaults in `runInference` at the user-message boundary. Cap-hit (`toolsUsed >= budget`) and doom-loop (`detectDoomLoop(recentToolCalls)`) checks both read from this envelope. Add new per-turn state to `TurnArgs` in `turn.ts`, not module-level closures.
|
||||
- **`services/inference/`** — Public surface re-exported via `inference/index.ts`; callers import from `./services/inference/index.js` explicitly (NodeNext doesn't honor directory-index resolution). Layout: `turn.ts` (runAssistantTurn / runInference / createInferenceRunner; exports `InferenceFrame`, `InferenceContext`, `TurnArgs`, `StreamResult`, `MAX_STEPS`), `stream-phase.ts` (streamCompletion as a v1.13.1-A AI SDK adapter + executeStreamPhase), `provider.ts` (`upstreamModel(baseURL, modelId)` wrapping `createOpenAICompatible` against llama-swap), `tool-phase.ts` (executeToolPhase → returns `ToolPhaseResult`; no longer recurses into runAssistantTurn — v1.14.0 converted the recursion to an explicit while loop in turn.ts), `sentinel-summaries.ts` (runCapHitSummary + runDoomLoopSummary + runStepCapSummary + their sentinel inserters), `error-handler.ts` (handleAbortOrError, finalizeCompletion), `payload.ts` (buildMessagesPayload, loadContext, maybeFlagForCompaction, `OpenAiMessage`), `sentinels.ts` (`detectDoomLoop`, `DOOM_LOOP_THRESHOLD`, sentinel predicates), `budget.ts` (resolveToolBudget), `xml-parser.ts` (qwen3.6 XML tool-call fallback — KEEP, AI SDK doesn't handle inline-XML tool calls), `parts.ts` (parts-table write helpers: `partsFromAssistantMessage`, `partsFromToolMessage`, `insertParts` — v1.13.20 made parts the sole source of truth), `prune.ts` (v1.13.4 two-tier compaction; `selectPruneTargets` is the pure decision helper), `types.ts` (`StreamPhaseState`, `DB_FLUSH_INTERVAL_MS`). **`TurnArgs`** is the per-turn state envelope populated from loop locals each iteration; reset in `runInference` at user-message boundary. The outer loop in `runAssistantTurn` (v1.14.0) runs `while (stepNumber < effectiveCap)` where `effectiveCap = Math.min(agent.steps ?? Infinity, MAX_STEPS=200)`. Per-agent `steps:` field in AGENTS.md frontmatter. `steps: 0` means text-only (no tool execution). Step-cap hit writes a `cap_hit` sentinel so `CapHitSentinel.tsx` renders it.
|
||||
- **AI SDK v6 streamCompletion adapter** (v1.13.1-A; `services/inference/stream-phase.ts`). `streamText` is the underlying call; the BooCode layer above (executeStreamPhase, finalize, dual-write) is shape-preserved via an adapter. Five gotchas the LSP/test suite won't catch:
|
||||
- **Abort signals are swallowed.** `streamText`'s `fullStream` iterator exits cleanly when `abortSignal` fires — no throw. Post-iteration `if (signal?.aborted) throw <AbortError>` is required; without it the row finalizes as `complete` instead of `cancelled`. Comment in stream-phase.ts pins this; don't refactor it away.
|
||||
- **Usage lands only at stream end** via `await result.usage` (`inputTokens` / `outputTokens` v6 names → mapped to `promptTokens` / `completionTokens` for the existing onUsage callback). Mid-stream live tok/s is gone vs v1.12.2; ChatThroughput shows a single value at stream end.
|
||||
- **Tools have NO `execute` field.** BooCode dispatches tools in tool-phase.ts, not the AI SDK loop. Only `description` + `inputSchema: jsonSchema(parameters)` — surfacing tool-call parts via `fullStream` and stopping is what we want.
|
||||
- **`includeUsage: true` MUST be set on `createOpenAICompatible`** in `services/inference/provider.ts`. The adapter defaults it false, omitting `stream_options.include_usage` from the request body; llama-swap then never emits the usage block and `result.usage.inputTokens/outputTokens` resolve to `undefined`. Latent regression from v1.13.1-A through v1.13.7 — every assistant row in that window has `tokens_used`/`ctx_used` NULL. Don't remove this flag during refactor.
|
||||
- **Tool-call-only turns may emit a leading `\n` text-delta** as the assistant content. `MessageList.flatten`'s `hasText` and `MessageBubble`'s `hasContent` both `.trim()` before the length check — otherwise whitespace-only content renders an empty bubble + ActionRow between every tool call (v1.13.7 fix). `payload.ts:buildMessagesPayload` also skips `status='failed'` AND complete-but-empty (no content, no tool_calls) assistant rows to avoid "Cannot have 2 or more assistant messages at the end of the list" upstream rejections after cap-hit + Continue.
|
||||
- **AI SDK ModelMessage conversion** (`toModelMessages` in stream-phase.ts). Tool messages need a `toolName` for `ToolResultPart` — BooCode's OpenAI-shape history doesn't carry it, so a forward-scan builds a `tool_call_id → toolName` map from prior assistant `tool_calls`. Tool outputs wrapped as `{ type: 'json' | 'text', value }` matching the v6 `ToolResultOutput` union. Assistant messages with reasoning emit a `ReasoningPart` first in the content array (v1.13.1-C).
|
||||
- **`experimental_repairToolCall`** (v1.13.3) wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through implementation — logs the bad call and returns it unmodified; `executeToolPhase`'s existing zod-reject error path routes it to the model on the next turn.
|
||||
- **`chat_status` frame shape** (published via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'` (widened from `working|idle|error` in v1.12.1). Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders inline beside `StatusDot` only when streaming or tool_running, fed by 500ms-throttled `'usage'` WS frames (`completion_tokens` + `ctx_used` + `ctx_max`). The `POST /api/chats/:id/discard_stale` endpoint exists to mark a stuck-streaming row as `failed` when the frontend's 60s no-token-activity timer (`ChatPane` content-length watcher) gives up.
|
||||
- **Boot-time stale-streaming sweep** in `apps/server/src/index.ts` after `applySchema()`: any `messages.status='streaming'` older than 5 minutes flips to `'failed'`. Logs only on non-zero count. Recovers from container restart while inference was mid-stream (v1.12.1).
|
||||
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
|
||||
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false.
|
||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = ctx_max - 20k`. **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out).
|
||||
- **Periodic 60s sweeper** in `apps/server/src/index.ts` (v1.13.3 + v1.13.5). Same `setInterval` runs `sweepStaleStreaming` (marks `messages.status='streaming'` older than 5 min as `failed`, publishes `chat_status='idle'` so the UI dot drops) and `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `app.addHook('onClose')` clears the timer. No-op when nothing to reap.
|
||||
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart. v1.13.11: every WS publish goes through `broker.publishFrame(sessionId, frame)` or `broker.publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). `ctx.publish` / `ctx.publishUser` in inference + auto_name route through the index.ts adapter that calls publishFrame internally. The schema is duplicated byte-identical at `apps/web/src/api/ws-frames.ts`; a `ws-frames.test.ts` case enforces parity. Don't add new raw `broker.publish()` / `publishUser()` calls.
|
||||
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false. v1.13.5 truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs at `BOOCODE_TRUNCATION_DIR` (default `/tmp/boocode-truncations`, 0o700) keyed by an opaque `tr_<12 base32 chars>` id, and the `view_truncated_output(id)` tool retrieves it. 5MB cap (matches `view_file`'s `MAX_FILE_BYTES`), 7-day TTL, reaped by the periodic sweeper. Tmpfs path means container restart loses retrieval — acceptable, the model usually has moved on.
|
||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)` (v1.13.9 opencode-pattern early trigger; was `ctx_max - 20k` pre-v1.13.9, which gave only 7.6% headroom at 262k and 0 budget for ≤20k contexts). **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out). First inferences after a boocode boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model yet; negative cache TTL is 60s, recovers on next turn. v1.13.6: `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on the assistant `content` (OpenAI wire shape has no structured reasoning field; the summarizer reads text). Standalone tag when content is empty (tool-call-only turn). `buildHeadPayload` + `OpenAiMessage` exported for test access — keep them exported.
|
||||
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string-returning shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. v1.13.8 instrumentation: SHA-256 of the assembled prefix is logged per `buildMessagesPayload` call (msg `prefix-fingerprint`, level=info); a `Map<sessionId, lastHash>` observer fires `prefix-drift` (level=warn) on hash change with a field-level `changed_inputs` diff. Smoke proved the prefix is byte-stable across turns in steady-state — the originally-planned `system_prompt_cache` DB table was dropped as redundant against the v1.12.0 input-layer mtime caches (BOOCHAT.md here + AGENTS.md global+per-project in `agents.ts:safeStat`).
|
||||
- **`services/inference/budget.ts`** — tool-call budgets: `BUDGET_READ_ONLY = 30`, `BUDGET_NON_READ_ONLY = 10` (forward-looking; no write tools yet), `BUDGET_NO_AGENT = 30` (v1.13.7; was 15 — every tool in `ALL_TOOLS` is read-only today, so no-agent mode shares the read-only-agent cap). Per-agent `max_tool_calls` from AGENTS.md frontmatter overrides.
|
||||
- **`messages_with_parts` view** (v1.13.1-B; `schema.sql`). Read sites that need `tool_calls` / `tool_results` / `reasoning_parts` SELECT from this view, NOT `messages` directly. v1.13.20 dropped the legacy `messages.tool_calls` / `messages.tool_results` JSON columns; the view now reads parts-only subselects. Writes target `message_parts` exclusively via `insertParts` (or via the helpers `partsFromAssistantMessage` / `partsFromToolMessage`). The `Message` wire type still carries `tool_calls?` / `tool_results?` because the view synthesizes them from parts — frontend reads are unchanged. Shapes: `tool_calls jsonb[]`, `tool_results jsonb` single object, `reasoning_parts jsonb[]` of `{text}`. If you ever need to UPDATE a message and return its full Message shape, do a two-step UPDATE returning `id` followed by SELECT from the view — RETURNING off the bare `messages` table no longer carries the tool fields.
|
||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||
- **`services/provider-registry.ts`** — Static registry of provider metadata (label, transport, model source). `PROVIDERS` array, `PROVIDERS_BY_NAME` map. 5 providers: boocode (native), opencode (acp), goose (pty), claude (pty), qwen (pty).
|
||||
- **`services/agent-probe.ts`** — Startup probe using direct `exec()` (not SSH). Discovers installed agents on host, their versions, ACP support, and models. Qwen models read from `~/.qwen/settings.json`. Claude models are static from the registry. Results persisted to `available_agents` table.
|
||||
- **`routes/providers.ts`** — `GET /api/providers` returns installed providers with models. Transport field reflects actual capability (checks `supports_acp` from DB, not just registry preference).
|
||||
- **Provider picker dispatch**: when `provider !== 'boocode'`, the message route creates a `tasks` row (with `session_id` set) instead of calling `inference.enqueue`. The dispatcher picks it up and dispatches via ACP or PTY using the agent's `install_path`.
|
||||
|
||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
||||
|
||||
### BooCoder (`apps/coder/src/`)
|
||||
|
||||
- Write-capable coding agent. Runs as a **systemd service on the host** (`boocoder.service`), NOT in Docker. Fastify server at port 9502, connects to postgres at `127.0.0.1:5500`.
|
||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. apps/server must build FIRST.
|
||||
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Follows Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||
- systemd hardening: only `NoNewPrivileges=true` is safe. `ProtectSystem`, `ProtectHome`, `PrivateTmp` all break agent dispatch (agents need full filesystem access to read configs, write to worktrees).
|
||||
- `apps/server/tsconfig.json` has `declaration: true` so `.d.ts` files exist for workspace consumers.
|
||||
- Write tools (`edit_file`, `create_file`, `delete_file`, `apply_pending`, `rewind`) queue in `pending_changes` table. Nothing hits disk until `apply_pending` is called. `write_guard.ts` validates paths (resolve + prefix-check, no realpath since files may not exist for creates).
|
||||
- Frontend: NOT a separate SPA. BooCoder is a `'coder'` pane type within BooChat's SPA (`apps/web/`). `CoderPane.tsx` in `apps/web/src/components/panes/`. API requests go through `/api/coder/*` proxy (Vite dev + Fastify production) which rewrites to the boocoder host service (`BOOCODER_URL` env var, default `http://100.114.205.53:9502`). WS connects directly to `:9502`.
|
||||
- `apps/coder/web/` is a STANDALONE fallback SPA served at `:9502` directly. The PRIMARY BooCoder frontend is the `CoderPane` in BooChat's SPA (`apps/web/src/components/panes/CoderPane.tsx`), accessible via the "Coder" pane in the workspace at `code.indifferentketchup.com`. Both exist; the pane is what Sam uses.
|
||||
|
||||
### Frontend (`apps/web/src/`)
|
||||
|
||||
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
|
||||
@@ -93,21 +121,32 @@ Sessions hold 1–5 panes (chat / empty / placeholder terminal+agent). v1.12.1 m
|
||||
|
||||
## Database
|
||||
|
||||
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`. (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
|
||||
PostgreSQL 16. Database name: `boochat` (renamed from `boocode` in v2.0.0-alpha; Docker service name stays `boocode_db`). Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `message_parts` (v1.13.0), `pending_changes` (v2.0.0), `tasks` (v2.0.0), `available_agents` (v2.0.0). Views: `messages_with_parts` (v1.13.1-B parts-merge read path), `tool_cost_stats` (v1.13.10 per-tool 100-call rolling window), `human_inbox` (v2.0.0 — tasks WHERE state IN blocked/failed). (`session_panes` was dropped in v1.12.1; workspace pane state lives in `sessions.workspace_panes jsonb`.) Schema applied idempotently on startup via `applySchema()`. Use `clock_timestamp()` (not `NOW()`) inside transactions. CHECK constraints in place: `projects_status_chk` ('open'|'archived'), `sessions_status_chk` (same), `chats_status_chk` (same), `messages_role_chk`, `messages_status_chk` — keep in sync with the `*_STATUSES` const arrays in `apps/server/src/types/api.ts`. The older anonymous `messages_status_check` (without 'cancelled') and `messages_role_check` (without 'system') were dropped in v1.12.1; only the `_chk` variants remain.
|
||||
|
||||
Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ... DROP CONSTRAINT IF EXISTS <system_name>` (inline `CREATE TABLE` checks get `<table>_<column>_check`), (2) `UPDATE` rows to new values, (3) wrap new constraint ADD in `DO $$ ... pg_constraint` guard — that block is the only way to get `ADD CONSTRAINT IF NOT EXISTS`.
|
||||
|
||||
|
||||
## Environment
|
||||
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context).
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context), `BOOCODE_TOOLS` (`core` | `standard` | `all`, default `all`; v1.13.15-tools tier filter — ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (optional; default `/data/mcp.json` — JSON config for MCP servers matching opencode's `mcpServers` shape; file missing = no MCP).
|
||||
|
||||
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
|
||||
|
||||
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL when unset. Set to a small model on llama-swap (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
|
||||
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 required on host (container stays Node 20; dispatch via SSH). No `--yolo` flag — non-interactive mode (`-p`) runs autonomously without approval prompts. ACP bridge is HTTP daemon (not stdio); use PTY dispatch.
|
||||
- Arena (v2.0.5): `POST /api/arena {project_id, input, contestants: [{agent?, model?}]}` dispatches the same task to N models/agents in parallel. Each contestant gets its own task + worktree. `GET /api/arena/:id` for results. `POST /api/arena/:id/select/:task_id` picks winner.
|
||||
|
||||
## Workflow
|
||||
|
||||
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
||||
- Per-batch docs live under `openspec/changes/<slug>/{proposal,tasks,design}.md`. Already-shipped batches are snapshots in `openspec/changes/archived/`. New batches follow the proposal+tasks shape; see `openspec/README.md` for the convention.
|
||||
- Tag naming: `vMAJOR.MINOR.PATCH-slug` (e.g. `v1.13.13-ws-publish`). Monotonic per minor — the slug describes the batch's content so the tag name alone is enough to recall what shipped. No letter suffixes (`-a`/`-b`), no pseudo-ranges (`v1.11.x`), no slug-only sub-versions sharing a number (`v1.13.15-tools` + `-openspec` + `-agentlint` — split into sequential patches instead).
|
||||
- `CHANGELOG.md` is the per-tag release log, most-recent on top. When a new tag is created, add a `## <tag> — <YYYY-MM-DD>` section with a 3–6 sentence paragraph summarizing what shipped, drawn from the commit body. Cross-reference other tags by name when the batch builds on, fixes, or pairs with prior work (e.g. "pairs with `v1.13.12-ws-schemas`", "fixed in `v1.13.5-stability-bundle`"). No nested bullets — one paragraph.
|
||||
- Deploy: `cd /opt/boocode && docker compose up --build -d` (or `docker compose build --no-cache boocode && docker compose up -d` if you suspect a layer-cache issue).
|
||||
- Git push to Gitea: `GIT_SSH_COMMAND="ssh -i /opt/boocode/secrets/boocode_gitea -o IdentitiesOnly=yes" git push origin <branch>`. The default agent identity is rejected; the in-repo deploy key (`secrets/`, gitignored) is the working one. Transient `Connection reset by peer` retries cleanly after `sleep 5`.
|
||||
- Don't accumulate `.bak-*` files. Clean them up in the same batch or immediately after merge.
|
||||
- DB-integration tests opt-in via env var: `DATABASE_URL='postgres://boocode:devpass@localhost:5500/boochat' pnpm -C apps/server test`. Host port is 5500 (mapped from `boocode_db:5432`); password is `${POSTGRES_PASSWORD}` from `.env` (`devpass`), NOT the literal in `.env`'s `DATABASE_URL=postgres://boocode:Ketchup1479@boocode_db:5432/...` line. Pattern: `describe.runIf(!!process.env.DATABASE_URL)(...)` with a `beforeAll` that applies the schema via `sql.unsafe(readFileSync(schemaPath))`. Tests skip cleanly when var is unset. `tool_cost_stats.test.ts` is the reference.
|
||||
- Host-side smoke endpoint: `curl http://100.114.205.53:9500/api/...`. The boocode container's port mapping binds to the Tailscale IP, not `0.0.0.0`, so `localhost:9500` doesn't work from the host shell. Same for booterm at `:9501`.
|
||||
- Fastify global JSON parser tolerates empty bodies (overridden in `index.ts`); bodyless POSTs (archive, unarchive, stop) work without setting `Content-Type` tricks on the client.
|
||||
- Event dedup discipline: for any mutation the server publishes via `broker.publishUser`, do NOT add a local `sessionEvents.emit(...)` after the API call — `useUserEvents` forwards the WS frame onto the bus. Frontend mutation handlers must be idempotent (dedup by id, no-op on already-present).
|
||||
- `node:20-*` base images ship a `node` user at uid/gid 1000 — delete it (`userdel`/`groupdel` on debian, `deluser`/`delgroup` on alpine) before adding samkintop at 1000.
|
||||
@@ -117,6 +156,8 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
||||
- `/opt/boolab` hosts a working sibling BooCode terminal at `boocode.indifferentketchup.com`. Useful for visual side-by-side comparison on the same iPhone when debugging booterm rendering. Boolab uses Tailwind v3 (`@tailwind base`); boocode uses v4 — many subtle build differences. Don't assume parity.
|
||||
- booterm SSHs to the host as `samkintop@100.114.205.53` (the Tailscale IP). The hostname `ubuntu-homelab` (shown in the bash prompt after login) does NOT resolve from inside the container — only the host's `/etc/hosts` knows it. Override via `BOOTERM_SSH_HOST` / `BOOTERM_SSH_USER` env vars in docker-compose if you ever move the shell to a different machine.
|
||||
- codecontext sidecar lives at `/opt/boocode/codecontext/`. Sidecar HTTP API at `http://codecontext:8080/v1/<tool_name>` over the `boocode_net` bridge (no host port). BooCode wrappers in `apps/server/src/services/tools/codecontext/`. The `.codecontextignore.template` documents recommended ignore patterns; users copy and adapt to project root manually.
|
||||
- codecontext fork at `/opt/forks/codecontext/` — separate git repo (branch `boocode-ts`), pushed via the same boocode_gitea SSH key to `indifferentketchup/codecontext`. Build: `go build ./...`. Test: `go test ./...`. Docker rebuild: `docker compose build --no-cache codecontext`.
|
||||
- Go binary: `/snap/go/current/bin/go` (not on PATH by default). Use `export PATH=$PATH:/snap/go/current/bin` or full path for Go commands.
|
||||
- `os/exec` child supervisors must explicitly call `child.Wait()` in a goroutine and `os.Exit` on child death. `Signal(0)` returns nil on zombies and is NOT a liveness check. Without `Wait()`, docker's `restart: unless-stopped` policy never fires because the parent stays alive. The `codecontext/shim.go` implementation is the reference pattern.
|
||||
|
||||
## Conventions
|
||||
@@ -139,3 +180,6 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
||||
- Tool-name whitelists must derive from `ALL_TOOLS` in `services/tools.ts`, never hardcoded. `services/agents.ts` `ALL_TOOL_NAMES` had this drift class until v1.12 — same pattern applies to any future tool-aware code.
|
||||
- Agent registry lives at `data/AGENTS.md` (global, bind-mounted at `/data/AGENTS.md`). No per-project `AGENTS.md` in this repo — removed in v1.12 to eliminate the two-files-must-stay-in-sync drift. The `getAgentsForProject` per-project override mechanism remains for *other* projects.
|
||||
- MCP stdio transport uses newline-delimited JSON (NDJSON), NOT LSP-style `Content-Length` headers. The `codecontext/shim.go` framing implementation is the reference; per the MCP spec (modelcontextprotocol.io/specification/server/transports).
|
||||
- **Workspace dependency pattern** (`apps/coder` → `@boocode/server`): the consuming package adds `"@boocode/server": "workspace:*"` in `package.json`. The provider's `package.json` needs `exports` with `types` + `default` conditions per subpath: `"./inference": { "types": "./dist/.../index.d.ts", "default": "./dist/.../index.js" }`. Without the `types` condition, NodeNext resolution can't find `.d.ts` files and tsc fails with "Cannot find module" in the consumer.
|
||||
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of a JSON object/array). Pattern established in `parts.ts`, `settings.ts`.
|
||||
- **`payload.ts:loadContext` SELECT**: must include every `Session` field that downstream code reads. The tool phase reads `session.allowed_read_paths`; if the SELECT omits it, cross-repo read grants silently fail. The `Session` TypeScript type doesn't catch this because `sql<Session[]>` doesn't enforce column coverage.
|
||||
|
||||
14
apps/coder/.env.host
Normal file
14
apps/coder/.env.host
Normal file
@@ -0,0 +1,14 @@
|
||||
NODE_ENV=production
|
||||
PORT=9502
|
||||
HOST=100.114.205.53
|
||||
DATABASE_URL=postgres://boocode:devpass@127.0.0.1:5500/boochat
|
||||
LLAMA_SWAP_URL=http://100.101.41.16:8401
|
||||
PROJECT_ROOT_WHITELIST=/opt
|
||||
BOOTSTRAP_ROOT=/opt/projects
|
||||
DEFAULT_MODEL=qwen3.6-35b-a3b-mxfp4
|
||||
LOG_LEVEL=info
|
||||
SEARXNG_URL=http://100.114.205.53:8888
|
||||
GITEA_BASE_URL=https://git.indifferentketchup.com
|
||||
GITEA_USER=indifferentketchup
|
||||
GITEA_SSH_HOST=100.114.205.53:2222
|
||||
MCP_CONFIG_PATH=/data/mcp.json
|
||||
35
apps/coder/Dockerfile
Normal file
35
apps/coder/Dockerfile
Normal file
@@ -0,0 +1,35 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
FROM node:20-alpine AS builder
|
||||
RUN corepack enable
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY apps/server/package.json ./apps/server/
|
||||
COPY apps/coder/package.json ./apps/coder/
|
||||
COPY apps/coder/web/package.json ./apps/coder/web/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# Build server first (coder depends on it via workspace dep for types + inference)
|
||||
COPY apps/server ./apps/server
|
||||
RUN pnpm -C apps/server build
|
||||
|
||||
COPY apps/coder ./apps/coder
|
||||
RUN pnpm -C apps/coder/web build
|
||||
RUN pnpm -C apps/coder build
|
||||
|
||||
RUN pnpm deploy --filter=@boocode/coder --prod --legacy /out/coder
|
||||
|
||||
|
||||
FROM node:20-bookworm-slim AS runtime
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git openssh-client && rm -rf /var/lib/apt/lists/*
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/coder ./
|
||||
COPY --from=builder /build/apps/coder/web/dist ./web
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "dist/index.js"]
|
||||
33
apps/coder/package.json
Normal file
33
apps/coder/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@boocode/coder",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||
"start": "node dist/index.js",
|
||||
"cli": "tsx src/cli.ts",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"@boocode/server": "workspace:*",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"fastify": "^4.28.1",
|
||||
"postgres": "^3.4.4",
|
||||
"ws": "^8.18.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/ws": "^8.5.10",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.5.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
249
apps/coder/src/cli.ts
Normal file
249
apps/coder/src/cli.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* BooCoder CLI client.
|
||||
*
|
||||
* Usage:
|
||||
* boocode run "task description" [--agent opencode] [--model claude-opus-4-7] [--project <id>]
|
||||
* boocode ls [--state pending|running|completed|failed]
|
||||
* boocode attach <task-id>
|
||||
* boocode send <task-id> "message"
|
||||
*/
|
||||
import { WebSocket } from 'ws';
|
||||
|
||||
const BASE_URL = process.env.BOOCODER_URL ?? 'http://100.114.205.53:9502';
|
||||
|
||||
// ─── Arg parsing ─────────────────────────────────────────────────────────────
|
||||
|
||||
function getFlag(args: string[], name: string): string | undefined {
|
||||
const idx = args.indexOf(name);
|
||||
if (idx === -1 || idx + 1 >= args.length) return undefined;
|
||||
return args[idx + 1];
|
||||
}
|
||||
|
||||
function hasFlag(args: string[], name: string): boolean {
|
||||
return args.includes(name);
|
||||
}
|
||||
|
||||
// ─── HTTP helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
async function api(method: string, path: string, body?: unknown): Promise<unknown> {
|
||||
const url = `${BASE_URL}${path}`;
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
throw new Error(`${method} ${path} → ${res.status}: ${text}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ─── WS streaming ────────────────────────────────────────────────────────────
|
||||
|
||||
function streamSession(sessionId: string): void {
|
||||
const wsUrl = BASE_URL.replace(/^http/, 'ws') + `/api/ws/sessions/${sessionId}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
|
||||
ws.on('message', (data) => {
|
||||
try {
|
||||
const frame = JSON.parse(data.toString()) as { type: string; content?: string; name?: string; arguments?: string };
|
||||
if (frame.type === 'delta' && frame.content) {
|
||||
process.stdout.write(frame.content);
|
||||
} else if (frame.type === 'tool_call') {
|
||||
process.stdout.write(`\n[tool: ${frame.name ?? '?'}(${(frame.arguments ?? '').slice(0, 80)})]\n`);
|
||||
} else if (frame.type === 'tool_result') {
|
||||
process.stdout.write(`[tool_result]\n`);
|
||||
} else if (frame.type === 'status' || frame.type === 'chat_status') {
|
||||
// Silent
|
||||
}
|
||||
} catch {
|
||||
// Non-JSON frame, ignore
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
process.stderr.write(`WS error: ${err.message}\n`);
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
process.stdout.write('\n');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGINT', () => {
|
||||
ws.close();
|
||||
process.exit(0);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Commands ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function cmdRun(args: string[]): Promise<void> {
|
||||
const input = args.find((a) => !a.startsWith('--'));
|
||||
if (!input) {
|
||||
process.stderr.write('Usage: boocode run "task description" [--agent X] [--model X] [--project X]\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const agent = getFlag(args, '--agent');
|
||||
const model = getFlag(args, '--model');
|
||||
const project_id = getFlag(args, '--project');
|
||||
|
||||
if (!project_id) {
|
||||
process.stderr.write('Error: --project <uuid> is required\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const result = (await api('POST', '/api/tasks', {
|
||||
project_id,
|
||||
input,
|
||||
...(agent && { agent }),
|
||||
...(model && { model }),
|
||||
})) as { id: string; state: string };
|
||||
|
||||
process.stdout.write(`Task created: ${result.id} (state: ${result.state})\n`);
|
||||
|
||||
// Poll until task has session_id, then stream; or poll until terminal state
|
||||
const POLL_MS = 2000;
|
||||
for (;;) {
|
||||
await sleep(POLL_MS);
|
||||
const task = (await api('GET', `/api/tasks/${result.id}`)) as {
|
||||
id: string; state: string; session_id?: string; output_summary?: string;
|
||||
};
|
||||
|
||||
if (task.session_id) {
|
||||
process.stdout.write(`Streaming session ${task.session_id}...\n`);
|
||||
streamSession(task.session_id);
|
||||
return; // streamSession handles exit
|
||||
}
|
||||
|
||||
if (task.state === 'completed') {
|
||||
process.stdout.write(`\nCompleted: ${task.output_summary ?? '(no summary)'}\n`);
|
||||
return;
|
||||
}
|
||||
if (task.state === 'failed') {
|
||||
process.stderr.write(`\nFailed: ${task.output_summary ?? '(no summary)'}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
if (task.state === 'cancelled') {
|
||||
process.stderr.write(`\nCancelled.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdLs(args: string[]): Promise<void> {
|
||||
const state = getFlag(args, '--state');
|
||||
const query = state ? `?state=${state}` : '';
|
||||
const tasks = (await api('GET', `/api/tasks${query}`)) as Array<{
|
||||
id: string; state: string; agent: string | null; input: string; created_at: string;
|
||||
}>;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
process.stdout.write('No tasks.\n');
|
||||
return;
|
||||
}
|
||||
|
||||
// Table header
|
||||
process.stdout.write(
|
||||
pad('ID', 38) + pad('STATE', 12) + pad('AGENT', 14) + pad('INPUT', 52) + 'CREATED\n',
|
||||
);
|
||||
process.stdout.write('-'.repeat(120) + '\n');
|
||||
|
||||
for (const t of tasks) {
|
||||
process.stdout.write(
|
||||
pad(t.id, 38) +
|
||||
pad(t.state, 12) +
|
||||
pad(t.agent ?? '-', 14) +
|
||||
pad(t.input.slice(0, 50), 52) +
|
||||
(t.created_at?.slice(0, 19) ?? '') + '\n',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdAttach(args: string[]): Promise<void> {
|
||||
const taskId = args[0];
|
||||
if (!taskId) {
|
||||
process.stderr.write('Usage: boocode attach <task-id>\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const task = (await api('GET', `/api/tasks/${taskId}`)) as { session_id?: string };
|
||||
if (!task.session_id) {
|
||||
process.stderr.write('Task has no session yet (still pending?).\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
streamSession(task.session_id);
|
||||
}
|
||||
|
||||
async function cmdSend(args: string[]): Promise<void> {
|
||||
const taskId = args[0];
|
||||
const message = args[1];
|
||||
if (!taskId || !message) {
|
||||
process.stderr.write('Usage: boocode send <task-id> "message"\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const task = (await api('GET', `/api/tasks/${taskId}`)) as { session_id?: string };
|
||||
if (!task.session_id) {
|
||||
process.stderr.write('Task has no session yet.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Find active chat
|
||||
const sessionId = task.session_id;
|
||||
// POST message to the session's chat (the messages route expects session_id in path)
|
||||
await api('POST', `/api/sessions/${sessionId}/messages`, { content: message });
|
||||
|
||||
// Then attach to stream the response
|
||||
streamSession(sessionId);
|
||||
}
|
||||
|
||||
// ─── Utils ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function pad(s: string, width: number): string {
|
||||
return s.length >= width ? s.slice(0, width) : s + ' '.repeat(width - s.length);
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// ─── Main ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const [cmd, ...rest] = process.argv.slice(2);
|
||||
|
||||
switch (cmd) {
|
||||
case 'run':
|
||||
cmdRun(rest).catch(fatal);
|
||||
break;
|
||||
case 'ls':
|
||||
cmdLs(rest).catch(fatal);
|
||||
break;
|
||||
case 'attach':
|
||||
cmdAttach(rest).catch(fatal);
|
||||
break;
|
||||
case 'send':
|
||||
cmdSend(rest).catch(fatal);
|
||||
break;
|
||||
default:
|
||||
process.stdout.write(
|
||||
'BooCoder CLI\n\n' +
|
||||
'Commands:\n' +
|
||||
' run "task" [--agent X] [--model X] [--project <id>] Create and stream a task\n' +
|
||||
' ls [--state pending|running|completed|failed] List tasks\n' +
|
||||
' attach <task-id> Stream a running task\n' +
|
||||
' send <task-id> "message" Send input to a task\n' +
|
||||
'\n' +
|
||||
`Base URL: ${BASE_URL} (set BOOCODER_URL to override)\n`,
|
||||
);
|
||||
if (cmd && cmd !== '--help' && cmd !== '-h') process.exit(1);
|
||||
}
|
||||
|
||||
function fatal(err: unknown): void {
|
||||
process.stderr.write(`Error: ${err instanceof Error ? err.message : String(err)}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
47
apps/coder/src/config.ts
Normal file
47
apps/coder/src/config.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
// BooCoder's config is a superset of the server's Config type so it can be
|
||||
// passed directly into the inference runner's InferenceContext. Fields the
|
||||
// inference loop reads: LLAMA_SWAP_URL, PROJECT_ROOT_WHITELIST. The rest
|
||||
// default to values that satisfy the server's Zod schema without BooCoder
|
||||
// needing to supply them in its environment.
|
||||
const ConfigSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
HOST: z.string().default('0.0.0.0'),
|
||||
DATABASE_URL: z.string().url(),
|
||||
LLAMA_SWAP_URL: z.string().url(),
|
||||
PROJECT_ROOT_WHITELIST: z.string().default('/opt'),
|
||||
BOOTSTRAP_ROOT: z.string().default('/opt/projects'),
|
||||
DEFAULT_MODEL: z.string().default('qwen3.6-35b-a3b-mxfp4'),
|
||||
LOG_LEVEL: z.string().default('info'),
|
||||
CONTAINER_GUIDANCE_FILE: z.string().optional(),
|
||||
// Fields needed to satisfy the server's Config type but unused by BooCoder:
|
||||
SEARXNG_URL: z.string().url().default('http://100.114.205.53:8888'),
|
||||
GITEA_BASE_URL: z.string().url().default('https://git.indifferentketchup.com'),
|
||||
GITEA_USER: z.string().default('indifferentketchup'),
|
||||
GITEA_TOKEN: z.string().optional(),
|
||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||
MCP_CONFIG_PATH: z.string().optional(),
|
||||
// v2.0.5: cheaper model for titles, summaries, labeling.
|
||||
FAST_MODEL: z.string().optional(),
|
||||
// SSH access to the host for external agent dispatch (Phase 5)
|
||||
BOOCODER_SSH_HOST: z.string().default('100.114.205.53'),
|
||||
BOOCODER_SSH_USER: z.string().default('samkintop'),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
let cached: Config | null = null;
|
||||
|
||||
export function loadConfig(): Config {
|
||||
if (cached) return cached;
|
||||
const parsed = ConfigSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
console.error('Invalid environment configuration:');
|
||||
console.error(parsed.error.flatten().fieldErrors);
|
||||
process.exit(1);
|
||||
}
|
||||
cached = parsed.data;
|
||||
return cached;
|
||||
}
|
||||
45
apps/coder/src/db.ts
Normal file
45
apps/coder/src/db.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import postgres from 'postgres';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import type { Config } from './config.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
export type Sql = ReturnType<typeof postgres>;
|
||||
|
||||
let sqlInstance: Sql | null = null;
|
||||
|
||||
export function getSql(config: Config): Sql {
|
||||
if (sqlInstance) return sqlInstance;
|
||||
sqlInstance = postgres(config.DATABASE_URL, {
|
||||
max: 10,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
onnotice: () => {},
|
||||
});
|
||||
return sqlInstance;
|
||||
}
|
||||
|
||||
export async function applySchema(sql: Sql): Promise<void> {
|
||||
const schemaPath = resolve(__dirname, 'schema.sql');
|
||||
const ddl = await readFile(schemaPath, 'utf8');
|
||||
await sql.unsafe(ddl);
|
||||
}
|
||||
|
||||
export async function pingDb(sql: Sql): Promise<boolean> {
|
||||
try {
|
||||
await sql`SELECT 1`;
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function closeDb(): Promise<void> {
|
||||
if (sqlInstance) {
|
||||
await sqlInstance.end({ timeout: 5 });
|
||||
sqlInstance = null;
|
||||
}
|
||||
}
|
||||
191
apps/coder/src/index.ts
Normal file
191
apps/coder/src/index.ts
Normal file
@@ -0,0 +1,191 @@
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync } from 'node:fs';
|
||||
import Fastify from 'fastify';
|
||||
import fastifyWebsocket from '@fastify/websocket';
|
||||
import fastifyStatic from '@fastify/static';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
import { loadConfig } from './config.js';
|
||||
import { getSql, applySchema, pingDb, closeDb } from './db.js';
|
||||
import { startMcpServer } from './services/mcp-server.js';
|
||||
// v2.0.0 Phase 2B: workspace dependency on @boocode/server — reuse the
|
||||
// inference loop, broker, and tool registry without duplication.
|
||||
import { createInferenceRunner } from '@boocode/server/inference';
|
||||
import { createBroker } from '@boocode/server/broker';
|
||||
import { appendMcpTools, ALL_TOOLS } from '@boocode/server/tools';
|
||||
import type { Config as ServerConfig } from '@boocode/server/config';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
// v2.0.0 Phase 2C: write tools + adapter for BooChat ToolDef compatibility.
|
||||
import { WRITE_TOOLS } from './services/tools/index.js';
|
||||
import { adaptWriteTool } from './services/tools/adapter.js';
|
||||
import { setInferenceContext, clearInferenceContext } from './services/tools/inference_context.js';
|
||||
// Routes
|
||||
import { registerMessageRoutes } from './routes/messages.js';
|
||||
import { registerPendingRoutes } from './routes/pending.js';
|
||||
import { registerTaskRoutes } from './routes/tasks.js';
|
||||
import { registerInboxRoutes } from './routes/inbox.js';
|
||||
import { registerStatsRoutes } from './routes/stats.js';
|
||||
import { registerArenaRoutes } from './routes/arena.js';
|
||||
import { registerProviderRoutes } from './routes/providers.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
// Phase 4: dispatcher + agent probe
|
||||
import { createDispatcher } from './services/dispatcher.js';
|
||||
import { probeAgents } from './services/agent-probe.js';
|
||||
|
||||
async function main() {
|
||||
// MCP mode: stdio transport, no HTTP server
|
||||
if (process.argv.includes('--mcp')) {
|
||||
const config = loadConfig();
|
||||
const sql = getSql(config);
|
||||
await applySchema(sql);
|
||||
await startMcpServer(sql);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
const app = Fastify({
|
||||
logger: { level: config.LOG_LEVEL },
|
||||
});
|
||||
|
||||
// Allow empty JSON bodies (same pattern as apps/server).
|
||||
app.removeContentTypeParser(['application/json']);
|
||||
app.addContentTypeParser('application/json', { parseAs: 'string' }, (_req, body, done) => {
|
||||
const str = (body as string) ?? '';
|
||||
if (str.trim().length === 0) {
|
||||
done(null, {});
|
||||
return;
|
||||
}
|
||||
try {
|
||||
done(null, JSON.parse(str));
|
||||
} catch (err) {
|
||||
done(err as Error, undefined);
|
||||
}
|
||||
});
|
||||
|
||||
const sql = getSql(config);
|
||||
await applySchema(sql);
|
||||
app.log.info('database schema applied');
|
||||
|
||||
// Broker: in-memory pub/sub for session + user channel streaming.
|
||||
const broker = createBroker(app.log);
|
||||
|
||||
// --- Tool registry extension ---
|
||||
// Append BooCoder write tools (adapted to BooChat's ToolDef interface) to
|
||||
// the shared ALL_TOOLS registry. appendMcpTools re-sorts and rebuilds
|
||||
// TOOLS_BY_NAME so tool-phase.ts dispatch sees the full set.
|
||||
const adaptedWriteTools = WRITE_TOOLS.map((t) => adaptWriteTool(t));
|
||||
appendMcpTools(adaptedWriteTools);
|
||||
app.log.info(`tool registry: ${ALL_TOOLS.length} tools loaded (${WRITE_TOOLS.length} write tools)`);
|
||||
|
||||
// Inference runner: same engine as BooChat, uses ALL_TOOLS (which includes
|
||||
// the appended write tools) for tool dispatch.
|
||||
const inference = createInferenceRunner(
|
||||
{
|
||||
sql,
|
||||
config: config as unknown as ServerConfig,
|
||||
log: app.log,
|
||||
publish: (sessionId, frame) => {
|
||||
broker.publishFrame(sessionId, frame as unknown as WsFrame);
|
||||
},
|
||||
broker,
|
||||
},
|
||||
(user, frame) => {
|
||||
broker.publishUserFrame(user, frame as unknown as WsFrame);
|
||||
}
|
||||
);
|
||||
|
||||
// Wrap the inference runner to set/clear the write-tool context around each run.
|
||||
// The inference runner calls enqueue() which fires asynchronously — we hook
|
||||
// into the enqueue to set context before the run starts.
|
||||
const inferenceApi = {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => {
|
||||
// Set the inference context so write tools can access sql + sessionId.
|
||||
// The context persists for the duration of the inference run. Since
|
||||
// BooCoder is single-user and runs one inference at a time per session,
|
||||
// this module-level state is safe.
|
||||
setInferenceContext({ sql, sessionId, taskId: null });
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
},
|
||||
cancel: async (sessionId: string, chatId: string) => {
|
||||
const result = await inference.cancel(sessionId, chatId);
|
||||
clearInferenceContext();
|
||||
return result;
|
||||
},
|
||||
hasActive: (chatId: string) => inference.hasActive(chatId),
|
||||
};
|
||||
|
||||
// Register WebSocket support
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
// Health endpoint
|
||||
app.get('/api/health', async (_req, reply) => {
|
||||
const dbOk = await pingDb(sql);
|
||||
const status = dbOk ? 200 : 503;
|
||||
return reply.status(status).send({
|
||||
ok: dbOk,
|
||||
db: dbOk,
|
||||
tools: ALL_TOOLS.length,
|
||||
});
|
||||
});
|
||||
|
||||
// Phase 4: probe available agents on startup
|
||||
await probeAgents(sql, app.log);
|
||||
|
||||
// Phase 4: dispatcher — polls tasks table and runs inference
|
||||
const dispatcher = createDispatcher({ sql, inference: inferenceApi, broker, log: app.log, config });
|
||||
dispatcher.start();
|
||||
app.addHook('onClose', () => dispatcher.stop());
|
||||
|
||||
// Register routes
|
||||
registerMessageRoutes(app, sql, broker, inferenceApi);
|
||||
registerPendingRoutes(app, sql);
|
||||
registerTaskRoutes(app, sql, inferenceApi);
|
||||
registerInboxRoutes(app, sql);
|
||||
registerStatsRoutes(app, sql);
|
||||
registerArenaRoutes(app, sql);
|
||||
registerProviderRoutes(app, sql, config);
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
// Serve static frontend (built web app). In production, the dist/ is
|
||||
// copied to ../web relative to the dist/ directory at /app/web. In dev,
|
||||
// check adjacent to the source.
|
||||
const webRoot = resolve(__dirname, '../web');
|
||||
if (existsSync(webRoot)) {
|
||||
await app.register(fastifyStatic, {
|
||||
root: webRoot,
|
||||
prefix: '/',
|
||||
// Don't intercept /api routes — static only serves files that exist.
|
||||
wildcard: false,
|
||||
});
|
||||
// SPA fallback: serve index.html for non-API routes that don't match a file.
|
||||
app.setNotFoundHandler(async (req, reply) => {
|
||||
if (req.url.startsWith('/api')) {
|
||||
reply.code(404);
|
||||
return { error: 'not found' };
|
||||
}
|
||||
return reply.sendFile('index.html');
|
||||
});
|
||||
app.log.info(`serving frontend from ${webRoot}`);
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
const shutdown = async () => {
|
||||
app.log.info('shutting down');
|
||||
await app.close();
|
||||
await closeDb();
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
|
||||
await app.listen({ port: config.PORT, host: config.HOST });
|
||||
app.log.info(`BooCoder listening on ${config.HOST}:${config.PORT}`);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('fatal:', err);
|
||||
process.exit(1);
|
||||
});
|
||||
122
apps/coder/src/routes/arena.ts
Normal file
122
apps/coder/src/routes/arena.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* v2.0.5: Arena routes — competitive dispatch of the same task to multiple agents.
|
||||
*
|
||||
* POST /api/arena — create an arena with 2-5 contestants
|
||||
* GET /api/arena/:id — get all tasks in an arena
|
||||
* POST /api/arena/:id/select/:task_id — mark a task as the arena winner
|
||||
*/
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
const ContestantSchema = z.object({
|
||||
agent: z.string().max(100).optional(),
|
||||
model: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
const CreateArenaBody = z.object({
|
||||
project_id: z.string().uuid(),
|
||||
input: z.string().min(1).max(64_000),
|
||||
contestants: z.array(ContestantSchema).min(2).max(5),
|
||||
});
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
agent: string | null;
|
||||
model: string | null;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export function registerArenaRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// POST /api/arena — create a new arena
|
||||
app.post('/api/arena', async (req, reply) => {
|
||||
const parsed = CreateArenaBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const { project_id, input, contestants } = parsed.data;
|
||||
const arenaId = crypto.randomUUID();
|
||||
|
||||
const tasks: TaskRow[] = [];
|
||||
for (const contestant of contestants) {
|
||||
const [task] = await sql<TaskRow[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, arena_id)
|
||||
VALUES (${project_id}, ${input}, ${contestant.agent ?? null}, ${contestant.model ?? null}, ${arenaId})
|
||||
RETURNING id, agent, model, state
|
||||
`;
|
||||
tasks.push(task!);
|
||||
}
|
||||
|
||||
reply.code(201);
|
||||
return {
|
||||
arena_id: arenaId,
|
||||
tasks: tasks.map(t => ({
|
||||
id: t.id,
|
||||
agent: t.agent,
|
||||
model: t.model,
|
||||
state: t.state,
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
// GET /api/arena/:arena_id — list all tasks in an arena
|
||||
app.get<{ Params: { arena_id: string } }>('/api/arena/:arena_id', async (req, reply) => {
|
||||
const { arena_id } = req.params;
|
||||
|
||||
// Validate UUID format
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
if (!uuidRegex.test(arena_id)) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid arena_id format' };
|
||||
}
|
||||
|
||||
const tasks = await sql`
|
||||
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at, arena_id
|
||||
FROM tasks
|
||||
WHERE arena_id = ${arena_id}
|
||||
ORDER BY created_at
|
||||
`;
|
||||
|
||||
if (tasks.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'arena not found' };
|
||||
}
|
||||
|
||||
return { arena_id, tasks };
|
||||
});
|
||||
|
||||
// POST /api/arena/:arena_id/select/:task_id — mark the winner
|
||||
app.post<{ Params: { arena_id: string; task_id: string } }>(
|
||||
'/api/arena/:arena_id/select/:task_id',
|
||||
async (req, reply) => {
|
||||
const { arena_id, task_id } = req.params;
|
||||
|
||||
// Verify the task belongs to this arena
|
||||
const rows = await sql<{ id: string; state: string; arena_id: string | null }[]>`
|
||||
SELECT id, state, arena_id FROM tasks WHERE id = ${task_id}
|
||||
`;
|
||||
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'task not found' };
|
||||
}
|
||||
|
||||
const task = rows[0]!;
|
||||
if (task.arena_id !== arena_id) {
|
||||
reply.code(409);
|
||||
return { error: 'task does not belong to this arena' };
|
||||
}
|
||||
|
||||
// Mark as selected via output_summary prefix (lightweight — no schema change)
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET output_summary = COALESCE('[SELECTED] ' || output_summary, '[SELECTED]')
|
||||
WHERE id = ${task_id}
|
||||
`;
|
||||
|
||||
return { selected: true, task_id, arena_id };
|
||||
}
|
||||
);
|
||||
}
|
||||
33
apps/coder/src/routes/inbox.ts
Normal file
33
apps/coder/src/routes/inbox.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
export function registerInboxRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// GET /api/inbox — tasks needing human attention (blocked or failed)
|
||||
app.get('/api/inbox', async () => {
|
||||
return sql`
|
||||
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, session_id, started_at, ended_at, created_at
|
||||
FROM human_inbox
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
});
|
||||
|
||||
// POST /api/inbox/:id/retry — reset a blocked/failed task to pending for re-dispatch
|
||||
app.post<{ Params: { id: string } }>('/api/inbox/:id/retry', async (req, reply) => {
|
||||
const taskId = req.params.id;
|
||||
|
||||
const result = await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'pending', started_at = NULL, ended_at = NULL, output_summary = NULL
|
||||
WHERE id = ${taskId} AND state IN ('blocked', 'failed')
|
||||
RETURNING id, state
|
||||
`;
|
||||
|
||||
if (result.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'task not found or not in retryable state' };
|
||||
}
|
||||
|
||||
return { id: result[0]!.id, state: result[0]!.state };
|
||||
});
|
||||
}
|
||||
159
apps/coder/src/routes/messages.ts
Normal file
159
apps/coder/src/routes/messages.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
chat_id: z.string().uuid().optional(),
|
||||
provider: z.string().max(100).optional(),
|
||||
model: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
interface InferenceApi {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
|
||||
export function registerMessageRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
broker: Broker,
|
||||
inference: InferenceApi,
|
||||
): void {
|
||||
// POST /api/sessions/:sessionId/messages — send a user message + kick off inference
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/messages',
|
||||
async (req, reply) => {
|
||||
const parsed = SendBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const sessionId = req.params.sessionId;
|
||||
const { content, chat_id: explicitChatId, provider, model } = parsed.data;
|
||||
const isExternal = provider && provider !== 'boocode';
|
||||
|
||||
// Validate session exists
|
||||
const sessionRows = await sql<{ id: string; project_id: string }[]>`
|
||||
SELECT id, project_id FROM sessions WHERE id = ${sessionId}
|
||||
`;
|
||||
if (sessionRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
// Resolve chat_id: use explicit value or find/create a default chat
|
||||
let chatId: string;
|
||||
if (explicitChatId) {
|
||||
const chatRows = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE id = ${explicitChatId} AND session_id = ${sessionId} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found or not open in this session' };
|
||||
}
|
||||
chatId = explicitChatId;
|
||||
} else {
|
||||
const existing = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at LIMIT 1
|
||||
`;
|
||||
if (existing.length > 0) {
|
||||
chatId = existing[0]!.id;
|
||||
} else {
|
||||
const [newChat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'Chat', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = newChat!.id;
|
||||
}
|
||||
}
|
||||
|
||||
if (!isExternal) {
|
||||
// Reject if inference is already running on this chat
|
||||
if (inference.hasActive(chatId)) {
|
||||
reply.code(409);
|
||||
return { error: 'inference already running on this chat' };
|
||||
}
|
||||
}
|
||||
|
||||
// Create user message
|
||||
const [userMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await sql`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
await sql`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||
|
||||
// Publish user message frames
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: userMsg!.id,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
} as unknown as WsFrame);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: userMsg!.id,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
} as unknown as WsFrame);
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: userMsg!.id,
|
||||
chat_id: chatId,
|
||||
} as unknown as WsFrame);
|
||||
|
||||
if (isExternal) {
|
||||
// External provider: create a task for the dispatcher
|
||||
const projectId = sessionRows[0]!.project_id;
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, session_id)
|
||||
VALUES (${projectId}, ${content}, ${provider}, ${model ?? null}, ${sessionId})
|
||||
RETURNING id, state
|
||||
`;
|
||||
reply.code(202);
|
||||
return { user_message_id: userMsg!.id, task_id: task!.id, dispatched: true };
|
||||
}
|
||||
|
||||
// Native provider: create streaming assistant row + enqueue inference
|
||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
inference.enqueue(sessionId, chatId, assistantMsg!.id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/stop — cancel active inference
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/stop',
|
||||
async (req, reply) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
// Find active chats in this session
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open'
|
||||
`;
|
||||
let cancelled = false;
|
||||
for (const chat of chats) {
|
||||
if (inference.hasActive(chat.id)) {
|
||||
cancelled = await inference.cancel(sessionId, chat.id);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return { cancelled };
|
||||
},
|
||||
);
|
||||
}
|
||||
121
apps/coder/src/routes/pending.ts
Normal file
121
apps/coder/src/routes/pending.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import {
|
||||
listPending,
|
||||
applyOne,
|
||||
applyAll,
|
||||
rejectOne,
|
||||
rewindOne,
|
||||
} from '../services/pending_changes.js';
|
||||
|
||||
/**
|
||||
* Resolve project root from a session's project path.
|
||||
*/
|
||||
async function resolveProjectRoot(sql: Sql, sessionId: string): Promise<string | null> {
|
||||
const rows = await sql<{ path: string }[]>`
|
||||
SELECT p.path FROM sessions s
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE s.id = ${sessionId}
|
||||
`;
|
||||
return rows.length > 0 ? rows[0]!.path : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve project root from a pending change's session.
|
||||
*/
|
||||
async function resolveProjectRootForChange(sql: Sql, changeId: string): Promise<string | null> {
|
||||
const rows = await sql<{ path: string }[]>`
|
||||
SELECT p.path FROM pending_changes pc
|
||||
JOIN sessions s ON pc.session_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE pc.id = ${changeId}
|
||||
`;
|
||||
return rows.length > 0 ? rows[0]!.path : null;
|
||||
}
|
||||
|
||||
export function registerPendingRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// GET /api/sessions/:sessionId/pending — list pending changes for a session
|
||||
app.get<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/pending',
|
||||
async (req, reply) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||
if (session.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
|
||||
const pending = await listPending(sql, sessionId);
|
||||
return pending;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/sessions/:sessionId/pending/apply — apply all pending changes
|
||||
app.post<{ Params: { sessionId: string } }>(
|
||||
'/api/sessions/:sessionId/pending/apply',
|
||||
async (req, reply) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
const projectRoot = await resolveProjectRoot(sql, sessionId);
|
||||
if (!projectRoot) {
|
||||
reply.code(404);
|
||||
return { error: 'session or project not found' };
|
||||
}
|
||||
|
||||
const results = await applyAll(sql, sessionId, projectRoot);
|
||||
return { results };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/pending/:id/apply — apply a single pending change
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/pending/:id/apply',
|
||||
async (req, reply) => {
|
||||
const changeId = req.params.id;
|
||||
|
||||
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
||||
if (!projectRoot) {
|
||||
reply.code(404);
|
||||
return { error: 'pending change or project not found' };
|
||||
}
|
||||
|
||||
const result = await applyOne(sql, changeId, projectRoot);
|
||||
if (!result.success) {
|
||||
reply.code(422);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/pending/:id/reject — reject a single pending change
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/pending/:id/reject',
|
||||
async (req, reply) => {
|
||||
const changeId = req.params.id;
|
||||
|
||||
await rejectOne(sql, changeId);
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/pending/:id/rewind — rewind (undo) an applied change
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/pending/:id/rewind',
|
||||
async (req, reply) => {
|
||||
const changeId = req.params.id;
|
||||
|
||||
const projectRoot = await resolveProjectRootForChange(sql, changeId);
|
||||
if (!projectRoot) {
|
||||
reply.code(404);
|
||||
return { error: 'pending change or project not found' };
|
||||
}
|
||||
|
||||
const result = await rewindOne(sql, changeId, projectRoot);
|
||||
if (!result.success) {
|
||||
reply.code(422);
|
||||
}
|
||||
return result;
|
||||
},
|
||||
);
|
||||
}
|
||||
80
apps/coder/src/routes/providers.ts
Normal file
80
apps/coder/src/routes/providers.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import { PROVIDERS } from '../services/provider-registry.js';
|
||||
|
||||
interface ProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
interface ProviderResponse {
|
||||
name: string;
|
||||
label: string;
|
||||
transport: string;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
}
|
||||
|
||||
interface LlamaSwapModel {
|
||||
id: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
async function fetchLlamaSwapModels(config: Config): Promise<ProviderModel[]> {
|
||||
try {
|
||||
const res = await fetch(`${config.LLAMA_SWAP_URL}/v1/models`);
|
||||
if (!res.ok) return [];
|
||||
const parsed = (await res.json()) as { data?: LlamaSwapModel[] };
|
||||
return (parsed.data ?? []).map((m) => ({ id: m.id, label: m.id }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function registerProviderRoutes(app: FastifyInstance, sql: Sql, config: Config): void {
|
||||
app.get('/api/providers', async (_req, _reply) => {
|
||||
const llamaModels = await fetchLlamaSwapModels(config);
|
||||
|
||||
const agents = await sql<{ name: string; models: ProviderModel[]; label: string | null; transport: string | null; supports_acp: boolean }[]>`
|
||||
SELECT name, models, label, transport, supports_acp FROM available_agents
|
||||
`;
|
||||
const agentMap = new Map(agents.map((a) => [a.name, a]));
|
||||
|
||||
const result: ProviderResponse[] = [];
|
||||
|
||||
for (const provider of PROVIDERS) {
|
||||
const isNative = provider.name === 'boocode';
|
||||
const agentRow = agentMap.get(provider.name);
|
||||
const installed = isNative || !!agentRow;
|
||||
|
||||
if (!installed) continue;
|
||||
|
||||
let models: ProviderModel[];
|
||||
if (provider.modelSource === 'llama-swap') {
|
||||
models = llamaModels;
|
||||
} else if (agentRow?.models && agentRow.models.length > 0) {
|
||||
models = agentRow.models;
|
||||
} else if (provider.staticModels) {
|
||||
models = provider.staticModels;
|
||||
} else {
|
||||
models = [];
|
||||
}
|
||||
|
||||
let transport: string = provider.transport;
|
||||
if (agentRow) {
|
||||
transport = provider.transport === 'acp' && !agentRow.supports_acp ? 'pty' : provider.transport;
|
||||
}
|
||||
|
||||
result.push({
|
||||
name: provider.name,
|
||||
label: agentRow?.label ?? provider.label,
|
||||
transport,
|
||||
installed,
|
||||
models,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
}
|
||||
48
apps/coder/src/routes/stats.ts
Normal file
48
apps/coder/src/routes/stats.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
const CostQuery = z.object({
|
||||
group_by: z.enum(['project', 'agent', 'day']).default('project'),
|
||||
});
|
||||
|
||||
export function registerStatsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
// GET /api/stats/costs — aggregate cost_tokens by project, agent, or day
|
||||
app.get('/api/stats/costs', async (req, reply) => {
|
||||
const parsed = CostQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const { group_by } = parsed.data;
|
||||
|
||||
switch (group_by) {
|
||||
case 'project':
|
||||
return sql`
|
||||
SELECT project_id, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
|
||||
FROM tasks
|
||||
WHERE cost_tokens IS NOT NULL
|
||||
GROUP BY project_id
|
||||
ORDER BY total_tokens DESC
|
||||
`;
|
||||
case 'agent':
|
||||
return sql`
|
||||
SELECT COALESCE(agent, 'native') AS agent, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
|
||||
FROM tasks
|
||||
WHERE cost_tokens IS NOT NULL
|
||||
GROUP BY agent
|
||||
ORDER BY total_tokens DESC
|
||||
`;
|
||||
case 'day':
|
||||
return sql`
|
||||
SELECT DATE(created_at) AS day, COUNT(*)::int AS task_count, COALESCE(SUM(cost_tokens), 0)::int AS total_tokens
|
||||
FROM tasks
|
||||
WHERE cost_tokens IS NOT NULL
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY day DESC
|
||||
LIMIT 90
|
||||
`;
|
||||
}
|
||||
});
|
||||
}
|
||||
138
apps/coder/src/routes/tasks.ts
Normal file
138
apps/coder/src/routes/tasks.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
interface InferenceApi {
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
const CreateBody = z.object({
|
||||
project_id: z.string().uuid(),
|
||||
input: z.string().min(1).max(64_000),
|
||||
agent: z.string().max(100).optional(),
|
||||
model: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
const ListQuery = z.object({
|
||||
state: z.enum(['pending', 'running', 'completed', 'failed', 'blocked', 'cancelled']).optional(),
|
||||
project_id: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void {
|
||||
// POST /api/tasks — create a new task
|
||||
app.post('/api/tasks', async (req, reply) => {
|
||||
const parsed = CreateBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid body', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const { project_id, input, agent, model } = parsed.data;
|
||||
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model)
|
||||
VALUES (${project_id}, ${input}, ${agent ?? null}, ${model ?? null})
|
||||
RETURNING id, state
|
||||
`;
|
||||
|
||||
reply.code(201);
|
||||
return { id: task!.id, state: task!.state };
|
||||
});
|
||||
|
||||
// GET /api/tasks — list tasks with optional filters
|
||||
app.get('/api/tasks', async (req, _reply) => {
|
||||
const parsed = ListQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||
}
|
||||
|
||||
const { state, project_id } = parsed.data;
|
||||
|
||||
// Build query with optional filters
|
||||
if (state && project_id) {
|
||||
return sql`
|
||||
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
||||
FROM tasks
|
||||
WHERE state = ${state} AND project_id = ${project_id}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
} else if (state) {
|
||||
return sql`
|
||||
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
||||
FROM tasks
|
||||
WHERE state = ${state}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
} else if (project_id) {
|
||||
return sql`
|
||||
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
||||
FROM tasks
|
||||
WHERE project_id = ${project_id}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
} else {
|
||||
return sql`
|
||||
SELECT id, project_id, state, input, output_summary, agent, model, execution_path, session_id, started_at, ended_at, created_at
|
||||
FROM tasks
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
`;
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/tasks/:id — single task detail
|
||||
app.get<{ Params: { id: string } }>('/api/tasks/:id', async (req, reply) => {
|
||||
const rows = await sql`
|
||||
SELECT id, project_id, parent_task_id, state, input, output_summary, agent, model, execution_path, worktree_path, session_id, cost_tokens, started_at, ended_at, created_at
|
||||
FROM tasks
|
||||
WHERE id = ${req.params.id}
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'task not found' };
|
||||
}
|
||||
return rows[0];
|
||||
});
|
||||
|
||||
// POST /api/tasks/:id/cancel — cancel a pending or running task
|
||||
app.post<{ Params: { id: string } }>('/api/tasks/:id/cancel', async (req, reply) => {
|
||||
const taskId = req.params.id;
|
||||
|
||||
// Get current task state + session info
|
||||
const rows = await sql<{ id: string; state: string; session_id: string | null }[]>`
|
||||
SELECT id, state, session_id FROM tasks WHERE id = ${taskId}
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'task not found' };
|
||||
}
|
||||
|
||||
const task = rows[0]!;
|
||||
if (task.state !== 'pending' && task.state !== 'running') {
|
||||
reply.code(409);
|
||||
return { error: `cannot cancel task in state '${task.state}'` };
|
||||
}
|
||||
|
||||
// If running, try to cancel inference
|
||||
if (task.state === 'running' && task.session_id) {
|
||||
// Find active chat in the task's session
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${task.session_id} AND status = 'open'
|
||||
`;
|
||||
for (const chat of chats) {
|
||||
await inference.cancel(task.session_id, chat.id);
|
||||
}
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||
WHERE id = ${taskId} AND state IN ('pending', 'running')
|
||||
`;
|
||||
|
||||
return { cancelled: true };
|
||||
});
|
||||
}
|
||||
51
apps/coder/src/routes/ws.ts
Normal file
51
apps/coder/src/routes/ws.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
|
||||
export function registerWebSocket(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
broker: Broker,
|
||||
): void {
|
||||
// Per-session streaming WebSocket. Clients connect here to receive live
|
||||
// inference frames (deltas, tool_calls, tool_results, message_complete).
|
||||
app.get<{ Params: { sessionId: string } }>(
|
||||
'/api/ws/sessions/:sessionId',
|
||||
{ websocket: true },
|
||||
async (socket, req) => {
|
||||
const sessionId = req.params.sessionId;
|
||||
|
||||
// Validate session exists
|
||||
const session = await sql<{ id: string }[]>`SELECT id FROM sessions WHERE id = ${sessionId}`;
|
||||
if (session.length === 0) {
|
||||
socket.send(JSON.stringify({ type: 'error', error: 'session not found' }));
|
||||
socket.close(1008, 'session not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Send snapshot of existing messages so client can hydrate
|
||||
const messages = await sql<Record<string, unknown>[]>`
|
||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
||||
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||
summary, tail_start_id, compacted_at
|
||||
FROM messages_with_parts
|
||||
WHERE session_id = ${sessionId}
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`;
|
||||
socket.send(JSON.stringify({ type: 'snapshot', messages }));
|
||||
|
||||
// Subscribe to broker for live frames
|
||||
const unsubscribe = broker.subscribe(sessionId, (frame) => {
|
||||
if (socket.readyState !== socket.OPEN) return;
|
||||
try {
|
||||
socket.send(JSON.stringify(frame));
|
||||
} catch (err) {
|
||||
app.log.warn({ err, sessionId }, 'ws send failed');
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('close', () => unsubscribe());
|
||||
socket.on('error', () => unsubscribe());
|
||||
},
|
||||
);
|
||||
}
|
||||
68
apps/coder/src/schema.sql
Normal file
68
apps/coder/src/schema.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- v2.0.0: BooCoder schema — pending changes, tasks, agent registry.
|
||||
-- Applied on startup by apps/coder/src/db.ts:applySchema().
|
||||
-- Lives in the same 'boochat' database as BooChat's tables.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS pending_changes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
session_id UUID NOT NULL,
|
||||
task_id UUID,
|
||||
file_path TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
diff TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT pending_changes_operation_chk CHECK (operation IN ('create', 'edit', 'delete')),
|
||||
CONSTRAINT pending_changes_status_chk CHECK (status IN ('pending', 'applied', 'rejected', 'reverted'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id UUID NOT NULL,
|
||||
parent_task_id UUID REFERENCES tasks(id),
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
input TEXT NOT NULL,
|
||||
output_summary TEXT,
|
||||
agent TEXT,
|
||||
model TEXT,
|
||||
execution_path TEXT,
|
||||
worktree_path TEXT,
|
||||
cost_tokens INTEGER,
|
||||
started_at TIMESTAMPTZ,
|
||||
ended_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT tasks_state_chk CHECK (state IN ('pending', 'running', 'completed', 'failed', 'blocked', 'cancelled')),
|
||||
CONSTRAINT tasks_execution_path_chk CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS available_agents (
|
||||
name TEXT PRIMARY KEY,
|
||||
install_path TEXT,
|
||||
version TEXT,
|
||||
supports_acp BOOLEAN NOT NULL DEFAULT false,
|
||||
supports_mcp_client BOOLEAN NOT NULL DEFAULT false,
|
||||
last_probed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
-- v2.0.0 Phase 4: link tasks to their inference sessions.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS session_id UUID REFERENCES sessions(id);
|
||||
|
||||
-- v2.0.5: add 'qwen' to execution_path CHECK + arena_id column.
|
||||
ALTER TABLE tasks DROP CONSTRAINT IF EXISTS tasks_execution_path_chk;
|
||||
DO $$ BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'tasks_execution_path_chk') THEN
|
||||
ALTER TABLE tasks ADD CONSTRAINT tasks_execution_path_chk
|
||||
CHECK (execution_path IS NULL OR execution_path IN ('native', 'acp', 'pty', 'qwen'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v2.0.5: arena support — group tasks into competitive arenas.
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS arena_id UUID;
|
||||
|
||||
-- Human inbox: tasks needing attention
|
||||
CREATE OR REPLACE VIEW human_inbox AS
|
||||
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||
|
||||
-- v2.1.0: provider picker — extend available_agents with model discovery.
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS models JSONB DEFAULT '[]'::jsonb;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS label TEXT;
|
||||
ALTER TABLE available_agents ADD COLUMN IF NOT EXISTS transport TEXT DEFAULT 'pty';
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFile, rm, mkdir } from 'node:fs/promises';
|
||||
import { resolve } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
import { queueCreate, queueEdit, queueDelete, applyOne, rewindOne, listPending } from '../pending_changes.js';
|
||||
|
||||
/**
|
||||
* Integration test for the full pending-changes lifecycle.
|
||||
* Requires DATABASE_URL env var pointing to a running postgres instance.
|
||||
* Skips cleanly when DATABASE_URL is not set.
|
||||
*
|
||||
* Run with:
|
||||
* DATABASE_URL='postgres://boocode:devpass@localhost:5500/boocode' pnpm -C apps/coder test
|
||||
*/
|
||||
describe.runIf(!!process.env.DATABASE_URL)('pending_changes integration', () => {
|
||||
let sql: ReturnType<typeof postgres>;
|
||||
const testDir = '/tmp/boocode-pending-changes-test-' + Date.now();
|
||||
const projectRoot = testDir;
|
||||
const testSessionId = '00000000-0000-0000-0000-000000000001';
|
||||
|
||||
beforeAll(async () => {
|
||||
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
|
||||
|
||||
// Apply schema
|
||||
const schemaPath = resolve(__dirname, '../../schema.sql');
|
||||
const ddl = readFileSync(schemaPath, 'utf8');
|
||||
await sql.unsafe(ddl);
|
||||
|
||||
// Create temp project directory
|
||||
await mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
// Cleanup test data
|
||||
await sql`DELETE FROM pending_changes WHERE session_id = ${testSessionId}`;
|
||||
await sql.end({ timeout: 5 });
|
||||
// Remove temp directory
|
||||
await rm(testDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('queueCreate → listPending → applyOne → verify file exists', async () => {
|
||||
const change = await queueCreate(sql, testSessionId, null, 'hello.txt', 'hello world', projectRoot);
|
||||
expect(change.status).toBe('pending');
|
||||
expect(change.operation).toBe('create');
|
||||
|
||||
const pending = await listPending(sql, testSessionId);
|
||||
expect(pending.some((p) => p.id === change.id)).toBe(true);
|
||||
|
||||
const result = await applyOne(sql, change.id, projectRoot);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const content = await readFile(resolve(testDir, 'hello.txt'), 'utf8');
|
||||
expect(content).toBe('hello world');
|
||||
});
|
||||
|
||||
it('queueEdit → apply → verify content changed', async () => {
|
||||
// Setup: create a file first
|
||||
const createChange = await queueCreate(sql, testSessionId, null, 'editable.txt', 'original content here', projectRoot);
|
||||
await applyOne(sql, createChange.id, projectRoot);
|
||||
|
||||
// Queue an edit
|
||||
const editChange = await queueEdit(sql, testSessionId, null, 'editable.txt', 'original', 'modified', projectRoot);
|
||||
expect(editChange.operation).toBe('edit');
|
||||
|
||||
const result = await applyOne(sql, editChange.id, projectRoot);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
const content = await readFile(resolve(testDir, 'editable.txt'), 'utf8');
|
||||
expect(content).toBe('modified content here');
|
||||
});
|
||||
|
||||
it('queueDelete → apply → verify file gone', async () => {
|
||||
// Setup: create a file
|
||||
const createChange = await queueCreate(sql, testSessionId, null, 'deleteme.txt', 'goodbye', projectRoot);
|
||||
await applyOne(sql, createChange.id, projectRoot);
|
||||
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(true);
|
||||
|
||||
// Queue a delete
|
||||
const deleteChange = await queueDelete(sql, testSessionId, null, 'deleteme.txt', projectRoot);
|
||||
const result = await applyOne(sql, deleteChange.id, projectRoot);
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(resolve(testDir, 'deleteme.txt'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rewindOne → verify reverted', async () => {
|
||||
// Setup: create and apply a file
|
||||
const createChange = await queueCreate(sql, testSessionId, null, 'rewindable.txt', 'initial', projectRoot);
|
||||
await applyOne(sql, createChange.id, projectRoot);
|
||||
|
||||
// Rewind the create (should delete the file)
|
||||
const result = await rewindOne(sql, createChange.id, projectRoot);
|
||||
expect(result.success).toBe(true);
|
||||
expect(existsSync(resolve(testDir, 'rewindable.txt'))).toBe(false);
|
||||
});
|
||||
});
|
||||
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
115
apps/coder/src/services/__tests__/write_guard.test.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveWritePath, isSecretPath, WriteGuardError } from '../write_guard.js';
|
||||
|
||||
const PROJECT_ROOT = '/opt/projects/my-app';
|
||||
|
||||
describe('resolveWritePath', () => {
|
||||
it('resolves a relative path correctly', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/index.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/src/index.ts');
|
||||
});
|
||||
|
||||
it('resolves nested relative path', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/lib/utils.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/src/lib/utils.ts');
|
||||
});
|
||||
|
||||
it('throws on ../ escape', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '../../../etc/passwd')).toThrow('path escapes project root');
|
||||
});
|
||||
|
||||
it('throws on absolute path outside project root', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '/etc/shadow')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '/tmp/exploit')).toThrow('path escapes project root');
|
||||
});
|
||||
|
||||
it('allows absolute path inside project root', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, '/opt/projects/my-app/src/new.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/src/new.ts');
|
||||
});
|
||||
|
||||
it('denies .env files', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.env')).toThrow('cannot write to secret file');
|
||||
});
|
||||
|
||||
it('denies .env.local', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.env.local')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies .env.production', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.env.production')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies *.pem files', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'certs/server.pem')).toThrow('cannot write to secret file');
|
||||
});
|
||||
|
||||
it('denies *.key files', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'ssl/private.key')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies id_rsa', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_rsa')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies id_ed25519', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '.ssh/id_ed25519')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('denies credentials.json', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'credentials.json')).toThrow(WriteGuardError);
|
||||
});
|
||||
|
||||
it('passes a normal file inside project', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/components/Button.tsx');
|
||||
expect(result).toBe('/opt/projects/my-app/src/components/Button.tsx');
|
||||
});
|
||||
|
||||
it('passes a non-existent nested file (no realpath)', () => {
|
||||
// This is the key difference from BooChat's pathGuard: no realpath means
|
||||
// files that don't exist yet still pass validation
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/new-dir/new-file.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/src/new-dir/new-file.ts');
|
||||
});
|
||||
|
||||
it('throws on null/empty path', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow(WriteGuardError);
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, '')).toThrow('file path is required');
|
||||
});
|
||||
|
||||
it('normalizes ../ within project root and still allows', () => {
|
||||
const result = resolveWritePath(PROJECT_ROOT, 'src/../lib/utils.ts');
|
||||
expect(result).toBe('/opt/projects/my-app/lib/utils.ts');
|
||||
});
|
||||
|
||||
it('rejects path that looks inside root but normalizes outside', () => {
|
||||
expect(() => resolveWritePath(PROJECT_ROOT, 'src/../../other-project/hack.ts')).toThrow(WriteGuardError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSecretPath', () => {
|
||||
it('detects .env', () => {
|
||||
expect(isSecretPath('.env')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects nested .env', () => {
|
||||
expect(isSecretPath('config/.env')).toBe(true);
|
||||
});
|
||||
|
||||
it('detects *.pfx', () => {
|
||||
expect(isSecretPath('certs/client.pfx')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not flag normal source files', () => {
|
||||
expect(isSecretPath('src/index.ts')).toBe(false);
|
||||
expect(isSecretPath('README.md')).toBe(false);
|
||||
expect(isSecretPath('package.json')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for empty string', () => {
|
||||
expect(isSecretPath('')).toBe(false);
|
||||
});
|
||||
});
|
||||
193
apps/coder/src/services/__tests__/write_guard_fuzz.test.ts
Normal file
193
apps/coder/src/services/__tests__/write_guard_fuzz.test.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { resolveWritePath } from '../write_guard.js';
|
||||
|
||||
const projectRoot = '/opt/testproject';
|
||||
|
||||
describe('write_guard fuzz — traversal attacks', () => {
|
||||
// Basic traversal
|
||||
it('rejects ../', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '../etc/passwd')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects ../../', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '../../etc/passwd')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects deeply nested ../../../', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '../../../../../../../etc/shadow')).toThrow();
|
||||
});
|
||||
|
||||
// Encoded traversal — resolve() doesn't decode percent-encoding, so these
|
||||
// stay as literal filenames. The guard must still not let them escape.
|
||||
it('rejects %2e%2e/ (literal percent-encoded dots)', () => {
|
||||
// resolve('/opt/testproject', '%2e%2e/etc/passwd') stays inside root
|
||||
// because Node's resolve treats the literal characters, not decoded.
|
||||
// The file would be /opt/testproject/%2e%2e/etc/passwd which IS inside root.
|
||||
// This test confirms it doesn't throw (it resolves inside) — defense in depth
|
||||
// is that the filesystem won't have this path, but no traversal occurs.
|
||||
const result = resolveWritePath(projectRoot, '%2e%2e/etc/passwd');
|
||||
expect(result).toContain(projectRoot);
|
||||
});
|
||||
|
||||
it('rejects ..%2f (literal percent-encoded slash)', () => {
|
||||
// '../%2fetc/passwd' — the ../ IS real traversal
|
||||
expect(() => resolveWritePath(projectRoot, '../%2fetc/passwd')).toThrow();
|
||||
});
|
||||
|
||||
// Null byte injection
|
||||
it('rejects null bytes', () => {
|
||||
expect(() => resolveWritePath(projectRoot, 'file.txt\x00.jpg')).toThrow();
|
||||
});
|
||||
|
||||
// Absolute path escape
|
||||
it('rejects /etc/passwd', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '/etc/passwd')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects /opt/other-project/file', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '/opt/other-project/file.ts')).toThrow();
|
||||
});
|
||||
|
||||
// Path that starts with project root as prefix but isn't under it
|
||||
it('rejects prefix match without separator', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '/opt/testproject-evil/file.ts')).toThrow();
|
||||
});
|
||||
|
||||
// Double slashes / traversal after valid prefix
|
||||
it('rejects /opt/testproject/../etc/passwd via double-dot after valid prefix', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '/opt/testproject/../etc/passwd')).toThrow();
|
||||
});
|
||||
|
||||
// Windows-style (defense-in-depth on Linux)
|
||||
it('rejects backslash traversal', () => {
|
||||
// On POSIX, backslash is a valid filename char, so '..\\etc\\passwd' resolves
|
||||
// as a single segment inside projectRoot. Not a traversal, but test that it
|
||||
// doesn't crash and stays within root.
|
||||
const result = resolveWritePath(projectRoot, '..\\etc\\passwd');
|
||||
// Node resolve on POSIX treats this as a literal filename segment containing backslashes
|
||||
// that starts with '..' — resolve normalizes: /opt/testproject/..\\etc\\passwd
|
||||
// Wait: resolve('/opt/testproject', '..\\etc\\passwd') — on POSIX backslash
|
||||
// is NOT a separator, so this is a file named '..\\etc\\passwd' inside projectRoot.
|
||||
// Actually no — resolve splits on '/' only on POSIX. '..' at start triggers parent.
|
||||
// Let's check: the string starts with '..' but the next char is '\\' not '/'.
|
||||
// Node's path.resolve on POSIX: the string '..\\etc\\passwd' does NOT contain '/'
|
||||
// so it IS treated as a single path component? No — resolve still splits on '/'.
|
||||
// '..\\etc\\passwd' has no '/', so resolve('/opt/testproject', '..\\etc\\passwd')
|
||||
// = resolve('/opt/testproject/..\\etc\\passwd') — but wait, resolve processes
|
||||
// segments separated by '/'. With no '/', the whole thing is one segment.
|
||||
// Actually wrong: path.resolve calls normalizeString which handles '.' and '..'
|
||||
// only when they are full segments delimited by '/'. Since there's no '/' in
|
||||
// '..\\etc\\passwd', it treats the entire string as one filename.
|
||||
// So: /opt/testproject/..\\etc\\passwd — inside root. No throw.
|
||||
expect(result).toContain(projectRoot);
|
||||
});
|
||||
|
||||
// Secret files (deny list)
|
||||
it('rejects .env', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '.env')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects nested .env', () => {
|
||||
expect(() => resolveWritePath(projectRoot, 'config/.env')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects .env.local', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '.env.local')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects id_rsa', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '.ssh/id_rsa')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects id_ed25519', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '.ssh/id_ed25519')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects *.pem', () => {
|
||||
expect(() => resolveWritePath(projectRoot, 'certs/server.pem')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects *.key', () => {
|
||||
expect(() => resolveWritePath(projectRoot, 'certs/private.key')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects credentials.json', () => {
|
||||
expect(() => resolveWritePath(projectRoot, 'credentials.json')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects *.p12', () => {
|
||||
expect(() => resolveWritePath(projectRoot, 'certs/client.p12')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects .netrc', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '.netrc')).toThrow();
|
||||
});
|
||||
|
||||
it('rejects *.kdbx', () => {
|
||||
expect(() => resolveWritePath(projectRoot, 'secrets/passwords.kdbx')).toThrow();
|
||||
});
|
||||
|
||||
// Valid paths (should NOT throw)
|
||||
it('allows simple relative path', () => {
|
||||
expect(resolveWritePath(projectRoot, 'src/index.ts')).toBe('/opt/testproject/src/index.ts');
|
||||
});
|
||||
|
||||
it('allows nested path', () => {
|
||||
expect(resolveWritePath(projectRoot, 'src/services/tools/edit_file.ts')).toContain(projectRoot);
|
||||
});
|
||||
|
||||
it('allows dotfile that is not in deny list', () => {
|
||||
expect(resolveWritePath(projectRoot, '.gitignore')).toContain(projectRoot);
|
||||
});
|
||||
|
||||
it('allows absolute path inside project', () => {
|
||||
expect(resolveWritePath(projectRoot, '/opt/testproject/new-file.ts')).toBe('/opt/testproject/new-file.ts');
|
||||
});
|
||||
|
||||
it('allows path with safe internal ../', () => {
|
||||
expect(resolveWritePath(projectRoot, 'src/../lib/utils.ts')).toBe('/opt/testproject/lib/utils.ts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('write_guard fuzz — edge cases', () => {
|
||||
it('throws on empty string', () => {
|
||||
expect(() => resolveWritePath(projectRoot, '')).toThrow();
|
||||
});
|
||||
|
||||
it('throws on whitespace-only', () => {
|
||||
expect(() => resolveWritePath(projectRoot, ' ')).toThrow();
|
||||
});
|
||||
|
||||
it('throws when path IS the project root itself', () => {
|
||||
// Writing to the directory itself makes no sense for a file write
|
||||
expect(() => resolveWritePath(projectRoot, '/opt/testproject')).not.toThrow();
|
||||
// The guard allows it (resolve === projectRoot passes the check).
|
||||
// This is acceptable because the filesystem write will fail on a directory.
|
||||
// If we want to block this, that's a separate concern.
|
||||
});
|
||||
|
||||
it('handles very long path without crashing', () => {
|
||||
const longSegment = 'a'.repeat(255);
|
||||
const longPath = Array(20).fill(longSegment).join('/');
|
||||
// Should not crash — may throw or succeed, but must not buffer-overflow
|
||||
expect(() => resolveWritePath(projectRoot, longPath)).not.toThrow();
|
||||
});
|
||||
|
||||
it('handles path with only dots', () => {
|
||||
// Single dot resolves to projectRoot itself
|
||||
const result = resolveWritePath(projectRoot, './src/file.ts');
|
||||
expect(result).toBe('/opt/testproject/src/file.ts');
|
||||
});
|
||||
|
||||
it('rejects triple-dot trick (... is not special but ../ within is)', () => {
|
||||
// '.../etc' is a literal directory name, not traversal
|
||||
const result = resolveWritePath(projectRoot, '.../etc');
|
||||
expect(result).toContain(projectRoot);
|
||||
});
|
||||
|
||||
it('rejects path with multiple consecutive slashes', () => {
|
||||
// resolve normalizes these; should still be inside root
|
||||
const result = resolveWritePath(projectRoot, 'src///file.ts');
|
||||
expect(result).toBe('/opt/testproject/src/file.ts');
|
||||
});
|
||||
});
|
||||
272
apps/coder/src/services/acp-dispatch.ts
Normal file
272
apps/coder/src/services/acp-dispatch.ts
Normal file
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* ACP dispatch — runs ACP-capable agents (opencode, goose) directly on the host.
|
||||
*
|
||||
* v2.1.1: BooCoder runs on the host now — agents are spawned directly,
|
||||
* no SSH needed. Uses @agentclientprotocol/sdk for structured JSON-RPC.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Spawn `opencode acp` (or `goose acp`) in the worktree
|
||||
* 2. Wrap child's stdin/stdout into NDJSON streams
|
||||
* 3. Create a ClientSideConnection from the SDK
|
||||
* 4. Initialize → newSession → prompt(task)
|
||||
* 5. Collect session updates (tool calls, text output)
|
||||
* 6. On prompt completion → return collected output
|
||||
*/
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { Readable, Writable } from 'node:stream';
|
||||
import {
|
||||
ClientSideConnection,
|
||||
ndJsonStream,
|
||||
type Client,
|
||||
type SessionNotification,
|
||||
type RequestPermissionRequest,
|
||||
type RequestPermissionResponse,
|
||||
type ReadTextFileRequest,
|
||||
type ReadTextFileResponse,
|
||||
type WriteTextFileRequest,
|
||||
type WriteTextFileResponse,
|
||||
type CreateTerminalRequest,
|
||||
type CreateTerminalResponse,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export interface AcpDispatchResult {
|
||||
exitCode: number;
|
||||
output: string;
|
||||
toolCalls: Array<{ title: string; input: unknown; output?: unknown }>;
|
||||
stopReason: string;
|
||||
}
|
||||
|
||||
export interface AcpDispatchOpts {
|
||||
agent: string;
|
||||
task: string;
|
||||
worktreePath: string;
|
||||
model?: string;
|
||||
installPath?: string;
|
||||
signal?: AbortSignal;
|
||||
log: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
function acpArgs(agent: string): string[] | null {
|
||||
switch (agent) {
|
||||
case 'opencode':
|
||||
return ['acp'];
|
||||
case 'goose':
|
||||
return ['acp'];
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Readable stream to a web ReadableStream<Uint8Array>.
|
||||
*/
|
||||
function nodeReadableToWeb(nodeStream: NodeJS.ReadableStream): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
nodeStream.on('data', (chunk: Buffer) => {
|
||||
controller.enqueue(new Uint8Array(chunk));
|
||||
});
|
||||
nodeStream.on('end', () => {
|
||||
controller.close();
|
||||
});
|
||||
nodeStream.on('error', (err) => {
|
||||
controller.error(err);
|
||||
});
|
||||
},
|
||||
cancel() {
|
||||
if ('destroy' in nodeStream && typeof (nodeStream as Readable).destroy === 'function') {
|
||||
(nodeStream as Readable).destroy();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a Node.js Writable stream to a web WritableStream<Uint8Array>.
|
||||
*/
|
||||
function nodeWritableToWeb(nodeStream: NodeJS.WritableStream): WritableStream<Uint8Array> {
|
||||
return new WritableStream<Uint8Array>({
|
||||
write(chunk) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const ok = (nodeStream as Writable).write(chunk, (err) => {
|
||||
if (err) reject(err);
|
||||
});
|
||||
if (ok) resolve();
|
||||
else (nodeStream as Writable).once('drain', resolve);
|
||||
});
|
||||
},
|
||||
close() {
|
||||
return new Promise<void>((resolve) => {
|
||||
(nodeStream as Writable).end(resolve);
|
||||
});
|
||||
},
|
||||
abort() {
|
||||
(nodeStream as Writable).destroy();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch a task to an ACP-capable agent via SSH.
|
||||
*
|
||||
* Opens a structured ACP session, sends the task as a prompt, and collects
|
||||
* all session updates. Returns the collected output and tool calls.
|
||||
*/
|
||||
export async function dispatchViaAcp(opts: AcpDispatchOpts): Promise<AcpDispatchResult> {
|
||||
const { agent, task, worktreePath, installPath, signal, log } = opts;
|
||||
|
||||
const args = acpArgs(agent);
|
||||
if (!args) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: `Agent '${agent}' does not support ACP.`,
|
||||
toolCalls: [],
|
||||
stopReason: 'error',
|
||||
};
|
||||
}
|
||||
|
||||
const binary = installPath ?? agent;
|
||||
log.info({ agent, binary, worktreePath }, 'acp-dispatch: spawning');
|
||||
const child = spawn(binary, args, {
|
||||
cwd: worktreePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
// Wire up abort
|
||||
let killed = false;
|
||||
const cleanup = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
cleanup();
|
||||
return { exitCode: 130, output: 'Aborted before start', toolCalls: [], stopReason: 'cancelled' };
|
||||
}
|
||||
signal.addEventListener('abort', cleanup, { once: true });
|
||||
}
|
||||
|
||||
try {
|
||||
// Create web streams from the child process stdio
|
||||
const inputStream = nodeReadableToWeb(child.stdout!);
|
||||
const outputStream = nodeWritableToWeb(child.stdin!);
|
||||
|
||||
// Create the NDJSON ACP stream
|
||||
const stream = ndJsonStream(outputStream, inputStream);
|
||||
|
||||
// Collected session updates
|
||||
const textChunks: string[] = [];
|
||||
const toolCalls: Array<{ title: string; input: unknown; output?: unknown }> = [];
|
||||
|
||||
// Create client-side connection — we are the "client" (editor), the agent is remote
|
||||
const connection = new ClientSideConnection(
|
||||
(_agentInterface): Client => ({
|
||||
// Handle session updates from the agent
|
||||
async sessionUpdate(params: SessionNotification): Promise<void> {
|
||||
const update = params.update;
|
||||
if (update.sessionUpdate === 'agent_message_chunk') {
|
||||
// ContentChunk with content: ContentBlock
|
||||
const content = update.content;
|
||||
if (content.type === 'text' && 'text' in content) {
|
||||
textChunks.push((content as { text: string }).text);
|
||||
}
|
||||
} else if (update.sessionUpdate === 'tool_call') {
|
||||
toolCalls.push({
|
||||
title: update.title,
|
||||
input: update.rawInput,
|
||||
});
|
||||
} else if (update.sessionUpdate === 'tool_call_update') {
|
||||
const last = toolCalls[toolCalls.length - 1];
|
||||
if (last && update.rawOutput !== undefined) {
|
||||
last.output = update.rawOutput;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Permission requests — auto-approve by selecting the first option (worktree is isolated)
|
||||
async requestPermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
||||
// Select the first available option to auto-approve
|
||||
const firstOption = params.options[0];
|
||||
if (firstOption) {
|
||||
return {
|
||||
outcome: { outcome: 'selected', optionId: firstOption.optionId },
|
||||
};
|
||||
}
|
||||
// No options available — cancel
|
||||
return { outcome: { outcome: 'cancelled' } };
|
||||
},
|
||||
|
||||
// File system operations — let the agent handle them directly in the worktree
|
||||
async readTextFile(_params: ReadTextFileRequest): Promise<ReadTextFileResponse> {
|
||||
return { content: '' };
|
||||
},
|
||||
async writeTextFile(_params: WriteTextFileRequest): Promise<WriteTextFileResponse> {
|
||||
return {};
|
||||
},
|
||||
async createTerminal(_params: CreateTerminalRequest): Promise<CreateTerminalResponse> {
|
||||
return { terminalId: 'noop' };
|
||||
},
|
||||
}),
|
||||
stream,
|
||||
);
|
||||
|
||||
// Initialize the connection
|
||||
// ProtocolVersion is a number in this SDK version
|
||||
const initResult = await connection.initialize({
|
||||
protocolVersion: 1,
|
||||
clientInfo: { name: 'boocoder', version: '2.0.1' },
|
||||
clientCapabilities: {},
|
||||
});
|
||||
log.info({ agentInfo: initResult.agentInfo }, 'acp-dispatch: initialized');
|
||||
|
||||
// Create a new session
|
||||
const session = await connection.newSession({
|
||||
cwd: worktreePath,
|
||||
mcpServers: [],
|
||||
});
|
||||
log.info({ sessionId: session.sessionId }, 'acp-dispatch: session created');
|
||||
|
||||
// Send the prompt
|
||||
const promptResult = await connection.prompt({
|
||||
sessionId: session.sessionId,
|
||||
prompt: [{ type: 'text', text: task }],
|
||||
});
|
||||
|
||||
const stopReason = promptResult.stopReason ?? 'end_turn';
|
||||
log.info({ agent, stopReason, toolCallCount: toolCalls.length }, 'acp-dispatch: prompt completed');
|
||||
|
||||
// Clean shutdown
|
||||
await connection.closeSession({ sessionId: session.sessionId }).catch(() => {});
|
||||
|
||||
return {
|
||||
exitCode: 0,
|
||||
output: textChunks.join(''),
|
||||
toolCalls,
|
||||
stopReason,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
log.error({ agent, err: message }, 'acp-dispatch: error');
|
||||
return {
|
||||
exitCode: 1,
|
||||
output: message,
|
||||
toolCalls: [],
|
||||
stopReason: 'error',
|
||||
};
|
||||
} finally {
|
||||
if (signal) signal.removeEventListener('abort', cleanup);
|
||||
cleanup();
|
||||
|
||||
// Wait for child to exit
|
||||
await new Promise<void>((resolve) => {
|
||||
child.on('close', resolve);
|
||||
setTimeout(resolve, 3_000);
|
||||
});
|
||||
}
|
||||
}
|
||||
93
apps/coder/src/services/agent-probe.ts
Normal file
93
apps/coder/src/services/agent-probe.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Sql } from '../db.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { exec as execCb } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { PROVIDERS_BY_NAME } from './provider-registry.js';
|
||||
|
||||
const exec = promisify(execCb);
|
||||
|
||||
const KNOWN_AGENTS = ['opencode', 'goose', 'claude', 'qwen'].map((name) => ({
|
||||
name,
|
||||
supportsAcp: PROVIDERS_BY_NAME.get(name)?.transport === 'acp',
|
||||
}));
|
||||
|
||||
/**
|
||||
* Probe for available agents on the HOST.
|
||||
*
|
||||
* v2.1.1: BooCoder runs on the host now — agents are local binaries,
|
||||
* no SSH needed. Direct `which` / `exec` calls.
|
||||
*/
|
||||
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
||||
log.info('agent-probe: scanning for known agents');
|
||||
|
||||
for (const agent of KNOWN_AGENTS) {
|
||||
try {
|
||||
const { stdout: whichOut } = await exec(`which ${agent.name}`, { timeout: 10_000 });
|
||||
const installPath = whichOut.trim();
|
||||
if (!installPath) continue;
|
||||
|
||||
let version: string | null = null;
|
||||
try {
|
||||
const { stdout: verOut } = await exec(`${agent.name} --version`, { timeout: 15_000 });
|
||||
version = verOut.trim().slice(0, 100);
|
||||
} catch {
|
||||
// Some agents may not support --version
|
||||
}
|
||||
|
||||
let supportsAcp = agent.supportsAcp;
|
||||
if (supportsAcp) {
|
||||
try {
|
||||
await exec(`${agent.name} acp --help`, { timeout: 10_000 });
|
||||
} catch {
|
||||
supportsAcp = false;
|
||||
}
|
||||
}
|
||||
|
||||
let models: Array<{ id: string; label: string }> = [];
|
||||
const providerDef = PROVIDERS_BY_NAME.get(agent.name);
|
||||
|
||||
if (providerDef?.modelSource === 'static' && providerDef.staticModels) {
|
||||
models = providerDef.staticModels;
|
||||
}
|
||||
|
||||
if (agent.name === 'qwen') {
|
||||
try {
|
||||
const { stdout: catOut } = await exec('cat ~/.qwen/settings.json', { timeout: 10_000 });
|
||||
if (catOut.trim()) {
|
||||
const settings = JSON.parse(catOut) as {
|
||||
modelProviders?: { openai?: Array<{ id: string }> };
|
||||
};
|
||||
const openaiModels = settings?.modelProviders?.openai;
|
||||
if (Array.isArray(openaiModels)) {
|
||||
models = openaiModels.map((m) => ({ id: m.id, label: m.id }));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ~/.qwen/settings.json missing or unparseable
|
||||
}
|
||||
}
|
||||
|
||||
const label = providerDef?.label ?? agent.name;
|
||||
const transport = providerDef?.transport ?? 'pty';
|
||||
|
||||
await sql`
|
||||
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at, models, label, transport)
|
||||
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp(), ${sql.json(models as never)}, ${label}, ${transport})
|
||||
ON CONFLICT (name) DO UPDATE SET
|
||||
install_path = EXCLUDED.install_path,
|
||||
version = EXCLUDED.version,
|
||||
supports_acp = EXCLUDED.supports_acp,
|
||||
last_probed_at = EXCLUDED.last_probed_at,
|
||||
models = EXCLUDED.models,
|
||||
label = EXCLUDED.label,
|
||||
transport = EXCLUDED.transport
|
||||
`;
|
||||
log.info({ agent: agent.name, version, installPath, supportsAcp, modelCount: models.length }, 'agent-probe: found');
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found');
|
||||
}
|
||||
}
|
||||
|
||||
log.info('agent-probe: scan complete');
|
||||
}
|
||||
406
apps/coder/src/services/dispatcher.ts
Normal file
406
apps/coder/src/services/dispatcher.ts
Normal file
@@ -0,0 +1,406 @@
|
||||
import type { Sql } from '../db.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree } from './worktrees.js';
|
||||
import { dispatchViaAcp } from './acp-dispatch.js';
|
||||
import { dispatchViaPty } from './pty-dispatch.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
hasActive: (chatId: string) => boolean;
|
||||
}
|
||||
|
||||
interface Deps {
|
||||
sql: Sql;
|
||||
inference: InferenceRunner;
|
||||
broker: Broker;
|
||||
log: FastifyBaseLogger;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
const POLL_INTERVAL_MS = 5_000;
|
||||
const COMPLETION_POLL_MS = 2_000;
|
||||
|
||||
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
||||
const { sql, inference, log, config } = deps;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let running = false;
|
||||
let stopping = false;
|
||||
let inflightPromise: Promise<void> | null = null;
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
if (running || stopping) return;
|
||||
|
||||
// Grab one pending task
|
||||
const rows = await sql<{ id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }[]>`
|
||||
SELECT id, project_id, input, agent, model, session_id
|
||||
FROM tasks
|
||||
WHERE state = 'pending'
|
||||
ORDER BY created_at
|
||||
LIMIT 1
|
||||
`;
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const task = rows[0]!;
|
||||
running = true;
|
||||
inflightPromise = runTask(task).finally(() => {
|
||||
running = false;
|
||||
inflightPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
async function runTask(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
|
||||
const taskId = task.id;
|
||||
|
||||
// Determine execution path: if agent is specified AND exists in available_agents → Path B
|
||||
if (task.agent) {
|
||||
const [agentRow] = await sql<{ name: string; supports_acp: boolean; install_path: string | null }[]>`
|
||||
SELECT name, supports_acp, install_path FROM available_agents WHERE name = ${task.agent}
|
||||
`;
|
||||
if (agentRow) {
|
||||
await runExternalAgent(task, agentRow.supports_acp, agentRow.install_path);
|
||||
return;
|
||||
}
|
||||
// Agent specified but not available — fall through to Path A with a warning
|
||||
log.warn({ taskId, agent: task.agent }, 'dispatcher: specified agent not available, falling back to native');
|
||||
}
|
||||
|
||||
// Path A — native inference (existing behavior)
|
||||
await runNativeInference(task);
|
||||
}
|
||||
|
||||
// ─── Path A: Native Inference ───────────────────────────────────────────────
|
||||
|
||||
async function runNativeInference(task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null }): Promise<void> {
|
||||
const taskId = task.id;
|
||||
log.info({ taskId }, 'dispatcher: starting task (path A — native)');
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'running', started_at = clock_timestamp(), execution_path = 'native'
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
|
||||
// Create session + chat for this task
|
||||
const model = task.model ?? config.DEFAULT_MODEL;
|
||||
const sessionName = 'Task: ' + task.input.slice(0, 40);
|
||||
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status)
|
||||
VALUES (${task.project_id}, ${sessionName}, ${model}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
const sessionId = session!.id;
|
||||
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'Task execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
const chatId = chat!.id;
|
||||
|
||||
// Link task to session
|
||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||
|
||||
// Create user message + streaming assistant
|
||||
await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const [assistantMsg] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
|
||||
// Enqueue inference
|
||||
inference.enqueue(sessionId, chatId, assistantId, 'default');
|
||||
|
||||
// Wait for inference to complete (poll message status)
|
||||
const finalStatus = await waitForCompletion(assistantId);
|
||||
|
||||
if (stopping) {
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'cancelled', ended_at = clock_timestamp()
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Aggregate token cost for the task's session
|
||||
const [costRow] = await sql<{ total: number | null }[]>`
|
||||
SELECT SUM(tokens_used)::int AS total
|
||||
FROM messages
|
||||
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
|
||||
`;
|
||||
const costTokens = costRow?.total ?? null;
|
||||
|
||||
if (finalStatus === 'complete') {
|
||||
const [msg] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages WHERE id = ${assistantId}
|
||||
`;
|
||||
const summary = (msg?.content ?? '').slice(0, 500);
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, costTokens }, 'dispatcher: task completed (native)');
|
||||
} else {
|
||||
const [msg] = await sql<{ content: string | null }[]>`
|
||||
SELECT content FROM messages WHERE id = ${assistantId}
|
||||
`;
|
||||
const summary = (msg?.content ?? 'Inference failed').slice(0, 500);
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${summary}, cost_tokens = ${costTokens}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.warn({ taskId, finalStatus }, 'dispatcher: task failed (native)');
|
||||
}
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log.error({ taskId, err: errMsg }, 'dispatcher: task error (native)');
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Path B: External Agent Dispatch ──────<E29480><E29480><EFBFBD>─────────────────────────────────
|
||||
|
||||
async function runExternalAgent(
|
||||
task: { id: string; project_id: string; input: string; agent: string | null; model: string | null; session_id: string | null },
|
||||
supportsAcp: boolean,
|
||||
installPath: string | null,
|
||||
): Promise<void> {
|
||||
const taskId = task.id;
|
||||
const agent = task.agent!;
|
||||
const executionPath = supportsAcp ? 'acp' : 'pty';
|
||||
|
||||
log.info({ taskId, agent, executionPath }, 'dispatcher: starting task (path B — external)');
|
||||
|
||||
// Resolve the project's root path
|
||||
const [project] = await sql<{ path: string | null }[]>`
|
||||
SELECT path FROM projects WHERE id = ${task.project_id}
|
||||
`;
|
||||
const projectPath = project?.path;
|
||||
if (!projectPath) {
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no path — cannot create worktree'
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an abort controller for this task
|
||||
const ac = new AbortController();
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'running', started_at = clock_timestamp(), execution_path = ${executionPath}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
|
||||
if (task.session_id) {
|
||||
sessionId = task.session_id;
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
SELECT id FROM chats WHERE session_id = ${sessionId} AND status = 'open' ORDER BY created_at DESC LIMIT 1
|
||||
`;
|
||||
if (chats.length === 0) {
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = chat!.id;
|
||||
} else {
|
||||
chatId = chats[0]!.id;
|
||||
}
|
||||
} else {
|
||||
const sessionName = `Task [${agent}]: ${task.input.slice(0, 30)}`;
|
||||
const [session] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status)
|
||||
VALUES (${task.project_id}, ${sessionName}, ${task.model ?? config.DEFAULT_MODEL}, 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
sessionId = session!.id;
|
||||
|
||||
const [chat] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status)
|
||||
VALUES (${sessionId}, 'External agent execution', 'open')
|
||||
RETURNING id
|
||||
`;
|
||||
chatId = chat!.id;
|
||||
|
||||
await sql`UPDATE tasks SET session_id = ${sessionId} WHERE id = ${taskId}`;
|
||||
}
|
||||
|
||||
if (!task.session_id) {
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'user', ${task.input}, 'complete', clock_timestamp())
|
||||
`;
|
||||
}
|
||||
|
||||
// Step 1: Create worktree
|
||||
log.info({ taskId, projectPath }, 'dispatcher: creating worktree');
|
||||
const worktreePath = await createWorktree(projectPath, taskId, { signal: ac.signal });
|
||||
log.info({ taskId, worktreePath }, 'dispatcher: worktree created');
|
||||
|
||||
// Step 2: Dispatch to agent
|
||||
let outputSummary: string;
|
||||
|
||||
if (supportsAcp) {
|
||||
const result = await dispatchViaAcp({
|
||||
agent,
|
||||
task: task.input,
|
||||
worktreePath,
|
||||
installPath: installPath ?? undefined,
|
||||
model: task.model ?? undefined,
|
||||
signal: ac.signal,
|
||||
log,
|
||||
});
|
||||
outputSummary = result.output.slice(0, 500);
|
||||
|
||||
// Store agent output as an assistant message
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', ${result.output.slice(0, 50_000)}, 'complete', clock_timestamp())
|
||||
`;
|
||||
} else {
|
||||
const result = await dispatchViaPty({
|
||||
agent,
|
||||
task: task.input,
|
||||
worktreePath,
|
||||
installPath: installPath ?? undefined,
|
||||
model: task.model ?? undefined,
|
||||
signal: ac.signal,
|
||||
log,
|
||||
});
|
||||
outputSummary = (result.stdout || result.stderr).slice(0, 500);
|
||||
|
||||
// Store agent output as an assistant message
|
||||
const content = result.stdout || result.stderr || '(no output)';
|
||||
await sql`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', ${content.slice(0, 50_000)}, 'complete', clock_timestamp())
|
||||
`;
|
||||
}
|
||||
|
||||
if (stopping) {
|
||||
await sql`
|
||||
UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}
|
||||
`;
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 3: Diff the worktree and queue pending changes
|
||||
log.info({ taskId }, 'dispatcher: diffing worktree');
|
||||
const diff = await diffWorktree(worktreePath, projectPath, { signal: ac.signal });
|
||||
|
||||
if (diff) {
|
||||
// Queue a single pending_change entry with the full unified diff
|
||||
await sql`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||
VALUES (${sessionId}, ${taskId}, ${projectPath}, 'edit', ${diff})
|
||||
`;
|
||||
log.info({ taskId, diffLength: diff.length }, 'dispatcher: diff queued as pending change');
|
||||
} else {
|
||||
log.info({ taskId }, 'dispatcher: no changes detected in worktree');
|
||||
}
|
||||
|
||||
// Step 4: Cleanup worktree
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
|
||||
// Step 5: Aggregate token cost
|
||||
const [extCostRow] = await sql<{ total: number | null }[]>`
|
||||
SELECT SUM(tokens_used)::int AS total
|
||||
FROM messages
|
||||
WHERE session_id = ${sessionId} AND tokens_used IS NOT NULL
|
||||
`;
|
||||
const extCostTokens = extCostRow?.total ?? null;
|
||||
|
||||
// Step 6: Mark task completed
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'completed', ended_at = clock_timestamp(), output_summary = ${outputSummary}, cost_tokens = ${extCostTokens}
|
||||
WHERE id = ${taskId}
|
||||
`;
|
||||
log.info({ taskId, agent, costTokens: extCostTokens }, 'dispatcher: task completed (external)');
|
||||
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
log.error({ taskId, agent, err: errMsg }, 'dispatcher: external agent error');
|
||||
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
`.catch(() => {});
|
||||
|
||||
// Best-effort cleanup
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function waitForCompletion(assistantId: string): Promise<string> {
|
||||
for (;;) {
|
||||
if (stopping) return 'cancelled';
|
||||
|
||||
const [row] = await sql<{ status: string }[]>`
|
||||
SELECT status FROM messages WHERE id = ${assistantId}
|
||||
`;
|
||||
const status = row?.status ?? 'failed';
|
||||
if (status !== 'streaming') return status;
|
||||
|
||||
await sleep(COMPLETION_POLL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
log.info('dispatcher: starting poll loop');
|
||||
timer = setInterval(() => {
|
||||
poll().catch((err) => {
|
||||
log.error({ err }, 'dispatcher: poll error');
|
||||
});
|
||||
}, POLL_INTERVAL_MS);
|
||||
},
|
||||
|
||||
async stop() {
|
||||
stopping = true;
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
if (inflightPromise) {
|
||||
log.info('dispatcher: waiting for in-flight task');
|
||||
await inflightPromise;
|
||||
}
|
||||
log.info('dispatcher: stopped');
|
||||
},
|
||||
};
|
||||
}
|
||||
201
apps/coder/src/services/mcp-server.ts
Normal file
201
apps/coder/src/services/mcp-server.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* BooCoder MCP Server — exposes task primitives as MCP tools.
|
||||
*
|
||||
* Started when `--mcp` flag is passed to the entry point. Runs stdio transport
|
||||
* so external tools (opencode in Termius) can drive the task queue.
|
||||
*/
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import { applyOne, rejectOne } from './pending_changes.js';
|
||||
|
||||
// --- Tool handlers -----------------------------------------------------------
|
||||
|
||||
interface TaskRow {
|
||||
id: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
interface PendingRow {
|
||||
id: string;
|
||||
file_path: string;
|
||||
operation: string;
|
||||
diff: string;
|
||||
session_id: string;
|
||||
}
|
||||
|
||||
interface WorktreeRow {
|
||||
id: string;
|
||||
worktree_path: string;
|
||||
agent: string;
|
||||
started_at: string;
|
||||
}
|
||||
|
||||
interface ProjectPathRow {
|
||||
path: string;
|
||||
}
|
||||
|
||||
function textResult(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
|
||||
// --- Public entry ------------------------------------------------------------
|
||||
|
||||
export async function startMcpServer(sql: Sql): Promise<void> {
|
||||
const server = new McpServer(
|
||||
{ name: 'boocoder', version: '2.0.2' },
|
||||
{ capabilities: { tools: {} } },
|
||||
);
|
||||
|
||||
// 1. boocoder.create_task
|
||||
server.tool(
|
||||
'boocoder.create_task',
|
||||
'Create a new task in the BooCoder task queue',
|
||||
{
|
||||
project_id: z.string().describe('Project UUID'),
|
||||
input: z.string().describe('Task description / prompt for the agent'),
|
||||
agent: z.string().optional().describe('Agent name (optional — uses default if omitted)'),
|
||||
model: z.string().optional().describe('Model override (optional)'),
|
||||
},
|
||||
async (args) => {
|
||||
const [row] = await sql<TaskRow[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, state)
|
||||
VALUES (${args.project_id}, ${args.input}, ${args.agent ?? null}, ${args.model ?? null}, 'pending')
|
||||
RETURNING id, state
|
||||
`;
|
||||
return textResult({ task_id: row!.id, state: row!.state });
|
||||
},
|
||||
);
|
||||
|
||||
// 2. boocoder.list_pending_changes
|
||||
server.tool(
|
||||
'boocoder.list_pending_changes',
|
||||
'List pending changes awaiting review',
|
||||
{
|
||||
session_id: z.string().optional().describe('Optional session filter'),
|
||||
},
|
||||
async (args) => {
|
||||
let rows: PendingRow[];
|
||||
if (args.session_id) {
|
||||
rows = await sql<PendingRow[]>`
|
||||
SELECT id, file_path, operation, diff, session_id
|
||||
FROM pending_changes
|
||||
WHERE status = 'pending' AND session_id = ${args.session_id}
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
} else {
|
||||
rows = await sql<PendingRow[]>`
|
||||
SELECT id, file_path, operation, diff, session_id
|
||||
FROM pending_changes
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
}
|
||||
const items = rows.map((r) => ({
|
||||
id: r.id,
|
||||
file_path: r.file_path,
|
||||
operation: r.operation,
|
||||
diff_preview: r.diff.slice(0, 200),
|
||||
}));
|
||||
return textResult(items);
|
||||
},
|
||||
);
|
||||
|
||||
// 3. boocoder.apply
|
||||
server.tool(
|
||||
'boocoder.apply',
|
||||
'Apply a pending change (write to disk)',
|
||||
{
|
||||
change_id: z.string().describe('Pending change UUID'),
|
||||
},
|
||||
async (args) => {
|
||||
// Resolve projectRoot from the change's session → project path
|
||||
const [proj] = await sql<ProjectPathRow[]>`
|
||||
SELECT p.path FROM pending_changes pc
|
||||
JOIN sessions s ON pc.session_id = s.id
|
||||
JOIN projects p ON s.project_id = p.id
|
||||
WHERE pc.id = ${args.change_id}
|
||||
`;
|
||||
if (!proj) {
|
||||
return textResult({ success: false, file_path: '', error: 'change not found or project path unresolved' });
|
||||
}
|
||||
const result = await applyOne(sql, args.change_id, proj.path);
|
||||
return textResult({ success: result.success, file_path: result.file_path, error: result.error });
|
||||
},
|
||||
);
|
||||
|
||||
// 4. boocoder.reject
|
||||
server.tool(
|
||||
'boocoder.reject',
|
||||
'Reject a pending change (mark as rejected, no disk write)',
|
||||
{
|
||||
change_id: z.string().describe('Pending change UUID'),
|
||||
},
|
||||
async (args) => {
|
||||
await rejectOne(sql, args.change_id);
|
||||
return textResult({ success: true });
|
||||
},
|
||||
);
|
||||
|
||||
// 5. boocoder.dispatch_external_agent
|
||||
server.tool(
|
||||
'boocoder.dispatch_external_agent',
|
||||
'Create a task targeting a specific external agent (ACP or PTY dispatch)',
|
||||
{
|
||||
project_id: z.string().describe('Project UUID'),
|
||||
input: z.string().describe('Task prompt'),
|
||||
agent: z.string().describe('Agent name (must match available_agents registry)'),
|
||||
model: z.string().optional().describe('Model override (optional)'),
|
||||
},
|
||||
async (args) => {
|
||||
const [row] = await sql<TaskRow[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, model, state)
|
||||
VALUES (${args.project_id}, ${args.input}, ${args.agent}, ${args.model ?? null}, 'pending')
|
||||
RETURNING id, state
|
||||
`;
|
||||
|
||||
// Determine execution path from available_agents
|
||||
const [agentRow] = await sql<{ supports_acp: boolean }[]>`
|
||||
SELECT supports_acp FROM available_agents WHERE name = ${args.agent}
|
||||
`;
|
||||
const executionPath = agentRow?.supports_acp ? 'acp' : 'pty';
|
||||
|
||||
return textResult({ task_id: row!.id, state: row!.state, execution_path: executionPath });
|
||||
},
|
||||
);
|
||||
|
||||
// 6. boocoder.list_worktrees
|
||||
server.tool(
|
||||
'boocoder.list_worktrees',
|
||||
'List active worktrees from running tasks',
|
||||
{},
|
||||
async () => {
|
||||
const rows = await sql<WorktreeRow[]>`
|
||||
SELECT id, worktree_path, agent, started_at
|
||||
FROM tasks
|
||||
WHERE worktree_path IS NOT NULL AND state = 'running'
|
||||
ORDER BY started_at DESC
|
||||
`;
|
||||
const items = rows.map((r) => ({
|
||||
task_id: r.id,
|
||||
worktree_path: r.worktree_path,
|
||||
agent: r.agent,
|
||||
started_at: r.started_at,
|
||||
}));
|
||||
return textResult(items);
|
||||
},
|
||||
);
|
||||
|
||||
// Connect via stdio
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
// Block until stdin closes (transport handles lifecycle)
|
||||
await new Promise<void>((resolve) => {
|
||||
process.stdin.on('end', resolve);
|
||||
process.stdin.on('close', resolve);
|
||||
});
|
||||
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
224
apps/coder/src/services/pending_changes.ts
Normal file
224
apps/coder/src/services/pending_changes.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { readFile, writeFile, unlink, mkdir } from 'node:fs/promises';
|
||||
import { dirname } from 'node:path';
|
||||
import type { Sql } from '../db.js';
|
||||
import { resolveWritePath } from './write_guard.js';
|
||||
|
||||
// --- Types -------------------------------------------------------------------
|
||||
|
||||
export interface PendingChange {
|
||||
id: string;
|
||||
session_id: string;
|
||||
task_id: string | null;
|
||||
file_path: string;
|
||||
operation: 'create' | 'edit' | 'delete';
|
||||
diff: string;
|
||||
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ApplyResult {
|
||||
id: string;
|
||||
file_path: string;
|
||||
operation: string;
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// --- Queue functions ---------------------------------------------------------
|
||||
|
||||
export async function queueEdit(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
taskId: string | null,
|
||||
filePath: string,
|
||||
oldString: string,
|
||||
newString: string,
|
||||
projectRoot: string,
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
const diff = JSON.stringify({ old: oldString, new: newString });
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'edit', ${diff})
|
||||
RETURNING *
|
||||
`;
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function queueCreate(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
taskId: string | null,
|
||||
filePath: string,
|
||||
content: string,
|
||||
projectRoot: string,
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'create', ${content})
|
||||
RETURNING *
|
||||
`;
|
||||
return row!;
|
||||
}
|
||||
|
||||
export async function queueDelete(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
taskId: string | null,
|
||||
filePath: string,
|
||||
projectRoot: string,
|
||||
): Promise<PendingChange> {
|
||||
const resolved = resolveWritePath(projectRoot, filePath);
|
||||
|
||||
const [row] = await sql<PendingChange[]>`
|
||||
INSERT INTO pending_changes (session_id, task_id, file_path, operation, diff)
|
||||
VALUES (${sessionId}, ${taskId}, ${resolved}, 'delete', '')
|
||||
RETURNING *
|
||||
`;
|
||||
return row!;
|
||||
}
|
||||
|
||||
// --- Apply functions ---------------------------------------------------------
|
||||
|
||||
export async function applyOne(
|
||||
sql: Sql,
|
||||
changeId: string,
|
||||
projectRoot: string,
|
||||
): Promise<ApplyResult> {
|
||||
const [change] = await sql<PendingChange[]>`
|
||||
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'pending'
|
||||
`;
|
||||
if (!change) {
|
||||
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not pending' };
|
||||
}
|
||||
|
||||
try {
|
||||
// Re-validate path in case projectRoot has shifted
|
||||
resolveWritePath(projectRoot, change.file_path);
|
||||
|
||||
switch (change.operation) {
|
||||
case 'create': {
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFile(change.file_path, change.diff, 'utf8');
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||
const content = await readFile(change.file_path, 'utf8');
|
||||
if (!content.includes(oldStr)) {
|
||||
throw new Error('old_string not found in file — file may have changed since the edit was queued');
|
||||
}
|
||||
const updated = content.replace(oldStr, newStr);
|
||||
await writeFile(change.file_path, updated, 'utf8');
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Stash current content in diff for potential rewind
|
||||
try {
|
||||
const existing = await readFile(change.file_path, 'utf8');
|
||||
await sql`UPDATE pending_changes SET diff = ${existing} WHERE id = ${changeId}`;
|
||||
} catch {
|
||||
// File may already be gone — proceed with status update
|
||||
}
|
||||
await unlink(change.file_path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await sql`UPDATE pending_changes SET status = 'applied' WHERE id = ${changeId}`;
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function applyAll(
|
||||
sql: Sql,
|
||||
sessionId: string,
|
||||
projectRoot: string,
|
||||
): Promise<ApplyResult[]> {
|
||||
const pending = await sql<PendingChange[]>`
|
||||
SELECT * FROM pending_changes
|
||||
WHERE session_id = ${sessionId} AND status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
const results: ApplyResult[] = [];
|
||||
for (const change of pending) {
|
||||
results.push(await applyOne(sql, change.id, projectRoot));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// --- Reject functions --------------------------------------------------------
|
||||
|
||||
export async function rejectOne(sql: Sql, changeId: string): Promise<void> {
|
||||
await sql`UPDATE pending_changes SET status = 'rejected' WHERE id = ${changeId} AND status = 'pending'`;
|
||||
}
|
||||
|
||||
export async function rejectAll(sql: Sql, sessionId: string): Promise<void> {
|
||||
await sql`UPDATE pending_changes SET status = 'rejected' WHERE session_id = ${sessionId} AND status = 'pending'`;
|
||||
}
|
||||
|
||||
// --- Rewind functions --------------------------------------------------------
|
||||
|
||||
export async function rewindOne(
|
||||
sql: Sql,
|
||||
changeId: string,
|
||||
projectRoot: string,
|
||||
): Promise<ApplyResult> {
|
||||
const [change] = await sql<PendingChange[]>`
|
||||
SELECT * FROM pending_changes WHERE id = ${changeId} AND status = 'applied'
|
||||
`;
|
||||
if (!change) {
|
||||
return { id: changeId, file_path: '', operation: '', success: false, error: 'change not found or not applied' };
|
||||
}
|
||||
|
||||
try {
|
||||
resolveWritePath(projectRoot, change.file_path);
|
||||
|
||||
switch (change.operation) {
|
||||
case 'create': {
|
||||
// Reverse a create: delete the file
|
||||
await unlink(change.file_path);
|
||||
break;
|
||||
}
|
||||
case 'edit': {
|
||||
// Reverse an edit: swap old and new
|
||||
const { old: oldStr, new: newStr } = JSON.parse(change.diff) as { old: string; new: string };
|
||||
const content = await readFile(change.file_path, 'utf8');
|
||||
if (!content.includes(newStr)) {
|
||||
throw new Error('new_string not found in file — cannot rewind; file may have been modified since apply');
|
||||
}
|
||||
const reverted = content.replace(newStr, oldStr);
|
||||
await writeFile(change.file_path, reverted, 'utf8');
|
||||
break;
|
||||
}
|
||||
case 'delete': {
|
||||
// Reverse a delete: recreate the file (diff holds the original content stashed at apply time)
|
||||
await mkdir(dirname(change.file_path), { recursive: true });
|
||||
await writeFile(change.file_path, change.diff, 'utf8');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await sql`UPDATE pending_changes SET status = 'reverted' WHERE id = ${changeId}`;
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: true };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { id: change.id, file_path: change.file_path, operation: change.operation, success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
// --- Query functions ---------------------------------------------------------
|
||||
|
||||
export async function listPending(sql: Sql, sessionId: string): Promise<PendingChange[]> {
|
||||
return sql<PendingChange[]>`
|
||||
SELECT * FROM pending_changes
|
||||
WHERE session_id = ${sessionId} AND status = 'pending'
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
}
|
||||
46
apps/coder/src/services/provider-registry.ts
Normal file
46
apps/coder/src/services/provider-registry.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
export interface ProviderDef {
|
||||
name: string;
|
||||
label: string;
|
||||
transport: 'native' | 'acp' | 'pty';
|
||||
modelSource: 'llama-swap' | 'static';
|
||||
staticModels?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
export const PROVIDERS: ProviderDef[] = [
|
||||
{
|
||||
name: 'boocode',
|
||||
label: 'BooCoder',
|
||||
transport: 'native',
|
||||
modelSource: 'llama-swap',
|
||||
},
|
||||
{
|
||||
name: 'opencode',
|
||||
label: 'OpenCode',
|
||||
transport: 'acp',
|
||||
modelSource: 'llama-swap',
|
||||
},
|
||||
{
|
||||
name: 'goose',
|
||||
label: 'Goose',
|
||||
transport: 'acp',
|
||||
modelSource: 'llama-swap',
|
||||
},
|
||||
{
|
||||
name: 'claude',
|
||||
label: 'Claude Code',
|
||||
transport: 'pty',
|
||||
modelSource: 'static',
|
||||
staticModels: [
|
||||
{ id: 'claude-opus-4-20250514', label: 'Opus 4' },
|
||||
{ id: 'claude-sonnet-4-20250514', label: 'Sonnet 4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'qwen',
|
||||
label: 'Qwen Code',
|
||||
transport: 'pty',
|
||||
modelSource: 'static',
|
||||
},
|
||||
];
|
||||
|
||||
export const PROVIDERS_BY_NAME = new Map(PROVIDERS.map((p) => [p.name, p]));
|
||||
140
apps/coder/src/services/pty-dispatch.ts
Normal file
140
apps/coder/src/services/pty-dispatch.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* PTY dispatch — runs external agents directly on the host.
|
||||
*
|
||||
* v2.1.3: Spawns agent binaries directly (no sh -c wrapper) using the
|
||||
* install_path from agent-probe. Follows Paseo's pattern: direct binary
|
||||
* path + args array + cwd.
|
||||
*
|
||||
* Supported agents:
|
||||
* - claude: `claude -p --model <model>` (print mode, reads task from stdin)
|
||||
* - opencode: `opencode --model <model>` (stdin pipe)
|
||||
* - qwen: `qwen -p <task> --output-format stream-json`
|
||||
* - goose: `goose run --text <task>`
|
||||
*/
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { spawn } from 'node:child_process';
|
||||
|
||||
export interface DispatchResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export interface PtyDispatchOpts {
|
||||
agent: string;
|
||||
task: string;
|
||||
worktreePath: string;
|
||||
model?: string;
|
||||
installPath?: string;
|
||||
signal?: AbortSignal;
|
||||
log: FastifyBaseLogger;
|
||||
}
|
||||
|
||||
interface AgentCommand {
|
||||
binary: string;
|
||||
args: string[];
|
||||
stdin?: string;
|
||||
}
|
||||
|
||||
function buildAgentCommand(agent: string, task: string, model?: string, installPath?: string): AgentCommand | null {
|
||||
const binary = installPath ?? agent;
|
||||
|
||||
switch (agent) {
|
||||
case 'claude':
|
||||
return {
|
||||
binary,
|
||||
args: model ? ['-p', '--model', model] : ['-p'],
|
||||
stdin: task,
|
||||
};
|
||||
|
||||
case 'opencode':
|
||||
return {
|
||||
binary,
|
||||
args: model ? ['--model', model] : [],
|
||||
stdin: task,
|
||||
};
|
||||
|
||||
case 'qwen':
|
||||
return {
|
||||
binary,
|
||||
args: model
|
||||
? ['-p', task, '--model', model, '--output-format', 'stream-json']
|
||||
: ['-p', task, '--output-format', 'stream-json'],
|
||||
};
|
||||
|
||||
case 'goose':
|
||||
return {
|
||||
binary,
|
||||
args: model
|
||||
? ['run', '--text', task, '--model', model]
|
||||
: ['run', '--text', task],
|
||||
};
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
|
||||
const { agent, task, worktreePath, model, installPath, signal, log } = opts;
|
||||
|
||||
const cmd = buildAgentCommand(agent, task, model, installPath);
|
||||
if (!cmd) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: `Agent '${agent}' is not yet supported for PTY dispatch.`,
|
||||
};
|
||||
}
|
||||
|
||||
log.info({ agent, binary: cmd.binary, worktreePath }, 'pty-dispatch: starting');
|
||||
|
||||
return new Promise<DispatchResult>((resolve, reject) => {
|
||||
const child = spawn(cmd.binary, cmd.args, {
|
||||
cwd: worktreePath,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env },
|
||||
});
|
||||
|
||||
if (cmd.stdin) {
|
||||
child.stdin!.write(cmd.stdin);
|
||||
}
|
||||
child.stdin!.end();
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
const cleanup = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
child.kill('SIGTERM');
|
||||
setTimeout(() => child.kill('SIGKILL'), 5_000);
|
||||
}
|
||||
};
|
||||
|
||||
if (signal) {
|
||||
if (signal.aborted) {
|
||||
cleanup();
|
||||
resolve({ exitCode: 130, stdout: '', stderr: 'Aborted before start' });
|
||||
return;
|
||||
}
|
||||
signal.addEventListener('abort', cleanup, { once: true });
|
||||
}
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (signal) signal.removeEventListener('abort', cleanup);
|
||||
log.info({ agent, exitCode: code }, 'pty-dispatch: completed');
|
||||
resolve({ exitCode: code ?? 1, stdout, stderr });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (signal) signal.removeEventListener('abort', cleanup);
|
||||
log.error({ agent, err: err.message }, 'pty-dispatch: spawn error');
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
129
apps/coder/src/services/ssh.ts
Normal file
129
apps/coder/src/services/ssh.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* @deprecated v2.1.1 — BooCoder runs on the host now. Use direct spawn/exec instead.
|
||||
* Kept for one release cycle in case of rollback.
|
||||
*
|
||||
* SSH helper — spawns commands on the host via SSH.
|
||||
*
|
||||
* BooCode's container cannot directly spawn host processes (opencode, goose, claude, pi).
|
||||
* They live on the HOST at /usr/local/bin/ or Sam's PATH. We SSH to the host over the
|
||||
* Tailscale IP (same mechanism BooTerm uses: samkintop@100.114.205.53).
|
||||
*/
|
||||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
|
||||
export const SSH_HOST = process.env.BOOCODER_SSH_HOST ?? '100.114.205.53';
|
||||
export const SSH_USER = process.env.BOOCODER_SSH_USER ?? 'samkintop';
|
||||
|
||||
/** Common SSH args — strict host checking disabled for container-to-host trust. */
|
||||
const SSH_BASE_ARGS = [
|
||||
'-o', 'StrictHostKeyChecking=no',
|
||||
'-o', 'UserKnownHostsFile=/dev/null',
|
||||
'-o', 'LogLevel=ERROR',
|
||||
'-o', 'BatchMode=yes',
|
||||
];
|
||||
|
||||
export interface SshExecResult {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command on the host via SSH, collecting all output.
|
||||
* Returns when the remote process exits.
|
||||
*/
|
||||
export async function sshExec(
|
||||
command: string,
|
||||
opts?: { signal?: AbortSignal; timeoutMs?: number },
|
||||
): Promise<SshExecResult> {
|
||||
return new Promise<SshExecResult>((resolve, reject) => {
|
||||
const child = spawn('ssh', [
|
||||
...SSH_BASE_ARGS,
|
||||
`${SSH_USER}@${SSH_HOST}`,
|
||||
command,
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let killed = false;
|
||||
|
||||
child.stdout!.on('data', (chunk: Buffer) => { stdout += chunk.toString(); });
|
||||
child.stderr!.on('data', (chunk: Buffer) => { stderr += chunk.toString(); });
|
||||
|
||||
const cleanup = () => {
|
||||
if (!killed) {
|
||||
killed = true;
|
||||
child.kill('SIGTERM');
|
||||
}
|
||||
};
|
||||
|
||||
// Abort signal
|
||||
if (opts?.signal) {
|
||||
if (opts.signal.aborted) {
|
||||
cleanup();
|
||||
reject(new Error('SSH exec aborted before start'));
|
||||
return;
|
||||
}
|
||||
opts.signal.addEventListener('abort', cleanup, { once: true });
|
||||
}
|
||||
|
||||
// Timeout
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
if (opts?.timeoutMs) {
|
||||
timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error(`SSH exec timed out after ${opts.timeoutMs}ms`));
|
||||
}, opts.timeoutMs);
|
||||
}
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
||||
resolve({ exitCode: code ?? 1, stdout, stderr });
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (timer) clearTimeout(timer);
|
||||
if (opts?.signal) opts.signal.removeEventListener('abort', cleanup);
|
||||
reject(err);
|
||||
});
|
||||
|
||||
// Close stdin immediately — we're not sending input via sshExec
|
||||
child.stdin!.end();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an SSH child process with a command on the host.
|
||||
* Returns the raw ChildProcess for callers that need streaming I/O (ACP, PTY).
|
||||
*/
|
||||
export function sshSpawn(command: string): ChildProcess {
|
||||
return spawn('ssh', [
|
||||
...SSH_BASE_ARGS,
|
||||
`${SSH_USER}@${SSH_HOST}`,
|
||||
command,
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn an SSH child process that pipes stdin through.
|
||||
* Used for agents that read a task from stdin (e.g. `echo "task" | claude -p`).
|
||||
*/
|
||||
export function sshSpawnWithStdin(command: string, input: string): ChildProcess {
|
||||
const child = spawn('ssh', [
|
||||
...SSH_BASE_ARGS,
|
||||
`${SSH_USER}@${SSH_HOST}`,
|
||||
command,
|
||||
], {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Write the input and close stdin
|
||||
child.stdin!.write(input);
|
||||
child.stdin!.end();
|
||||
|
||||
return child;
|
||||
}
|
||||
30
apps/coder/src/services/tools/adapter.ts
Normal file
30
apps/coder/src/services/tools/adapter.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* Adapts BooCoder write tools (which take ToolContext) into BooChat's ToolDef
|
||||
* interface (which takes `projectRoot, extraRoots?`).
|
||||
*
|
||||
* The adapter reads the module-level inference context at execute time, so the
|
||||
* wrapping happens at boot (static) — no per-inference re-wrap needed.
|
||||
*/
|
||||
|
||||
import type { ToolDef as ServerToolDef } from '@boocode/server/tools';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { getInferenceContext } from './inference_context.js';
|
||||
|
||||
/**
|
||||
* Wrap a BooCoder write tool (execute takes ToolContext) into a BooChat
|
||||
* ToolDef (execute takes projectRoot + optional extraRoots). The adapter
|
||||
* builds the ToolContext from the module-level inference context at call time.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function adaptWriteTool(tool: ToolDef<any>): ServerToolDef<any> {
|
||||
return {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
jsonSchema: tool.jsonSchema,
|
||||
async execute(input: unknown, projectRoot: string, _extraRoots?: readonly string[]): Promise<unknown> {
|
||||
const ctx: ToolContext = getInferenceContext();
|
||||
return tool.execute(input, projectRoot, ctx);
|
||||
},
|
||||
};
|
||||
}
|
||||
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
44
apps/coder/src/services/tools/apply_pending.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { applyAll } from '../pending_changes.js';
|
||||
|
||||
const ApplyPendingInput = z.object({});
|
||||
type ApplyPendingInputT = z.infer<typeof ApplyPendingInput>;
|
||||
|
||||
export const applyPendingTool: ToolDef<ApplyPendingInputT> = {
|
||||
name: 'apply_pending',
|
||||
description:
|
||||
'Apply all pending changes for the current session to disk. ' +
|
||||
'Each queued create/edit/delete is executed in order.',
|
||||
inputSchema: ApplyPendingInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'apply_pending',
|
||||
description:
|
||||
'Apply all pending changes for the current session to disk. ' +
|
||||
'Each queued create/edit/delete is executed in order.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(_input: ApplyPendingInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const results = await applyAll(context.sql, context.sessionId, projectRoot);
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
const failed = results.filter((r) => !r.success).length;
|
||||
|
||||
return {
|
||||
total: results.length,
|
||||
succeeded,
|
||||
failed,
|
||||
results,
|
||||
message:
|
||||
results.length === 0
|
||||
? 'No pending changes to apply.'
|
||||
: `Applied ${succeeded}/${results.length} changes.${failed > 0 ? ` ${failed} failed.` : ''}`,
|
||||
};
|
||||
},
|
||||
};
|
||||
50
apps/coder/src/services/tools/check_task_status.ts
Normal file
50
apps/coder/src/services/tools/check_task_status.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
|
||||
const CheckTaskStatusInput = z.object({
|
||||
task_id: z.string().uuid().describe('ID of the task to check'),
|
||||
});
|
||||
|
||||
type CheckTaskStatusInputT = z.infer<typeof CheckTaskStatusInput>;
|
||||
|
||||
export const checkTaskStatusTool: ToolDef<CheckTaskStatusInputT> = {
|
||||
name: 'check_task_status',
|
||||
description: 'Check the status and output of a subtask by ID. Returns state, output_summary, and timing.',
|
||||
inputSchema: CheckTaskStatusInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'check_task_status',
|
||||
description: 'Check the status and output of a subtask by ID.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
task_id: { type: 'string', description: 'ID of the task to check' },
|
||||
},
|
||||
required: ['task_id'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: CheckTaskStatusInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const { sql } = context;
|
||||
|
||||
const [task] = await sql<{ id: string; state: string; output_summary: string | null; started_at: string | null; ended_at: string | null }[]>`
|
||||
SELECT id, state, output_summary, started_at, ended_at
|
||||
FROM tasks
|
||||
WHERE id = ${input.task_id}
|
||||
`;
|
||||
|
||||
if (!task) {
|
||||
return { error: `Task ${input.task_id} not found` };
|
||||
}
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
state: task.state,
|
||||
output_summary: task.output_summary,
|
||||
started_at: task.started_at,
|
||||
ended_at: task.ended_at,
|
||||
};
|
||||
},
|
||||
};
|
||||
51
apps/coder/src/services/tools/create_file.ts
Normal file
51
apps/coder/src/services/tools/create_file.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueCreate } from '../pending_changes.js';
|
||||
|
||||
const CreateFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
content: z.string(),
|
||||
});
|
||||
type CreateFileInputT = z.infer<typeof CreateFileInput>;
|
||||
|
||||
export const createFileTool: ToolDef<CreateFileInputT> = {
|
||||
name: 'create_file',
|
||||
description:
|
||||
'Queue creation of a new file with the given content. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
inputSchema: CreateFileInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'create_file',
|
||||
description:
|
||||
'Queue creation of a new file with the given content. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path for the new file (relative to project root or absolute)' },
|
||||
content: { type: 'string', description: 'Full content of the file to create' },
|
||||
},
|
||||
required: ['file_path', 'content'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: CreateFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const change = await queueCreate(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
context.taskId,
|
||||
input.file_path,
|
||||
input.content,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'create',
|
||||
message: `File creation queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
48
apps/coder/src/services/tools/delete_file.ts
Normal file
48
apps/coder/src/services/tools/delete_file.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueDelete } from '../pending_changes.js';
|
||||
|
||||
const DeleteFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
});
|
||||
type DeleteFileInputT = z.infer<typeof DeleteFileInput>;
|
||||
|
||||
export const deleteFileTool: ToolDef<DeleteFileInputT> = {
|
||||
name: 'delete_file',
|
||||
description:
|
||||
'Queue deletion of a file. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
inputSchema: DeleteFileInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'delete_file',
|
||||
description:
|
||||
'Queue deletion of a file. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path to the file to delete (relative to project root or absolute)' },
|
||||
},
|
||||
required: ['file_path'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: DeleteFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const change = await queueDelete(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
context.taskId,
|
||||
input.file_path,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'delete',
|
||||
message: `File deletion queued: ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
54
apps/coder/src/services/tools/edit_file.ts
Normal file
54
apps/coder/src/services/tools/edit_file.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { queueEdit } from '../pending_changes.js';
|
||||
|
||||
const EditFileInput = z.object({
|
||||
file_path: z.string().min(1),
|
||||
old_string: z.string().min(1),
|
||||
new_string: z.string(),
|
||||
});
|
||||
type EditFileInputT = z.infer<typeof EditFileInput>;
|
||||
|
||||
export const editFileTool: ToolDef<EditFileInputT> = {
|
||||
name: 'edit_file',
|
||||
description:
|
||||
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
inputSchema: EditFileInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'edit_file',
|
||||
description:
|
||||
'Queue an edit to a file. The edit replaces old_string with new_string. ' +
|
||||
'The change is staged in pending_changes and must be applied explicitly.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
file_path: { type: 'string', description: 'Path to the file to edit (relative to project root or absolute)' },
|
||||
old_string: { type: 'string', description: 'The exact string to find and replace (must appear in the file)' },
|
||||
new_string: { type: 'string', description: 'The replacement string' },
|
||||
},
|
||||
required: ['file_path', 'old_string', 'new_string'],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: EditFileInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const change = await queueEdit(
|
||||
context.sql,
|
||||
context.sessionId,
|
||||
context.taskId,
|
||||
input.file_path,
|
||||
input.old_string,
|
||||
input.new_string,
|
||||
projectRoot,
|
||||
);
|
||||
return {
|
||||
status: 'queued',
|
||||
change_id: change.id,
|
||||
file_path: change.file_path,
|
||||
operation: 'edit',
|
||||
message: `Edit queued for ${change.file_path}. Use apply_pending to write changes to disk.`,
|
||||
};
|
||||
},
|
||||
};
|
||||
34
apps/coder/src/services/tools/index.ts
Normal file
34
apps/coder/src/services/tools/index.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { ToolDef } from './types.js';
|
||||
import { editFileTool } from './edit_file.js';
|
||||
import { createFileTool } from './create_file.js';
|
||||
import { deleteFileTool } from './delete_file.js';
|
||||
import { applyPendingTool } from './apply_pending.js';
|
||||
import { rewindTool } from './rewind.js';
|
||||
import { newTaskTool } from './new_task.js';
|
||||
import { listTasksTool } from './list_tasks.js';
|
||||
import { checkTaskStatusTool } from './check_task_status.js';
|
||||
|
||||
export type { ToolDef, ToolContext, ToolJsonSchema } from './types.js';
|
||||
|
||||
// All BooCoder write tools. The inference loop (Phase 2B) will combine these
|
||||
// with BooChat's read-only tools to form the full tool set available to agents.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const WRITE_TOOLS: readonly ToolDef<any>[] = [
|
||||
applyPendingTool,
|
||||
createFileTool,
|
||||
deleteFileTool,
|
||||
editFileTool,
|
||||
rewindTool,
|
||||
// Boomerang subtask tools — orchestrator agents call these to spawn/monitor child tasks.
|
||||
// An "Orchestrator" agent profile would whitelist [new_task, list_tasks, check_task_status].
|
||||
newTaskTool,
|
||||
listTasksTool,
|
||||
checkTaskStatusTool,
|
||||
];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export const WRITE_TOOLS_BY_NAME: ReadonlyMap<string, ToolDef<any>> = new Map(
|
||||
WRITE_TOOLS.map((t) => [t.name, t]),
|
||||
);
|
||||
|
||||
export { editFileTool, createFileTool, deleteFileTool, applyPendingTool, rewindTool, newTaskTool, listTasksTool, checkTaskStatusTool };
|
||||
36
apps/coder/src/services/tools/inference_context.ts
Normal file
36
apps/coder/src/services/tools/inference_context.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
/**
|
||||
* Module-level inference context for write tools.
|
||||
*
|
||||
* Set via `setInferenceContext()` before each inference run starts.
|
||||
* Write tools read it via `getInferenceContext()` during execute.
|
||||
* Same pattern as BooChat's `loadConfig()` singleton — tools need
|
||||
* ambient state that can't be threaded through the tool-phase execute
|
||||
* signature (which is `execute(input, projectRoot, extraRoots?)`).
|
||||
*/
|
||||
|
||||
export interface InferenceContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
}
|
||||
|
||||
let current: InferenceContext | null = null;
|
||||
|
||||
export function setInferenceContext(ctx: InferenceContext): void {
|
||||
current = ctx;
|
||||
}
|
||||
|
||||
export function clearInferenceContext(): void {
|
||||
current = null;
|
||||
}
|
||||
|
||||
export function getInferenceContext(): InferenceContext {
|
||||
if (!current) {
|
||||
throw new Error(
|
||||
'Write tool called outside inference context — setInferenceContext() was not called before this run',
|
||||
);
|
||||
}
|
||||
return current;
|
||||
}
|
||||
56
apps/coder/src/services/tools/list_tasks.ts
Normal file
56
apps/coder/src/services/tools/list_tasks.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { getInferenceContext } from './inference_context.js';
|
||||
|
||||
const ListTasksInput = z.object({
|
||||
parent_task_id: z.string().uuid().optional().describe('Filter by parent task ID. Omit to list children of current task.'),
|
||||
});
|
||||
|
||||
type ListTasksInputT = z.infer<typeof ListTasksInput>;
|
||||
|
||||
export const listTasksTool: ToolDef<ListTasksInputT> = {
|
||||
name: 'list_tasks',
|
||||
description: 'List child tasks of the current task (or a specified parent). Returns id, state, input preview, and output_summary.',
|
||||
inputSchema: ListTasksInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'list_tasks',
|
||||
description: 'List child tasks of the current task (or a specified parent).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
parent_task_id: { type: 'string', description: 'Filter by parent task ID. Omit to list children of current task.' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: ListTasksInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const { sql } = context;
|
||||
const ctx = getInferenceContext();
|
||||
const parentId = input.parent_task_id ?? ctx.taskId;
|
||||
|
||||
if (!parentId) {
|
||||
return { tasks: [], note: 'No parent task context — not running inside a task.' };
|
||||
}
|
||||
|
||||
const rows = await sql<{ id: string; state: string; input: string; output_summary: string | null }[]>`
|
||||
SELECT id, state, input, output_summary
|
||||
FROM tasks
|
||||
WHERE parent_task_id = ${parentId}
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50
|
||||
`;
|
||||
|
||||
return {
|
||||
tasks: rows.map((r) => ({
|
||||
id: r.id,
|
||||
state: r.state,
|
||||
input_preview: r.input.slice(0, 100),
|
||||
output_summary: r.output_summary,
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
65
apps/coder/src/services/tools/new_task.ts
Normal file
65
apps/coder/src/services/tools/new_task.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { getInferenceContext } from './inference_context.js';
|
||||
|
||||
const NewTaskInput = z.object({
|
||||
input: z.string().min(1).describe('Task description for the child subtask'),
|
||||
agent: z.string().optional().describe('Optional: dispatch to a specific agent'),
|
||||
model: z.string().optional().describe('Optional: model override for the subtask'),
|
||||
});
|
||||
|
||||
type NewTaskInputT = z.infer<typeof NewTaskInput>;
|
||||
|
||||
export const newTaskTool: ToolDef<NewTaskInputT> = {
|
||||
name: 'new_task',
|
||||
description:
|
||||
'Spawn a subtask that runs in isolation. The subtask gets its own session and ' +
|
||||
'worktree. Use check_task_status to monitor progress. Only the output_summary is ' +
|
||||
'accessible to the parent — full isolation (Boomerang pattern).',
|
||||
inputSchema: NewTaskInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'new_task',
|
||||
description:
|
||||
'Spawn a subtask that runs in isolation. The subtask gets its own session and ' +
|
||||
'worktree. Use check_task_status to monitor progress.',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
input: { type: 'string', description: 'Task description for the child subtask' },
|
||||
agent: { type: 'string', description: 'Optional: dispatch to a specific agent' },
|
||||
model: { type: 'string', description: 'Optional: model override for the subtask' },
|
||||
},
|
||||
required: ['input'],
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
async execute(input: NewTaskInputT, _projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
const { sql } = context;
|
||||
// Get the current task's project_id from the inference context
|
||||
const ctx = getInferenceContext();
|
||||
const currentTaskId = ctx.taskId;
|
||||
|
||||
// Look up the project_id from the current session
|
||||
const [session] = await sql<{ project_id: string }[]>`
|
||||
SELECT project_id FROM sessions WHERE id = ${ctx.sessionId}
|
||||
`;
|
||||
if (!session) {
|
||||
return { error: 'Cannot determine project_id from current session' };
|
||||
}
|
||||
|
||||
const [task] = await sql<{ id: string; state: string }[]>`
|
||||
INSERT INTO tasks (project_id, parent_task_id, input, agent, model)
|
||||
VALUES (${session.project_id}, ${currentTaskId}, ${input.input}, ${input.agent ?? null}, ${input.model ?? null})
|
||||
RETURNING id, state
|
||||
`;
|
||||
|
||||
return {
|
||||
message: `Subtask created (id: ${task!.id}). It will run in isolation. Use check_task_status to monitor.`,
|
||||
task_id: task!.id,
|
||||
state: task!.state,
|
||||
};
|
||||
},
|
||||
};
|
||||
71
apps/coder/src/services/tools/rewind.ts
Normal file
71
apps/coder/src/services/tools/rewind.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { z } from 'zod';
|
||||
import type { ToolDef, ToolContext } from './types.js';
|
||||
import { rewindOne } from '../pending_changes.js';
|
||||
|
||||
const RewindInput = z.object({
|
||||
change_id: z.string().uuid().optional(),
|
||||
all: z.boolean().optional(),
|
||||
});
|
||||
type RewindInputT = z.infer<typeof RewindInput>;
|
||||
|
||||
export const rewindTool: ToolDef<RewindInputT> = {
|
||||
name: 'rewind',
|
||||
description:
|
||||
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||
inputSchema: RewindInput,
|
||||
jsonSchema: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'rewind',
|
||||
description:
|
||||
'Revert applied changes. Provide change_id to revert a specific change, ' +
|
||||
'or set all=true to revert all applied changes for the session (in reverse order).',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
change_id: { type: 'string', format: 'uuid', description: 'ID of a specific change to revert' },
|
||||
all: { type: 'boolean', description: 'If true, revert all applied changes for this session' },
|
||||
},
|
||||
required: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
async execute(input: RewindInputT, projectRoot: string, context: ToolContext): Promise<unknown> {
|
||||
if (input.change_id) {
|
||||
const result = await rewindOne(context.sql, input.change_id, projectRoot);
|
||||
return {
|
||||
results: [result],
|
||||
message: result.success
|
||||
? `Reverted change ${input.change_id} (${result.operation} on ${result.file_path}).`
|
||||
: `Failed to revert: ${result.error}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (input.all) {
|
||||
// Rewind all applied changes for this session in reverse order
|
||||
const applied = await context.sql<{ id: string }[]>`
|
||||
SELECT id FROM pending_changes
|
||||
WHERE session_id = ${context.sessionId} AND status = 'applied'
|
||||
ORDER BY created_at DESC
|
||||
`;
|
||||
const results = [];
|
||||
for (const row of applied) {
|
||||
results.push(await rewindOne(context.sql, row.id, projectRoot));
|
||||
}
|
||||
const succeeded = results.filter((r) => r.success).length;
|
||||
return {
|
||||
total: results.length,
|
||||
succeeded,
|
||||
failed: results.length - succeeded,
|
||||
results,
|
||||
message:
|
||||
results.length === 0
|
||||
? 'No applied changes to revert.'
|
||||
: `Reverted ${succeeded}/${results.length} changes.`,
|
||||
};
|
||||
}
|
||||
|
||||
return { error: 'Provide either change_id or all=true.' };
|
||||
},
|
||||
};
|
||||
32
apps/coder/src/services/tools/types.ts
Normal file
32
apps/coder/src/services/tools/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import type { z } from 'zod';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
export interface ToolJsonSchema {
|
||||
type: 'function';
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Context passed to BooCoder tool execute functions.
|
||||
*
|
||||
* Unlike BooChat's tools (which only need projectRoot), BooCoder's write tools
|
||||
* interact with the database (pending_changes table) and need session/task
|
||||
* context for proper attribution.
|
||||
*/
|
||||
export interface ToolContext {
|
||||
sql: Sql;
|
||||
sessionId: string;
|
||||
taskId: string | null;
|
||||
}
|
||||
|
||||
export interface ToolDef<TInput> {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: z.ZodType<TInput>;
|
||||
jsonSchema: ToolJsonSchema;
|
||||
execute(input: TInput, projectRoot: string, context: ToolContext): Promise<unknown>;
|
||||
}
|
||||
118
apps/coder/src/services/worktrees.ts
Normal file
118
apps/coder/src/services/worktrees.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Git worktree management for external agent dispatch.
|
||||
*
|
||||
* Each dispatched task gets its own git worktree so the external agent
|
||||
* can modify files freely without touching the main working tree.
|
||||
* After the agent completes, we diff the worktree against HEAD and
|
||||
* queue the diff into pending_changes.
|
||||
*/
|
||||
import { sshExec } from './ssh.js';
|
||||
|
||||
const WORKTREE_BASE = '/tmp/booworktrees';
|
||||
|
||||
/**
|
||||
* Create a git worktree for a task on the host.
|
||||
* Returns the absolute path to the worktree directory.
|
||||
*/
|
||||
export async function createWorktree(
|
||||
projectPath: string,
|
||||
taskId: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
|
||||
const branchName = `task-${taskId}`;
|
||||
|
||||
// Ensure the base directory exists
|
||||
await sshExec(`mkdir -p ${WORKTREE_BASE}`, { signal: opts?.signal });
|
||||
|
||||
// Create the worktree with a new branch from HEAD
|
||||
const result = await sshExec(
|
||||
`git -C ${shellEscape(projectPath)} worktree add ${shellEscape(worktreePath)} -b ${shellEscape(branchName)} HEAD`,
|
||||
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||
);
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(`Failed to create worktree: ${result.stderr.trim() || result.stdout.trim()}`);
|
||||
}
|
||||
|
||||
return worktreePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unified diff of changes made in the worktree vs the parent branch (HEAD).
|
||||
* Returns an empty string if there are no changes.
|
||||
*/
|
||||
export async function diffWorktree(
|
||||
worktreePath: string,
|
||||
projectPath: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<string> {
|
||||
// First, commit any uncommitted changes in the worktree so we can diff branches
|
||||
// Stage all changes
|
||||
const addResult = await sshExec(
|
||||
`cd ${shellEscape(worktreePath)} && git add -A`,
|
||||
{ signal: opts?.signal, timeoutMs: 30_000 },
|
||||
);
|
||||
if (addResult.exitCode !== 0) {
|
||||
throw new Error(`Failed to stage worktree changes: ${addResult.stderr.trim()}`);
|
||||
}
|
||||
|
||||
// Check if there are staged changes
|
||||
const statusResult = await sshExec(
|
||||
`cd ${shellEscape(worktreePath)} && git diff --cached --quiet`,
|
||||
{ signal: opts?.signal, timeoutMs: 10_000 },
|
||||
);
|
||||
|
||||
if (statusResult.exitCode === 0) {
|
||||
// No changes
|
||||
return '';
|
||||
}
|
||||
|
||||
// Commit staged changes (needed to produce a clean branch diff)
|
||||
await sshExec(
|
||||
`cd ${shellEscape(worktreePath)} && git -c user.email=boocoder@local -c user.name=BooCoder commit -m "task changes" --allow-empty`,
|
||||
{ signal: opts?.signal, timeoutMs: 15_000 },
|
||||
);
|
||||
|
||||
// Diff the worktree branch against the parent commit (HEAD of main tree)
|
||||
const diffResult = await sshExec(
|
||||
`git -C ${shellEscape(projectPath)} diff HEAD...$(git -C ${shellEscape(worktreePath)} rev-parse HEAD)`,
|
||||
{ signal: opts?.signal, timeoutMs: 60_000 },
|
||||
);
|
||||
|
||||
if (diffResult.exitCode !== 0) {
|
||||
throw new Error(`Failed to diff worktree: ${diffResult.stderr.trim()}`);
|
||||
}
|
||||
|
||||
return diffResult.stdout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a worktree and its associated branch.
|
||||
* Best-effort — does not throw on failure (task may have already been cleaned up).
|
||||
*/
|
||||
export async function cleanupWorktree(
|
||||
projectPath: string,
|
||||
taskId: string,
|
||||
): Promise<void> {
|
||||
const worktreePath = `${WORKTREE_BASE}/${taskId}`;
|
||||
const branchName = `task-${taskId}`;
|
||||
|
||||
// Remove the worktree (--force handles dirty state)
|
||||
await sshExec(
|
||||
`git -C ${shellEscape(projectPath)} worktree remove ${shellEscape(worktreePath)} --force`,
|
||||
{ timeoutMs: 15_000 },
|
||||
).catch(() => {});
|
||||
|
||||
// Delete the task branch
|
||||
await sshExec(
|
||||
`git -C ${shellEscape(projectPath)} branch -D ${shellEscape(branchName)}`,
|
||||
{ timeoutMs: 10_000 },
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
/** Minimal shell escape for paths (single-quote wrapping). */
|
||||
function shellEscape(s: string): string {
|
||||
// Replace single quotes with escaped version, wrap in single quotes
|
||||
return "'" + s.replace(/'/g, "'\\''") + "'";
|
||||
}
|
||||
77
apps/coder/src/services/write_guard.ts
Normal file
77
apps/coder/src/services/write_guard.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { resolve, sep } from 'node:path';
|
||||
|
||||
export class WriteGuardError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'WriteGuardError';
|
||||
}
|
||||
}
|
||||
|
||||
// Deny list: files that should never be written regardless of path-guard.
|
||||
// Subset of BooChat's secret_guard.ts — covers the most dangerous patterns.
|
||||
// Full parity with BooChat's deny list is not needed for write-guard because
|
||||
// the write tools are intentional (model chose to create/edit); we block only
|
||||
// files that are unambiguously secrets.
|
||||
const SECRET_PATTERNS: readonly string[] = [
|
||||
'.env',
|
||||
'.env.local',
|
||||
'.env.production',
|
||||
'.env.development',
|
||||
'.env.staging',
|
||||
'id_rsa',
|
||||
'id_dsa',
|
||||
'id_ecdsa',
|
||||
'id_ed25519',
|
||||
'*.pem',
|
||||
'*.key',
|
||||
'*.p12',
|
||||
'*.pfx',
|
||||
'*.crt',
|
||||
'credentials.json',
|
||||
'*.kdbx',
|
||||
'.netrc',
|
||||
];
|
||||
|
||||
export function isSecretPath(filePath: string): boolean {
|
||||
const normalized = filePath.replace(/\\/g, '/');
|
||||
const segments = normalized.split('/').filter((s) => s.length > 0);
|
||||
if (segments.length === 0) return false;
|
||||
const basename = segments[segments.length - 1]!;
|
||||
|
||||
return SECRET_PATTERNS.some((pattern) => {
|
||||
if (pattern.startsWith('*')) {
|
||||
return basename.endsWith(pattern.slice(1));
|
||||
}
|
||||
return basename === pattern;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve and validate a write target path.
|
||||
*
|
||||
* Key difference from BooChat's pathGuard: no realpath() — the file may not
|
||||
* exist yet (creates). Uses resolve() to normalize ../ segments and then
|
||||
* checks the result stays within projectRoot.
|
||||
*/
|
||||
export function resolveWritePath(projectRoot: string, filePath: string): string {
|
||||
if (!filePath || filePath.trim().length === 0) {
|
||||
throw new WriteGuardError('file path is required');
|
||||
}
|
||||
|
||||
if (filePath.includes('\x00')) {
|
||||
throw new WriteGuardError('file path contains null byte');
|
||||
}
|
||||
|
||||
const candidate = filePath.startsWith('/') ? filePath : resolve(projectRoot, filePath);
|
||||
const normalized = resolve(candidate); // normalizes ../ segments
|
||||
|
||||
if (!normalized.startsWith(projectRoot + sep) && normalized !== projectRoot) {
|
||||
throw new WriteGuardError(`path escapes project root: ${filePath}`);
|
||||
}
|
||||
|
||||
if (isSecretPath(normalized)) {
|
||||
throw new WriteGuardError(`cannot write to secret file: ${filePath}`);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
15
apps/coder/tsconfig.json
Normal file
15
apps/coder/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"lib": ["ES2022"],
|
||||
"types": ["node"],
|
||||
"declaration": false,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["src/**/__tests__/**", "**/*.test.ts"]
|
||||
}
|
||||
9
apps/coder/vitest.config.ts
Normal file
9
apps/coder/vitest.config.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: false,
|
||||
include: ['src/**/__tests__/**/*.test.ts'],
|
||||
},
|
||||
});
|
||||
12
apps/coder/web/index.html
Normal file
12
apps/coder/web/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>BooCoder</title>
|
||||
</head>
|
||||
<body class="bg-zinc-900 text-zinc-100">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
29
apps/coder/web/package.json
Normal file
29
apps/coder/web/package.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "@boocode/coder-web",
|
||||
"version": "2.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.16.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.3.0",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.3.4"
|
||||
}
|
||||
}
|
||||
5
apps/coder/web/postcss.config.js
Normal file
5
apps/coder/web/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
13
apps/coder/web/src/App.tsx
Normal file
13
apps/coder/web/src/App.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Home } from './pages/Home';
|
||||
import { Session } from './pages/Session';
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/sessions/:sessionId" element={<Session />} />
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
93
apps/coder/web/src/api/client.ts
Normal file
93
apps/coder/web/src/api/client.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { Project, Session, Chat, Message, PendingChange } from './types';
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: unknown,
|
||||
) {
|
||||
super(
|
||||
typeof body === 'object' && body && 'error' in body
|
||||
? String((body as { error: unknown }).error)
|
||||
: `HTTP ${status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
if (res.status === 204) return undefined as T;
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : undefined;
|
||||
if (!res.ok) throw new ApiError(res.status, data);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () => request<{ ok: boolean; db: boolean; tools: number }>('/api/health'),
|
||||
|
||||
projects: {
|
||||
list: (params?: { status?: 'open' | 'archived' }) =>
|
||||
request<Project[]>(`/api/projects${params?.status ? `?status=${params.status}` : ''}`),
|
||||
},
|
||||
|
||||
sessions: {
|
||||
listForProject: (projectId: string, status?: 'open' | 'archived') =>
|
||||
request<Session[]>(
|
||||
`/api/projects/${projectId}/sessions${status ? `?status=${status}` : ''}`,
|
||||
),
|
||||
get: (id: string) => request<Session>(`/api/sessions/${id}`),
|
||||
},
|
||||
|
||||
chats: {
|
||||
listForSession: (sessionId: string) =>
|
||||
request<Chat[]>(`/api/sessions/${sessionId}/chats`),
|
||||
create: (sessionId: string, body?: { name?: string }) =>
|
||||
request<Chat>(`/api/sessions/${sessionId}/chats`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body ?? {}),
|
||||
}),
|
||||
},
|
||||
|
||||
messages: {
|
||||
send: (sessionId: string, chatId: string, content: string) =>
|
||||
request<{ user_message_id: string; assistant_message_id: string }>(
|
||||
`/api/sessions/${sessionId}/messages`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ content, chat_id: chatId }),
|
||||
},
|
||||
),
|
||||
stop: (sessionId: string) =>
|
||||
request<{ cancelled: boolean }>(`/api/sessions/${sessionId}/stop`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
},
|
||||
|
||||
pending: {
|
||||
list: (sessionId: string) =>
|
||||
request<PendingChange[]>(`/api/sessions/${sessionId}/pending`),
|
||||
applyAll: (sessionId: string) =>
|
||||
request<{ results: Array<{ id: string; success: boolean; error?: string }> }>(
|
||||
`/api/sessions/${sessionId}/pending/apply`,
|
||||
{ method: 'POST' },
|
||||
),
|
||||
applyOne: (changeId: string) =>
|
||||
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/apply`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
rejectOne: (changeId: string) =>
|
||||
request<{ ok: boolean }>(`/api/pending/${changeId}/reject`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
rewindOne: (changeId: string) =>
|
||||
request<{ success: boolean; error?: string }>(`/api/pending/${changeId}/rewind`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
},
|
||||
};
|
||||
89
apps/coder/web/src/api/types.ts
Normal file
89
apps/coder/web/src/api/types.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// Minimal types for the BooCoder frontend.
|
||||
// Shared DB entities (same schema as BooChat).
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
path: string;
|
||||
status: 'open' | 'archived';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
project_id: string;
|
||||
name: string | null;
|
||||
model: string | null;
|
||||
status: 'open' | 'archived';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Chat {
|
||||
id: string;
|
||||
session_id: string;
|
||||
name: string | null;
|
||||
status: 'open' | 'archived';
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ToolCall {
|
||||
id: string;
|
||||
name: string;
|
||||
arguments: string;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
tool_call_id: string;
|
||||
output: string;
|
||||
truncated?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
session_id: string;
|
||||
chat_id: string;
|
||||
role: 'user' | 'assistant' | 'tool' | 'system';
|
||||
content: string;
|
||||
kind: string;
|
||||
tool_calls: ToolCall[] | null;
|
||||
tool_results: ToolResult | null;
|
||||
status: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||
tokens_used: number | null;
|
||||
ctx_used: number | null;
|
||||
ctx_max: number | null;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
created_at: string;
|
||||
metadata: unknown;
|
||||
}
|
||||
|
||||
export interface PendingChange {
|
||||
id: string;
|
||||
session_id: string;
|
||||
task_id: string | null;
|
||||
file_path: string;
|
||||
operation: 'create' | 'edit' | 'delete';
|
||||
old_string: string | null;
|
||||
new_string: string | null;
|
||||
content: string | null;
|
||||
diff: string | null;
|
||||
status: 'pending' | 'applied' | 'rejected' | 'reverted';
|
||||
created_at: string;
|
||||
applied_at: string | null;
|
||||
}
|
||||
|
||||
// WebSocket frame types (subset of what the coder backend publishes)
|
||||
export type WsFrame =
|
||||
| { type: 'snapshot'; messages: Message[] }
|
||||
| { type: 'message_started'; message_id: string; chat_id: string; role: Message['role'] }
|
||||
| { type: 'delta'; message_id: string; chat_id: string; content: string }
|
||||
| { type: 'tool_call'; message_id: string; chat_id: string; tool_call: ToolCall }
|
||||
| { type: 'tool_result'; tool_message_id: string; chat_id: string; tool_call_id: string; output: string; truncated?: boolean; error?: boolean }
|
||||
| { type: 'message_complete'; message_id: string; chat_id: string; tokens_used?: number; ctx_used?: number; ctx_max?: number; started_at?: string; finished_at?: string; metadata?: unknown }
|
||||
| { type: 'error'; message_id?: string; error: string; reason?: string }
|
||||
| { type: 'pending_change_added'; change: PendingChange }
|
||||
| { type: 'pending_change_updated'; change: PendingChange };
|
||||
131
apps/coder/web/src/components/ChatPane.tsx
Normal file
131
apps/coder/web/src/components/ChatPane.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Square } from 'lucide-react';
|
||||
import type { Message } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
chatId: string;
|
||||
messages: Message[];
|
||||
isStreaming: boolean;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
export function ChatPane({ sessionId, chatId, messages, isStreaming, connected }: Props) {
|
||||
const [input, setInput] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when messages change
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages]);
|
||||
|
||||
// Auto-resize textarea
|
||||
useEffect(() => {
|
||||
const el = textareaRef.current;
|
||||
if (!el) return;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = Math.min(el.scrollHeight, 200) + 'px';
|
||||
}, [input]);
|
||||
|
||||
const handleSend = async () => {
|
||||
const content = input.trim();
|
||||
if (!content || sending || isStreaming) return;
|
||||
|
||||
setInput('');
|
||||
setSending(true);
|
||||
try {
|
||||
await api.messages.send(sessionId, chatId, content);
|
||||
} catch (err) {
|
||||
console.error('send failed:', err);
|
||||
// Restore input on failure
|
||||
setInput(content);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
try {
|
||||
await api.messages.stop(sessionId);
|
||||
} catch (err) {
|
||||
console.error('stop failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Filter out system messages for display (sentinels)
|
||||
const visibleMessages = messages.filter((m) => m.role !== 'system');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Connection indicator */}
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-zinc-800 text-xs text-zinc-500">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
/>
|
||||
<span>{connected ? 'Connected' : 'Disconnected'}</span>
|
||||
{isStreaming && (
|
||||
<span className="text-blue-400 ml-auto">Generating...</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Messages list */}
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{visibleMessages.length === 0 && (
|
||||
<div className="text-center text-zinc-500 mt-8">
|
||||
<p className="text-lg font-medium">BooCoder</p>
|
||||
<p className="text-sm mt-1">Send a message to start coding.</p>
|
||||
</div>
|
||||
)}
|
||||
{visibleMessages.map((msg) => (
|
||||
<MessageBubble key={msg.id} message={msg} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="border-t border-zinc-800 px-4 py-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Message BooCoder..."
|
||||
rows={1}
|
||||
className="flex-1 bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 resize-none focus:outline-none focus:ring-1 focus:ring-blue-500 focus:border-blue-500"
|
||||
disabled={sending}
|
||||
/>
|
||||
{isStreaming ? (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="p-2 rounded-lg bg-red-600 hover:bg-red-500 text-white transition-colors"
|
||||
title="Stop generation"
|
||||
>
|
||||
<Square size={18} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!input.trim() || sending}
|
||||
className="p-2 rounded-lg bg-blue-600 hover:bg-blue-500 disabled:opacity-40 disabled:cursor-not-allowed text-white transition-colors"
|
||||
title="Send message"
|
||||
>
|
||||
<Send size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
352
apps/coder/web/src/components/DiffPane.tsx
Normal file
352
apps/coder/web/src/components/DiffPane.tsx
Normal file
@@ -0,0 +1,352 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Check, X, RotateCcw, FileText, FilePlus, Trash2, RefreshCw } from 'lucide-react';
|
||||
import type { PendingChange } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
|
||||
interface Props {
|
||||
sessionId: string;
|
||||
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
||||
}
|
||||
|
||||
export function DiffPane({ sessionId, onPendingChange }: Props) {
|
||||
const [changes, setChanges] = useState<PendingChange[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||
|
||||
const fetchPending = useCallback(async () => {
|
||||
try {
|
||||
const result = await api.pending.list(sessionId);
|
||||
setChanges(result);
|
||||
} catch (err) {
|
||||
console.error('fetch pending failed:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchPending();
|
||||
}, [fetchPending]);
|
||||
|
||||
// Listen for WS pending change events
|
||||
useEffect(() => {
|
||||
const unsub = onPendingChange((change) => {
|
||||
setChanges((prev) => {
|
||||
const idx = prev.findIndex((c) => c.id === change.id);
|
||||
if (idx >= 0) {
|
||||
const next = [...prev];
|
||||
next[idx] = change;
|
||||
return next;
|
||||
}
|
||||
return [...prev, change];
|
||||
});
|
||||
});
|
||||
return unsub;
|
||||
}, [onPendingChange]);
|
||||
|
||||
const pendingChanges = changes.filter((c) => c.status === 'pending');
|
||||
const resolvedChanges = changes.filter((c) => c.status !== 'pending');
|
||||
|
||||
const handleApplyOne = async (id: string) => {
|
||||
try {
|
||||
await api.pending.applyOne(id);
|
||||
setChanges((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, status: 'applied' as const } : c)),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('apply failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectOne = async (id: string) => {
|
||||
try {
|
||||
await api.pending.rejectOne(id);
|
||||
setChanges((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, status: 'rejected' as const } : c)),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('reject failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRewindOne = async (id: string) => {
|
||||
try {
|
||||
await api.pending.rewindOne(id);
|
||||
setChanges((prev) =>
|
||||
prev.map((c) => (c.id === id ? { ...c, status: 'reverted' as const } : c)),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('rewind failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyAll = async () => {
|
||||
try {
|
||||
const result = await api.pending.applyAll(sessionId);
|
||||
const appliedIds = new Set(
|
||||
result.results.filter((r) => r.success).map((r) => r.id),
|
||||
);
|
||||
setChanges((prev) =>
|
||||
prev.map((c) =>
|
||||
appliedIds.has(c.id) ? { ...c, status: 'applied' as const } : c,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
console.error('apply all failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRejectAll = async () => {
|
||||
// Reject each pending change individually (no batch reject endpoint)
|
||||
for (const c of pendingChanges) {
|
||||
await handleRejectOne(c.id);
|
||||
}
|
||||
};
|
||||
|
||||
const OpIcon = ({ op }: { op: PendingChange['operation'] }) => {
|
||||
switch (op) {
|
||||
case 'create':
|
||||
return <FilePlus size={14} className="text-green-400" />;
|
||||
case 'edit':
|
||||
return <FileText size={14} className="text-blue-400" />;
|
||||
case 'delete':
|
||||
return <Trash2 size={14} className="text-red-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const StatusBadge = ({ status }: { status: PendingChange['status'] }) => {
|
||||
const colors: Record<PendingChange['status'], string> = {
|
||||
pending: 'bg-yellow-500/20 text-yellow-400',
|
||||
applied: 'bg-green-500/20 text-green-400',
|
||||
rejected: 'bg-zinc-500/20 text-zinc-400',
|
||||
reverted: 'bg-orange-500/20 text-orange-400',
|
||||
};
|
||||
return (
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded ${colors[status]}`}>
|
||||
{status}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-zinc-800">
|
||||
<h2 className="text-sm font-medium text-zinc-300">
|
||||
Pending Changes
|
||||
{pendingChanges.length > 0 && (
|
||||
<span className="ml-1.5 text-xs text-zinc-500">
|
||||
({pendingChanges.length})
|
||||
</span>
|
||||
)}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={fetchPending}
|
||||
className="p-1 rounded hover:bg-zinc-800 text-zinc-400 hover:text-zinc-200"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={14} />
|
||||
</button>
|
||||
{pendingChanges.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleApplyAll}
|
||||
className="text-xs px-2 py-1 rounded bg-green-600/80 hover:bg-green-600 text-white"
|
||||
>
|
||||
Apply All
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRejectAll}
|
||||
className="text-xs px-2 py-1 rounded bg-zinc-700 hover:bg-zinc-600 text-zinc-300"
|
||||
>
|
||||
Reject All
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Changes list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading && (
|
||||
<div className="text-center text-zinc-500 text-sm py-8">Loading...</div>
|
||||
)}
|
||||
|
||||
{!loading && changes.length === 0 && (
|
||||
<div className="text-center text-zinc-500 text-sm py-8">
|
||||
No pending changes yet.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending changes first */}
|
||||
{pendingChanges.map((change) => (
|
||||
<ChangeItem
|
||||
key={change.id}
|
||||
change={change}
|
||||
expanded={expandedId === change.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === change.id ? null : change.id))
|
||||
}
|
||||
onApply={() => handleApplyOne(change.id)}
|
||||
onReject={() => handleRejectOne(change.id)}
|
||||
OpIcon={OpIcon}
|
||||
StatusBadge={StatusBadge}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Resolved changes */}
|
||||
{resolvedChanges.length > 0 && pendingChanges.length > 0 && (
|
||||
<div className="border-t border-zinc-800 my-1" />
|
||||
)}
|
||||
{resolvedChanges.map((change) => (
|
||||
<ChangeItem
|
||||
key={change.id}
|
||||
change={change}
|
||||
expanded={expandedId === change.id}
|
||||
onToggle={() =>
|
||||
setExpandedId((prev) => (prev === change.id ? null : change.id))
|
||||
}
|
||||
onRewind={
|
||||
change.status === 'applied'
|
||||
? () => handleRewindOne(change.id)
|
||||
: undefined
|
||||
}
|
||||
OpIcon={OpIcon}
|
||||
StatusBadge={StatusBadge}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ChangeItemProps {
|
||||
change: PendingChange;
|
||||
expanded: boolean;
|
||||
onToggle: () => void;
|
||||
onApply?: () => void;
|
||||
onReject?: () => void;
|
||||
onRewind?: () => void;
|
||||
OpIcon: React.ComponentType<{ op: PendingChange['operation'] }>;
|
||||
StatusBadge: React.ComponentType<{ status: PendingChange['status'] }>;
|
||||
}
|
||||
|
||||
function ChangeItem({
|
||||
change,
|
||||
expanded,
|
||||
onToggle,
|
||||
onApply,
|
||||
onReject,
|
||||
onRewind,
|
||||
OpIcon,
|
||||
StatusBadge,
|
||||
}: ChangeItemProps) {
|
||||
const fileName = change.file_path.split('/').pop() || change.file_path;
|
||||
const dirPath = change.file_path.split('/').slice(0, -1).join('/');
|
||||
|
||||
return (
|
||||
<div className="border-b border-zinc-800/50">
|
||||
<div
|
||||
className="flex items-center gap-2 px-4 py-2 hover:bg-zinc-800/50 cursor-pointer"
|
||||
onClick={onToggle}
|
||||
>
|
||||
<OpIcon op={change.operation} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="text-sm font-mono text-zinc-200 truncate block">
|
||||
{fileName}
|
||||
</span>
|
||||
{dirPath && (
|
||||
<span className="text-[11px] text-zinc-500 truncate block">
|
||||
{dirPath}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={change.status} />
|
||||
{change.status === 'pending' && (
|
||||
<div className="flex items-center gap-1 ml-1">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onApply?.();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-green-600/30 text-green-400"
|
||||
title="Apply"
|
||||
>
|
||||
<Check size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onReject?.();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-red-600/30 text-red-400"
|
||||
title="Reject"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{change.status === 'applied' && onRewind && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRewind();
|
||||
}}
|
||||
className="p-1 rounded hover:bg-orange-600/30 text-orange-400"
|
||||
title="Rewind"
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-4 pb-3">
|
||||
{change.operation === 'edit' && (
|
||||
<div className="space-y-2">
|
||||
{change.old_string && (
|
||||
<div className="rounded bg-red-950/30 border border-red-900/30 p-2">
|
||||
<div className="text-[10px] text-red-400 mb-1 font-medium">
|
||||
Remove
|
||||
</div>
|
||||
<pre className="text-xs text-red-200 whitespace-pre-wrap break-all font-mono">
|
||||
{change.old_string}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{change.new_string && (
|
||||
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
|
||||
<div className="text-[10px] text-green-400 mb-1 font-medium">
|
||||
Add
|
||||
</div>
|
||||
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono">
|
||||
{change.new_string}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{change.operation === 'create' && change.content && (
|
||||
<div className="rounded bg-green-950/30 border border-green-900/30 p-2">
|
||||
<div className="text-[10px] text-green-400 mb-1 font-medium">
|
||||
New file
|
||||
</div>
|
||||
<pre className="text-xs text-green-200 whitespace-pre-wrap break-all font-mono max-h-60 overflow-y-auto">
|
||||
{change.content.length > 2000
|
||||
? change.content.slice(0, 2000) + '\n... (truncated)'
|
||||
: change.content}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{change.operation === 'delete' && (
|
||||
<div className="rounded bg-red-950/30 border border-red-900/30 p-2 text-xs text-red-300">
|
||||
This file will be deleted.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
apps/coder/web/src/components/Layout.tsx
Normal file
62
apps/coder/web/src/components/Layout.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import { Code2, MessageSquare, GitPullRequest } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
chatPane: React.ReactNode;
|
||||
diffPane: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Layout({ chatPane, diffPane }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'chat' | 'diff'>('chat');
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-zinc-900">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center gap-3 px-4 py-2 border-b border-zinc-800 bg-zinc-900/95">
|
||||
<Code2 size={20} className="text-blue-400" />
|
||||
<h1 className="text-sm font-semibold text-zinc-200">BooCoder</h1>
|
||||
</header>
|
||||
|
||||
{/* Mobile tab bar (visible below lg breakpoint) */}
|
||||
<div className="lg:hidden flex border-b border-zinc-800">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
|
||||
activeTab === 'chat'
|
||||
? 'text-blue-400 border-b-2 border-blue-400'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<MessageSquare size={14} />
|
||||
Chat
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('diff')}
|
||||
className={`flex-1 flex items-center justify-center gap-1.5 py-2 text-sm ${
|
||||
activeTab === 'diff'
|
||||
? 'text-blue-400 border-b-2 border-blue-400'
|
||||
: 'text-zinc-500'
|
||||
}`}
|
||||
>
|
||||
<GitPullRequest size={14} />
|
||||
Changes
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop split layout */}
|
||||
<div className="flex-1 hidden lg:flex overflow-hidden">
|
||||
<div className="w-[60%] border-r border-zinc-800 overflow-hidden">
|
||||
{chatPane}
|
||||
</div>
|
||||
<div className="w-[40%] overflow-hidden">
|
||||
{diffPane}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile: show only the active tab */}
|
||||
<div className="flex-1 lg:hidden overflow-hidden">
|
||||
{activeTab === 'chat' ? chatPane : diffPane}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
115
apps/coder/web/src/components/MessageBubble.tsx
Normal file
115
apps/coder/web/src/components/MessageBubble.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Message } from '@/api/types';
|
||||
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message }: Props) {
|
||||
if (message.role === 'tool') {
|
||||
return <ToolResultBubble message={message} />;
|
||||
}
|
||||
|
||||
const isUser = message.role === 'user';
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const isFailed = message.status === 'failed';
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'} mb-3`}>
|
||||
<div
|
||||
className={`max-w-[85%] rounded-lg px-4 py-2.5 ${
|
||||
isUser
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-zinc-800 text-zinc-100 border border-zinc-700'
|
||||
}`}
|
||||
>
|
||||
{isFailed && (
|
||||
<div className="flex items-center gap-1.5 text-red-400 text-xs mb-1">
|
||||
<AlertCircle size={12} />
|
||||
<span>Failed</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.tool_calls && message.tool_calls.length > 0 && (
|
||||
<div className="mb-2 space-y-1">
|
||||
{message.tool_calls.map((tc) => (
|
||||
<div
|
||||
key={tc.id}
|
||||
className="flex items-center gap-1.5 text-xs text-zinc-400 bg-zinc-900/50 rounded px-2 py-1"
|
||||
>
|
||||
<Wrench size={11} />
|
||||
<span className="font-mono">{tc.name}</span>
|
||||
<span className="text-zinc-500 truncate max-w-[200px]">
|
||||
{truncateArgs(tc.arguments)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message.content.trim() && (
|
||||
<div className="prose prose-invert prose-sm max-w-none [&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_pre]:overflow-x-auto [&_code]:text-zinc-300 [&_p]:my-1.5">
|
||||
<Markdown remarkPlugins={[remarkGfm]}>{message.content}</Markdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && !message.content.trim() && (
|
||||
<div className="flex items-center gap-1.5 text-zinc-400">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
<span className="text-xs">Thinking...</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isStreaming && message.content.trim() && (
|
||||
<span className="inline-block w-1.5 h-4 bg-zinc-400 animate-pulse ml-0.5 align-middle" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolResultBubble({ message }: Props) {
|
||||
const result = message.tool_results;
|
||||
if (!result) return null;
|
||||
|
||||
const isError = result.error;
|
||||
const output = result.output || '';
|
||||
const displayOutput =
|
||||
output.length > 300 ? output.slice(0, 300) + '...' : output;
|
||||
|
||||
return (
|
||||
<div className="flex justify-start mb-2 ml-6">
|
||||
<div
|
||||
className={`max-w-[80%] rounded px-3 py-2 text-xs font-mono border ${
|
||||
isError
|
||||
? 'bg-red-950/30 border-red-800/50 text-red-300'
|
||||
: 'bg-zinc-800/50 border-zinc-700/50 text-zinc-400'
|
||||
}`}
|
||||
>
|
||||
{result.truncated && (
|
||||
<span className="text-yellow-500 text-[10px] block mb-1">
|
||||
[truncated]
|
||||
</span>
|
||||
)}
|
||||
<pre className="whitespace-pre-wrap break-all">{displayOutput}</pre>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function truncateArgs(args: string): string {
|
||||
if (!args) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(args);
|
||||
const keys = Object.keys(parsed);
|
||||
if (keys.length === 0) return '';
|
||||
const first = keys[0]!;
|
||||
const val = String(parsed[first]);
|
||||
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
||||
return `${first}: ${display}`;
|
||||
} catch {
|
||||
return args.length > 50 ? args.slice(0, 50) + '...' : args;
|
||||
}
|
||||
}
|
||||
22
apps/coder/web/src/globals.css
Normal file
22
apps/coder/web/src/globals.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Scrollbar styling for dark theme */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #3f3f46;
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #52525b;
|
||||
}
|
||||
230
apps/coder/web/src/hooks/useSessionStream.ts
Normal file
230
apps/coder/web/src/hooks/useSessionStream.ts
Normal file
@@ -0,0 +1,230 @@
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import type { Message, WsFrame, PendingChange } from '@/api/types';
|
||||
|
||||
interface State {
|
||||
messages: Message[];
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function applyFrame(state: State, frame: WsFrame): State {
|
||||
switch (frame.type) {
|
||||
case 'snapshot': {
|
||||
return { ...state, messages: frame.messages };
|
||||
}
|
||||
case 'message_started': {
|
||||
const exists = state.messages.some((m) => m.id === frame.message_id);
|
||||
if (exists) return state;
|
||||
const newMsg: Message = {
|
||||
id: frame.message_id,
|
||||
session_id: '',
|
||||
chat_id: frame.chat_id,
|
||||
role: frame.role,
|
||||
content: '',
|
||||
kind: 'message',
|
||||
tool_calls: null,
|
||||
tool_results: null,
|
||||
status: frame.role === 'system' ? 'complete' : 'streaming',
|
||||
tokens_used: null,
|
||||
ctx_used: null,
|
||||
ctx_max: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
metadata: null,
|
||||
};
|
||||
return { ...state, messages: [...state.messages, newMsg] };
|
||||
}
|
||||
case 'delta': {
|
||||
const next = state.messages.map((m) =>
|
||||
m.id === frame.message_id ? { ...m, content: m.content + frame.content } : m,
|
||||
);
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
case 'tool_call': {
|
||||
const next = state.messages.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? { ...m, tool_calls: [...(m.tool_calls ?? []), frame.tool_call] }
|
||||
: m,
|
||||
);
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
case 'tool_result': {
|
||||
const exists = state.messages.some((m) => m.id === frame.tool_message_id);
|
||||
if (exists) {
|
||||
const next = state.messages.map((m) =>
|
||||
m.id === frame.tool_message_id
|
||||
? {
|
||||
...m,
|
||||
role: 'tool' as const,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
output: frame.output,
|
||||
truncated: frame.truncated,
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
},
|
||||
status: 'complete' as const,
|
||||
}
|
||||
: m,
|
||||
);
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
const newMsg: Message = {
|
||||
id: frame.tool_message_id,
|
||||
session_id: '',
|
||||
chat_id: frame.chat_id,
|
||||
role: 'tool',
|
||||
content: '',
|
||||
kind: 'message',
|
||||
tool_calls: null,
|
||||
tool_results: {
|
||||
tool_call_id: frame.tool_call_id,
|
||||
output: frame.output,
|
||||
truncated: frame.truncated,
|
||||
...(frame.error ? { error: frame.error } : {}),
|
||||
},
|
||||
status: 'complete',
|
||||
tokens_used: null,
|
||||
ctx_used: null,
|
||||
ctx_max: null,
|
||||
started_at: null,
|
||||
finished_at: null,
|
||||
created_at: new Date().toISOString(),
|
||||
metadata: null,
|
||||
};
|
||||
return { ...state, messages: [...state.messages, newMsg] };
|
||||
}
|
||||
case 'message_complete': {
|
||||
const next = state.messages.map((m) =>
|
||||
m.id === frame.message_id
|
||||
? {
|
||||
...m,
|
||||
status: 'complete' as const,
|
||||
...(frame.tokens_used !== undefined ? { tokens_used: frame.tokens_used } : {}),
|
||||
...(frame.ctx_used !== undefined ? { ctx_used: frame.ctx_used } : {}),
|
||||
...(frame.ctx_max !== undefined ? { ctx_max: frame.ctx_max } : {}),
|
||||
...(frame.started_at !== undefined ? { started_at: frame.started_at } : {}),
|
||||
...(frame.finished_at !== undefined ? { finished_at: frame.finished_at } : {}),
|
||||
...(frame.metadata !== undefined ? { metadata: frame.metadata } : {}),
|
||||
}
|
||||
: m,
|
||||
);
|
||||
return { ...state, messages: next };
|
||||
}
|
||||
case 'error': {
|
||||
const next = frame.message_id
|
||||
? state.messages.map((m) =>
|
||||
m.id === frame.message_id ? { ...m, status: 'failed' as const } : m,
|
||||
)
|
||||
: state.messages;
|
||||
return { ...state, messages: next, error: frame.error };
|
||||
}
|
||||
case 'pending_change_added':
|
||||
case 'pending_change_updated':
|
||||
// These are handled by the pending changes listener, not the message state
|
||||
return state;
|
||||
}
|
||||
}
|
||||
|
||||
const RECONNECT_INITIAL_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
|
||||
interface SessionStreamResult {
|
||||
messages: Message[];
|
||||
connected: boolean;
|
||||
error: string | null;
|
||||
isStreaming: boolean;
|
||||
/** Listeners for pending change frames */
|
||||
onPendingChange: (cb: (change: PendingChange) => void) => () => void;
|
||||
}
|
||||
|
||||
export function useSessionStream(sessionId: string | undefined): SessionStreamResult {
|
||||
const [state, setState] = useState<State>({ messages: [], connected: false, error: null });
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const pendingListenersRef = useRef<Set<(change: PendingChange) => void>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
setState({ messages: [], connected: false, error: null });
|
||||
|
||||
let unmounted = false;
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
|
||||
const connect = () => {
|
||||
if (unmounted) return;
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const url = `${proto}://${window.location.host}/api/ws/sessions/${sessionId}`;
|
||||
const ws = new WebSocket(url);
|
||||
wsRef.current = ws;
|
||||
|
||||
ws.onopen = () => {
|
||||
reconnectDelay = RECONNECT_INITIAL_MS;
|
||||
setState((s) => ({ ...s, connected: true, error: null }));
|
||||
};
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let frame: WsFrame;
|
||||
try {
|
||||
frame = JSON.parse(typeof ev.data === 'string' ? ev.data : '') as WsFrame;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
// Notify pending change listeners
|
||||
if (frame.type === 'pending_change_added' || frame.type === 'pending_change_updated') {
|
||||
for (const cb of pendingListenersRef.current) {
|
||||
cb(frame.change);
|
||||
}
|
||||
}
|
||||
|
||||
setState((s) => applyFrame(s, frame));
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
if (unmounted) return;
|
||||
setState((s) => ({ ...s, connected: false }));
|
||||
const delay = reconnectDelay;
|
||||
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS);
|
||||
reconnectTimer = setTimeout(connect, delay);
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
unmounted = true;
|
||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||
const ws = wsRef.current;
|
||||
wsRef.current = null;
|
||||
if (ws)
|
||||
try {
|
||||
ws.close();
|
||||
} catch {}
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
const isStreaming = state.messages.some((m) => m.status === 'streaming');
|
||||
|
||||
const onPendingChange = useCallback((cb: (change: PendingChange) => void) => {
|
||||
pendingListenersRef.current.add(cb);
|
||||
return () => {
|
||||
pendingListenersRef.current.delete(cb);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages: state.messages,
|
||||
connected: state.connected,
|
||||
error: state.error,
|
||||
isStreaming,
|
||||
onPendingChange,
|
||||
};
|
||||
}
|
||||
13
apps/coder/web/src/main.tsx
Normal file
13
apps/coder/web/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { App } from './App';
|
||||
import './globals.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</StrictMode>,
|
||||
);
|
||||
138
apps/coder/web/src/pages/Home.tsx
Normal file
138
apps/coder/web/src/pages/Home.tsx
Normal file
@@ -0,0 +1,138 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Code2, Folder, ArrowRight } from 'lucide-react';
|
||||
import type { Project, Session } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
|
||||
export function Home() {
|
||||
const navigate = useNavigate();
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [selectedProject, setSelectedProject] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// Fetch projects on mount
|
||||
useEffect(() => {
|
||||
api.projects
|
||||
.list({ status: 'open' })
|
||||
.then(setProjects)
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Fetch sessions when a project is selected
|
||||
useEffect(() => {
|
||||
if (!selectedProject) {
|
||||
setSessions([]);
|
||||
return;
|
||||
}
|
||||
api.sessions
|
||||
.listForProject(selectedProject, 'open')
|
||||
.then(setSessions)
|
||||
.catch(console.error);
|
||||
}, [selectedProject]);
|
||||
|
||||
const handleSessionClick = (session: Session) => {
|
||||
navigate(`/sessions/${session.id}`);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||
<div className="text-zinc-500">Loading...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 p-6">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-8">
|
||||
<Code2 size={28} className="text-blue-400" />
|
||||
<h1 className="text-2xl font-bold text-zinc-100">BooCoder</h1>
|
||||
</div>
|
||||
|
||||
{/* Projects list */}
|
||||
<div className="mb-8">
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
|
||||
Projects
|
||||
</h2>
|
||||
{projects.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">
|
||||
No projects found. Create one in BooChat first.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{projects.map((project) => (
|
||||
<button
|
||||
key={project.id}
|
||||
onClick={() => setSelectedProject(project.id)}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 rounded-lg text-left transition-colors ${
|
||||
selectedProject === project.id
|
||||
? 'bg-blue-600/20 border border-blue-500/40'
|
||||
: 'bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800'
|
||||
}`}
|
||||
>
|
||||
<Folder
|
||||
size={16}
|
||||
className={
|
||||
selectedProject === project.id
|
||||
? 'text-blue-400'
|
||||
: 'text-zinc-500'
|
||||
}
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-zinc-200 truncate">
|
||||
{project.name}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500 truncate">
|
||||
{project.path}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sessions list */}
|
||||
{selectedProject && (
|
||||
<div>
|
||||
<h2 className="text-sm font-medium text-zinc-400 uppercase tracking-wide mb-3">
|
||||
Sessions
|
||||
</h2>
|
||||
{sessions.length === 0 ? (
|
||||
<p className="text-zinc-500 text-sm">
|
||||
No open sessions. Create one in BooChat first.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{sessions.map((session) => (
|
||||
<button
|
||||
key={session.id}
|
||||
onClick={() => handleSessionClick(session)}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 rounded-lg bg-zinc-800/50 border border-zinc-800 hover:bg-zinc-800 text-left transition-colors group"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-zinc-200 truncate">
|
||||
{session.name || 'Untitled session'}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{new Date(session.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
<ArrowRight
|
||||
size={16}
|
||||
className="text-zinc-600 group-hover:text-zinc-400 transition-colors"
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
apps/coder/web/src/pages/Session.tsx
Normal file
86
apps/coder/web/src/pages/Session.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import type { Chat } from '@/api/types';
|
||||
import { api } from '@/api/client';
|
||||
import { useSessionStream } from '@/hooks/useSessionStream';
|
||||
import { ChatPane } from '@/components/ChatPane';
|
||||
import { DiffPane } from '@/components/DiffPane';
|
||||
import { Layout } from '@/components/Layout';
|
||||
|
||||
export function Session() {
|
||||
const { sessionId } = useParams<{ sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [chat, setChat] = useState<Chat | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const { messages, connected, isStreaming, onPendingChange } =
|
||||
useSessionStream(sessionId);
|
||||
|
||||
// Get or create a chat for this session
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
|
||||
api.chats
|
||||
.listForSession(sessionId)
|
||||
.then((chats) => {
|
||||
// Use the first open chat, or create one
|
||||
const openChat = chats.find((c) => c.status === 'open');
|
||||
if (openChat) {
|
||||
setChat(openChat);
|
||||
} else {
|
||||
// Create a new chat
|
||||
return api.chats.create(sessionId).then((newChat) => {
|
||||
setChat(newChat);
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(console.error)
|
||||
.finally(() => setLoading(false));
|
||||
}, [sessionId]);
|
||||
|
||||
if (!sessionId) {
|
||||
navigate('/');
|
||||
return null;
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex items-center justify-center">
|
||||
<div className="text-zinc-500">Loading session...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!chat) {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-900 flex flex-col items-center justify-center gap-4">
|
||||
<div className="text-zinc-500">Could not load chat for this session.</div>
|
||||
<button
|
||||
onClick={() => navigate('/')}
|
||||
className="flex items-center gap-2 text-sm text-blue-400 hover:text-blue-300"
|
||||
>
|
||||
<ArrowLeft size={14} />
|
||||
Back to projects
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout
|
||||
chatPane={
|
||||
<ChatPane
|
||||
sessionId={sessionId}
|
||||
chatId={chat.id}
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
connected={connected}
|
||||
/>
|
||||
}
|
||||
diffPane={
|
||||
<DiffPane sessionId={sessionId} onPendingChange={onPendingChange} />
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
1
apps/coder/web/src/vite-env.d.ts
vendored
Normal file
1
apps/coder/web/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
27
apps/coder/web/tsconfig.app.json
Normal file
27
apps/coder/web/tsconfig.app.json
Normal file
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true,
|
||||
"useDefineForClassFields": true,
|
||||
"allowImportingTsExtensions": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
13
apps/coder/web/tsconfig.json
Normal file
13
apps/coder/web/tsconfig.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
14
apps/coder/web/tsconfig.node.json
Normal file
14
apps/coder/web/tsconfig.node.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"composite": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
2
apps/coder/web/vite.config.d.ts
vendored
Normal file
2
apps/coder/web/vite.config.d.ts
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
declare const _default: import("vite").UserConfig;
|
||||
export default _default;
|
||||
25
apps/coder/web/vite.config.js
Normal file
25
apps/coder/web/vite.config.js
Normal file
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
26
apps/coder/web/vite.config.ts
Normal file
26
apps/coder/web/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5174,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3000',
|
||||
changeOrigin: true,
|
||||
ws: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
});
|
||||
@@ -4,6 +4,23 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": { "types": "./dist/index.d.ts", "default": "./dist/index.js" },
|
||||
"./inference": { "types": "./dist/services/inference/index.d.ts", "default": "./dist/services/inference/index.js" },
|
||||
"./tools": { "types": "./dist/services/tools.d.ts", "default": "./dist/services/tools.js" },
|
||||
"./broker": { "types": "./dist/services/broker.d.ts", "default": "./dist/services/broker.js" },
|
||||
"./compaction": { "types": "./dist/services/compaction.d.ts", "default": "./dist/services/compaction.js" },
|
||||
"./model-context": { "types": "./dist/services/model-context.d.ts", "default": "./dist/services/model-context.js" },
|
||||
"./system-prompt": { "types": "./dist/services/system-prompt.d.ts", "default": "./dist/services/system-prompt.js" },
|
||||
"./agents": { "types": "./dist/services/agents.d.ts", "default": "./dist/services/agents.js" },
|
||||
"./truncate": { "types": "./dist/services/truncate.d.ts", "default": "./dist/services/truncate.js" },
|
||||
"./path-guard": { "types": "./dist/services/path_guard.d.ts", "default": "./dist/services/path_guard.js" },
|
||||
"./file-ops": { "types": "./dist/services/file_ops.d.ts", "default": "./dist/services/file_ops.js" },
|
||||
"./types": { "types": "./dist/types/api.d.ts", "default": "./dist/types/api.js" },
|
||||
"./ws-frames": { "types": "./dist/types/ws-frames.d.ts", "default": "./dist/types/ws-frames.js" },
|
||||
"./db": { "types": "./dist/db.d.ts", "default": "./dist/db.js" },
|
||||
"./config": { "types": "./dist/config.d.ts", "default": "./dist/config.js" }
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||
@@ -14,6 +31,7 @@
|
||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"ai": "^6.0.190",
|
||||
"fastify": "^4.28.1",
|
||||
"postgres": "^3.4.4",
|
||||
|
||||
@@ -19,6 +19,12 @@ const ConfigSchema = z.object({
|
||||
GITEA_USER: z.string().default('indifferentketchup'),
|
||||
GITEA_TOKEN: z.string().optional(),
|
||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
||||
// v1.15.0-mcp-multi: path to the MCP config JSON file. Default /data/mcp.json
|
||||
// (bind-mounted alongside AGENTS.md). File missing = no MCP (opt-in).
|
||||
MCP_CONFIG_PATH: z.string().optional(),
|
||||
// v2.0.5: cheaper model for titles, summaries, labeling. Falls back to
|
||||
// session model (auto_name) or DEFAULT_MODEL when unset.
|
||||
FAST_MODEL: z.string().optional(),
|
||||
});
|
||||
|
||||
export type Config = z.infer<typeof ConfigSchema>;
|
||||
|
||||
@@ -10,17 +10,24 @@ import { registerProjectRoutes } from './routes/projects.js';
|
||||
import { registerSessionRoutes } from './routes/sessions.js';
|
||||
import { registerSettingsRoutes } from './routes/settings.js';
|
||||
import { registerMessageRoutes } from './routes/messages.js';
|
||||
import { registerArtifactRoutes } from './routes/artifacts.js';
|
||||
import { registerChatRoutes } from './routes/chats.js';
|
||||
import { registerSidebarRoutes } from './routes/sidebar.js';
|
||||
import { registerWebSocket } from './routes/ws.js';
|
||||
import { registerModelRoutes } from './routes/models.js';
|
||||
import { registerAgentRoutes } from './routes/agents.js';
|
||||
import { registerSkillsRoutes } from './routes/skills.js';
|
||||
import { registerToolsRoutes } from './routes/tools.js';
|
||||
import { createInferenceRunner } from './services/inference/index.js';
|
||||
import { createBroker } from './services/broker.js';
|
||||
import { listSkills } from './services/skills.js';
|
||||
import * as compaction from './services/compaction.js';
|
||||
import { configureModelContext } from './services/model-context.js';
|
||||
import { cleanupTruncations } from './services/truncate.js';
|
||||
import { loadMcpConfig } from './services/mcp-config.js';
|
||||
import { initialize as initMcp, getTools as getMcpTools, shutdown as shutdownMcp } from './services/mcp-client.js';
|
||||
import { appendMcpTools } from './services/tools.js';
|
||||
import { refreshToolNames } from './services/agents.js';
|
||||
|
||||
async function main() {
|
||||
const config = loadConfig();
|
||||
@@ -66,6 +73,23 @@ async function main() {
|
||||
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
||||
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
||||
|
||||
// v1.15.0-mcp-multi: read MCP config file and connect to all enabled servers.
|
||||
// Runs before route registration so the tool list is complete when the first
|
||||
// inference request arrives. Per-server graceful degradation: one failing
|
||||
// server doesn't block others.
|
||||
const mcpConfigPath = config.MCP_CONFIG_PATH ?? '/data/mcp.json';
|
||||
const mcpServers = loadMcpConfig(mcpConfigPath, app.log);
|
||||
if (mcpServers.length > 0) {
|
||||
await initMcp(mcpServers, app.log);
|
||||
const mcpTools = getMcpTools();
|
||||
if (mcpTools.length > 0) {
|
||||
appendMcpTools(mcpTools);
|
||||
refreshToolNames();
|
||||
app.log.info({ servers: mcpServers.length, tools: mcpTools.length }, 'mcp: registered');
|
||||
}
|
||||
}
|
||||
app.addHook('onClose', async () => { await shutdownMcp(); });
|
||||
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
app.get('/api/health', async () => {
|
||||
@@ -73,7 +97,7 @@ async function main() {
|
||||
return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
|
||||
});
|
||||
|
||||
const broker = createBroker();
|
||||
const broker = createBroker(app.log);
|
||||
|
||||
registerProjectRoutes(app, sql, config, broker);
|
||||
registerSessionRoutes(app, sql, config, broker);
|
||||
@@ -82,6 +106,7 @@ async function main() {
|
||||
registerAgentRoutes(app, sql);
|
||||
registerSidebarRoutes(app, sql);
|
||||
registerChatRoutes(app, sql, broker);
|
||||
registerToolsRoutes(app, sql);
|
||||
|
||||
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
||||
// missing /data/skills is non-fatal — the skill tools just return empty.
|
||||
@@ -98,7 +123,9 @@ async function main() {
|
||||
config,
|
||||
log: app.log,
|
||||
publish: (sessionId, frame) => {
|
||||
broker.publish(sessionId, frame as unknown as Record<string, unknown> & { type: string });
|
||||
// v1.13.11-b: route through the typed publishFrame so the broker's
|
||||
// Zod gate validates every inference frame before delivery.
|
||||
broker.publishFrame(sessionId, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||
},
|
||||
// v1.11: broker handle for compaction.process to publish 'compacted'
|
||||
// frames on the per-session channel. Inference's regular publish path
|
||||
@@ -107,10 +134,10 @@ async function main() {
|
||||
broker,
|
||||
},
|
||||
(user, frame) => {
|
||||
broker.publishUser(user, frame as unknown as Record<string, unknown> & { type: string });
|
||||
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||
}
|
||||
);
|
||||
registerMessageRoutes(app, sql, {
|
||||
registerMessageRoutes(app, sql, config, broker, {
|
||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
},
|
||||
@@ -126,64 +153,96 @@ async function main() {
|
||||
},
|
||||
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
});
|
||||
},
|
||||
publishMessagesDeleted: (sessionId, chatId, messageIds) => {
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'messages_deleted',
|
||||
message_ids: messageIds,
|
||||
chat_id: chatId,
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publish(sessionId, frame);
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
},
|
||||
});
|
||||
registerArtifactRoutes(app, sql);
|
||||
registerSkillsRoutes(app, sql, {
|
||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||
},
|
||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_started',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
role: 'user',
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'delta',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
content,
|
||||
});
|
||||
broker.publish(sessionId, {
|
||||
broker.publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: userMessageId,
|
||||
chat_id: chatId,
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publish(sessionId, frame);
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
},
|
||||
});
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
// v2.0.0: reverse proxy /api/coder/* to the boocoder container. Keeps the
|
||||
// SPA's HTTP requests going through a single origin (avoids CORS). WS for
|
||||
// the coder pane connects directly to boocoder:9502 from the browser (same
|
||||
// Tailscale network — no CORS issue for WebSocket upgrade requests).
|
||||
const BOOCODER_ORIGIN = process.env.BOOCODER_URL ?? 'http://boocoder:3000';
|
||||
app.all('/api/coder/*', async (req, reply) => {
|
||||
const targetPath = req.url.replace('/api/coder', '/api');
|
||||
const targetUrl = `${BOOCODER_ORIGIN}${targetPath}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (req.headers['content-type']) headers['content-type'] = req.headers['content-type'] as string;
|
||||
if (req.headers['authorization']) headers['authorization'] = req.headers['authorization'] as string;
|
||||
|
||||
try {
|
||||
const res = await fetch(targetUrl, {
|
||||
method: req.method as string,
|
||||
headers,
|
||||
body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined,
|
||||
});
|
||||
reply.code(res.status);
|
||||
for (const [key, value] of res.headers) {
|
||||
if (key === 'transfer-encoding') continue;
|
||||
reply.header(key, value);
|
||||
}
|
||||
const body = await res.text();
|
||||
return reply.send(body);
|
||||
} catch (err) {
|
||||
app.log.error({ err, targetUrl }, 'coder proxy error');
|
||||
reply.code(502).send({ error: 'boocoder backend unavailable' });
|
||||
}
|
||||
});
|
||||
|
||||
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
||||
if (existsSync(webDist)) {
|
||||
await app.register(fastifyStatic, {
|
||||
@@ -227,7 +286,7 @@ async function main() {
|
||||
for (const row of rows) {
|
||||
if (seenChats.has(row.chat_id)) continue;
|
||||
seenChats.add(row.chat_id);
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: row.chat_id,
|
||||
status: 'idle',
|
||||
@@ -238,7 +297,13 @@ async function main() {
|
||||
app.log.error({ err }, 'stuck-row sweeper failed');
|
||||
}
|
||||
};
|
||||
const sweepTimer = setInterval(() => { void sweepStaleStreaming(); }, SWEEP_INTERVAL_MS);
|
||||
// v1.13.5: truncation cleanup rides the same cadence — 60s tick reaps
|
||||
// tmpfs files past the 7-day TTL plus any orphans whose owning part has
|
||||
// been pruned (v1.13.4) or deleted. No-op when the dir is empty.
|
||||
const sweepTimer = setInterval(() => {
|
||||
void sweepStaleStreaming();
|
||||
void cleanupTruncations({ sql, log: app.log });
|
||||
}, SWEEP_INTERVAL_MS);
|
||||
app.addHook('onClose', async () => { clearInterval(sweepTimer); });
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
|
||||
70
apps/server/src/routes/__tests__/sessions.test.ts
Normal file
70
apps/server/src/routes/__tests__/sessions.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// v1.13.17-cross-repo-reads: PATCH /api/sessions/:id allowed_read_paths
|
||||
// subset enforcement. Sam flagged in the compliance review that without a
|
||||
// runtime subset check, a malicious client could POST
|
||||
// {"allowed_read_paths":["/etc"]}
|
||||
// and bypass the user-consent grant flow entirely. The findUnauthorizedAdditions
|
||||
// helper is the guard; tests pin its behavior so a regression in the helper
|
||||
// or its callsite (PATCH handler in sessions.ts) trips CI before prod.
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { findUnauthorizedAdditions } from '../sessions.js';
|
||||
|
||||
describe('findUnauthorizedAdditions — PATCH allowed_read_paths subset guard', () => {
|
||||
it('returns no extras when requested is empty (full revoke)', () => {
|
||||
expect(findUnauthorizedAdditions(['/opt/forks/foo'], [])).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no extras when requested is a strict subset (single revoke)', () => {
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], ['/opt/forks/foo']),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns no extras when requested equals prior (no-op PATCH)', () => {
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
|
||||
'/opt/forks/foo',
|
||||
'/opt/forks/bar',
|
||||
]),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('flags an unauthorized addition when prior is empty', () => {
|
||||
// The /etc bypass attempt — Sam's specific concern from the compliance
|
||||
// review. Without this guard, the PATCH would have written /etc directly.
|
||||
expect(findUnauthorizedAdditions([], ['/etc'])).toEqual(['/etc']);
|
||||
});
|
||||
|
||||
it('flags a single unauthorized addition mixed in with valid revokes', () => {
|
||||
// The attacker still tries to be sneaky: keep one legit entry, drop
|
||||
// another, slip in a new one. The guard catches the addition regardless
|
||||
// of how the rest of the array shrinks.
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo', '/opt/forks/bar'], [
|
||||
'/opt/forks/foo',
|
||||
'/var/secrets',
|
||||
]),
|
||||
).toEqual(['/var/secrets']);
|
||||
});
|
||||
|
||||
it('flags every unauthorized addition when there are multiple', () => {
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/etc', '/root']),
|
||||
).toEqual(['/etc', '/root']);
|
||||
});
|
||||
|
||||
it('treats requested duplicates correctly (each occurrence checked)', () => {
|
||||
// If the requested array has duplicates of an unauthorized entry, the
|
||||
// guard surfaces each one. (A frontend would never send duplicates, but
|
||||
// the guard's contract shouldn't assume that.)
|
||||
expect(findUnauthorizedAdditions([], ['/etc', '/etc'])).toEqual(['/etc', '/etc']);
|
||||
});
|
||||
|
||||
it('does not flag entries present in prior even if requested has duplicates', () => {
|
||||
// Duplicate of an authorized entry passes — the membership check is by
|
||||
// value, not by index. Settled by Set.has semantics.
|
||||
expect(
|
||||
findUnauthorizedAdditions(['/opt/forks/foo'], ['/opt/forks/foo', '/opt/forks/foo']),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
231
apps/server/src/routes/artifacts.ts
Normal file
231
apps/server/src/routes/artifacts.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
// v1.14.x-html-artifact-panes: artifact download routes.
|
||||
//
|
||||
// Two endpoints:
|
||||
// POST /api/chats/:id/messages/:msg_id/artifacts/download?fmt=md|html
|
||||
// Materialises a file under <projectRoot>/.boocode/artifacts/ and
|
||||
// returns {path, url}. fmt=html requires an existing html_artifact part
|
||||
// on the message (404 otherwise). fmt=md works on any assistant
|
||||
// message with non-empty content.
|
||||
//
|
||||
// GET /api/projects/:project_id/artifacts/:filename
|
||||
// Streams a previously-written artifact back with
|
||||
// Content-Disposition: attachment. Path-guarded to the project's
|
||||
// artifacts dir; rejects traversal attempts.
|
||||
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { realpath, stat } from 'node:fs/promises';
|
||||
import { resolve, sep, basename } from 'node:path';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import {
|
||||
writeHtmlArtifact,
|
||||
writeMarkdownArtifact,
|
||||
type HtmlArtifactPayload,
|
||||
} from '../services/artifacts.js';
|
||||
|
||||
const DownloadQuery = z.object({
|
||||
fmt: z.enum(['md', 'html']),
|
||||
});
|
||||
|
||||
// Filename safety: alnum, dash, dot, underscore only. Blocks `..`, slashes,
|
||||
// nul bytes, etc. before we even touch the filesystem.
|
||||
const FilenameRe = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
interface ChatRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
project_id: string;
|
||||
project_path: string;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
chat_id: string;
|
||||
role: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function registerArtifactRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
app.post<{
|
||||
Params: { id: string; msg_id: string };
|
||||
Querystring: { fmt?: string };
|
||||
}>(
|
||||
'/api/chats/:id/messages/:msg_id/artifacts/download',
|
||||
async (req, reply) => {
|
||||
const parsed = DownloadQuery.safeParse(req.query);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid query', details: parsed.error.flatten() };
|
||||
}
|
||||
const { fmt } = parsed.data;
|
||||
const { id: chatId, msg_id: messageId } = req.params;
|
||||
|
||||
const chatRows = await sql<ChatRow[]>`
|
||||
SELECT c.id, c.session_id, s.project_id, p.path AS project_path
|
||||
FROM chats c
|
||||
JOIN sessions s ON s.id = c.session_id
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE c.id = ${chatId}
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
|
||||
const msgRows = await sql<MessageRow[]>`
|
||||
SELECT id, chat_id, role, content
|
||||
FROM messages
|
||||
WHERE id = ${messageId} AND chat_id = ${chatId}
|
||||
`;
|
||||
if (msgRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'message not found' };
|
||||
}
|
||||
const msg = msgRows[0]!;
|
||||
if (msg.role !== 'assistant') {
|
||||
reply.code(400);
|
||||
return { error: 'only assistant messages produce artifacts' };
|
||||
}
|
||||
|
||||
const ctx = { projectId: chat.project_id, projectRoot: chat.project_path };
|
||||
|
||||
try {
|
||||
if (fmt === 'md') {
|
||||
if (!msg.content || msg.content.trim().length === 0) {
|
||||
reply.code(400);
|
||||
return { error: 'message has no content to export' };
|
||||
}
|
||||
const result = await writeMarkdownArtifact(
|
||||
{ content: msg.content },
|
||||
ctx,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
// fmt === 'html': require an html_artifact part on the message.
|
||||
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
|
||||
SELECT payload
|
||||
FROM message_parts
|
||||
WHERE message_id = ${messageId} AND kind = 'html_artifact'
|
||||
ORDER BY sequence ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (partRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'no html_artifact part on this message' };
|
||||
}
|
||||
const result = await writeHtmlArtifact(partRows[0]!.payload, ctx);
|
||||
return result;
|
||||
} catch (err) {
|
||||
req.log.error({ err, messageId, fmt }, 'artifact write failed');
|
||||
reply.code(500);
|
||||
return {
|
||||
error: err instanceof Error ? err.message : 'artifact write failed',
|
||||
};
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// v1.14.x-html-artifact-panes: HtmlArtifactPane needs the payload on click
|
||||
// to render its iframe. Returns 404 when the message has no html_artifact
|
||||
// sibling part — frontend uses that signal to open the markdown_artifact
|
||||
// pane variant instead. Payload shape matches HtmlArtifactPayload in
|
||||
// services/artifacts.ts.
|
||||
app.get<{ Params: { id: string; msg_id: string } }>(
|
||||
'/api/chats/:id/messages/:msg_id/html_artifact',
|
||||
async (req, reply) => {
|
||||
const { id: chatId, msg_id: messageId } = req.params;
|
||||
const partRows = await sql<{ payload: HtmlArtifactPayload }[]>`
|
||||
SELECT payload
|
||||
FROM message_parts mp
|
||||
JOIN messages m ON m.id = mp.message_id
|
||||
WHERE mp.message_id = ${messageId}
|
||||
AND m.chat_id = ${chatId}
|
||||
AND mp.kind = 'html_artifact'
|
||||
ORDER BY mp.sequence ASC
|
||||
LIMIT 1
|
||||
`;
|
||||
if (partRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'no html_artifact part on this message' };
|
||||
}
|
||||
return partRows[0]!.payload;
|
||||
},
|
||||
);
|
||||
|
||||
app.get<{ Params: { project_id: string; filename: string } }>(
|
||||
'/api/projects/:project_id/artifacts/:filename',
|
||||
async (req, reply) => {
|
||||
const { project_id: projectId, filename } = req.params;
|
||||
// Strip directory components defensively; only the basename is allowed.
|
||||
const base = basename(filename);
|
||||
if (base !== filename || !FilenameRe.test(base)) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid filename' };
|
||||
}
|
||||
|
||||
const projectRows = await sql<{ id: string; path: string }[]>`
|
||||
SELECT id, path FROM projects WHERE id = ${projectId}
|
||||
`;
|
||||
if (projectRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'project not found' };
|
||||
}
|
||||
const project = projectRows[0]!;
|
||||
|
||||
let resolvedRoot: string;
|
||||
try {
|
||||
resolvedRoot = await realpath(project.path);
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { error: 'project path missing' };
|
||||
}
|
||||
const artifactsDir = resolve(resolvedRoot, '.boocode/artifacts');
|
||||
const absPath = resolve(artifactsDir, base);
|
||||
if (!absPath.startsWith(artifactsDir + sep)) {
|
||||
reply.code(400);
|
||||
return { error: 'path traversal rejected' };
|
||||
}
|
||||
// Close the symlink-escape gap: if `.boocode/artifacts` (or an
|
||||
// ancestor) is a symlink pointing outside resolvedRoot, the lexical
|
||||
// prefix check above passes but the actual read lands outside the
|
||||
// sandbox. Realpath the artifacts dir and re-verify.
|
||||
try {
|
||||
const realArtifactsDir = await realpath(artifactsDir);
|
||||
if (
|
||||
realArtifactsDir !== resolvedRoot &&
|
||||
!realArtifactsDir.startsWith(resolvedRoot + sep)
|
||||
) {
|
||||
reply.code(400);
|
||||
return { error: 'path traversal rejected' };
|
||||
}
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { error: 'artifact not found' };
|
||||
}
|
||||
try {
|
||||
await stat(absPath);
|
||||
} catch {
|
||||
reply.code(404);
|
||||
return { error: 'artifact not found' };
|
||||
}
|
||||
const ext = base.toLowerCase().endsWith('.html')
|
||||
? 'text/html; charset=utf-8'
|
||||
: base.toLowerCase().endsWith('.md')
|
||||
? 'text/markdown; charset=utf-8'
|
||||
: 'application/octet-stream';
|
||||
reply.header('Content-Type', ext);
|
||||
// Defense-in-depth on LLM-generated HTML served through this route.
|
||||
// Authelia gates the proxy; these headers limit blast radius if a
|
||||
// payload tries to escape that boundary in-browser.
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.header('Content-Security-Policy', 'sandbox');
|
||||
reply.header(
|
||||
'Content-Disposition',
|
||||
`attachment; filename="${base.replace(/"/g, '')}"`,
|
||||
);
|
||||
return reply.send(createReadStream(absPath));
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -102,7 +102,7 @@ export function registerChatRoutes(
|
||||
VALUES (${req.params.id}, ${parsed.data.name ?? null}, 'open')
|
||||
RETURNING id, session_id, name, status, created_at, updated_at
|
||||
`;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_created',
|
||||
chat: chat!,
|
||||
session_id: req.params.id,
|
||||
@@ -132,7 +132,7 @@ export function registerChatRoutes(
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const chat = rows[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_updated',
|
||||
chat_id: chat.id,
|
||||
session_id: chat.session_id,
|
||||
@@ -162,7 +162,7 @@ export function registerChatRoutes(
|
||||
`;
|
||||
const ids = rows.map((r) => r.id);
|
||||
for (const id of ids) {
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_archived',
|
||||
chat_id: id,
|
||||
session_id: req.params.id,
|
||||
@@ -203,7 +203,7 @@ export function registerChatRoutes(
|
||||
return { error: 'chat not found or already archived' };
|
||||
}
|
||||
const row = rows[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_archived',
|
||||
chat_id: row.id,
|
||||
session_id: row.session_id,
|
||||
@@ -226,7 +226,7 @@ export function registerChatRoutes(
|
||||
return { error: 'chat not found or not archived' };
|
||||
}
|
||||
const chat = rows[0]!;
|
||||
broker.publishUser('default', { type: 'chat_unarchived', chat });
|
||||
broker.publishUserFrame('default', { type: 'chat_unarchived', chat });
|
||||
return chat;
|
||||
}
|
||||
);
|
||||
@@ -243,7 +243,7 @@ export function registerChatRoutes(
|
||||
return { error: 'chat not found' };
|
||||
}
|
||||
const row = result[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_deleted',
|
||||
chat_id: row.id,
|
||||
session_id: row.session_id,
|
||||
@@ -296,13 +296,13 @@ export function registerChatRoutes(
|
||||
`;
|
||||
await tx`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
||||
session_id, chat_id, role, content, kind,
|
||||
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||
created_at, metadata
|
||||
)
|
||||
SELECT
|
||||
${source.session_id}, ${chat!.id}, role, content, kind,
|
||||
tool_calls, tool_results, status,
|
||||
status,
|
||||
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||
clock_timestamp() + (
|
||||
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
||||
@@ -338,7 +338,7 @@ export function registerChatRoutes(
|
||||
return chat!;
|
||||
});
|
||||
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_created',
|
||||
chat: newChat,
|
||||
session_id: source.session_id,
|
||||
@@ -385,33 +385,37 @@ export function registerChatRoutes(
|
||||
reply.code(409);
|
||||
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
|
||||
}
|
||||
const updated = await sql<Message[]>`
|
||||
const updated = await sql<{ id: string }[]>`
|
||||
UPDATE messages
|
||||
SET status = 'failed',
|
||||
content = COALESCE(content, ''),
|
||||
finished_at = clock_timestamp()
|
||||
WHERE id = ${msg.id} AND status = 'streaming'
|
||||
RETURNING id, session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
||||
status, last_seq, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||
created_at, metadata, summary, tail_start_id, compacted_at
|
||||
RETURNING id
|
||||
`;
|
||||
if (updated.length === 0) {
|
||||
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
|
||||
reply.code(409);
|
||||
return { error: 'message status changed mid-request' };
|
||||
}
|
||||
broker.publishUser('default', {
|
||||
// v1.13.20: re-fetch via messages_with_parts so the returned shape
|
||||
// carries parts-synthesized tool_calls / tool_results. The dropped
|
||||
// legacy columns can no longer be selected directly.
|
||||
const refreshed = await sql<Message[]>`
|
||||
SELECT * FROM messages_with_parts WHERE id = ${msg.id}
|
||||
`;
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: msg.chat_id,
|
||||
status: 'idle',
|
||||
at: new Date().toISOString(),
|
||||
});
|
||||
broker.publish(msg.session_id, {
|
||||
broker.publishFrame(msg.session_id, {
|
||||
type: 'message_complete',
|
||||
message_id: msg.id,
|
||||
chat_id: msg.chat_id,
|
||||
});
|
||||
return updated[0];
|
||||
return refreshed[0];
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import type { Sql } from '../db.js';
|
||||
import type { Config } from '../config.js';
|
||||
import type { Broker } from '../services/broker.js';
|
||||
import type { Chat, Message, Session, ToolCall } from '../types/api.js';
|
||||
// v1.13.17-cross-repo-reads: grant_read_access resolves the grant root at
|
||||
// decision time (not at request time) so concurrent project changes don't
|
||||
// stale-bind the resolution.
|
||||
import { resolveGrantRoot } from '../services/grant_resolver.js';
|
||||
|
||||
const SendBody = z.object({
|
||||
content: z.string().min(1).max(64_000),
|
||||
@@ -47,6 +53,21 @@ const AskUserInputArgs = z.object({
|
||||
.max(3),
|
||||
});
|
||||
|
||||
// v1.13.17-cross-repo-reads: grant decision body. tool_call_id is the
|
||||
// model-emitted id (e.g. "call_abc123"), not a UUID. decision is binary.
|
||||
const GrantReadAccessBody = z.object({
|
||||
tool_call_id: z.string().min(1),
|
||||
decision: z.enum(['allow', 'deny']),
|
||||
});
|
||||
|
||||
// Same shape as services/request_read_access.ts RequestReadAccessInput.
|
||||
// Re-derived to avoid the services/tools.ts import (matches the
|
||||
// AskUserInputArgs pattern above).
|
||||
const RequestReadAccessArgs = z.object({
|
||||
path: z.string().min(1),
|
||||
reason: z.string().min(1).max(500),
|
||||
});
|
||||
|
||||
interface MessageHandlers {
|
||||
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
||||
// v1.11: returns a promise that resolves after compaction.process finishes
|
||||
@@ -76,6 +97,8 @@ interface MessageHandlers {
|
||||
export function registerMessageRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
config: Config,
|
||||
broker: Broker,
|
||||
handlers: MessageHandlers
|
||||
): void {
|
||||
app.get<{ Params: { id: string } }>(
|
||||
@@ -582,15 +605,11 @@ export function registerMessageRoutes(
|
||||
|
||||
const toolMessageId = toolRow.message_id;
|
||||
const result = await sql.begin(async (tx) => {
|
||||
await tx`
|
||||
UPDATE messages
|
||||
SET tool_results = ${tx.json(newToolResults as never)}
|
||||
WHERE id = ${toolMessageId}
|
||||
`;
|
||||
// v1.13.0: replace the pending tool_result part inserted at message
|
||||
// creation (tool-phase.ts) with the answered one. Delete-then-insert
|
||||
// is simpler than UPDATE because parts are append-style elsewhere;
|
||||
// the UNIQUE (message_id, sequence) constraint blocks plain insert.
|
||||
// v1.13.20: parts-only. Replace the pending tool_result part inserted
|
||||
// at message creation (tool-phase.ts) with the answered one. Delete-
|
||||
// then-insert is simpler than UPDATE because parts are append-style
|
||||
// elsewhere; the UNIQUE (message_id, sequence) constraint blocks
|
||||
// plain insert.
|
||||
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
@@ -626,4 +645,230 @@ export function registerMessageRoutes(
|
||||
return result;
|
||||
},
|
||||
);
|
||||
|
||||
// v1.13.17-cross-repo-reads: resume an awaiting-grant pause. Mirror shape
|
||||
// of /answer_user_input (validate, look up via message_parts, UPDATE,
|
||||
// publish, enqueue). Differences vs /answer_user_input:
|
||||
// - On 'allow', re-resolves the grant root via grant_resolver (state
|
||||
// may have changed since the prompt fired — concurrent project add,
|
||||
// etc.). Resolution failure auto-falls to a denial with reason text
|
||||
// rather than 500ing.
|
||||
// - On 'allow' with a valid root, appends to sessions.allowed_read_paths
|
||||
// (deduplicated) inside the same transaction.
|
||||
// - On success, also publishes session_updated so an open SettingsPane
|
||||
// refetches the new grant list.
|
||||
// Error codes match /answer:
|
||||
// 400 invalid_body / mismatched_answer_shape (bad args on the tool_call)
|
||||
// 404 chat_not_found / unknown_tool_call_id
|
||||
// 409 tool_call_already_answered
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/chats/:id/grant_read_access',
|
||||
async (req, reply) => {
|
||||
const parsed = GrantReadAccessBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'invalid_body', details: parsed.error.flatten() };
|
||||
}
|
||||
const { tool_call_id, decision } = parsed.data;
|
||||
|
||||
const chatRows = await sql<Chat[]>`
|
||||
SELECT id, session_id FROM chats WHERE id = ${req.params.id} AND status = 'open'
|
||||
`;
|
||||
if (chatRows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'chat_not_found' };
|
||||
}
|
||||
const chat = chatRows[0]!;
|
||||
const sessionId = chat.session_id;
|
||||
|
||||
// Mirror the /answer lookup: assistant tool_call by id via message_parts.
|
||||
const callerRows = await sql<{
|
||||
message_id: string;
|
||||
payload: { id: string; name: string; args: Record<string, unknown> };
|
||||
}[]>`
|
||||
SELECT p.message_id, p.payload
|
||||
FROM message_parts p
|
||||
JOIN messages m ON m.id = p.message_id
|
||||
WHERE m.chat_id = ${chat.id}
|
||||
AND m.role = 'assistant'
|
||||
AND p.kind = 'tool_call'
|
||||
AND p.payload->>'id' = ${tool_call_id}
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const callerRow = callerRows[0];
|
||||
if (!callerRow) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_tool_call_id' };
|
||||
}
|
||||
const foundCall: ToolCall = {
|
||||
id: callerRow.payload.id,
|
||||
name: callerRow.payload.name,
|
||||
args: callerRow.payload.args,
|
||||
};
|
||||
if (foundCall.name !== 'request_read_access') {
|
||||
reply.code(400);
|
||||
return { error: 'tool_call_not_request_read_access' };
|
||||
}
|
||||
const argsParsed = RequestReadAccessArgs.safeParse(foundCall.args);
|
||||
if (!argsParsed.success) {
|
||||
reply.code(400);
|
||||
return { error: 'mismatched_answer_shape', detail: 'tool_call args invalid' };
|
||||
}
|
||||
const requestedPath = argsParsed.data.path;
|
||||
|
||||
// Find the pending tool row.
|
||||
const toolRows = await sql<{
|
||||
message_id: string;
|
||||
payload: { tool_call_id: string; output: unknown };
|
||||
}[]>`
|
||||
SELECT p.message_id, p.payload
|
||||
FROM message_parts p
|
||||
JOIN messages m ON m.id = p.message_id
|
||||
WHERE m.chat_id = ${chat.id}
|
||||
AND m.role = 'tool'
|
||||
AND p.kind = 'tool_result'
|
||||
AND p.payload->>'tool_call_id' = ${tool_call_id}
|
||||
ORDER BY m.created_at DESC
|
||||
LIMIT 1
|
||||
`;
|
||||
const toolRow = toolRows[0];
|
||||
if (!toolRow) {
|
||||
reply.code(404);
|
||||
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||
}
|
||||
if (toolRow.payload && toolRow.payload.output !== null) {
|
||||
reply.code(409);
|
||||
return { error: 'tool_call_already_answered' };
|
||||
}
|
||||
|
||||
// Look up session + project so we can re-resolve the grant root and
|
||||
// append to allowed_read_paths atomically. We don't need agent or
|
||||
// history here — just the project path for the resolver.
|
||||
const sessionRows = await sql<{
|
||||
id: string;
|
||||
project_id: string;
|
||||
allowed_read_paths: string[];
|
||||
project_path: string;
|
||||
}[]>`
|
||||
SELECT s.id, s.project_id, s.allowed_read_paths, p.path AS project_path
|
||||
FROM sessions s
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
WHERE s.id = ${sessionId}
|
||||
`;
|
||||
const sessionRow = sessionRows[0];
|
||||
if (!sessionRow) {
|
||||
reply.code(404);
|
||||
return { error: 'session_not_found' };
|
||||
}
|
||||
|
||||
// Decision branch. 'deny' is the easy path: nothing to resolve or
|
||||
// persist. 'allow' resolves the grant root; if resolution fails (e.g.
|
||||
// path was deleted, project removed since prompt) the tool gets a
|
||||
// denial with the resolver's reason text instead of a 500.
|
||||
let resultOutput: string;
|
||||
let grantRoot: string | null = null;
|
||||
if (decision === 'allow') {
|
||||
const resolution = await resolveGrantRoot(
|
||||
sql,
|
||||
requestedPath,
|
||||
sessionRow.project_path,
|
||||
config.PROJECT_ROOT_WHITELIST,
|
||||
);
|
||||
if (!resolution.ok) {
|
||||
resultOutput = `denied: ${resolution.reason}`;
|
||||
} else {
|
||||
grantRoot = resolution.root;
|
||||
resultOutput = `granted: ${grantRoot}`;
|
||||
}
|
||||
} else {
|
||||
resultOutput = 'denied';
|
||||
}
|
||||
|
||||
const newToolResults = {
|
||||
tool_call_id,
|
||||
output: resultOutput,
|
||||
truncated: false,
|
||||
};
|
||||
const toolMessageId = toolRow.message_id;
|
||||
const dbResult = await sql.begin(async (tx) => {
|
||||
// v1.13.20: parts-only. Same delete+insert dance as /answer —
|
||||
// UNIQUE (message_id, sequence) blocks plain UPDATE on append-style
|
||||
// parts.
|
||||
await tx`DELETE FROM message_parts WHERE message_id = ${toolMessageId} AND kind = 'tool_result'`;
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
|
||||
`;
|
||||
// Persist the grant if we have one. ARRAY-level dedup — append only
|
||||
// when the root isn't already present. The session row gets
|
||||
// touched (updated_at) so the post-update publish below has a
|
||||
// fresh timestamp.
|
||||
let allowedRootsAfter = sessionRow.allowed_read_paths;
|
||||
if (grantRoot !== null) {
|
||||
if (!sessionRow.allowed_read_paths.includes(grantRoot)) {
|
||||
const updated = await tx<{ allowed_read_paths: string[] }[]>`
|
||||
UPDATE sessions
|
||||
SET allowed_read_paths = array_append(allowed_read_paths, ${grantRoot}),
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${sessionId}
|
||||
RETURNING allowed_read_paths
|
||||
`;
|
||||
allowedRootsAfter = updated[0]?.allowed_read_paths ?? sessionRow.allowed_read_paths;
|
||||
} else {
|
||||
// Already present — touch updated_at so any open settings
|
||||
// panel still picks up the no-op via session_updated.
|
||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||
}
|
||||
}
|
||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'streaming', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||
return {
|
||||
tool_message_id: toolMessageId,
|
||||
assistant_message_id: assistantMsg!.id,
|
||||
allowed_roots_after: allowedRootsAfter,
|
||||
};
|
||||
});
|
||||
|
||||
// Publish the deferred tool_result frame so the pending card flips to
|
||||
// its answered view without a refetch.
|
||||
handlers.publishSessionFrame(sessionId, {
|
||||
type: 'tool_result',
|
||||
tool_message_id: dbResult.tool_message_id,
|
||||
tool_call_id,
|
||||
chat_id: chat.id,
|
||||
output: resultOutput,
|
||||
truncated: false,
|
||||
});
|
||||
// session_updated nudge so any open SettingsPane refetches and sees
|
||||
// the new allowed_read_paths. We publish on the user channel to match
|
||||
// the existing PATCH /api/sessions/:id behavior — frontend refetches
|
||||
// via api.sessions.get on receipt.
|
||||
const nowIso = new Date().toISOString();
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_updated',
|
||||
session_id: sessionId,
|
||||
project_id: sessionRow.project_id,
|
||||
// session name doesn't change on grant; we look it up fresh to
|
||||
// avoid carrying stale state if a rename raced us.
|
||||
name:
|
||||
(
|
||||
await sql<{ name: string }[]>`SELECT name FROM sessions WHERE id = ${sessionId}`
|
||||
)[0]?.name ?? '',
|
||||
updated_at: nowIso,
|
||||
});
|
||||
handlers.enqueueInference(sessionId, chat.id, dbResult.assistant_message_id, 'default');
|
||||
|
||||
reply.code(202);
|
||||
return {
|
||||
tool_message_id: dbResult.tool_message_id,
|
||||
assistant_message_id: dbResult.assistant_message_id,
|
||||
allowed_read_paths: dbResult.allowed_roots_after,
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -129,7 +129,7 @@ export function registerProjectRoutes(
|
||||
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||
default_system_prompt, default_web_search_enabled
|
||||
`;
|
||||
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
|
||||
broker.publishUserFrame('default', { type: 'project_created', project: row as unknown as Project });
|
||||
reply.code(201);
|
||||
return {
|
||||
project: row,
|
||||
@@ -186,11 +186,11 @@ export function registerProjectRoutes(
|
||||
`;
|
||||
|
||||
if (existing.length === 0) {
|
||||
broker.publishUser('default', { type: 'project_created', project: row as unknown as Project });
|
||||
broker.publishUserFrame('default', { type: 'project_created', project: row as unknown as Project });
|
||||
reply.code(201);
|
||||
} else {
|
||||
// existing.status was 'archived' — row has been restored.
|
||||
broker.publishUser('default', { type: 'project_unarchived', project: row as unknown as Project });
|
||||
broker.publishUserFrame('default', { type: 'project_unarchived', project: row as unknown as Project });
|
||||
reply.code(200);
|
||||
}
|
||||
return row;
|
||||
@@ -243,7 +243,7 @@ export function registerProjectRoutes(
|
||||
// v1.9: the project_updated frame still only carries id + name. Clients
|
||||
// that need the new fields refetch via api.projects.list() — keeps the
|
||||
// frame payload lean, per the locked recon decision (d).
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'project_updated',
|
||||
project_id: project.id,
|
||||
name: project.name,
|
||||
@@ -260,7 +260,7 @@ export function registerProjectRoutes(
|
||||
reply.code(404);
|
||||
return { error: 'not found or already archived' };
|
||||
}
|
||||
broker.publishUser('default', { type: 'project_archived', project_id: req.params.id });
|
||||
broker.publishUserFrame('default', { type: 'project_archived', project_id: req.params.id });
|
||||
reply.code(204);
|
||||
return null;
|
||||
});
|
||||
@@ -277,7 +277,7 @@ export function registerProjectRoutes(
|
||||
return { error: 'not found or not archived' };
|
||||
}
|
||||
const project = rows[0]!;
|
||||
broker.publishUser('default', { type: 'project_unarchived', project });
|
||||
broker.publishUserFrame('default', { type: 'project_unarchived', project });
|
||||
return project;
|
||||
});
|
||||
|
||||
@@ -288,7 +288,7 @@ export function registerProjectRoutes(
|
||||
reply.code(404);
|
||||
return { error: 'not found' };
|
||||
}
|
||||
broker.publishUser('default', { type: 'project_deleted', project_id: id });
|
||||
broker.publishUserFrame('default', { type: 'project_deleted', project_id: id });
|
||||
reply.code(204);
|
||||
return null;
|
||||
});
|
||||
|
||||
@@ -13,12 +13,37 @@ const CreateBody = z.object({
|
||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||
});
|
||||
|
||||
// v1.14.x-html-artifact-panes: 'markdown_artifact' + 'html_artifact' added
|
||||
// as pane kinds. Pane state is a reference only (chat_id + message_id +
|
||||
// title) — the actual artifact body is fetched from the message row or
|
||||
// message_parts.payload by the pane component on mount.
|
||||
const MarkdownArtifactStateZ = z.object({
|
||||
chat_id: z.string().min(1).max(200),
|
||||
message_id: z.string().min(1).max(200),
|
||||
title: z.string().max(500),
|
||||
});
|
||||
const HtmlArtifactStateZ = z.object({
|
||||
chat_id: z.string().min(1).max(200),
|
||||
message_id: z.string().min(1).max(200),
|
||||
title: z.string().max(500),
|
||||
});
|
||||
|
||||
const WorkspacePaneZ = z.object({
|
||||
id: z.string().min(1).max(200),
|
||||
kind: z.enum(['chat', 'terminal', 'agent', 'empty', 'settings']),
|
||||
kind: z.enum([
|
||||
'chat',
|
||||
'terminal',
|
||||
'agent',
|
||||
'empty',
|
||||
'settings',
|
||||
'markdown_artifact',
|
||||
'html_artifact',
|
||||
]),
|
||||
chatId: z.string().min(1).max(200).optional(),
|
||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||
activeChatIdx: z.number().int(),
|
||||
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
|
||||
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||
});
|
||||
|
||||
const WorkspacePanesBody = z.object({
|
||||
@@ -32,6 +57,29 @@ const PatchBody = z.object({
|
||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||
// v1.9: null = inherit from project default; true/false = explicit override.
|
||||
web_search_enabled: z.boolean().nullable().optional(),
|
||||
// v1.13.17-cross-repo-reads: revocation pathway. PATCH with a shortened
|
||||
// list deletes entries; the grant flow itself APPENDS via the separate
|
||||
// grant_read_access endpoint, never via this PATCH. Frontend treats this
|
||||
// as "send the new whole array". Per-entry shape validation: must be
|
||||
// absolute, no NUL, no `/..` traversal segment. Server doesn't re-validate
|
||||
// whitelist membership on PATCH — entries already in the array were
|
||||
// placed there by the grant endpoint after a full whitelist+repo-shape
|
||||
// check. THE SUBSET CHECK (every entry must already be in the current
|
||||
// array) is enforced at runtime in the PATCH handler below, NOT in this
|
||||
// zod refinement, because the refinement has no access to the existing
|
||||
// session row.
|
||||
allowed_read_paths: z
|
||||
.array(
|
||||
z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(1024)
|
||||
.refine((p) => p.startsWith('/') && !p.includes('\0') && !p.includes('/..'), {
|
||||
message: 'must be an absolute path without traversal markers',
|
||||
}),
|
||||
)
|
||||
.max(64)
|
||||
.optional(),
|
||||
});
|
||||
|
||||
async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
||||
@@ -40,6 +88,19 @@ async function resolveDefaultModel(sql: Sql, config: Config): Promise<string> {
|
||||
return config.DEFAULT_MODEL;
|
||||
}
|
||||
|
||||
// v1.13.17-cross-repo-reads: subset enforcement for PATCH allowed_read_paths.
|
||||
// The PATCH route can only SHRINK the array; growth happens exclusively via
|
||||
// POST /api/chats/:id/grant_read_access (which requires user consent).
|
||||
// Returns the list of disallowed-additions; an empty list means the request
|
||||
// is a valid shrink-or-no-op. Exported for the unit test.
|
||||
export function findUnauthorizedAdditions(
|
||||
prior: readonly string[],
|
||||
requested: readonly string[],
|
||||
): string[] {
|
||||
const priorSet = new Set(prior);
|
||||
return requested.filter((p) => !priorSet.has(p));
|
||||
}
|
||||
|
||||
export function registerSessionRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
@@ -56,7 +117,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||
const rows = await sql<Session[]>`
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||
FROM sessions
|
||||
WHERE project_id = ${req.params.id} AND status = ${status}
|
||||
ORDER BY updated_at DESC
|
||||
@@ -112,7 +173,7 @@ export function registerSessionRoutes(
|
||||
`;
|
||||
return session!;
|
||||
});
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_created',
|
||||
session: row,
|
||||
project_id: row.project_id,
|
||||
@@ -124,7 +185,7 @@ export function registerSessionRoutes(
|
||||
|
||||
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
||||
const rows = await sql<Session[]>`
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes
|
||||
SELECT id, project_id, name, model, system_prompt, status, created_at, updated_at, agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||
FROM sessions WHERE id = ${req.params.id}
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
@@ -150,15 +211,53 @@ export function registerSessionRoutes(
|
||||
const newAgentId = parsed.data.agent_id ?? null;
|
||||
const wseProvided = parsed.data.web_search_enabled !== undefined;
|
||||
const newWse = parsed.data.web_search_enabled ?? null;
|
||||
// Read the prior name so the post-update publish can skip no-op renames
|
||||
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
||||
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
||||
// a concurrent rename in that gap would just mean one stale publish, which
|
||||
// existing clients dedup by id.
|
||||
const before = await sql<{ name: string }[]>`
|
||||
SELECT name FROM sessions WHERE id = ${req.params.id}
|
||||
// v1.13.17-cross-repo-reads: tri-state on the wire (undefined = no
|
||||
// change, [] = clear). Frontend currently uses this PATCH only for
|
||||
// revocation (delete a single entry from the existing array, send
|
||||
// shortened result). Append-style grants go through the dedicated
|
||||
// grant_read_access endpoint inside the inference loop.
|
||||
const arpProvided = parsed.data.allowed_read_paths !== undefined;
|
||||
const newArp = parsed.data.allowed_read_paths ?? [];
|
||||
// Read the prior name + grants so the post-update publish can skip no-op
|
||||
// renames (PATCH { name: "Foo" } where the session is already "Foo") AND
|
||||
// so the subset check below has the current grant list to compare against.
|
||||
// The window between SELECT and UPDATE is sub-millisecond in the same
|
||||
// request handler; a concurrent rename in that gap would just mean one
|
||||
// stale publish, which existing clients dedup by id.
|
||||
const before = await sql<{ name: string; allowed_read_paths: string[] }[]>`
|
||||
SELECT name, allowed_read_paths FROM sessions WHERE id = ${req.params.id}
|
||||
`;
|
||||
const priorName = before[0]?.name;
|
||||
const priorArp = before[0]?.allowed_read_paths ?? [];
|
||||
|
||||
// v1.13.17-cross-repo-reads: subset enforcement. The grant flow is the
|
||||
// ONLY path that can add entries to allowed_read_paths — PATCH can only
|
||||
// shrink the array, never grow it. Without this guard, a malicious
|
||||
// client could POST {"allowed_read_paths":["/etc"]} and bypass the
|
||||
// user-consent prompt entirely. Sam flagged this in the v1.13.17
|
||||
// compliance review (2026-05-22).
|
||||
// Race note: a concurrent grant landing between this SELECT and the
|
||||
// UPDATE below would briefly make a "shouldn't-have-been-valid" PATCH
|
||||
// succeed (the newly-granted root sneaks in). Inverse race — a
|
||||
// legitimate revoke happening alongside a concurrent grant — could
|
||||
// briefly reject the revoke; the user retries. Both are acceptable
|
||||
// given the single-user threat model + sub-millisecond window.
|
||||
if (arpProvided) {
|
||||
const extras = findUnauthorizedAdditions(priorArp, newArp);
|
||||
if (extras.length > 0) {
|
||||
reply.code(400);
|
||||
return {
|
||||
error: 'invalid body',
|
||||
details: {
|
||||
fieldErrors: {
|
||||
allowed_read_paths: [
|
||||
`entries must already be granted; cannot add via PATCH: ${extras.join(', ')}`,
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
const rows = await sql<Session[]>`
|
||||
UPDATE sessions
|
||||
SET
|
||||
@@ -167,10 +266,11 @@ export function registerSessionRoutes(
|
||||
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
||||
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
|
||||
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled END,
|
||||
allowed_read_paths = CASE WHEN ${arpProvided} THEN ${sql.array(newArp, 25)} ELSE allowed_read_paths END,
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${req.params.id}
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
agent_id, web_search_enabled, workspace_panes
|
||||
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
@@ -178,7 +278,7 @@ export function registerSessionRoutes(
|
||||
}
|
||||
const session = rows[0]!;
|
||||
if (name !== undefined && session.name !== priorName) {
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_renamed',
|
||||
session_id: session.id,
|
||||
name: session.name,
|
||||
@@ -188,7 +288,7 @@ export function registerSessionRoutes(
|
||||
// (notably the SettingsPane open in another tab) can refetch and pick
|
||||
// up the new fields. Frame stays lean (decision d) — payload is just
|
||||
// ids + name + updated_at, the client refetches via api.sessions.get.
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_updated',
|
||||
session_id: session.id,
|
||||
project_id: session.project_id,
|
||||
@@ -213,14 +313,14 @@ export function registerSessionRoutes(
|
||||
updated_at = clock_timestamp()
|
||||
WHERE id = ${req.params.id}
|
||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
||||
agent_id, web_search_enabled, workspace_panes
|
||||
agent_id, web_search_enabled, workspace_panes, allowed_read_paths
|
||||
`;
|
||||
if (rows.length === 0) {
|
||||
reply.code(404);
|
||||
return { error: 'session not found' };
|
||||
}
|
||||
const session = rows[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_workspace_updated',
|
||||
session_id: session.id,
|
||||
workspace_panes: session.workspace_panes,
|
||||
@@ -248,7 +348,7 @@ export function registerSessionRoutes(
|
||||
`;
|
||||
const ids = rows.map((r) => r.id);
|
||||
for (const id of ids) {
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_archived',
|
||||
session_id: id,
|
||||
project_id: req.params.id,
|
||||
@@ -289,7 +389,7 @@ export function registerSessionRoutes(
|
||||
reply.code(404);
|
||||
return { error: 'session not found or already archived' };
|
||||
}
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_archived',
|
||||
session_id: rows[0]!.id,
|
||||
project_id: rows[0]!.project_id,
|
||||
@@ -312,7 +412,7 @@ export function registerSessionRoutes(
|
||||
return { error: 'session not found or not archived' };
|
||||
}
|
||||
const session = rows[0]!;
|
||||
broker.publishUser('default', {
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'session_created',
|
||||
session: session,
|
||||
project_id: session.project_id,
|
||||
@@ -334,7 +434,7 @@ export function registerSessionRoutes(
|
||||
return { error: 'not found' };
|
||||
}
|
||||
const project_id = deleted[0]!.project_id;
|
||||
broker.publishUser('default', { type: 'session_deleted', session_id: id, project_id });
|
||||
broker.publishUserFrame('default', { type: 'session_deleted', session_id: id, project_id });
|
||||
reply.code(204);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -86,12 +86,12 @@ export function registerSkillsRoutes(
|
||||
|
||||
const result = await sql.begin(async (tx) => {
|
||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
// v1.13.0: dual-write the synthetic assistant message's tool_call.
|
||||
// Single skill_use tool_call, no text content, so one part at seq 0.
|
||||
// v1.13.20: parts-only write. Single skill_use tool_call, no text
|
||||
// content, so one part at seq 0.
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${synthAssistant!.id}, 0, 'tool_call', ${tx.json({
|
||||
@@ -101,11 +101,11 @@ export function registerSkillsRoutes(
|
||||
} as never)})
|
||||
`;
|
||||
const [toolMsg] = await tx<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
// v1.13.0: dual-write the synthetic tool result (the skill body).
|
||||
// v1.13.20: parts-only write of the synthetic tool result (skill body).
|
||||
await tx`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${toolMsg!.id}, 0, 'tool_result', ${tx.json(toolResults as never)})
|
||||
|
||||
40
apps/server/src/routes/tools.ts
Normal file
40
apps/server/src/routes/tools.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import type { Sql } from '../db.js';
|
||||
|
||||
export interface ToolCostStat {
|
||||
tool_name: string;
|
||||
mean_prompt_tokens: number;
|
||||
mean_completion_tokens: number;
|
||||
n_calls: number;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// v1.13.10: per-tool token cost rolling window read endpoint. Backed by the
|
||||
// tool_cost_stats view in schema.sql (last 100 calls per tool, equal-split
|
||||
// attribution across multi-tool turns, sentinel/failed-turn excluded).
|
||||
// Consumed by AgentPicker for at-a-glance per-agent cost hints.
|
||||
export function registerToolsRoutes(app: FastifyInstance, sql: Sql): void {
|
||||
app.get('/api/tools/cost_stats', async () => {
|
||||
const rows = await sql<
|
||||
{
|
||||
tool_name: string;
|
||||
prompt_tokens_sum: number;
|
||||
completion_tokens_sum: number;
|
||||
n_calls: number;
|
||||
updated_at: string;
|
||||
}[]
|
||||
>`
|
||||
SELECT tool_name, prompt_tokens_sum, completion_tokens_sum, n_calls, updated_at
|
||||
FROM tool_cost_stats
|
||||
ORDER BY tool_name ASC
|
||||
`;
|
||||
const stats: ToolCostStat[] = rows.map((r) => ({
|
||||
tool_name: r.tool_name,
|
||||
mean_prompt_tokens: Math.round(r.prompt_tokens_sum / r.n_calls),
|
||||
mean_completion_tokens: Math.round(r.completion_tokens_sum / r.n_calls),
|
||||
n_calls: r.n_calls,
|
||||
updated_at: r.updated_at,
|
||||
}));
|
||||
return { stats };
|
||||
});
|
||||
}
|
||||
@@ -51,7 +51,7 @@ CREATE TABLE IF NOT EXISTS message_parts (
|
||||
kind text NOT NULL,
|
||||
payload jsonb NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT clock_timestamp(),
|
||||
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start')),
|
||||
CONSTRAINT message_parts_kind_chk CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact')),
|
||||
CONSTRAINT message_parts_seq_uniq UNIQUE (message_id, sequence)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS message_parts_msg_seq_idx ON message_parts (message_id, sequence);
|
||||
@@ -74,51 +74,127 @@ END $$;
|
||||
CREATE INDEX IF NOT EXISTS message_parts_hidden_idx
|
||||
ON message_parts (message_id) WHERE hidden_at IS NULL;
|
||||
|
||||
-- v1.13.13: extend message_parts.kind to allow 'synthesis'. Existing DBs were
|
||||
-- created with the pre-v1.13.13 CHECK constraint that did NOT include
|
||||
-- 'synthesis'; drop + re-add the constraint with the extended enum. Fresh
|
||||
-- installs hit the inline constraint above (already updated) and skip this
|
||||
-- block via the pg_constraint guard.
|
||||
-- v1.14.x-html-artifact-panes: extend the same constraint with 'html_artifact'.
|
||||
-- DROP IF EXISTS + DO $$ pg_constraint $$ guard remains idempotent across
|
||||
-- both v1.13.13 and v1.14.x boots; the IN list below is the union of every
|
||||
-- kind ever shipped.
|
||||
ALTER TABLE message_parts DROP CONSTRAINT IF EXISTS message_parts_kind_chk;
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint WHERE conname = 'message_parts_kind_chk'
|
||||
) THEN
|
||||
ALTER TABLE message_parts
|
||||
ADD CONSTRAINT message_parts_kind_chk
|
||||
CHECK (kind IN ('text', 'tool_call', 'tool_result', 'reasoning', 'step_start', 'synthesis', 'html_artifact'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.13.1-B: read-path view. Read sites SELECT FROM messages_with_parts
|
||||
-- instead of messages so tool_calls / tool_results / reasoning_parts come
|
||||
-- from the granular message_parts table. The COALESCE means pre-v1.13.0
|
||||
-- history (no parts rows) still resolves via the legacy JSON columns; the
|
||||
-- dual-write from v1.13.0 keeps both in sync for all rows written since.
|
||||
-- Writes continue to target `messages` directly — the view is read-only.
|
||||
-- Shapes match the in-memory ToolCall / ToolResult types: tool_calls is a
|
||||
-- jsonb array of {id, name, args}, tool_results is a single jsonb object
|
||||
-- {tool_call_id, output, truncated, error?}. reasoning_parts is new — only
|
||||
-- consumed by the inference history fetch (payload.ts) so v1.13.1-C can
|
||||
-- wire reasoning into the model payload. Not surfaced in external APIs yet.
|
||||
-- from the granular message_parts table.
|
||||
-- v1.13.20: post column-drop. The legacy COALESCE fallback over
|
||||
-- messages.tool_calls / messages.tool_results was removed because those
|
||||
-- columns no longer exist on the table (see the ALTER TABLE DROP COLUMN
|
||||
-- statements below). Writes continue to target `messages` directly — the
|
||||
-- view is read-only. Shapes match the in-memory ToolCall / ToolResult
|
||||
-- types: tool_calls is a jsonb array of {id, name, args}, tool_results is
|
||||
-- a single jsonb object {tool_call_id, output, truncated, error?}.
|
||||
-- reasoning_parts is consumed by the inference history fetch (payload.ts)
|
||||
-- for v1.13.1-C reasoning round-tripping. Not surfaced in external APIs.
|
||||
CREATE OR REPLACE VIEW messages_with_parts AS
|
||||
SELECT
|
||||
m.id, m.session_id, m.chat_id, m.role, m.content, m.kind, m.status,
|
||||
m.last_seq, m.tokens_used, m.ctx_used, m.ctx_max,
|
||||
m.started_at, m.finished_at, m.created_at, m.metadata,
|
||||
m.summary, m.tail_start_id, m.compacted_at,
|
||||
-- v1.13.4: prune semantics need to distinguish "no parts row exists"
|
||||
-- (pre-v1.13.0 fallback to legacy column) from "all parts hidden"
|
||||
-- (prune intended — return null/empty so the row drops from the model
|
||||
-- payload). A naive COALESCE would fall back to the legacy column when
|
||||
-- every part is hidden, undoing the prune. CASE on EXISTS(any kind)
|
||||
-- splits the two cases.
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_call')
|
||||
THEN (SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL)
|
||||
ELSE m.tool_calls
|
||||
END AS tool_calls,
|
||||
CASE
|
||||
WHEN EXISTS (SELECT 1 FROM message_parts pp
|
||||
WHERE pp.message_id = m.id AND pp.kind = 'tool_result')
|
||||
THEN (SELECT p.payload
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
|
||||
ORDER BY p.sequence LIMIT 1)
|
||||
ELSE m.tool_results
|
||||
END AS tool_results,
|
||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_call' AND p.hidden_at IS NULL) AS tool_calls,
|
||||
(SELECT p.payload
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'tool_result' AND p.hidden_at IS NULL
|
||||
ORDER BY p.sequence LIMIT 1) AS tool_results,
|
||||
(SELECT jsonb_agg(p.payload ORDER BY p.sequence)
|
||||
FROM message_parts p
|
||||
WHERE p.message_id = m.id AND p.kind = 'reasoning' AND p.hidden_at IS NULL) AS reasoning_parts
|
||||
FROM messages m;
|
||||
|
||||
-- v1.13.20: drop legacy tool_calls/tool_results columns. Reads have routed
|
||||
-- through messages_with_parts since v1.13.1-B; dual-writes removed in this
|
||||
-- batch. The view above was simplified to remove COALESCE fallbacks before
|
||||
-- this drop (Postgres rejects column-drop on view-referenced columns).
|
||||
-- Idempotent via IF EXISTS.
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS tool_calls;
|
||||
ALTER TABLE messages DROP COLUMN IF EXISTS tool_results;
|
||||
|
||||
-- v1.13.10: per-tool token cost rolling window. Derives from
|
||||
-- messages_with_parts (the v1.13.1-B view that COALESCEs message_parts over
|
||||
-- the legacy JSON column) so this works whether the chat predates v1.13.0
|
||||
-- or postdates v1.13.2 (column drop). No new write site — all source data
|
||||
-- already lands via the existing tool-phase.ts:94-95 UPDATE.
|
||||
--
|
||||
-- Attribution model: equal split. A turn emitting N tool calls divides its
|
||||
-- prompt/completion tokens by N before attribution. See v1.13.10 dispatch
|
||||
-- brief for rationale + rejected alternatives.
|
||||
--
|
||||
-- Column mapping: messages.ctx_used = prompt (input), messages.tokens_used
|
||||
-- = completion (output). Non-obvious naming; pinned via canonical writes at
|
||||
-- tool-phase.ts:94-95 et al.
|
||||
--
|
||||
-- Filtering rationale:
|
||||
-- status='complete' — exclude failed/cancelled (defense in
|
||||
-- depth; failed-path doesn't write
|
||||
-- tokens_used so they're filtered
|
||||
-- indirectly too).
|
||||
-- metadata->>'kind' exclusions — exclude cap_hit / doom_loop sentinels
|
||||
-- (defense in depth; sentinels are
|
||||
-- role='system' with tool_calls=NULL
|
||||
-- so they're filtered indirectly too).
|
||||
-- experimental_repairToolCall — no special handling; retries flow
|
||||
-- as normal next-turn tool_result
|
||||
-- errors and count naturally.
|
||||
--
|
||||
-- Rolling window: last 100 calls per tool_name, ordered by created_at DESC.
|
||||
-- Aggregate-on-read is microseconds at BooCode scale (single user, ~30
|
||||
-- tools, < 100 calls each). DROP VIEW + recreate to change window size.
|
||||
CREATE OR REPLACE VIEW tool_cost_stats AS
|
||||
WITH per_call AS (
|
||||
SELECT
|
||||
(tc->>'name')::text AS tool_name,
|
||||
(m.ctx_used::float / NULLIF(jsonb_array_length(m.tool_calls), 0)) AS prompt_tokens,
|
||||
(m.tokens_used::float / NULLIF(jsonb_array_length(m.tool_calls), 0)) AS completion_tokens,
|
||||
m.created_at,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY (tc->>'name')::text
|
||||
ORDER BY m.created_at DESC
|
||||
) AS rn
|
||||
FROM messages_with_parts m,
|
||||
LATERAL jsonb_array_elements(m.tool_calls) AS tc
|
||||
WHERE m.tool_calls IS NOT NULL
|
||||
AND jsonb_array_length(m.tool_calls) > 0
|
||||
AND m.tokens_used IS NOT NULL
|
||||
AND m.ctx_used IS NOT NULL
|
||||
AND m.status = 'complete'
|
||||
AND (m.metadata IS NULL
|
||||
OR m.metadata->>'kind' IS NULL
|
||||
OR m.metadata->>'kind' NOT IN ('cap_hit', 'doom_loop'))
|
||||
)
|
||||
SELECT
|
||||
tool_name,
|
||||
ROUND(SUM(prompt_tokens))::int AS prompt_tokens_sum,
|
||||
ROUND(SUM(completion_tokens))::int AS completion_tokens_sum,
|
||||
COUNT(*)::int AS n_calls,
|
||||
MAX(created_at) AS updated_at
|
||||
FROM per_call
|
||||
WHERE rn <= 100
|
||||
GROUP BY tool_name;
|
||||
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS tokens_used INTEGER;
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_used INTEGER;
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_max INTEGER;
|
||||
@@ -207,19 +283,6 @@ BEGIN
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.12.1: drop stale inline CHECK constraints that were superseded by the
|
||||
-- named *_chk variants above. messages_status_check missed 'cancelled' and
|
||||
-- messages_role_check missed 'system' — both narrower than what's in use.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_status_check') THEN
|
||||
ALTER TABLE messages DROP CONSTRAINT messages_status_check;
|
||||
END IF;
|
||||
IF EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'messages_role_check') THEN
|
||||
ALTER TABLE messages DROP CONSTRAINT messages_role_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
||||
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
||||
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||
@@ -251,6 +314,16 @@ END $$;
|
||||
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
|
||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
|
||||
|
||||
-- v1.13.17-cross-repo-reads: session-scoped read grants for paths outside the
|
||||
-- session's primary project root. Populated only by the request_read_access
|
||||
-- tool's approve branch; revoked via PATCH /api/sessions/:id. Values are
|
||||
-- absolute paths to project roots OR repo-shaped dirs under
|
||||
-- PROJECT_ROOT_WHITELIST (default /opt). No CHECK constraint — validation
|
||||
-- happens at write time in services/grant_resolver.ts. Cleared automatically
|
||||
-- when the session row is deleted (no cascade needed; the column goes with it).
|
||||
ALTER TABLE sessions
|
||||
ADD COLUMN IF NOT EXISTS allowed_read_paths TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||
|
||||
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
|
||||
-- reasons. JSONB so future kinds can extend without further schema churn.
|
||||
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,
|
||||
|
||||
261
apps/server/src/services/__tests__/artifacts.test.ts
Normal file
261
apps/server/src/services/__tests__/artifacts.test.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { mkdtemp, mkdir, readFile, rm, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
decideHtmlArtifactWrite,
|
||||
deriveHtmlSlug,
|
||||
deriveHtmlTitle,
|
||||
deriveMarkdownSlug,
|
||||
detectHtmlArtifact,
|
||||
HTML_ARTIFACT_MAX_BYTES,
|
||||
writeHtmlArtifact,
|
||||
writeMarkdownArtifact,
|
||||
} from '../artifacts.js';
|
||||
import { PathScopeError } from '../path_guard.js';
|
||||
|
||||
describe('deriveMarkdownSlug', () => {
|
||||
it('uses the first # heading when present', () => {
|
||||
expect(deriveMarkdownSlug('# Hello World\n\nbody')).toBe('hello-world');
|
||||
});
|
||||
|
||||
it('falls back to first 6 words', () => {
|
||||
const s = deriveMarkdownSlug('the quick brown fox jumps over the lazy dog');
|
||||
expect(s).toBe('the-quick-brown-fox-jumps-over');
|
||||
});
|
||||
|
||||
it('returns "artifact" for empty input', () => {
|
||||
expect(deriveMarkdownSlug('')).toBe('artifact');
|
||||
});
|
||||
|
||||
it('caps at 60 chars and lowercases', () => {
|
||||
const long = '# ' + 'A'.repeat(200);
|
||||
const s = deriveMarkdownSlug(long);
|
||||
expect(s.length).toBeLessThanOrEqual(60);
|
||||
expect(s).toMatch(/^[a-z0-9-]+$/);
|
||||
});
|
||||
|
||||
it('strips trailing punctuation', () => {
|
||||
expect(deriveMarkdownSlug('# Hello, World!!!')).toBe('hello-world');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveHtmlSlug', () => {
|
||||
it('prefers payload.title when set', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({ html_content: '<html></html>', title: 'My Title' }),
|
||||
).toBe('my-title');
|
||||
});
|
||||
|
||||
it('falls back to <title> tag', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<html><head><title>Page Title</title></head></html>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('page-title');
|
||||
});
|
||||
|
||||
it('falls back to first <h1> when no <title>', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<html><body><h1>Heading One</h1></body></html>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('heading-one');
|
||||
});
|
||||
|
||||
it('falls back to inner text words', () => {
|
||||
expect(
|
||||
deriveHtmlSlug({
|
||||
html_content: '<div>one two three four five six seven</div>',
|
||||
title: null,
|
||||
}),
|
||||
).toBe('one-two-three-four-five-six');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deriveHtmlTitle', () => {
|
||||
it('returns <title> content', () => {
|
||||
expect(deriveHtmlTitle('<html><head><title>T</title></head></html>')).toBe('T');
|
||||
});
|
||||
|
||||
it('falls back to <h1>', () => {
|
||||
expect(deriveHtmlTitle('<body><h1>H</h1></body>')).toBe('H');
|
||||
});
|
||||
|
||||
it('falls back to first 80 chars of inner text', () => {
|
||||
const html = '<div>' + 'x '.repeat(100) + '</div>';
|
||||
const t = deriveHtmlTitle(html);
|
||||
expect(t).not.toBeNull();
|
||||
expect(t!.length).toBeLessThanOrEqual(80);
|
||||
});
|
||||
|
||||
it('returns null for empty html', () => {
|
||||
expect(deriveHtmlTitle('')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectHtmlArtifact', () => {
|
||||
it('detects <!DOCTYPE html> prefix case-insensitively', () => {
|
||||
const html = '<!doctype HTML><html><body>x</body></html>';
|
||||
expect(detectHtmlArtifact(html)).toBe(html);
|
||||
});
|
||||
|
||||
it('strips leading/trailing whitespace before matching', () => {
|
||||
const html = '\n\n<!DOCTYPE html>\n<html></html>\n';
|
||||
expect(detectHtmlArtifact(html)).toBe(html.trim());
|
||||
});
|
||||
|
||||
it('detects fenced ```html block wrapping entire message', () => {
|
||||
const wrapped = '```html\n<!DOCTYPE html>\n<html></html>\n```';
|
||||
expect(detectHtmlArtifact(wrapped)).toContain('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('rejects plain markdown', () => {
|
||||
expect(detectHtmlArtifact('# heading\n\nsome text')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects message with prose before the doctype', () => {
|
||||
expect(
|
||||
detectHtmlArtifact('Here you go: <!DOCTYPE html><html></html>'),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects empty input', () => {
|
||||
expect(detectHtmlArtifact('')).toBeNull();
|
||||
expect(detectHtmlArtifact(' \n ')).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects fenced block without doctype/<html>', () => {
|
||||
expect(detectHtmlArtifact('```html\n<div>x</div>\n```')).toBeNull();
|
||||
});
|
||||
|
||||
it('accepts fenced block containing <html> tag (no doctype)', () => {
|
||||
const r = detectHtmlArtifact('```html\n<html><body>x</body></html>\n```');
|
||||
expect(r).toContain('<html>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeMarkdownArtifact / writeHtmlArtifact', () => {
|
||||
let projectRoot: string;
|
||||
beforeEach(async () => {
|
||||
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-test-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('writes a markdown artifact under .boocode/artifacts/', async () => {
|
||||
const result = await writeMarkdownArtifact(
|
||||
{ content: '# Hello\n\nbody' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toMatch(/\.boocode\/artifacts\/hello-\d+\.md$/);
|
||||
expect(result.url).toMatch(/^\/api\/projects\/pid\/artifacts\/hello-\d+\.md$/);
|
||||
const written = await readFile(result.path, 'utf8');
|
||||
expect(written).toBe('# Hello\n\nbody');
|
||||
});
|
||||
|
||||
it('writes an html artifact', async () => {
|
||||
const result = await writeHtmlArtifact(
|
||||
{
|
||||
html_content: '<!DOCTYPE html><html><head><title>X</title></head></html>',
|
||||
char_count: 56,
|
||||
title: 'X',
|
||||
},
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toMatch(/\.boocode\/artifacts\/x-\d+\.html$/);
|
||||
const written = await readFile(result.path, 'utf8');
|
||||
expect(written).toContain('<!DOCTYPE html>');
|
||||
});
|
||||
|
||||
it('creates the artifacts directory if absent', async () => {
|
||||
// Confirm the writer mkdir-recursive's the artifacts dir on first call.
|
||||
const result = await writeMarkdownArtifact(
|
||||
{ content: '# T' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
);
|
||||
expect(result.path).toContain('.boocode/artifacts');
|
||||
});
|
||||
});
|
||||
|
||||
describe('1MB cap behavior', () => {
|
||||
it('reports the correct byte threshold', () => {
|
||||
expect(HTML_ARTIFACT_MAX_BYTES).toBe(1_048_576);
|
||||
});
|
||||
|
||||
it('exceeds threshold for oversize payload', () => {
|
||||
const oversize = '<!DOCTYPE html>' + 'A'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
expect(Buffer.byteLength(oversize, 'utf8')).toBeGreaterThan(
|
||||
HTML_ARTIFACT_MAX_BYTES,
|
||||
);
|
||||
});
|
||||
|
||||
it('detectHtmlArtifact still returns content above the cap (cap is checked by caller)', () => {
|
||||
// Detection is content-shape; the cap check lives in finalizeCompletion
|
||||
// (error-handler.ts). This test pins that contract: the helper does not
|
||||
// silently drop oversize payloads on the floor.
|
||||
const big = '<!DOCTYPE html>' + 'x'.repeat(2_000_000);
|
||||
expect(detectHtmlArtifact(big)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('decideHtmlArtifactWrite', () => {
|
||||
// Pure helper extracted from finalizeCompletion's cap-skip branch. Pins
|
||||
// the warn-and-skip decision without mocking the full InferenceContext.
|
||||
it('returns write=true for payloads under the cap', () => {
|
||||
const html = '<!DOCTYPE html><html></html>';
|
||||
const decision = decideHtmlArtifactWrite(html);
|
||||
expect(decision.write).toBe(true);
|
||||
expect(decision.byteLen).toBe(Buffer.byteLength(html, 'utf8'));
|
||||
});
|
||||
|
||||
it('returns write=false with cap_exceeded reason for oversize payloads', () => {
|
||||
const big = '<!DOCTYPE html>' + 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
const decision = decideHtmlArtifactWrite(big);
|
||||
expect(decision.write).toBe(false);
|
||||
if (!decision.write) {
|
||||
expect(decision.reason).toBe('cap_exceeded');
|
||||
expect(decision.byteLen).toBeGreaterThan(HTML_ARTIFACT_MAX_BYTES);
|
||||
}
|
||||
});
|
||||
|
||||
it('accepts payload exactly at the cap (boundary)', () => {
|
||||
// byteLen === cap should write; only strictly greater skips.
|
||||
const exact = 'x'.repeat(HTML_ARTIFACT_MAX_BYTES);
|
||||
const decision = decideHtmlArtifactWrite(exact);
|
||||
expect(decision.write).toBe(true);
|
||||
expect(decision.byteLen).toBe(HTML_ARTIFACT_MAX_BYTES);
|
||||
});
|
||||
});
|
||||
|
||||
describe('symlink escape protection', () => {
|
||||
// Closes the gap where `.boocode/artifacts` is a symlink pointing
|
||||
// outside the project root. The lexical prefix check on the resolved
|
||||
// candidate path passes (it's under projectRoot textually), but the
|
||||
// post-mkdir realpath verification must catch the escape.
|
||||
let projectRoot: string;
|
||||
let outside: string;
|
||||
beforeEach(async () => {
|
||||
projectRoot = await mkdtemp(join(tmpdir(), 'artifacts-symlink-root-'));
|
||||
outside = await mkdtemp(join(tmpdir(), 'artifacts-symlink-outside-'));
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(projectRoot, { recursive: true, force: true });
|
||||
await rm(outside, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('throws PathScopeError when .boocode/artifacts is a symlink to outside the project', async () => {
|
||||
// Create .boocode dir, then make `artifacts` a symlink pointing outside.
|
||||
await mkdir(join(projectRoot, '.boocode'), { recursive: true });
|
||||
await symlink(outside, join(projectRoot, '.boocode', 'artifacts'));
|
||||
await expect(
|
||||
writeMarkdownArtifact(
|
||||
{ content: '# Hello' },
|
||||
{ projectId: 'pid', projectRoot },
|
||||
),
|
||||
).rejects.toBeInstanceOf(PathScopeError);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
|
||||
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { callCodecontext } from '../codecontext_client.js';
|
||||
@@ -203,3 +203,197 @@ describe('callCodecontext — error paths', () => {
|
||||
).rejects.toThrow(/timed out after 30000ms/);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- v1.13.18: file_path resolution tests -----------------------------------
|
||||
|
||||
describe('callCodecontext — file_path resolution', () => {
|
||||
// Case 1: relative path resolves to absolute under project root
|
||||
it('resolves a relative file_path to an absolute path inside project root', async () => {
|
||||
// Create a real file so realpath can canonicalise it
|
||||
const fileName = 'src_module.ts';
|
||||
await writeFile(join(projectDir, fileName), '// hello');
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: 'file analysis', error: null }),
|
||||
);
|
||||
await callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: fileName },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
// Should be the resolved absolute path
|
||||
expect(body.file_path).toBe(join(projectDir, fileName));
|
||||
});
|
||||
|
||||
// Case 2: absolute path inside project root → realpathed → forwarded
|
||||
it('passes through an absolute file_path inside project root', async () => {
|
||||
const fileName = 'absolute_target.ts';
|
||||
const absPath = join(projectDir, fileName);
|
||||
await writeFile(absPath, '// absolute');
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: 'analysis', error: null }),
|
||||
);
|
||||
await callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: absPath },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
expect(body.file_path).toBe(absPath);
|
||||
});
|
||||
|
||||
// Case 3: relative escape path → rejected with same error shape as target_dir escape
|
||||
it('rejects a relative file_path that escapes the project root', async () => {
|
||||
const fetcher = vi.fn();
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: '../../etc/passwd' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/escapes project root/);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Case 4: absolute path outside project root → rejected
|
||||
it('rejects an absolute file_path outside the project root', async () => {
|
||||
const fetcher = vi.fn();
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
// /etc/passwd is outside any tmpdir project root
|
||||
args: { file_path: '/etc/passwd' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/escapes project root/);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Case 5: nonexistent file (ENOENT) → forwarded as un-realpath'd absolute
|
||||
it('forwards a nonexistent file_path as absolute without throwing', async () => {
|
||||
const missingPath = join(projectDir, 'does_not_exist.ts');
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: null, error: 'File not found in graph: ' + missingPath }),
|
||||
);
|
||||
// The resolver should NOT throw; the error comes back from the sidecar
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: 'does_not_exist.ts' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/File not found in graph/);
|
||||
// Wire was still called — resolver forwarded the path
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
// Should receive the absolute (non-realpathed) path
|
||||
expect(body.file_path).toBe(missingPath);
|
||||
});
|
||||
|
||||
// Case 6: empty string → skipped by guard, reaches wire unmodified
|
||||
// Note: Zod .trim().min(1) in get_file_analysis rejects empty before the
|
||||
// shim is reached in production. At the shim layer, the guard
|
||||
// `file_path.trim() !== ''` skips the resolver for empty strings so that
|
||||
// optional-file_path wrappers treat '' as "not provided". This is a
|
||||
// deliberate design; callers that require file_path validate at the Zod layer.
|
||||
it('skips resolver for empty string file_path (treated as not provided)', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: 'analysis', error: null }),
|
||||
);
|
||||
// Should succeed — empty string is treated as "no file_path"
|
||||
await callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: '' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
// Empty string passes through unchanged (resolver not invoked)
|
||||
expect(body.file_path).toBe('');
|
||||
});
|
||||
|
||||
// Case 7: wrapper without file_path (e.g. get_codebase_overview) → resolver not invoked
|
||||
it('does not invoke file_path resolver when file_path is absent from args', async () => {
|
||||
const fetcher = vi.fn().mockResolvedValue(
|
||||
mockJSONResponse({ result: 'overview', error: null }),
|
||||
);
|
||||
await callCodecontext(
|
||||
{
|
||||
toolName: 'get_codebase_overview',
|
||||
args: { include_stats: true },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
);
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
const body = JSON.parse(fetcher.mock.calls[0]![1]!.body as string);
|
||||
// No file_path in the wire body
|
||||
expect('file_path' in body).toBe(false);
|
||||
});
|
||||
|
||||
// Case 8: absolute path with `..` that resolves outside project root, even
|
||||
// when the literal path is ENOENT. Without resolve() in the absolute branch
|
||||
// the prefix check false-positives because the raw `<projectDir>/../etc/x`
|
||||
// literal starts with `<projectDir>/`.
|
||||
it('rejects absolute file_path with `..` resolving outside project root (ENOENT branch)', async () => {
|
||||
const fetcher = vi.fn();
|
||||
const escapingAbsolute = `${projectDir}/../etc/non_existent_passwd`;
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: escapingAbsolute },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/escapes project root/);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Case 9: in-project symlink targeting outside the project root. This is the
|
||||
// canonical realpath defense — realpath must canonicalise the symlink and
|
||||
// the escape check must reject. Without this test, a symlink-out hole could
|
||||
// regress silently.
|
||||
it('rejects file_path that resolves through a symlink leaving project root', async () => {
|
||||
const outsideDir = await mkdtemp(join(tmpdir(), 'codecontext-outside-'));
|
||||
try {
|
||||
const evilTarget = join(outsideDir, 'secrets.txt');
|
||||
await writeFile(evilTarget, 'top secret');
|
||||
await symlink(evilTarget, join(projectDir, 'evil-link'));
|
||||
const fetcher = vi.fn();
|
||||
await expect(
|
||||
callCodecontext(
|
||||
{
|
||||
toolName: 'get_file_analysis',
|
||||
args: { file_path: 'evil-link' },
|
||||
projectPath: projectDir,
|
||||
},
|
||||
fetcher as unknown as typeof fetch,
|
||||
),
|
||||
).rejects.toThrow(/escapes project root/);
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await rm(outsideDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('codecontext wrappers — toolName + args forwarding', () => {
|
||||
const { url, body } = parsePOST(fetcher);
|
||||
expect(url).toMatch(/\/v1\/get_file_analysis$/);
|
||||
expect(body).toMatchObject({
|
||||
file_path: 'apps/server/src/index.ts',
|
||||
file_path: join(projectDir, 'apps/server/src/index.ts'),
|
||||
target_dir: projectDir,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
turns,
|
||||
select,
|
||||
buildPrompt,
|
||||
buildHeadPayload,
|
||||
type CompactionMessage,
|
||||
} from '../compaction.js';
|
||||
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
||||
@@ -31,6 +32,7 @@ function mkMsg(
|
||||
status: 'complete',
|
||||
tool_calls: null,
|
||||
tool_results: null,
|
||||
reasoning_parts: null,
|
||||
metadata: null,
|
||||
created_at: new Date(counter * 1000).toISOString(),
|
||||
...overrides,
|
||||
@@ -39,49 +41,58 @@ function mkMsg(
|
||||
|
||||
// ---- usable -----------------------------------------------------------------
|
||||
|
||||
describe('usable', () => {
|
||||
it('returns 0 when contextLimit is 0', () => {
|
||||
// v1.13.9: ratio-only early trigger at 0.85 × contextLimit. Replaces the
|
||||
// v1.11.0-era `contextLimit - 20_000` math, which degenerated to 0 for
|
||||
// contexts ≤20k and gave only 7-8% headroom at 262k.
|
||||
describe('usable() — ratio-only early trigger (v1.13.9)', () => {
|
||||
it('returns floor(0.85 * limit) for the qwen3.6 daily-driver context', () => {
|
||||
// floor(0.85 * 262144) = floor(222822.4) = 222822 — 15% headroom for
|
||||
// the summarizer to do its turn without itself overflowing.
|
||||
expect(usable(262144)).toBe(222822);
|
||||
});
|
||||
|
||||
it('returns 0.85× for a mid-sized context', () => {
|
||||
expect(usable(100_000)).toBe(85_000);
|
||||
});
|
||||
|
||||
it('returns 0.85× for a small context (no degenerate 0)', () => {
|
||||
// floor(0.85 * 8192) = 6963. Under the old formula this returned 0
|
||||
// (8192 - 20_000 clamped to 0), effectively disabling compaction for
|
||||
// small-context models. The ratio keeps the trigger active.
|
||||
expect(usable(8192)).toBe(6963);
|
||||
});
|
||||
|
||||
it('returns 0 for zero or negative contextLimit', () => {
|
||||
expect(usable(0)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns 0 when contextLimit is below the 20k buffer', () => {
|
||||
// Math.max(0, x - 20000) clamps the subtraction so we never report
|
||||
// negative headroom. A 10k-context model reports 0 usable, which makes
|
||||
// isOverflow short-circuit to false (correct — we can't size the
|
||||
// compaction with no headroom).
|
||||
expect(usable(10_000)).toBe(0);
|
||||
expect(usable(19_999)).toBe(0);
|
||||
expect(usable(20_000)).toBe(0);
|
||||
});
|
||||
|
||||
it('subtracts the 20k buffer from a normal-sized context window', () => {
|
||||
expect(usable(100_000)).toBe(80_000);
|
||||
expect(usable(32_768)).toBe(12_768);
|
||||
expect(usable(-1)).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- isOverflow -------------------------------------------------------------
|
||||
|
||||
describe('isOverflow', () => {
|
||||
it('returns false when usable is 0 (unknown / sub-buffer context)', () => {
|
||||
it('returns false when usable is 0 (unknown contextLimit)', () => {
|
||||
expect(isOverflow({ prompt_tokens: 999_999, completion_tokens: 0 }, 0)).toBe(false);
|
||||
expect(isOverflow({ prompt_tokens: 0, completion_tokens: 999_999 }, 10_000)).toBe(false);
|
||||
expect(isOverflow({ prompt_tokens: 0, completion_tokens: 999_999 }, -1)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false at 50% of usable', () => {
|
||||
// usable(100k) = 80k → 50% = 40k.
|
||||
// v1.13.9: usable(100k) = 85k → 50% ≈ 42.5k.
|
||||
expect(isOverflow({ prompt_tokens: 30_000, completion_tokens: 10_000 }, 100_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false just under usable', () => {
|
||||
expect(isOverflow({ prompt_tokens: 79_000, completion_tokens: 999 }, 100_000)).toBe(false);
|
||||
// v1.13.9: 84_000 + 999 = 84_999 < 85_000 budget.
|
||||
expect(isOverflow({ prompt_tokens: 84_000, completion_tokens: 999 }, 100_000)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true exactly at usable (>=, not strict >)', () => {
|
||||
expect(isOverflow({ prompt_tokens: 80_000, completion_tokens: 0 }, 100_000)).toBe(true);
|
||||
// v1.13.9: 85_000 == usable(100_000).
|
||||
expect(isOverflow({ prompt_tokens: 85_000, completion_tokens: 0 }, 100_000)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true above usable', () => {
|
||||
// 50_000 + 40_000 = 90_000 > 85_000.
|
||||
expect(isOverflow({ prompt_tokens: 50_000, completion_tokens: 40_000 }, 100_000)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -224,8 +235,9 @@ describe('select', () => {
|
||||
const u = mkMsg('user', 'oversized');
|
||||
const a = mkMsg('assistant', 'Y'.repeat(40_000));
|
||||
const result = select([u, a], 30_000, 1);
|
||||
// usable(30k) = 10k → budget = min(8k, max(2k, floor(10k*0.25))) =
|
||||
// min(8k, max(2k, 2500)) = 2500. 40k chars ≈ 10k tokens. Can't fit.
|
||||
// v1.13.9: usable(30k) = floor(0.85*30k) = 25500 → budget =
|
||||
// min(8k, max(2k, floor(25500*0.25))) = min(8k, max(2k, 6375)) = 6375.
|
||||
// 40k chars ≈ 10k tokens. Still can't fit (10k > 6375).
|
||||
expect(result.tail_start_id).toBeUndefined();
|
||||
expect(result.head).toEqual([u, a]);
|
||||
});
|
||||
@@ -256,3 +268,56 @@ describe('buildPrompt', () => {
|
||||
expect(out.endsWith('extra-context-line')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---- buildHeadPayload (v1.13.6) -----------------------------------------------
|
||||
|
||||
describe('buildHeadPayload reasoning render', () => {
|
||||
it('emits reasoning as a <reasoning> tag prefixed onto the assistant content', () => {
|
||||
const out = buildHeadPayload([
|
||||
mkMsg('user', 'show me the file'),
|
||||
mkMsg('assistant', 'reading it now', {
|
||||
reasoning_parts: [{ text: 'user wants src/index.ts; I should view it' }],
|
||||
}),
|
||||
]);
|
||||
expect(out).toHaveLength(2);
|
||||
expect(out[1]!.role).toBe('assistant');
|
||||
expect(out[1]!.content).toBe(
|
||||
'<reasoning>user wants src/index.ts; I should view it</reasoning>\n\nreading it now',
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a standalone <reasoning> tag when reasoning is present but content is empty (tool-call-only turn)', () => {
|
||||
const out = buildHeadPayload([
|
||||
mkMsg('assistant', '', {
|
||||
reasoning_parts: [{ text: 'jumping straight to grep' }],
|
||||
tool_calls: [{ id: 'c1', name: 'grep', args: { pattern: 'foo' } }],
|
||||
}),
|
||||
]);
|
||||
expect(out).toHaveLength(1);
|
||||
expect(out[0]!.content).toBe('<reasoning>jumping straight to grep</reasoning>');
|
||||
expect(out[0]!.tool_calls).toHaveLength(1);
|
||||
expect(out[0]!.tool_calls![0]!.function.name).toBe('grep');
|
||||
});
|
||||
|
||||
it('joins multiple reasoning parts without separators (matches the streaming concat)', () => {
|
||||
const out = buildHeadPayload([
|
||||
mkMsg('assistant', 'final answer', {
|
||||
reasoning_parts: [{ text: 'first thought ' }, { text: 'second thought' }],
|
||||
}),
|
||||
]);
|
||||
expect(out[0]!.content).toBe(
|
||||
'<reasoning>first thought second thought</reasoning>\n\nfinal answer',
|
||||
);
|
||||
});
|
||||
|
||||
it('omits the reasoning tag entirely when reasoning_parts is null or empty', () => {
|
||||
const out = buildHeadPayload([
|
||||
mkMsg('assistant', 'plain answer', { reasoning_parts: null }),
|
||||
mkMsg('assistant', 'other answer', { reasoning_parts: [] }),
|
||||
]);
|
||||
expect(out[0]!.content).toBe('plain answer');
|
||||
expect(out[1]!.content).toBe('other answer');
|
||||
expect(out[0]!.content).not.toContain('<reasoning>');
|
||||
expect(out[1]!.content).not.toContain('<reasoning>');
|
||||
});
|
||||
});
|
||||
|
||||
199
apps/server/src/services/__tests__/grant_resolver.test.ts
Normal file
199
apps/server/src/services/__tests__/grant_resolver.test.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
// v1.13.17-cross-repo-reads: resolveGrantRoot decision tree.
|
||||
//
|
||||
// Sam's dispatch note (2026-05-22): "in the project-root resolver ancestor
|
||||
// walk, stop the moment parent exits PROJECT_ROOT_WHITELIST or hits
|
||||
// filesystem root — check on every iteration, not just final parent.
|
||||
// Symlinked input must not be able to escape the whitelist during the
|
||||
// walk." The symlink-escape-mid-walk test below pins that invariant —
|
||||
// without the per-iteration whitelist check, this case would walk OUTSIDE
|
||||
// the whitelist root and return a phantom grant.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { resolveGrantRoot } from '../grant_resolver.js';
|
||||
import type { Sql } from '../../db.js';
|
||||
|
||||
let tmp: string;
|
||||
let whitelist: string;
|
||||
let project: string;
|
||||
let fork: string;
|
||||
let outside: string;
|
||||
|
||||
// Fake sql tag — returns the projects rows we want without touching a real
|
||||
// database. The resolver only ever does a single SELECT, so a single-shot
|
||||
// mock that returns the prepared rows on every invocation is enough.
|
||||
function makeSql(rows: Array<{ path: string }>): Sql {
|
||||
const tag = ((..._args: unknown[]) => Promise.resolve(rows)) as unknown as Sql;
|
||||
return tag;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gr-')));
|
||||
whitelist = join(tmp, 'whitelist');
|
||||
project = join(whitelist, 'boocode');
|
||||
fork = join(whitelist, 'forks', 'codecontext');
|
||||
outside = join(tmp, 'outside');
|
||||
await mkdir(project, { recursive: true });
|
||||
await mkdir(fork, { recursive: true });
|
||||
await mkdir(outside, { recursive: true });
|
||||
// Mark project as a repo (.git directory).
|
||||
await mkdir(join(project, '.git'));
|
||||
await writeFile(join(project, 'README.md'), 'project readme');
|
||||
// Mark fork as a repo via go.mod (matches the proposal's example).
|
||||
await writeFile(join(fork, 'go.mod'), 'module example.com/foo');
|
||||
await writeFile(join(fork, 'main.go'), 'package main');
|
||||
await writeFile(join(outside, 'secret.txt'), 'forbidden');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — happy paths', () => {
|
||||
it('refuses when the requested path is already under projectRoot', async () => {
|
||||
const result = await resolveGrantRoot(makeSql([]), join(project, 'README.md'), project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/already accessible/);
|
||||
});
|
||||
|
||||
it('returns the project root when the path falls under a registered project', async () => {
|
||||
// Register `fork` as a known project. Resolver should return the project
|
||||
// ancestor (LONGEST match wins) rather than the repo-shape fallback.
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([{ path: fork }]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.root).toBe(fork);
|
||||
expect(result.source).toBe('project');
|
||||
}
|
||||
});
|
||||
|
||||
it('falls back to the nearest repo-shaped ancestor when no project matches', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) {
|
||||
expect(result.root).toBe(fork);
|
||||
expect(result.source).toBe('whitelist');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — refusals', () => {
|
||||
it('refuses paths outside PROJECT_ROOT_WHITELIST', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(outside, 'secret.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
|
||||
});
|
||||
|
||||
it('refuses non-absolute paths', async () => {
|
||||
const result = await resolveGrantRoot(makeSql([]), 'relative/path', project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/absolute/);
|
||||
});
|
||||
|
||||
it('refuses missing paths without prompting', async () => {
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(whitelist, 'nope'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/does not exist/);
|
||||
});
|
||||
|
||||
it('refuses when no repo-shape marker is found before hitting the whitelist root', async () => {
|
||||
// Build a directory tree under the whitelist that has NO repo markers
|
||||
// all the way up to the whitelist root.
|
||||
const plain = join(whitelist, 'plain-dir', 'nested');
|
||||
await mkdir(plain, { recursive: true });
|
||||
await writeFile(join(plain, 'just-a-file.txt'), 'x');
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(plain, 'just-a-file.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
|
||||
});
|
||||
|
||||
it('does not grant the whitelist root itself as a fallback', async () => {
|
||||
// Even if .git existed at the whitelist root (it doesn't), we'd refuse.
|
||||
// Easier to assert: a path directly under whitelist with no repo marker.
|
||||
const direct = join(whitelist, 'lone-file.txt');
|
||||
await writeFile(direct, 'x');
|
||||
const result = await resolveGrantRoot(makeSql([]), direct, project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — symlink-escape-mid-walk guard (Sam 2026-05-22)', () => {
|
||||
it('refuses a symlinked input whose realpath sits outside the whitelist', async () => {
|
||||
// The symlink lives nominally inside the whitelist, but its target
|
||||
// (realpath) is outside. The guard's first realpath() call normalizes
|
||||
// and the up-front whitelist check refuses immediately.
|
||||
const link = join(whitelist, 'escape-link');
|
||||
try {
|
||||
await symlink(outside, link);
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([]),
|
||||
join(link, 'secret.txt'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/outside permitted scope/);
|
||||
} finally {
|
||||
await rm(link, { force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('walk loop terminates at the whitelist root, not at filesystem /', async () => {
|
||||
// Construct a deep tree with NO repo markers anywhere. Without a bound,
|
||||
// the walk would chase parents up to "/". The bound flips the loop into
|
||||
// a refusal once the cursor equals the realpath'd whitelist root.
|
||||
const deep = join(whitelist, 'a', 'b', 'c', 'd');
|
||||
await mkdir(deep, { recursive: true });
|
||||
await writeFile(join(deep, 'leaf.txt'), 'x');
|
||||
const result = await resolveGrantRoot(makeSql([]), join(deep, 'leaf.txt'), project, whitelist);
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/no repo-shaped ancestor/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveGrantRoot — nearest-project disambiguation', () => {
|
||||
it('prefers the longest matching project path over a shorter ancestor', async () => {
|
||||
const outer = whitelist;
|
||||
const inner = fork; // /whitelist/forks/codecontext, deeper than outer
|
||||
const result = await resolveGrantRoot(
|
||||
makeSql([{ path: outer }, { path: inner }]),
|
||||
join(fork, 'main.go'),
|
||||
project,
|
||||
whitelist,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
if (result.ok) expect(result.root).toBe(inner);
|
||||
});
|
||||
});
|
||||
|
||||
// Belt-and-suspenders: silence a known dynamic-import warning that vitest
|
||||
// occasionally emits on transient fs operations in CI but never in dev.
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => {});
|
||||
169
apps/server/src/services/__tests__/mcp-client.test.ts
Normal file
169
apps/server/src/services/__tests__/mcp-client.test.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* v1.15.0-mcp-multi: unit tests for the multi-server MCP client.
|
||||
* Pure unit tests — no live MCP server needed. Tests tool-wrapping,
|
||||
* read-only guard, name prefixing, content extraction, and error handling.
|
||||
* Multi-server routing tested via wrapMcpTool's server-name prefix.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { wrapMcpTool, extractContent, isToolReadOnly } from '../mcp-client.js';
|
||||
|
||||
describe('mcp-client', () => {
|
||||
describe('wrapMcpTool — multi-server prefixing', () => {
|
||||
it('produces a ToolDef with <serverName>_ prefix', () => {
|
||||
const mcpTool = {
|
||||
name: 'resolve-library-id',
|
||||
description: 'Resolve a library identifier',
|
||||
inputSchema: {
|
||||
type: 'object' as const,
|
||||
properties: { query: { type: 'string' } },
|
||||
required: ['query'],
|
||||
},
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool('context7', mcpTool);
|
||||
|
||||
expect(wrapped.name).toBe('context7_resolve-library-id');
|
||||
expect(wrapped.description).toBe('Resolve a library identifier');
|
||||
expect(wrapped.jsonSchema.type).toBe('function');
|
||||
expect(wrapped.jsonSchema.function.name).toBe('context7_resolve-library-id');
|
||||
expect(wrapped.jsonSchema.function.parameters).toEqual(mcpTool.inputSchema);
|
||||
expect(typeof wrapped.execute).toBe('function');
|
||||
});
|
||||
|
||||
it('prefixes tools from different servers correctly', () => {
|
||||
const toolA = {
|
||||
name: 'query-docs',
|
||||
description: 'Query docs',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
const toolB = {
|
||||
name: 'overview',
|
||||
description: 'Get overview',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrappedA = wrapMcpTool('context7', toolA);
|
||||
const wrappedB = wrapMcpTool('codecontext', toolB);
|
||||
|
||||
expect(wrappedA.name).toBe('context7_query-docs');
|
||||
expect(wrappedB.name).toBe('codecontext_overview');
|
||||
});
|
||||
|
||||
it('multi-server: two servers with 2 tools each produce 4 prefixed tools', () => {
|
||||
const serverATools = [
|
||||
{ name: 'query-docs', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
{ name: 'resolve-library-id', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
];
|
||||
const serverBTools = [
|
||||
{ name: 'overview', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
{ name: 'search', inputSchema: { type: 'object' as const, properties: {} } },
|
||||
];
|
||||
|
||||
const allWrapped = [
|
||||
...serverATools.map((t) => wrapMcpTool('context7', t)),
|
||||
...serverBTools.map((t) => wrapMcpTool('codecontext', t)),
|
||||
];
|
||||
|
||||
expect(allWrapped).toHaveLength(4);
|
||||
expect(allWrapped.map((t) => t.name)).toEqual([
|
||||
'context7_query-docs',
|
||||
'context7_resolve-library-id',
|
||||
'codecontext_overview',
|
||||
'codecontext_search',
|
||||
]);
|
||||
});
|
||||
|
||||
it('defaults description to empty string when absent', () => {
|
||||
const mcpTool = {
|
||||
name: 'no-desc',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool('myserver', mcpTool);
|
||||
|
||||
expect(wrapped.description).toBe('');
|
||||
expect(wrapped.jsonSchema.function.description).toBe('');
|
||||
});
|
||||
|
||||
it('uses passthrough Zod schema (z.record)', () => {
|
||||
const mcpTool = {
|
||||
name: 'test',
|
||||
inputSchema: { type: 'object' as const, properties: {} },
|
||||
};
|
||||
|
||||
const wrapped = wrapMcpTool('s', mcpTool);
|
||||
|
||||
const result = wrapped.inputSchema.safeParse({ foo: 'bar', baz: 123 });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isToolReadOnly', () => {
|
||||
it('accepts tools with readOnlyHint: true', () => {
|
||||
expect(isToolReadOnly({ readOnlyHint: true })).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts tools with no annotations', () => {
|
||||
expect(isToolReadOnly(undefined)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts tools with empty annotations', () => {
|
||||
expect(isToolReadOnly({})).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects tools with readOnlyHint: false', () => {
|
||||
expect(isToolReadOnly({ readOnlyHint: false })).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts tools with only destructiveHint set', () => {
|
||||
expect(isToolReadOnly({ destructiveHint: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractContent', () => {
|
||||
it('extracts single text block', () => {
|
||||
const content = [{ type: 'text', text: 'hello world' }];
|
||||
expect(extractContent(content)).toBe('hello world');
|
||||
});
|
||||
|
||||
it('joins multiple text blocks with newline', () => {
|
||||
const content = [
|
||||
{ type: 'text', text: 'line 1' },
|
||||
{ type: 'text', text: 'line 2' },
|
||||
];
|
||||
expect(extractContent(content)).toBe('line 1\nline 2');
|
||||
});
|
||||
|
||||
it('returns "(no output)" for empty content', () => {
|
||||
expect(extractContent([])).toBe('(no output)');
|
||||
});
|
||||
|
||||
it('returns "(no output)" for undefined content', () => {
|
||||
expect(extractContent(undefined)).toBe('(no output)');
|
||||
});
|
||||
|
||||
it('serializes non-text blocks as JSON', () => {
|
||||
const content = [
|
||||
{ type: 'resource', uri: 'file:///foo', mimeType: 'text/plain' },
|
||||
];
|
||||
const result = extractContent(content);
|
||||
expect(result).toContain('"type":"resource"');
|
||||
expect(result).toContain('"uri":"file:///foo"');
|
||||
});
|
||||
|
||||
it('returns error shape when isError is true', () => {
|
||||
const content = [{ type: 'text', text: 'something failed' }];
|
||||
const result = extractContent(content, true);
|
||||
expect(result).toEqual({ error: true, output: 'something failed' });
|
||||
});
|
||||
|
||||
it('returns error shape with joined content on isError', () => {
|
||||
const content = [
|
||||
{ type: 'text', text: 'error 1' },
|
||||
{ type: 'text', text: 'error 2' },
|
||||
];
|
||||
const result = extractContent(content, true);
|
||||
expect(result).toEqual({ error: true, output: 'error 1\nerror 2' });
|
||||
});
|
||||
});
|
||||
});
|
||||
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
82
apps/server/src/services/__tests__/mcp-glob.test.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
/**
|
||||
* v1.15.0-mcp-multi: unit tests for matchToolGlob.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { matchToolGlob } from '../agents.js';
|
||||
|
||||
describe('matchToolGlob', () => {
|
||||
it('exact match: "grep" matches "grep"', () => {
|
||||
expect(matchToolGlob('grep', ['grep'])).toBe(true);
|
||||
});
|
||||
|
||||
it('exact match: "grep" does not match "grep2"', () => {
|
||||
expect(matchToolGlob('grep2', ['grep'])).toBe(false);
|
||||
});
|
||||
|
||||
it('exact match: multiple tools', () => {
|
||||
expect(matchToolGlob('grep', ['grep', 'view_file'])).toBe(true);
|
||||
expect(matchToolGlob('view_file', ['grep', 'view_file'])).toBe(true);
|
||||
expect(matchToolGlob('find_files', ['grep', 'view_file'])).toBe(false);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" matches "context7_query-docs"', () => {
|
||||
expect(matchToolGlob('context7_query-docs', ['context7_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" matches "context7_resolve-library-id"', () => {
|
||||
expect(matchToolGlob('context7_resolve-library-id', ['context7_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "context7_*" does not match "codecontext_overview"', () => {
|
||||
expect(matchToolGlob('codecontext_overview', ['context7_*'])).toBe(false);
|
||||
});
|
||||
|
||||
it('wildcard: "view_*" matches "view_file" and "view_truncated_output"', () => {
|
||||
expect(matchToolGlob('view_file', ['view_*'])).toBe(true);
|
||||
expect(matchToolGlob('view_truncated_output', ['view_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('wildcard: "*" matches everything', () => {
|
||||
expect(matchToolGlob('anything', ['*'])).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', ['*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('deny: "!web_*" excludes "web_search"', () => {
|
||||
// With only a deny rule and no prior match, the tool is not matched
|
||||
expect(matchToolGlob('web_search', ['!web_*'])).toBe(false);
|
||||
});
|
||||
|
||||
it('last-match-wins: ["*", "!web_*"] excludes web tools, includes others', () => {
|
||||
expect(matchToolGlob('web_search', ['*', '!web_*'])).toBe(false);
|
||||
expect(matchToolGlob('web_fetch', ['*', '!web_*'])).toBe(false);
|
||||
expect(matchToolGlob('grep', ['*', '!web_*'])).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', ['*', '!web_*'])).toBe(true);
|
||||
});
|
||||
|
||||
it('last-match-wins: deny then re-allow', () => {
|
||||
// ["!web_*", "web_search"] — deny all web, then re-allow web_search
|
||||
expect(matchToolGlob('web_search', ['!web_*', 'web_search'])).toBe(true);
|
||||
expect(matchToolGlob('web_fetch', ['!web_*', 'web_fetch'])).toBe(true);
|
||||
});
|
||||
|
||||
it('empty patterns: nothing matches', () => {
|
||||
expect(matchToolGlob('grep', [])).toBe(false);
|
||||
expect(matchToolGlob('anything', [])).toBe(false);
|
||||
});
|
||||
|
||||
it('no-glob fallback: exact-match only, same as pre-v1.15', () => {
|
||||
const patterns = ['grep', 'view_file'];
|
||||
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||
expect(matchToolGlob('view_file', patterns)).toBe(true);
|
||||
expect(matchToolGlob('find_files', patterns)).toBe(false);
|
||||
expect(matchToolGlob('web_search', patterns)).toBe(false);
|
||||
});
|
||||
|
||||
it('mixed glob and exact patterns', () => {
|
||||
const patterns = ['grep', 'context7_*', '!context7_dangerous'];
|
||||
expect(matchToolGlob('grep', patterns)).toBe(true);
|
||||
expect(matchToolGlob('context7_query-docs', patterns)).toBe(true);
|
||||
expect(matchToolGlob('context7_dangerous', patterns)).toBe(false);
|
||||
expect(matchToolGlob('view_file', patterns)).toBe(false);
|
||||
});
|
||||
});
|
||||
93
apps/server/src/services/__tests__/path_guard.test.ts
Normal file
93
apps/server/src/services/__tests__/path_guard.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
// v1.13.17-cross-repo-reads: pathGuard now accepts an optional extraRoots
|
||||
// list. Validates the primary-root path stays the source of truth and that
|
||||
// extra roots are consulted when (and only when) the primary rejects.
|
||||
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { pathGuard, PathScopeError } from '../path_guard.js';
|
||||
|
||||
let tmp: string;
|
||||
let projectRoot: string;
|
||||
let altRoot: string;
|
||||
let outsideDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-pg-')));
|
||||
projectRoot = join(tmp, 'project');
|
||||
altRoot = join(tmp, 'alt');
|
||||
outsideDir = join(tmp, 'outside');
|
||||
await mkdir(projectRoot, { recursive: true });
|
||||
await mkdir(altRoot, { recursive: true });
|
||||
await mkdir(outsideDir, { recursive: true });
|
||||
await writeFile(join(projectRoot, 'inside.txt'), 'p');
|
||||
await writeFile(join(altRoot, 'cross.txt'), 'a');
|
||||
await writeFile(join(outsideDir, 'forbidden.txt'), 'x');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('pathGuard (v1.13.17 extraRoots)', () => {
|
||||
it('accepts paths inside the primary projectRoot', async () => {
|
||||
const real = await pathGuard(projectRoot, 'inside.txt');
|
||||
expect(real).toBe(join(projectRoot, 'inside.txt'));
|
||||
});
|
||||
|
||||
it('rejects paths outside the primary root when no extra roots given', async () => {
|
||||
await expect(pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'))).rejects.toBeInstanceOf(
|
||||
PathScopeError,
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts cross-root paths when the matching extra root is provided', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [altRoot]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
|
||||
it('rejects cross-root paths even with extra roots when no root matches', async () => {
|
||||
await expect(
|
||||
pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'), [altRoot]),
|
||||
).rejects.toBeInstanceOf(PathScopeError);
|
||||
});
|
||||
|
||||
it('ignores empty-string extra roots silently', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), ['', altRoot]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
|
||||
it('error message contains the request_read_access hint when scope rejects', async () => {
|
||||
try {
|
||||
await pathGuard(projectRoot, join(outsideDir, 'forbidden.txt'));
|
||||
throw new Error('should have thrown');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(PathScopeError);
|
||||
expect((err as Error).message).toContain('request_read_access');
|
||||
}
|
||||
});
|
||||
|
||||
it('still resolves symlinks before the scope check', async () => {
|
||||
const linkPath = join(projectRoot, 'link-to-outside');
|
||||
await symlink(join(outsideDir, 'forbidden.txt'), linkPath);
|
||||
// Symlink target escapes both primary and the single extra root, so
|
||||
// even though the surface path "looks" inside projectRoot, the real
|
||||
// path resolves outside and the guard rejects.
|
||||
await expect(pathGuard(projectRoot, linkPath, [altRoot])).rejects.toBeInstanceOf(
|
||||
PathScopeError,
|
||||
);
|
||||
// But adding outsideDir as an extra root accepts (realpath inside it).
|
||||
const real = await pathGuard(projectRoot, linkPath, [altRoot, outsideDir]);
|
||||
expect(real).toBe(join(outsideDir, 'forbidden.txt'));
|
||||
});
|
||||
|
||||
it('tries extra roots in order until one accepts', async () => {
|
||||
const real = await pathGuard(projectRoot, join(altRoot, 'cross.txt'), [
|
||||
outsideDir, // rejects
|
||||
altRoot, // accepts
|
||||
]);
|
||||
expect(real).toBe(join(altRoot, 'cross.txt'));
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,9 @@ import {
|
||||
loadContainerGuidance,
|
||||
getContainerGuidance,
|
||||
buildSystemPrompt,
|
||||
buildSystemPromptWithFingerprint,
|
||||
_resetContainerGuidanceCacheForTests,
|
||||
_resetPrefixObserverForTests,
|
||||
} from '../system-prompt.js';
|
||||
import type { Agent, Project, Session } from '../../types/api.js';
|
||||
|
||||
@@ -17,12 +19,14 @@ let tmpDir: string;
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'system-prompt-test-'));
|
||||
_resetContainerGuidanceCacheForTests();
|
||||
_resetPrefixObserverForTests();
|
||||
delete process.env['CONTAINER_GUIDANCE_FILE'];
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
delete process.env['CONTAINER_GUIDANCE_FILE'];
|
||||
_resetContainerGuidanceCacheForTests();
|
||||
_resetPrefixObserverForTests();
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
@@ -176,3 +180,75 @@ describe('buildSystemPrompt', () => {
|
||||
expect(prompt).not.toContain('--- end container guidance ---');
|
||||
});
|
||||
});
|
||||
|
||||
// v1.13.8: byte-stability instrumentation surface.
|
||||
describe('buildSystemPromptWithFingerprint (v1.13.8)', () => {
|
||||
it('returns byte-identical prompts for two consecutive calls with the same inputs', async () => {
|
||||
const path = join(tmpDir, 'BOOCHAT.md');
|
||||
await writeFile(path, 'stable guidance', 'utf8');
|
||||
process.env['CONTAINER_GUIDANCE_FILE'] = path;
|
||||
|
||||
const session = makeSession();
|
||||
const project = makeProject({ path: '/tmp/stable-proj' });
|
||||
const agent = makeAgent({ system_prompt: 'be terse' });
|
||||
|
||||
const first = await buildSystemPromptWithFingerprint(project, session, agent);
|
||||
const second = await buildSystemPromptWithFingerprint(project, session, agent);
|
||||
|
||||
expect(first.prompt).toBe(second.prompt);
|
||||
expect(first.fingerprint.prefix_hash).toBe(second.fingerprint.prefix_hash);
|
||||
expect(first.fingerprint.prefix_length).toBe(second.fingerprint.prefix_length);
|
||||
});
|
||||
|
||||
it('emits drift=null on the first call for a fresh session, then null again when nothing changes', async () => {
|
||||
process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'absent.md');
|
||||
const session = makeSession();
|
||||
const project = makeProject({ path: '/tmp/stable-proj' });
|
||||
|
||||
const first = await buildSystemPromptWithFingerprint(project, session, null);
|
||||
expect(first.drift).toBeNull();
|
||||
|
||||
const second = await buildSystemPromptWithFingerprint(project, session, null);
|
||||
expect(second.drift).toBeNull();
|
||||
expect(second.fingerprint.prefix_hash).toBe(first.fingerprint.prefix_hash);
|
||||
});
|
||||
|
||||
it('emits drift with prev/new hashes and a changed_inputs entry when an input mutates', async () => {
|
||||
// Two BOOCHAT.md contents with different mtimes → guidance cache picks
|
||||
// up the change → fingerprint hash flips → drift fires.
|
||||
const path = join(tmpDir, 'BOOCHAT.md');
|
||||
await writeFile(path, 'first', 'utf8');
|
||||
process.env['CONTAINER_GUIDANCE_FILE'] = path;
|
||||
|
||||
const session = makeSession();
|
||||
const project = makeProject({ path: '/tmp/stable-proj' });
|
||||
|
||||
const first = await buildSystemPromptWithFingerprint(project, session, null);
|
||||
expect(first.drift).toBeNull();
|
||||
|
||||
await writeFile(path, 'second — different content', 'utf8');
|
||||
const later = new Date(Date.now() + 60_000);
|
||||
await utimes(path, later, later);
|
||||
|
||||
const second = await buildSystemPromptWithFingerprint(project, session, null);
|
||||
expect(second.drift).not.toBeNull();
|
||||
expect(second.drift!.prev_hash).toBe(first.fingerprint.prefix_hash);
|
||||
expect(second.drift!.new_hash).toBe(second.fingerprint.prefix_hash);
|
||||
expect(second.drift!.prev_hash).not.toBe(second.drift!.new_hash);
|
||||
expect(second.drift!.changed_inputs).toContain('mtime_boochat');
|
||||
});
|
||||
|
||||
it('does not fire drift across distinct sessions even if their hashes differ', async () => {
|
||||
process.env['CONTAINER_GUIDANCE_FILE'] = join(tmpDir, 'absent.md');
|
||||
const sessionA = makeSession({ id: 'sess-A' });
|
||||
const sessionB = makeSession({ id: 'sess-B', system_prompt: 'B-only override' });
|
||||
const project = makeProject({ path: '/tmp/stable-proj' });
|
||||
|
||||
const a = await buildSystemPromptWithFingerprint(project, sessionA, null);
|
||||
const b = await buildSystemPromptWithFingerprint(project, sessionB, null);
|
||||
|
||||
expect(a.drift).toBeNull();
|
||||
expect(b.drift).toBeNull();
|
||||
expect(a.fingerprint.prefix_hash).not.toBe(b.fingerprint.prefix_hash);
|
||||
});
|
||||
});
|
||||
|
||||
236
apps/server/src/services/__tests__/tool_cost_stats.test.ts
Normal file
236
apps/server/src/services/__tests__/tool_cost_stats.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import postgres from 'postgres';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
// v1.13.10: integration tests for the tool_cost_stats view. Skipped unless
|
||||
// DATABASE_URL is set so they don't break `pnpm test` on a fresh checkout.
|
||||
// Run with:
|
||||
// DATABASE_URL=postgres://boocode:<pw>@localhost:5500/boocode pnpm -C apps/server test
|
||||
//
|
||||
// Isolation: each test uses a unique tool_name suffix derived from a per-test
|
||||
// counter. The view aggregates globally across all chats, so without unique
|
||||
// tool names parallel test runs would interfere. Cleanup deletes by tool_name
|
||||
// suffix in afterAll.
|
||||
|
||||
const DB_URL = process.env.DATABASE_URL;
|
||||
const describeFn = DB_URL ? describe : describe.skip;
|
||||
|
||||
const TEST_RUN_ID = `v13_10_${Date.now()}`;
|
||||
const tname = (suffix: string) => `${TEST_RUN_ID}_${suffix}`;
|
||||
|
||||
describeFn('tool_cost_stats view (v1.13.10)', () => {
|
||||
let sql: ReturnType<typeof postgres>;
|
||||
let projectId: string;
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!DB_URL) return;
|
||||
sql = postgres(DB_URL, { max: 2, idle_timeout: 5, connect_timeout: 5, onnotice: () => {} });
|
||||
|
||||
// Apply the schema before fixtures so the view exists. Idempotent via
|
||||
// CREATE OR REPLACE VIEW + CREATE TABLE IF NOT EXISTS; safe to run on a
|
||||
// pre-populated DB. Mirrors apps/server/src/db.ts:applySchema.
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const schemaPath = resolve(here, '../../../schema.sql');
|
||||
const ddl = readFileSync(schemaPath, 'utf8');
|
||||
await sql.unsafe(ddl);
|
||||
|
||||
// Fixture project + session + chat for all inserts in this file.
|
||||
const proj = await sql<{ id: string }[]>`
|
||||
INSERT INTO projects (name, path)
|
||||
VALUES (${`tool_cost_stats_test_${TEST_RUN_ID}`}, ${`/tmp/${TEST_RUN_ID}`})
|
||||
RETURNING id
|
||||
`;
|
||||
projectId = proj[0]!.id;
|
||||
const sess = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model)
|
||||
VALUES (${projectId}, ${'test'}, ${'test-model'})
|
||||
RETURNING id
|
||||
`;
|
||||
sessionId = sess[0]!.id;
|
||||
const chat = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name) VALUES (${sessionId}, ${'test'}) RETURNING id
|
||||
`;
|
||||
chatId = chat[0]!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!DB_URL) return;
|
||||
// Project FK CASCADE cleans sessions/chats/messages/parts in one shot.
|
||||
await sql`DELETE FROM projects WHERE id = ${projectId}`;
|
||||
await sql.end({ timeout: 5 });
|
||||
});
|
||||
|
||||
async function insertAssistantTurn(opts: {
|
||||
toolNames: string[];
|
||||
tokensUsed: number | null;
|
||||
ctxUsed: number | null;
|
||||
status?: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||
metadata?: { kind: string } | null;
|
||||
createdAt?: Date;
|
||||
}): Promise<string> {
|
||||
const toolCalls = opts.toolNames.map((name, i) => ({
|
||||
id: `call_${TEST_RUN_ID}_${name}_${i}`,
|
||||
name,
|
||||
args: {},
|
||||
}));
|
||||
const created = opts.createdAt ?? new Date();
|
||||
// v1.13.20: parts-only. messages.tool_calls column was dropped; the
|
||||
// tool_cost_stats view reads through messages_with_parts which derives
|
||||
// tool_calls from message_parts rows.
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, status,
|
||||
tokens_used, ctx_used,
|
||||
metadata, created_at
|
||||
)
|
||||
VALUES (
|
||||
${sessionId}, ${chatId}, 'assistant', '', 'message',
|
||||
${opts.status ?? 'complete'},
|
||||
${opts.tokensUsed},
|
||||
${opts.ctxUsed},
|
||||
${opts.metadata ? sql.json(opts.metadata as never) : null},
|
||||
${created}
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const messageId = rows[0]!.id;
|
||||
for (let i = 0; i < toolCalls.length; i++) {
|
||||
await sql`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (${messageId}, ${i}, 'tool_call', ${sql.json(toolCalls[i] as never)})
|
||||
`;
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
it('returns empty when no tool calls exist for a tool name', async () => {
|
||||
const t = tname('absent');
|
||||
const stats = await sql<{ tool_name: string }[]>`
|
||||
SELECT * FROM tool_cost_stats WHERE tool_name = ${t}
|
||||
`;
|
||||
expect(stats).toEqual([]);
|
||||
});
|
||||
|
||||
it('attributes single-tool turn fully to that tool', async () => {
|
||||
const t = tname('single');
|
||||
await insertAssistantTurn({ toolNames: [t], tokensUsed: 300, ctxUsed: 15000 });
|
||||
const stats = await sql<{
|
||||
tool_name: string;
|
||||
prompt_tokens_sum: number;
|
||||
completion_tokens_sum: number;
|
||||
n_calls: number;
|
||||
}[]>`SELECT * FROM tool_cost_stats WHERE tool_name = ${t}`;
|
||||
expect(stats[0]).toMatchObject({
|
||||
tool_name: t,
|
||||
prompt_tokens_sum: 15000,
|
||||
completion_tokens_sum: 300,
|
||||
n_calls: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('splits multi-tool turn equally across tools', async () => {
|
||||
const a = tname('multi_a');
|
||||
const b = tname('multi_b');
|
||||
const c = tname('multi_c');
|
||||
// 3 tools, 300 completion / 15000 prompt → each gets 100 / 5000
|
||||
await insertAssistantTurn({ toolNames: [a, b, c], tokensUsed: 300, ctxUsed: 15000 });
|
||||
const stats = await sql<{
|
||||
tool_name: string;
|
||||
prompt_tokens_sum: number;
|
||||
completion_tokens_sum: number;
|
||||
n_calls: number;
|
||||
}[]>`
|
||||
SELECT * FROM tool_cost_stats
|
||||
WHERE tool_name IN (${a}, ${b}, ${c})
|
||||
ORDER BY tool_name
|
||||
`;
|
||||
expect(stats).toHaveLength(3);
|
||||
for (const s of stats) {
|
||||
expect(s.completion_tokens_sum).toBe(100);
|
||||
expect(s.prompt_tokens_sum).toBe(5000);
|
||||
expect(s.n_calls).toBe(1);
|
||||
}
|
||||
});
|
||||
|
||||
it('limits to last 100 calls per tool (FIFO window)', async () => {
|
||||
const t = tname('window');
|
||||
// Insert 110 turns with monotonically-increasing created_at and tokensUsed.
|
||||
// Expect view to keep only the most recent 100.
|
||||
const base = Date.now() + 1_000_000; // distant future to avoid colliding with other tests
|
||||
for (let i = 1; i <= 110; i++) {
|
||||
await insertAssistantTurn({
|
||||
toolNames: [t],
|
||||
tokensUsed: i, // 1..110
|
||||
ctxUsed: i * 10,
|
||||
createdAt: new Date(base + i),
|
||||
});
|
||||
}
|
||||
const [stat] = await sql<{
|
||||
n_calls: number;
|
||||
completion_tokens_sum: number;
|
||||
}[]>`SELECT n_calls, completion_tokens_sum FROM tool_cost_stats WHERE tool_name = ${t}`;
|
||||
expect(stat!.n_calls).toBe(100);
|
||||
// Last 100 are tokensUsed=11..110, sum = (11+110)*100/2 = 6050.
|
||||
expect(stat!.completion_tokens_sum).toBe(6050);
|
||||
});
|
||||
|
||||
it('excludes turns with NULL tokens_used (pre-v1.13.7 latent regression)', async () => {
|
||||
const t = tname('null_tokens');
|
||||
await insertAssistantTurn({ toolNames: [t], tokensUsed: null, ctxUsed: 1000 });
|
||||
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: null });
|
||||
const stats = await sql`SELECT * FROM tool_cost_stats WHERE tool_name = ${t}`;
|
||||
expect(stats).toEqual([]);
|
||||
});
|
||||
|
||||
it('excludes failed/cancelled turns and cap_hit/doom_loop sentinel rows', async () => {
|
||||
const t = tname('filtered');
|
||||
// A: status='failed' — excluded
|
||||
// B: status='cancelled' — excluded
|
||||
// C: status='complete', metadata={kind:'cap_hit'} — excluded
|
||||
// D: status='complete', metadata={kind:'doom_loop'} — excluded
|
||||
// E: status='complete', metadata=null — included
|
||||
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, status: 'failed' });
|
||||
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, status: 'cancelled' });
|
||||
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, metadata: { kind: 'cap_hit' } });
|
||||
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, metadata: { kind: 'doom_loop' } });
|
||||
await insertAssistantTurn({ toolNames: [t], tokensUsed: 100, ctxUsed: 1000, metadata: null });
|
||||
const [stat] = await sql<{ n_calls: number }[]>`
|
||||
SELECT n_calls FROM tool_cost_stats WHERE tool_name = ${t}
|
||||
`;
|
||||
expect(stat!.n_calls).toBe(1);
|
||||
});
|
||||
|
||||
it('reads tool_calls via messages_with_parts (parts-authoritative)', async () => {
|
||||
const t = tname('parts');
|
||||
// v1.13.20: post-column-drop the only source for tool_calls is
|
||||
// message_parts. This test asserts the same path the view always took
|
||||
// (parts-derived), now that the legacy column COALESCE fallback is gone.
|
||||
const rows = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (
|
||||
session_id, chat_id, role, content, kind, status,
|
||||
tokens_used, ctx_used
|
||||
)
|
||||
VALUES (
|
||||
${sessionId}, ${chatId}, 'assistant', '', 'message', 'complete',
|
||||
200, 5000
|
||||
)
|
||||
RETURNING id
|
||||
`;
|
||||
const messageId = rows[0]!.id;
|
||||
await sql`
|
||||
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||
VALUES (
|
||||
${messageId}, 0, 'tool_call',
|
||||
${sql.json({ id: `tc_parts_${TEST_RUN_ID}`, name: t, args: {} } as never)}
|
||||
)
|
||||
`;
|
||||
const [stat] = await sql<{ n_calls: number }[]>`
|
||||
SELECT n_calls FROM tool_cost_stats WHERE tool_name = ${t}
|
||||
`;
|
||||
expect(stat!.n_calls).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,11 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { ALL_TOOLS } from '../tools.js';
|
||||
import {
|
||||
ALL_TOOLS,
|
||||
CORE_TOOL_NAMES,
|
||||
STANDARD_TOOL_NAMES,
|
||||
TOOLS_BY_NAME,
|
||||
resolveToolTier,
|
||||
} from '../tools.js';
|
||||
|
||||
describe('ALL_TOOLS registry', () => {
|
||||
// v1.13.3: tools must be alpha-sorted at module load. llama.cpp's prompt
|
||||
@@ -12,3 +18,59 @@ describe('ALL_TOOLS registry', () => {
|
||||
expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b)));
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveToolTier (v1.13.15-tools)', () => {
|
||||
it('returns CORE tools for tier=core', () => {
|
||||
expect(resolveToolTier('core')).toEqual(CORE_TOOL_NAMES);
|
||||
});
|
||||
|
||||
it('returns STANDARD tools for tier=standard', () => {
|
||||
const result = resolveToolTier('standard');
|
||||
expect(result.length).toBe(STANDARD_TOOL_NAMES.length);
|
||||
expect(result.length).toBeGreaterThan(CORE_TOOL_NAMES.length);
|
||||
// STANDARD is a strict superset of CORE.
|
||||
expect(result).toEqual(expect.arrayContaining([...CORE_TOOL_NAMES]));
|
||||
});
|
||||
|
||||
it('returns ALL tool names for tier=all', () => {
|
||||
expect(resolveToolTier('all').length).toBe(ALL_TOOLS.length);
|
||||
});
|
||||
|
||||
it('defaults to all when env var is undefined', () => {
|
||||
expect(resolveToolTier(undefined).length).toBe(ALL_TOOLS.length);
|
||||
});
|
||||
|
||||
it('is case-insensitive', () => {
|
||||
expect(resolveToolTier('CORE')).toEqual(CORE_TOOL_NAMES);
|
||||
expect(resolveToolTier('Standard').length).toBe(STANDARD_TOOL_NAMES.length);
|
||||
});
|
||||
|
||||
it('falls back to all for unknown tier strings', () => {
|
||||
expect(resolveToolTier('bogus').length).toBe(ALL_TOOLS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CORE_TOOL_NAMES + STANDARD_TOOL_NAMES validation', () => {
|
||||
// The module-load validation in tools.ts throws if a tier references a
|
||||
// tool that doesn't exist in TOOLS_BY_NAME. These tests double-check that
|
||||
// invariant from the consumer side so a future tier-list edit can't smuggle
|
||||
// in a typo without a test failure.
|
||||
it('every CORE name exists in TOOLS_BY_NAME', () => {
|
||||
for (const name of CORE_TOOL_NAMES) {
|
||||
expect(TOOLS_BY_NAME[name], `CORE references unknown tool '${name}'`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('every STANDARD name exists in TOOLS_BY_NAME', () => {
|
||||
for (const name of STANDARD_TOOL_NAMES) {
|
||||
expect(TOOLS_BY_NAME[name], `STANDARD references unknown tool '${name}'`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('CORE is a subset of STANDARD', () => {
|
||||
const standardSet = new Set<string>(STANDARD_TOOL_NAMES);
|
||||
for (const name of CORE_TOOL_NAMES) {
|
||||
expect(standardSet.has(name), `'${name}' is in CORE but not STANDARD`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
104
apps/server/src/services/__tests__/truncate.test.ts
Normal file
104
apps/server/src/services/__tests__/truncate.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// v1.13.5: truncate.ts unit coverage. Each test isolates TRUNCATION_DIR
|
||||
// under os.tmpdir() so concurrent vitest runs don't collide and the suite
|
||||
// stays self-cleaning. cleanupTruncations is covered by file-system half
|
||||
// only; the orphan-reap branch needs a real Postgres and is tested via the
|
||||
// smoke flow rather than vitest.
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
|
||||
// Set the env var BEFORE importing the module so its module-load constant
|
||||
// reads the test directory rather than /tmp/boocode-truncations.
|
||||
const testDir = path.join(os.tmpdir(), `boocode-truncate-test-${process.pid}-${Date.now()}`);
|
||||
process.env.BOOCODE_TRUNCATION_DIR = testDir;
|
||||
|
||||
const mod = await import('../truncate.js');
|
||||
const { storeTruncation, readTruncation, truncateIfNeeded, MAX_TRUNCATION_BYTES } = mod;
|
||||
|
||||
beforeAll(async () => {
|
||||
await fs.mkdir(testDir, { recursive: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
// Drop every file between tests so id-collision asserts and orphan-style
|
||||
// counts start from zero.
|
||||
const entries = await fs.readdir(testDir).catch(() => [] as string[]);
|
||||
await Promise.all(entries.map((n) => fs.unlink(path.join(testDir, n)).catch(() => {})));
|
||||
});
|
||||
|
||||
describe('storeTruncation / readTruncation roundtrip', () => {
|
||||
it('writes and reads identical content', async () => {
|
||||
const original = 'hello\nworld\n' + 'x'.repeat(500);
|
||||
const id = await storeTruncation(original);
|
||||
expect(id).toMatch(/^tr_[0-9a-v]{12}$/);
|
||||
const got = await readTruncation(id);
|
||||
expect(got).toBe(original);
|
||||
});
|
||||
|
||||
it('readTruncation returns null for unknown ids', async () => {
|
||||
const got = await readTruncation('tr_000000000000');
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
|
||||
it('readTruncation rejects malformed ids (returns null, never escapes dir)', async () => {
|
||||
// Path traversal attempt; readTruncation should not even try to open.
|
||||
const got = await readTruncation('../../etc/passwd');
|
||||
expect(got).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('truncateIfNeeded', () => {
|
||||
it('returns sliced content with no outputPath when wasTruncated=false', async () => {
|
||||
const out = await truncateIfNeeded({
|
||||
fullContent: 'irrelevant',
|
||||
slicedContent: 'visible',
|
||||
wasTruncated: false,
|
||||
});
|
||||
expect(out).toEqual({ content: 'visible', truncated: false });
|
||||
expect('outputPath' in out).toBe(false);
|
||||
});
|
||||
|
||||
it('stashes full content and returns outputPath when wasTruncated=true', async () => {
|
||||
const full = 'line1\nline2\nline3\nline4\n';
|
||||
const sliced = 'line1\nline2\n[truncated]';
|
||||
const out = await truncateIfNeeded({
|
||||
fullContent: full,
|
||||
slicedContent: sliced,
|
||||
wasTruncated: true,
|
||||
});
|
||||
expect(out.content).toBe(sliced);
|
||||
expect(out.truncated).toBe(true);
|
||||
expect(out.outputPath).toMatch(/^tr_[0-9a-v]{12}$/);
|
||||
const stashed = await readTruncation(out.outputPath!);
|
||||
expect(stashed).toBe(full);
|
||||
});
|
||||
|
||||
it('skips storage but still reports truncated when fullContent exceeds the cap', async () => {
|
||||
// Build content larger than MAX_TRUNCATION_BYTES. Use a Buffer to size
|
||||
// it without holding a literal that triggers the gigantic-string lint.
|
||||
const oversized = Buffer.alloc(MAX_TRUNCATION_BYTES + 1, 'x').toString('utf8');
|
||||
const sliced = 'preview...';
|
||||
const out = await truncateIfNeeded({
|
||||
fullContent: oversized,
|
||||
slicedContent: sliced,
|
||||
wasTruncated: true,
|
||||
});
|
||||
expect(out).toEqual({ content: sliced, truncated: true });
|
||||
expect('outputPath' in out).toBe(false);
|
||||
});
|
||||
|
||||
it('storage failure surfaces as truncated without outputPath', async () => {
|
||||
// Force writeFile to throw. Spy at the fs module level since truncate.ts
|
||||
// imports { promises as fs } and storeTruncation calls fs.writeFile.
|
||||
const spy = vi.spyOn(fs, 'writeFile').mockRejectedValueOnce(new Error('disk full'));
|
||||
const out = await truncateIfNeeded({
|
||||
fullContent: 'short',
|
||||
slicedContent: 'sliced',
|
||||
wasTruncated: true,
|
||||
});
|
||||
expect(out).toEqual({ content: 'sliced', truncated: true });
|
||||
expect('outputPath' in out).toBe(false);
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
218
apps/server/src/services/__tests__/ws-frames.test.ts
Normal file
218
apps/server/src/services/__tests__/ws-frames.test.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
WsFrameSchema,
|
||||
KNOWN_FRAME_TYPES,
|
||||
type WsFrame,
|
||||
} from '../../types/ws-frames.js';
|
||||
import { createBroker } from '../broker.js';
|
||||
|
||||
const VALID_UUID_A = '00000000-0000-0000-0000-000000000001';
|
||||
const VALID_UUID_B = '00000000-0000-0000-0000-000000000002';
|
||||
const VALID_UUID_C = '00000000-0000-0000-0000-000000000003';
|
||||
const VALID_TIMESTAMP = '2026-05-22T14:30:00.000Z';
|
||||
|
||||
describe('WsFrameSchema (v1.13.11-a)', () => {
|
||||
it('accepts a well-formed chat_status frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: VALID_UUID_A,
|
||||
status: 'streaming',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects an unknown frame type', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'cosmic_ray_strike',
|
||||
chat_id: VALID_UUID_A,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a chat_status frame with invalid status enum', () => {
|
||||
// v1.12.1 dropped the legacy 'working' status. Any frame still emitting it
|
||||
// should fail validation — that's a drift catcher.
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: VALID_UUID_A,
|
||||
status: 'working',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a UUID field with a non-UUID string', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'chat_status',
|
||||
chat_id: 'not-a-uuid',
|
||||
status: 'idle',
|
||||
at: VALID_TIMESTAMP,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects negative token counts in usage frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'usage',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
completion_tokens: -1,
|
||||
ctx_used: 100,
|
||||
ctx_max: 1000,
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts a usage frame with nullable token counts (pre-v1.13.7 history)', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'usage',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
completion_tokens: null,
|
||||
ctx_used: null,
|
||||
ctx_max: null,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a tool_result frame with non-UUID tool_call_id (model-emitted)', () => {
|
||||
// Model-emitted tool_call_ids look like "call_abc123", not UUIDs.
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'tool_result',
|
||||
tool_message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
tool_call_id: 'call_abc123',
|
||||
output: { whatever: true },
|
||||
truncated: false,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a compacted frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'compacted',
|
||||
session_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
summary_message_id: VALID_UUID_C,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts a session_workspace_updated frame', () => {
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'session_workspace_updated',
|
||||
session_id: VALID_UUID_A,
|
||||
workspace_panes: [{ id: 'p1', kind: 'chat', chatIds: [], activeChatIdx: 0 }],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('every KNOWN_FRAME_TYPES entry has a discriminated branch', () => {
|
||||
// Probe each known type by attempting a minimal valid construction.
|
||||
// Failure here means the union and the KNOWN_FRAME_TYPES list drifted.
|
||||
for (const type of KNOWN_FRAME_TYPES) {
|
||||
const probe = WsFrameSchema.safeParse({ type, __dummy__: true });
|
||||
// We expect FAILURE on every type because we're missing required fields,
|
||||
// but the failure must be ABOUT the missing fields, not about an unknown
|
||||
// type. A "Invalid discriminator value" error means the type isn't in
|
||||
// the union — that's a drift.
|
||||
if (probe.success) continue;
|
||||
const issues = probe.error.issues;
|
||||
const hasInvalidDiscriminator = issues.some(
|
||||
(i) => i.code === 'invalid_union_discriminator',
|
||||
);
|
||||
expect(hasInvalidDiscriminator, `frame type '${type}' is missing from the discriminated union`).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ws-frames.ts file mirror parity', () => {
|
||||
it('apps/server and apps/web copies are byte-identical', () => {
|
||||
const here = fileURLToPath(import.meta.url);
|
||||
const serverPath = resolve(here, '../../../types/ws-frames.ts');
|
||||
const webPath = resolve(here, '../../../../../web/src/api/ws-frames.ts');
|
||||
const serverContent = readFileSync(serverPath, 'utf8');
|
||||
const webContent = readFileSync(webPath, 'utf8');
|
||||
expect(webContent, 'apps/web/src/api/ws-frames.ts must be byte-identical to apps/server/src/types/ws-frames.ts').toBe(serverContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('broker.publishFrame / publishUserFrame fail-closed behavior', () => {
|
||||
let logErrors: Array<{ obj: unknown; msg: string }>;
|
||||
let mockLog: Parameters<typeof createBroker>[0];
|
||||
|
||||
beforeEach(() => {
|
||||
logErrors = [];
|
||||
mockLog = {
|
||||
error: (obj: unknown, msg: string) => {
|
||||
logErrors.push({ obj, msg });
|
||||
},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
debug: () => {},
|
||||
trace: () => {},
|
||||
fatal: () => {},
|
||||
child: () => mockLog as never,
|
||||
level: 'info',
|
||||
silent: () => {},
|
||||
} as unknown as Parameters<typeof createBroker>[0];
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('publishFrame delivers a valid frame to subscribers', () => {
|
||||
const broker = createBroker(mockLog);
|
||||
const received: WsFrame[] = [];
|
||||
broker.subscribe('sess-1', (f) => received.push(f as WsFrame));
|
||||
broker.publishFrame('sess-1', {
|
||||
type: 'delta',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
content: 'hello',
|
||||
});
|
||||
expect(received).toHaveLength(1);
|
||||
expect((received[0] as { type: string }).type).toBe('delta');
|
||||
expect(logErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('publishFrame drops + logs an invalid frame instead of delivering it', () => {
|
||||
const broker = createBroker(mockLog);
|
||||
const received: WsFrame[] = [];
|
||||
broker.subscribe('sess-1', (f) => received.push(f as WsFrame));
|
||||
broker.publishFrame('sess-1', {
|
||||
type: 'delta',
|
||||
message_id: 'not-a-uuid',
|
||||
content: 'hello',
|
||||
} as never);
|
||||
expect(received).toHaveLength(0);
|
||||
expect(logErrors).toHaveLength(1);
|
||||
expect(logErrors[0]!.msg).toMatch(/ws-frame-validation-failed/);
|
||||
});
|
||||
|
||||
it('publishUserFrame drops + logs an invalid user-channel frame', () => {
|
||||
const broker = createBroker(mockLog);
|
||||
const received: WsFrame[] = [];
|
||||
broker.subscribeUser('default', (f) => received.push(f as WsFrame));
|
||||
broker.publishUserFrame('default', {
|
||||
type: 'chat_status',
|
||||
chat_id: VALID_UUID_A,
|
||||
status: 'working', // v1.12.1 dropped this enum value
|
||||
at: VALID_TIMESTAMP,
|
||||
} as never);
|
||||
expect(received).toHaveLength(0);
|
||||
expect(logErrors).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('publishFrame validation failure does not throw (no cascade into stream-phase)', () => {
|
||||
const broker = createBroker(mockLog);
|
||||
expect(() =>
|
||||
broker.publishFrame('sess-1', { type: 'unknown_type' } as never),
|
||||
).not.toThrow();
|
||||
});
|
||||
});
|
||||
357
apps/server/src/services/__tests__/xml-parser.test.ts
Normal file
357
apps/server/src/services/__tests__/xml-parser.test.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
// v1.13.16: covers the Qwen/Hermes <tool_call> parser, the new Anthropic
|
||||
// <invoke> parser, the partial-opener detector for both flavors, the unified
|
||||
// extraction helper, and the unknown-tool error formatter that downstream
|
||||
// dispatch uses to give the model a recovery hint when it drifts to a
|
||||
// Claude Code tool name like read_file instead of BooCode's view_file.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseXmlToolCall,
|
||||
parseInvokeToolCall,
|
||||
partialXmlOpenerStart,
|
||||
extractToolCallBlocks,
|
||||
XML_TOOL_OPEN,
|
||||
XML_TOOL_CLOSE,
|
||||
INVOKE_TOOL_OPEN,
|
||||
INVOKE_TOOL_CLOSE,
|
||||
} from '../inference/xml-parser.js';
|
||||
import {
|
||||
levenshtein,
|
||||
suggestToolName,
|
||||
formatUnknownToolError,
|
||||
} from '../inference/tool-suggestions.js';
|
||||
|
||||
describe('parseXmlToolCall (Qwen/Hermes <tool_call>)', () => {
|
||||
it('parses a well-formed single-parameter call', () => {
|
||||
const block = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses multi-parameter call', () => {
|
||||
const block = '<tool_call><function=grep><parameter=pattern>foo</parameter><parameter=path>src/</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo', path: 'src/' },
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON-parses numeric parameter values', () => {
|
||||
const block = '<tool_call><function=foo><parameter=count>42</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
||||
});
|
||||
|
||||
it('tolerates whitespace around = in function (v1.13.16 tightening)', () => {
|
||||
const block = '<tool_call><function = view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('tolerates whitespace around = in parameter (v1.13.16 tightening)', () => {
|
||||
const block = '<tool_call><function=view_file><parameter = path>/tmp/foo</parameter></function></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when function name is missing', () => {
|
||||
const block = '<tool_call><parameter=path>/tmp/foo</parameter></tool_call>';
|
||||
expect(parseXmlToolCall(block)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseInvokeToolCall (Anthropic <invoke>) — v1.13.16', () => {
|
||||
// Spec case 1
|
||||
it('parses a well-formed single-parameter call (spec case 1)', () => {
|
||||
const block = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
// Spec case 2
|
||||
it('parses a multi-parameter call (spec case 2)', () => {
|
||||
const block = '<invoke name="grep"><parameter name="pattern">foo</parameter><parameter name="path">src/</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'grep',
|
||||
args: { pattern: 'foo', path: 'src/' },
|
||||
});
|
||||
});
|
||||
|
||||
// Spec case 3
|
||||
it('tolerates newlines and spaces in attributes (spec case 3)', () => {
|
||||
const block = `<invoke
|
||||
name="view_file"
|
||||
>
|
||||
<parameter
|
||||
name="path"
|
||||
>/tmp/foo</parameter>
|
||||
</invoke>`;
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
// Spec case 4 (parser portion — the not-found enrichment is tested below)
|
||||
it('parses a call whose name is not a registered BooCode tool (spec case 4)', () => {
|
||||
const block = '<invoke name="read_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'read_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('supports single-quoted attribute values', () => {
|
||||
const block = "<invoke name='view_file'><parameter name='path'>/tmp/foo</parameter></invoke>";
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('JSON-parses numeric parameter values', () => {
|
||||
const block = '<invoke name="foo"><parameter name="count">42</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({ name: 'foo', args: { count: 42 } });
|
||||
});
|
||||
|
||||
it('tolerates spaces around = inside name attribute', () => {
|
||||
const block = '<invoke name = "view_file"><parameter name = "path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toEqual({
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/foo' },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when name attribute is missing', () => {
|
||||
const block = '<invoke><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when name attribute is empty', () => {
|
||||
const block = '<invoke name=""><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
expect(parseInvokeToolCall(block)).toBeNull();
|
||||
});
|
||||
|
||||
it('exports the expected delimiters', () => {
|
||||
expect(INVOKE_TOOL_OPEN).toBe('<invoke');
|
||||
expect(INVOKE_TOOL_CLOSE).toBe('</invoke>');
|
||||
expect(XML_TOOL_OPEN).toBe('<tool_call>');
|
||||
expect(XML_TOOL_CLOSE).toBe('</tool_call>');
|
||||
});
|
||||
});
|
||||
|
||||
describe('partialXmlOpenerStart (v1.13.16 — both flavors)', () => {
|
||||
it('returns -1 when the buffer is empty', () => {
|
||||
expect(partialXmlOpenerStart('')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns -1 when the buffer has no openers', () => {
|
||||
expect(partialXmlOpenerStart('plain prose, no markup')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns the index of a complete <tool_call> opener (existing)', () => {
|
||||
expect(partialXmlOpenerStart('prose <tool_call>more')).toBe(6);
|
||||
});
|
||||
|
||||
it('returns the index of a complete <invoke opener (v1.13.16)', () => {
|
||||
expect(partialXmlOpenerStart('prose <invoke name=')).toBe(6);
|
||||
});
|
||||
|
||||
it('holds a partial <tool_ prefix at end of buffer', () => {
|
||||
expect(partialXmlOpenerStart('text <tool_')).toBe(5);
|
||||
});
|
||||
|
||||
it('holds a partial <invo prefix at end of buffer (v1.13.16)', () => {
|
||||
expect(partialXmlOpenerStart('text <invo')).toBe(5);
|
||||
});
|
||||
|
||||
it('holds a bare < at end of buffer', () => {
|
||||
expect(partialXmlOpenerStart('text <')).toBe(5);
|
||||
});
|
||||
|
||||
it('returns -1 when < is followed by non-opener text', () => {
|
||||
expect(partialXmlOpenerStart('text <unknown>')).toBe(-1);
|
||||
});
|
||||
|
||||
it('returns the earliest opener when both flavors are present', () => {
|
||||
expect(partialXmlOpenerStart('xxx <tool_call>YYY <invoke>')).toBe(4);
|
||||
expect(partialXmlOpenerStart('xxx <invoke>YYY <tool_call>')).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractToolCallBlocks (v1.13.16 — unified extraction)', () => {
|
||||
// Spec case 1 (extraction-level)
|
||||
it('extracts a single <invoke> block (spec case 1)', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||
expect(result.flushed).toBe('');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
// Spec case 5: opener arrives in one chunk, closer in the next.
|
||||
it('holds the partial <invoke> chunk when the closer has not arrived (spec case 5, first chunk)', () => {
|
||||
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
||||
const result = extractToolCallBlocks(firstChunk);
|
||||
expect(result.calls).toEqual([]);
|
||||
expect(result.flushed).toBe('');
|
||||
expect(result.remaining).toBe(firstChunk);
|
||||
});
|
||||
|
||||
it('extracts the block once the closer arrives in a later chunk (spec case 5, completion)', () => {
|
||||
const firstChunk = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter>';
|
||||
const r1 = extractToolCallBlocks(firstChunk);
|
||||
const combined = r1.remaining + '</invoke>';
|
||||
const r2 = extractToolCallBlocks(combined);
|
||||
expect(r2.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||
expect(r2.flushed).toBe('');
|
||||
expect(r2.remaining).toBe('');
|
||||
});
|
||||
|
||||
// Spec case 6: prose interleaving
|
||||
it('flushes prose around a recognized block but not the markup itself (spec case 6)', () => {
|
||||
const input = 'I will read the file.\n<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>\nThanks.';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||
expect(result.flushed).toBe('I will read the file.\n\nThanks.');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
// Spec case 7 regression
|
||||
it('extracts a <tool_call> Qwen block alongside the new code path (spec case 7 regression)', () => {
|
||||
const input = '<tool_call><function=view_file><parameter=path>/tmp/foo</parameter></function></tool_call>';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/tmp/foo' } }]);
|
||||
expect(result.flushed).toBe('');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
it('extracts mixed-format blocks in source order (hand-back: shared counter)', () => {
|
||||
const input =
|
||||
'<invoke name="view_file"><parameter name="path">/a</parameter></invoke>' +
|
||||
' middle ' +
|
||||
'<tool_call><function=grep><parameter=pattern>foo</parameter></function></tool_call>';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([
|
||||
{ name: 'view_file', args: { path: '/a' } },
|
||||
{ name: 'grep', args: { pattern: 'foo' } },
|
||||
]);
|
||||
expect(result.flushed).toBe(' middle ');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
it('drops a malformed <invoke> block silently (matches existing <tool_call> behavior)', () => {
|
||||
const input = 'prose <invoke><parameter name="path">/a</parameter></invoke> trailing';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([]);
|
||||
expect(result.flushed).toBe('prose trailing');
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
|
||||
it('holds a tail with a fresh partial opener after extracting earlier complete blocks', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/a</parameter></invoke> next: <tool_';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([{ name: 'view_file', args: { path: '/a' } }]);
|
||||
expect(result.flushed).toBe(' next: ');
|
||||
expect(result.remaining).toBe('<tool_');
|
||||
});
|
||||
|
||||
it('passes plain prose straight through when no markup is present', () => {
|
||||
const input = 'just some text with a < character but no opener';
|
||||
const result = extractToolCallBlocks(input);
|
||||
expect(result.calls).toEqual([]);
|
||||
expect(result.flushed).toBe(input);
|
||||
expect(result.remaining).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('levenshtein', () => {
|
||||
it('returns 0 for identical strings', () => {
|
||||
expect(levenshtein('view_file', 'view_file')).toBe(0);
|
||||
});
|
||||
|
||||
it('returns the length when one string is empty', () => {
|
||||
expect(levenshtein('', 'view_file')).toBe(9);
|
||||
expect(levenshtein('view_file', '')).toBe(9);
|
||||
});
|
||||
|
||||
it('computes a small distance for a single-character substitution', () => {
|
||||
expect(levenshtein('cat', 'bat')).toBe(1);
|
||||
});
|
||||
|
||||
it('computes a known case: read_file → view_file is 4', () => {
|
||||
// r→v, e→i, a→e, d→w → 4 substitutions, same length
|
||||
expect(levenshtein('read_file', 'view_file')).toBe(4);
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestToolName (v1.13.16)', () => {
|
||||
const tools = [
|
||||
'view_file',
|
||||
'list_dir',
|
||||
'grep',
|
||||
'find_files',
|
||||
'view_truncated_output',
|
||||
'ask_user_input',
|
||||
'web_search',
|
||||
];
|
||||
|
||||
it('suggests the closest match when distance is small', () => {
|
||||
expect(suggestToolName('view_files', tools)).toBe('view_file');
|
||||
});
|
||||
|
||||
it('suggests via substring match when distance alone would miss', () => {
|
||||
// 'file' is a substring of multiple tools; closest by distance wins.
|
||||
expect(suggestToolName('file', tools)).toBe('view_file');
|
||||
});
|
||||
|
||||
it('returns null when nothing is close', () => {
|
||||
expect(suggestToolName('xxxx_yyyy_zzzz', tools)).toBeNull();
|
||||
});
|
||||
|
||||
it('is case-insensitive in the distance check', () => {
|
||||
expect(suggestToolName('VIEW_FILE', tools)).toBe('view_file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatUnknownToolError (v1.13.16)', () => {
|
||||
const tools = ['view_file', 'list_dir', 'grep', 'find_files'];
|
||||
|
||||
it('includes the wrong name and the available tools list', () => {
|
||||
const msg = formatUnknownToolError('read_file', tools);
|
||||
expect(msg).toContain("Tool 'read_file' not found");
|
||||
expect(msg).toContain('Available tools:');
|
||||
expect(msg).toContain('view_file');
|
||||
expect(msg).toContain('find_files');
|
||||
});
|
||||
|
||||
it('includes a suggestion when the drifted name is within threshold', () => {
|
||||
// distance(view_files, view_file) = 1 (one extra char)
|
||||
const msg = formatUnknownToolError('view_files', tools);
|
||||
expect(msg).toContain('Did you mean: view_file?');
|
||||
});
|
||||
|
||||
it('omits the suggestion clause when no tool is close enough', () => {
|
||||
const msg = formatUnknownToolError('zzzzzzz', tools);
|
||||
expect(msg).toContain("Tool 'zzzzzzz' not found");
|
||||
expect(msg).toContain('Available tools:');
|
||||
expect(msg).not.toContain('Did you mean');
|
||||
});
|
||||
|
||||
// The drift incident in the recon (chat 30d8…1be7167, msg 7ff558f4) had the
|
||||
// model emit <invoke name="read_file">. lev(read_file, view_file) = 4, so
|
||||
// the spec's threshold (<=3) doesn't suggest view_file — the model still
|
||||
// gets the available-tools list to pick from. This pins that behavior so a
|
||||
// future loosening of the threshold is a deliberate choice.
|
||||
it('does not suggest view_file for the read_file drift case (distance is 4, over threshold)', () => {
|
||||
const msg = formatUnknownToolError('read_file', tools);
|
||||
expect(msg).not.toContain('Did you mean');
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,7 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
||||
import { ALL_TOOLS } from './tools.js';
|
||||
import { ALL_TOOLS, resolveToolTier } from './tools.js';
|
||||
|
||||
// v1.8.1: global agents live at /data/AGENTS.md inside the container
|
||||
// (./data:/data:ro mount on the host). Per-project AGENTS.md at the project
|
||||
@@ -16,10 +16,62 @@ const CACHE_TTL_MS = 60_000;
|
||||
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
||||
// codecontext tools were missing), silently filtering valid tool names out
|
||||
// of agents that opted in. Single source of truth is tools.ts now.
|
||||
const ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||
let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||
let DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
||||
|
||||
export function refreshToolNames(): void {
|
||||
ALL_TOOL_NAMES = ALL_TOOLS.map((t) => t.name);
|
||||
DEFAULT_TOOLS = [...ALL_TOOL_NAMES];
|
||||
}
|
||||
const DEFAULT_TEMPERATURE = 0.7;
|
||||
|
||||
// ---- Tool glob matching (v1.15.0-mcp-multi) --------------------------------
|
||||
|
||||
/**
|
||||
* Simple glob match for tool names. Supports `*` as a wildcard for any
|
||||
* characters. No `?` or `**` — tool names are flat (no path separators).
|
||||
*/
|
||||
function simpleGlobMatch(str: string, pattern: string): boolean {
|
||||
if (pattern === '*') return true;
|
||||
if (!pattern.includes('*')) return str === pattern;
|
||||
// Escape regex metacharacters, then replace escaped \* with .*
|
||||
const regex = new RegExp(
|
||||
'^' + pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*') + '$',
|
||||
);
|
||||
return regex.test(str);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool name matches a set of glob patterns. Last-match-wins.
|
||||
* Patterns starting with `!` are deny rules.
|
||||
*
|
||||
* Examples:
|
||||
* - `["grep", "view_file"]` — exact-match whitelist (same as pre-v1.15)
|
||||
* - `["context7_*"]` — all tools from the context7 MCP server
|
||||
* - `["*", "!web_*"]` — all tools except web tools
|
||||
* - `[]` — nothing matches (agent gets no tools)
|
||||
*/
|
||||
export function matchToolGlob(toolName: string, patterns: string[]): boolean {
|
||||
let matched = false;
|
||||
for (const pattern of patterns) {
|
||||
const deny = pattern.startsWith('!');
|
||||
const glob = deny ? pattern.slice(1) : pattern;
|
||||
if (simpleGlobMatch(toolName, glob)) {
|
||||
matched = !deny;
|
||||
}
|
||||
}
|
||||
return matched;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a tools: entry is a glob pattern (contains * or starts
|
||||
* with !). Glob patterns can't be validated against the current tool list
|
||||
* since MCP tools are discovered at runtime.
|
||||
*/
|
||||
function isGlobPattern(entry: string): boolean {
|
||||
return entry.includes('*') || entry.startsWith('!');
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
@@ -37,6 +89,10 @@ interface ParsedFrontmatter {
|
||||
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
|
||||
// from the agent's toolset at runtime.
|
||||
max_tool_calls?: number;
|
||||
// v1.14.0: optional per-agent step cap. Absent → bounded only by MAX_STEPS
|
||||
// (200) in the outer loop. Integer ≥ 0; steps: 0 means "no tool calls
|
||||
// allowed" — the model responds text-only.
|
||||
steps?: number;
|
||||
}
|
||||
|
||||
function stripQuotes(s: string): string {
|
||||
@@ -112,6 +168,21 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
||||
} else {
|
||||
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
|
||||
}
|
||||
} else if (key === 'steps') {
|
||||
// v1.14.0: per-agent step cap for the outer inference loop. Integer ≥ 0.
|
||||
// steps: 0 means "no tool calls allowed" — model responds text-only.
|
||||
// Non-integer or negative values are warned and ignored (falls back to
|
||||
// MAX_STEPS ceiling), matching the max_tool_calls pattern above.
|
||||
const n = Number(valueRaw);
|
||||
if (Number.isInteger(n) && n >= 0) {
|
||||
data.steps = n;
|
||||
} else if (Number.isInteger(n)) {
|
||||
console.warn(
|
||||
`agents: steps ${n} is negative, ignoring (falling back to default)`,
|
||||
);
|
||||
} else {
|
||||
errors.push(`steps must be a non-negative integer (got "${valueRaw}")`);
|
||||
}
|
||||
}
|
||||
// Unknown keys silently ignored — forward-compat.
|
||||
}
|
||||
@@ -186,11 +257,18 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
||||
throw new Error(fmErrors.join('; '));
|
||||
}
|
||||
|
||||
// v1.13.15-tools: intersect with BOOCODE_TOOLS tier (ceiling, not expansion).
|
||||
// Unset → resolveToolTier returns ALL tool names → no narrowing.
|
||||
// v1.15.0-mcp-multi: glob patterns (entries containing * or starting with !)
|
||||
// pass through unvalidated — MCP tools are discovered at runtime and can't
|
||||
// be checked against ALL_TOOL_NAMES at parse time.
|
||||
const tierAllowed = new Set(resolveToolTier(process.env.BOOCODE_TOOLS));
|
||||
const filteredTools = Array.isArray(fm.tools)
|
||||
? fm.tools.filter((t): t is string =>
|
||||
(ALL_TOOL_NAMES as readonly string[]).includes(t),
|
||||
isGlobPattern(t) ||
|
||||
((ALL_TOOL_NAMES as readonly string[]).includes(t) && tierAllowed.has(t)),
|
||||
)
|
||||
: DEFAULT_TOOLS;
|
||||
: DEFAULT_TOOLS.filter((t) => tierAllowed.has(t));
|
||||
|
||||
return {
|
||||
id: slugify(section.name),
|
||||
@@ -201,6 +279,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
||||
tools: filteredTools,
|
||||
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
||||
max_tool_calls: typeof fm.max_tool_calls === 'number' ? fm.max_tool_calls : null,
|
||||
steps: typeof fm.steps === 'number' ? fm.steps : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -252,6 +331,22 @@ export function invalidateAgentsCache(projectPath?: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
// v1.13.8: cache-read accessor for the system-prompt prefix-fingerprint log.
|
||||
// Returns the AGENTS.md mtimes that getAgentsForProject() observed on its
|
||||
// last cache fill for this projectPath. Both fields are null when the cache
|
||||
// is cold (e.g. tests, fresh boot before the first inference turn). Does no
|
||||
// I/O — a fresh stat would race the cache and isn't what the fingerprint
|
||||
// wants anyway (we want what was actually used to resolve the agent).
|
||||
export function getAgentsMtimes(projectPath: string): {
|
||||
global: number | null;
|
||||
project: number | null;
|
||||
} {
|
||||
const key = projectPath || '__none__';
|
||||
const entry = cache.get(key);
|
||||
if (!entry) return { global: null, project: null };
|
||||
return { global: entry.globalMtime, project: entry.projectMtime };
|
||||
}
|
||||
|
||||
async function safeStat(path: string): Promise<number | null> {
|
||||
try {
|
||||
const s = await fs.stat(path);
|
||||
|
||||
255
apps/server/src/services/artifacts.ts
Normal file
255
apps/server/src/services/artifacts.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
// v1.14.x-html-artifact-panes: artifact writer + slug derivation.
|
||||
//
|
||||
// Writes Markdown and HTML artifacts to `<projectRoot>/.boocode/artifacts/`
|
||||
// as plain files. Returns `{path, url}` where:
|
||||
// - path is the absolute on-disk path
|
||||
// - url is a project-scoped REST URL pointing at the GET download route
|
||||
// registered in routes/artifacts.ts. The route streams the file with
|
||||
// Content-Disposition: attachment.
|
||||
//
|
||||
// Path safety: we do NOT use path_guard.ts (it realpaths and throws ENOENT
|
||||
// for files that don't exist yet, which artifact creation requires).
|
||||
// Instead we mirror the v1.13.18 codecontext_client.ts pattern: resolve
|
||||
// the candidate path against the realpath'd projectRoot, then verify the
|
||||
// result starts with projectRoot + sep (or equals projectRoot).
|
||||
|
||||
import { mkdir, realpath, writeFile } from 'node:fs/promises';
|
||||
import { resolve, sep } from 'node:path';
|
||||
import { PathScopeError } from './path_guard.js';
|
||||
import type { Message } from '../types/api.js';
|
||||
|
||||
export interface HtmlArtifactPayload {
|
||||
html_content: string;
|
||||
char_count: number;
|
||||
title: string | null;
|
||||
}
|
||||
|
||||
export interface ArtifactWriteResult {
|
||||
path: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
const ARTIFACT_SUBDIR = '.boocode/artifacts';
|
||||
|
||||
// ---- slug helpers ----
|
||||
|
||||
// Lowercase, replace non-alnum runs with '-', trim leading/trailing '-',
|
||||
// collapse repeated '-', cap at 60 chars. Empty → 'artifact'.
|
||||
function slugify(input: string): string {
|
||||
const cleaned = input
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.replace(/-{2,}/g, '-')
|
||||
.slice(0, 60)
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return cleaned || 'artifact';
|
||||
}
|
||||
|
||||
function firstHeading(md: string): string | null {
|
||||
// Match the first `# ` ATX heading at the start of a line.
|
||||
const m = md.match(/^[ \t]*#[ \t]+(.+?)\s*$/m);
|
||||
if (!m) return null;
|
||||
const text = m[1]?.trim() ?? '';
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function firstNWords(s: string, n: number): string {
|
||||
const words = s.trim().split(/\s+/).filter(Boolean).slice(0, n);
|
||||
return words.join(' ');
|
||||
}
|
||||
|
||||
export function deriveMarkdownSlug(messageContent: string): string {
|
||||
const heading = firstHeading(messageContent);
|
||||
if (heading) return slugify(heading);
|
||||
const sixWords = firstNWords(messageContent, 6);
|
||||
return slugify(sixWords);
|
||||
}
|
||||
|
||||
// Strip HTML tags for inner-text extraction. Crude but sufficient for slug
|
||||
// derivation — we're not rendering, just finding readable words.
|
||||
function stripTags(html: string): string {
|
||||
return html
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, ' ')
|
||||
.replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, ' ')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function extractTitleTag(html: string): string | null {
|
||||
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (!m) return null;
|
||||
const text = stripTags(m[1] ?? '').trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
function extractH1(html: string): string | null {
|
||||
const m = html.match(/<h1[^>]*>([\s\S]*?)<\/h1>/i);
|
||||
if (!m) return null;
|
||||
const text = stripTags(m[1] ?? '').trim();
|
||||
return text.length > 0 ? text : null;
|
||||
}
|
||||
|
||||
export function deriveHtmlSlug(payload: {
|
||||
html_content: string;
|
||||
title: string | null;
|
||||
}): string {
|
||||
if (payload.title && payload.title.trim().length > 0) {
|
||||
return slugify(payload.title);
|
||||
}
|
||||
const title = extractTitleTag(payload.html_content);
|
||||
if (title) return slugify(title);
|
||||
const h1 = extractH1(payload.html_content);
|
||||
if (h1) return slugify(h1);
|
||||
const inner = stripTags(payload.html_content);
|
||||
return slugify(firstNWords(inner, 6));
|
||||
}
|
||||
|
||||
// Derive title for the html_artifact part payload: <title> → first <h1> →
|
||||
// first 80 chars of inner text. Returns null if nothing useful is found.
|
||||
export function deriveHtmlTitle(html: string): string | null {
|
||||
const t = extractTitleTag(html);
|
||||
if (t) return t;
|
||||
const h1 = extractH1(html);
|
||||
if (h1) return h1;
|
||||
const inner = stripTags(html);
|
||||
if (inner.length === 0) return null;
|
||||
return inner.slice(0, 80);
|
||||
}
|
||||
|
||||
// ---- HTML detection (B4) ----
|
||||
|
||||
// Returns the inner HTML content if `text` is a recognised HTML artifact:
|
||||
// - starts with <!DOCTYPE html> (case-insensitive, whitespace-trimmed), OR
|
||||
// - wrapped entirely in a fenced ```html ... ``` block.
|
||||
// Returns null if neither matches.
|
||||
export function detectHtmlArtifact(text: string): string | null {
|
||||
const trimmed = text.trim();
|
||||
if (trimmed.length === 0) return null;
|
||||
if (/^<!doctype\s+html/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
// Fenced ```html block consuming the entire (trimmed) message. Allow an
|
||||
// optional trailing newline before the closing fence.
|
||||
const fence = trimmed.match(/^```html\s*\n([\s\S]*?)\n?```\s*$/i);
|
||||
if (fence) {
|
||||
const inner = fence[1] ?? '';
|
||||
if (/^\s*<!doctype\s+html/i.test(inner) || /<html[\s>]/i.test(inner)) {
|
||||
return inner.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---- path resolution ----
|
||||
|
||||
// Resolve `<projectRoot>/.boocode/artifacts/<filename>` and verify the
|
||||
// result stays under projectRoot. Mirrors the v1.13.18 codecontext_client.ts
|
||||
// approach: realpath projectRoot first, then prefix-check the candidate.
|
||||
// Throws on escape.
|
||||
async function resolveArtifactPath(
|
||||
projectRoot: string,
|
||||
filename: string,
|
||||
): Promise<{ resolvedRoot: string; artifactsDir: string; absPath: string }> {
|
||||
const resolvedRoot = await realpath(projectRoot);
|
||||
const artifactsDir = resolve(resolvedRoot, ARTIFACT_SUBDIR);
|
||||
const absPath = resolve(artifactsDir, filename);
|
||||
// Lexical prefix check on the resolved candidates. (The `!== resolvedRoot`
|
||||
// branch was dead — ARTIFACT_SUBDIR is non-empty so artifactsDir always
|
||||
// differs from resolvedRoot.)
|
||||
if (!artifactsDir.startsWith(resolvedRoot + sep)) {
|
||||
throw new PathScopeError(
|
||||
`artifacts dir escapes project root: ${artifactsDir}`,
|
||||
);
|
||||
}
|
||||
if (!absPath.startsWith(artifactsDir + sep)) {
|
||||
throw new PathScopeError(
|
||||
`artifact filename escapes artifacts dir: ${filename}`,
|
||||
);
|
||||
}
|
||||
return { resolvedRoot, artifactsDir, absPath };
|
||||
}
|
||||
|
||||
// After mkdir, realpath the artifacts dir and re-verify it stays under
|
||||
// resolvedRoot. Closes the symlink-escape gap: if `.boocode/artifacts` (or
|
||||
// any ancestor below resolvedRoot) is a symlink pointing outside the
|
||||
// project, the lexical check in resolveArtifactPath passes but the actual
|
||||
// write lands outside the sandbox. Throws PathScopeError on escape.
|
||||
async function assertArtifactsDirSafe(
|
||||
artifactsDir: string,
|
||||
resolvedRoot: string,
|
||||
): Promise<void> {
|
||||
const realDir = await realpath(artifactsDir);
|
||||
if (realDir !== resolvedRoot && !realDir.startsWith(resolvedRoot + sep)) {
|
||||
throw new PathScopeError(
|
||||
`artifacts dir resolves outside project root: ${realDir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Pure decision helper for whether finalizeCompletion should write the
|
||||
// `html_artifact` part. Exported for unit testing the cap-skip branch.
|
||||
// Returns `{write: true, byteLen}` when the payload is under the cap, or
|
||||
// `{write: false, byteLen, reason: 'cap_exceeded'}` when oversize.
|
||||
export type HtmlArtifactDecision =
|
||||
| { write: true; byteLen: number }
|
||||
| { write: false; byteLen: number; reason: 'cap_exceeded' };
|
||||
|
||||
export function decideHtmlArtifactWrite(
|
||||
htmlContent: string,
|
||||
): HtmlArtifactDecision {
|
||||
const byteLen = Buffer.byteLength(htmlContent, 'utf8');
|
||||
if (byteLen > HTML_ARTIFACT_MAX_BYTES) {
|
||||
return { write: false, byteLen, reason: 'cap_exceeded' };
|
||||
}
|
||||
return { write: true, byteLen };
|
||||
}
|
||||
|
||||
function buildUrl(projectId: string, filename: string): string {
|
||||
return `/api/projects/${projectId}/artifacts/${encodeURIComponent(filename)}`;
|
||||
}
|
||||
|
||||
export interface WriteContext {
|
||||
projectId: string;
|
||||
projectRoot: string;
|
||||
}
|
||||
|
||||
export async function writeMarkdownArtifact(
|
||||
message: Pick<Message, 'content'>,
|
||||
ctx: WriteContext,
|
||||
): Promise<ArtifactWriteResult> {
|
||||
const slug = deriveMarkdownSlug(message.content);
|
||||
const filename = `${slug}-${Date.now()}.md`;
|
||||
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
|
||||
ctx.projectRoot,
|
||||
filename,
|
||||
);
|
||||
await mkdir(artifactsDir, { recursive: true });
|
||||
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
|
||||
await writeFile(absPath, message.content, 'utf8');
|
||||
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
|
||||
}
|
||||
|
||||
export async function writeHtmlArtifact(
|
||||
payload: HtmlArtifactPayload,
|
||||
ctx: WriteContext,
|
||||
): Promise<ArtifactWriteResult> {
|
||||
const slug = deriveHtmlSlug(payload);
|
||||
const filename = `${slug}-${Date.now()}.html`;
|
||||
const { resolvedRoot, artifactsDir, absPath } = await resolveArtifactPath(
|
||||
ctx.projectRoot,
|
||||
filename,
|
||||
);
|
||||
await mkdir(artifactsDir, { recursive: true });
|
||||
await assertArtifactsDirSafe(artifactsDir, resolvedRoot);
|
||||
await writeFile(absPath, payload.html_content, 'utf8');
|
||||
return { path: absPath, url: buildUrl(ctx.projectId, filename) };
|
||||
}
|
||||
|
||||
// 1MB cap on HTML artifacts (proposal S6). Larger payloads are not written
|
||||
// to the `html_artifact` part — the assistant text lands as plain content
|
||||
// and a warning is logged. Streaming abort was considered but the graceful
|
||||
// "no artifact, plain text falls back" path is simpler and lossless from
|
||||
// the user's perspective.
|
||||
export const HTML_ARTIFACT_MAX_BYTES = 1_048_576;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user