Compare commits
50 Commits
v1.12.3-st
...
v2.0.4-har
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| ec8593cf77 | |||
| a08d809b73 | |||
| ac1a71f583 | |||
| 13c3aa5b4e | |||
| c2c4f78a26 | |||
| 1cb6eee24c | |||
| ca64bf9f0a | |||
| 9ef00c0268 | |||
| c87df6981a | |||
| 8fa7b7fce9 | |||
| ea468ca7fb |
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
|
# Internal Tailscale address that bypasses Authelia. Override if you
|
||||||
# point BooCode at a different SearXNG instance.
|
# point BooCode at a different SearXNG instance.
|
||||||
SEARXNG_URL=http://100.114.205.53:8888
|
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
|
node_modules
|
||||||
dist
|
dist
|
||||||
.env
|
.env
|
||||||
|
CLAUDE.local.md
|
||||||
*.log
|
*.log
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.vite
|
.vite
|
||||||
coverage
|
coverage
|
||||||
secrets/
|
secrets/
|
||||||
data/
|
data/*
|
||||||
|
!data/AGENTS.md
|
||||||
|
!data/skills/
|
||||||
|
!data/mcp.json
|
||||||
|
|||||||
21
BOOCHAT.md
21
BOOCHAT.md
@@ -1,7 +1,5 @@
|
|||||||
# BooChat
|
# BooChat
|
||||||
|
|
||||||
You are the assistant running inside BooChat — a self-hosted developer chat app.
|
|
||||||
|
|
||||||
## Capabilities
|
## Capabilities
|
||||||
|
|
||||||
- Read-only file tools: `view_file`, `list_dir`, `grep`, `find_files`
|
- 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
|
- 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
|
- 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.
|
- 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
|
## 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`
|
## You cannot
|
||||||
- Write tools (pending): `write_file`, `edit_file`, `delete_file` (all gated through pending-changes sandbox)
|
|
||||||
- Shell (pending): `run_command` (Docker-isolated per-session)
|
|
||||||
|
|
||||||
## 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`
|
## Pending changes discipline
|
||||||
- `run_command` executes inside the session sandbox, not the host
|
|
||||||
- No git commits, pushes, or pulls — Sam owns those
|
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.
|
||||||
- Stop and ask before destructive operations (delete, overwrite, recreate)
|
|
||||||
|
|
||||||
## Behavior
|
## Behavior
|
||||||
|
|
||||||
- Show a diff preview before any write
|
- Show diffs clearly. Explain what you're changing and why.
|
||||||
- Group related edits into a single `/apply` batch
|
- For multi-file changes, organize as a logical unit (one task = one coherent change set).
|
||||||
- If a tool fails, surface the error verbatim — don't paper over it
|
- 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.
|
||||||
|
|||||||
203
CHANGELOG.md
Normal file
203
CHANGELOG.md
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# 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.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.
|
||||||
49
CLAUDE.md
49
CLAUDE.md
@@ -46,15 +46,37 @@ Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `app
|
|||||||
- **Zod** for request validation and config parsing.
|
- **Zod** for request validation and config parsing.
|
||||||
|
|
||||||
Key services:
|
Key services:
|
||||||
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max depth 15, see `MAX_TOOL_LOOP_DEPTH`), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker. **`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 here, not in 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.
|
||||||
- **`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.
|
- **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:
|
||||||
- **`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.
|
- **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.
|
||||||
- **`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).
|
- **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).
|
||||||
|
- **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/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/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||||
|
|
||||||
Route registration: all routes registered in `index.ts` via `register*Routes(app, sql, ...)` functions. Routes are in `routes/*.ts`.
|
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. Separate Fastify server at port 9502, same docker network (`boocode_net`).
|
||||||
|
- **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 (Dockerfile builds server → coder).
|
||||||
|
- `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 `http://boocoder:3000/api/*`. WS connects directly to `:9502`.
|
||||||
|
|
||||||
### Frontend (`apps/web/src/`)
|
### Frontend (`apps/web/src/`)
|
||||||
|
|
||||||
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
|
- **React 18** + React Router v6 + **Tailwind v4** + shadcn/radix-ui primitives.
|
||||||
@@ -87,26 +109,32 @@ Font / CSS pipeline (apps/web):
|
|||||||
|
|
||||||
### Multi-pane workspace
|
### Multi-pane workspace
|
||||||
|
|
||||||
Sessions hold 1–5 panes (chat / empty / placeholder terminal+agent). Workspace pane state is **client-side only** (localStorage key `boocode.workspace.panes.<sessionId>`); the legacy `session_panes` table and its REST endpoints are deprecated — no `/api/panes/*` routes exist. Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Sessions 1:N chats; chats own messages. Tab reorder via native HTML5 drag events.
|
Sessions hold 1–5 panes (chat / empty / placeholder terminal+agent). v1.12.1 moved pane state from per-device localStorage to `sessions.workspace_panes jsonb` for cross-device sync. `PATCH /api/sessions/:id/workspace` persists; `session_workspace_updated` user-channel frame broadcasts to every device watching the session. `useWorkspacePanes` debounces saves 300ms and dedups echoes by JSON string. Legacy localStorage key `boocode.workspace.panes.<sessionId>` is read once on first hydrate (one-time seed-and-delete migration when server is empty but localStorage has data); no longer written. The deprecated `session_panes` table was dropped. `validatePanes(validChatIds)` prunes panes referencing chat IDs that no longer exist (called by `useSessionChats` after the chat list fetch lands). Each chat lives in at most one pane; tab strip is per-pane and tracks `chatIds[]` + `activeChatIdx`. Tab reorder via native HTML5 drag events.
|
||||||
|
|
||||||
## Database
|
## Database
|
||||||
|
|
||||||
PostgreSQL 16. Tables: `projects`, `sessions`, `chats`, `messages`, `settings`, `session_panes` (deprecated). 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`.
|
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`.
|
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`.
|
||||||
|
|
||||||
Position-shift pattern for panes (legacy `session_panes` table): negate-and-restore to avoid UNIQUE(session_id, position) collisions during reorder/insert/delete. Sentinel value -100 for the moving pane.
|
|
||||||
|
|
||||||
## Environment
|
## 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`. Same Tailscale IP binding as BooChat. Health reports tool count: `{"ok":true,"db":true,"tools":30}`.
|
||||||
|
|
||||||
## Workflow
|
## Workflow
|
||||||
|
|
||||||
- Sam reviews all diffs and commits manually. Do not commit unless explicitly asked.
|
- 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).
|
- 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`.
|
- 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.
|
- 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/boocode' 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.
|
- 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).
|
- 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.
|
- `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.
|
||||||
@@ -116,6 +144,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.
|
- `/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.
|
- 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 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.
|
- `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
|
## Conventions
|
||||||
@@ -125,6 +155,7 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
|||||||
- TypeScript strict mode. Both apps share `tsconfig.base.json`.
|
- TypeScript strict mode. Both apps share `tsconfig.base.json`.
|
||||||
- Server uses NodeNext module resolution (`.js` extensions in imports).
|
- Server uses NodeNext module resolution (`.js` extensions in imports).
|
||||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||||
|
- **Adding a new WS frame type** requires updating BOTH the server's `InferenceFrame` (loose `type:` union + optional fields in `services/inference/turn.ts`) AND the web `WsFrame` (strict discriminated union in `apps/web/src/api/types.ts`). Server publish is permissive; the frontend type is the wire-format gate. The `'usage'` frame added in v1.12.2 needed both sides; missing the web side silently drops the frame at JSON-parse.
|
||||||
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
- shadcn primitives live in `components/ui/`. Don't modify them unless adding a new primitive.
|
||||||
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
- `inferLanguage()` from `lib/attachments.ts` is the canonical file-extension-to-language map. `CodeBlock.tsx` keeps its own `LANG_MAP` because it also resolves markdown fence names.
|
||||||
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
- Two UI event buses: `hooks/sessionEvents.ts` for DB-state events (chat_created, session_updated); `lib/events.ts` for ephemeral UI (`sendToTerminal`, `terminalsRegistry`). Don't merge — different subscriber lifecycles.
|
||||||
@@ -137,3 +168,5 @@ 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.
|
- 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.
|
- 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).
|
- 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.
|
||||||
|
- **Docker build order for workspace deps**: the Dockerfile must `COPY` + `RUN pnpm build` the provider app BEFORE the consumer app. `apps/coder/Dockerfile` builds `apps/server` first, then `apps/coder`.
|
||||||
|
|||||||
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);
|
||||||
|
}
|
||||||
45
apps/coder/src/config.ts
Normal file
45
apps/coder/src/config.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
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(),
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
apps/coder/src/index.ts
Normal file
187
apps/coder/src/index.ts
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
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 { 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);
|
||||||
|
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);
|
||||||
|
});
|
||||||
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
126
apps/coder/src/routes/messages.ts
Normal file
126
apps/coder/src/routes/messages.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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(),
|
||||||
|
});
|
||||||
|
|
||||||
|
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: chatId } = parsed.data;
|
||||||
|
|
||||||
|
// Validate session exists
|
||||||
|
const sessionRows = await sql<{ id: string }[]>`
|
||||||
|
SELECT id FROM sessions WHERE id = ${sessionId}
|
||||||
|
`;
|
||||||
|
if (sessionRows.length === 0) {
|
||||||
|
reply.code(404);
|
||||||
|
return { error: 'session not found' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate chat belongs to session and is open
|
||||||
|
const chatRows = await sql<{ id: string; session_id: string }[]>`
|
||||||
|
SELECT id, session_id FROM chats WHERE id = ${chatId} 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' };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 + streaming assistant row in a transaction
|
||||||
|
const result = await sql.begin(async (tx) => {
|
||||||
|
const [userMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'user', ${content}, 'complete', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
|
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', clock_timestamp())
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chatId}`;
|
||||||
|
return { user_message_id: userMsg!.id, assistant_message_id: assistantMsg!.id };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Publish user message frames so WS subscribers see it immediately
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_started',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
role: 'user',
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'delta',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
content,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
broker.publishFrame(sessionId, {
|
||||||
|
type: 'message_complete',
|
||||||
|
message_id: result.user_message_id,
|
||||||
|
chat_id: chatId,
|
||||||
|
} as unknown as WsFrame);
|
||||||
|
|
||||||
|
// Enqueue inference — the runner will stream assistant deltas via broker
|
||||||
|
inference.enqueue(sessionId, chatId, result.assistant_message_id, 'default');
|
||||||
|
|
||||||
|
reply.code(202);
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
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());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
51
apps/coder/src/schema.sql
Normal file
51
apps/coder/src/schema.sql
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
-- 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'))
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
-- Human inbox: tasks needing attention
|
||||||
|
CREATE OR REPLACE VIEW human_inbox AS
|
||||||
|
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
271
apps/coder/src/services/acp-dispatch.ts
Normal file
271
apps/coder/src/services/acp-dispatch.ts
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
/**
|
||||||
|
* ACP dispatch — runs ACP-capable agents (opencode, goose) on the host via SSH.
|
||||||
|
*
|
||||||
|
* Uses the @agentclientprotocol/sdk to establish a structured JSON-RPC session
|
||||||
|
* with the agent subprocess. The SSH tunnel provides stdio transport.
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. SSH to host, start `opencode acp` (or `goose acp`) in the worktree
|
||||||
|
* 2. Wrap SSH 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 { sshSpawn } from './ssh.js';
|
||||||
|
|
||||||
|
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;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
log: FastifyBaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map agent name to the ACP command it exposes. */
|
||||||
|
function acpCommand(agent: string): string | null {
|
||||||
|
switch (agent) {
|
||||||
|
case 'opencode':
|
||||||
|
return 'opencode acp';
|
||||||
|
case 'goose':
|
||||||
|
return 'goose 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, signal, log } = opts;
|
||||||
|
|
||||||
|
const cmd = acpCommand(agent);
|
||||||
|
if (!cmd) {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
output: `Agent '${agent}' does not support ACP.`,
|
||||||
|
toolCalls: [],
|
||||||
|
stopReason: 'error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spawn SSH with the ACP command running in the worktree
|
||||||
|
const escapedPath = worktreePath.replace(/'/g, "'\\''");
|
||||||
|
const fullCommand = `cd '${escapedPath}' && ${cmd}`;
|
||||||
|
|
||||||
|
log.info({ agent, worktreePath }, 'acp-dispatch: spawning');
|
||||||
|
const child = sshSpawn(fullCommand);
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
70
apps/coder/src/services/agent-probe.ts
Normal file
70
apps/coder/src/services/agent-probe.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import type { Sql } from '../db.js';
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { sshExec } from './ssh.js';
|
||||||
|
|
||||||
|
const KNOWN_AGENTS: Array<{ name: string; supportsAcp: boolean }> = [
|
||||||
|
{ name: 'opencode', supportsAcp: true },
|
||||||
|
{ name: 'goose', supportsAcp: true },
|
||||||
|
{ name: 'claude', supportsAcp: false },
|
||||||
|
{ name: 'pi', supportsAcp: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe for available agents on the HOST via SSH.
|
||||||
|
*
|
||||||
|
* The boocoder container can't run agents locally — they live on the host.
|
||||||
|
* We SSH to the host (same mechanism BooTerm uses) and check which agent
|
||||||
|
* binaries are on PATH.
|
||||||
|
*/
|
||||||
|
export async function probeAgents(sql: Sql, log: FastifyBaseLogger): Promise<void> {
|
||||||
|
log.info('agent-probe: scanning HOST for known agents via SSH');
|
||||||
|
|
||||||
|
for (const agent of KNOWN_AGENTS) {
|
||||||
|
try {
|
||||||
|
// Check if the agent binary is on the host's PATH
|
||||||
|
const whichResult = await sshExec(`which ${agent.name}`, { timeoutMs: 10_000 });
|
||||||
|
const installPath = whichResult.stdout.trim();
|
||||||
|
if (whichResult.exitCode !== 0 || !installPath) continue;
|
||||||
|
|
||||||
|
// Get version
|
||||||
|
let version: string | null = null;
|
||||||
|
try {
|
||||||
|
const verResult = await sshExec(`${agent.name} --version`, { timeoutMs: 15_000 });
|
||||||
|
if (verResult.exitCode === 0) {
|
||||||
|
version = verResult.stdout.trim().slice(0, 100);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Some agents may not support --version — that's fine
|
||||||
|
}
|
||||||
|
|
||||||
|
// For ACP-capable agents, verify ACP mode actually works
|
||||||
|
let supportsAcp = agent.supportsAcp;
|
||||||
|
if (supportsAcp) {
|
||||||
|
try {
|
||||||
|
const acpCheck = await sshExec(`${agent.name} acp --help`, { timeoutMs: 10_000 });
|
||||||
|
supportsAcp = acpCheck.exitCode === 0;
|
||||||
|
} catch {
|
||||||
|
supportsAcp = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UPSERT into available_agents
|
||||||
|
await sql`
|
||||||
|
INSERT INTO available_agents (name, install_path, version, supports_acp, last_probed_at)
|
||||||
|
VALUES (${agent.name}, ${installPath}, ${version}, ${supportsAcp}, clock_timestamp())
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
log.info({ agent: agent.name, version, installPath, supportsAcp }, 'agent-probe: found on host');
|
||||||
|
} catch (err) {
|
||||||
|
// SSH failed or agent not found — skip silently
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
log.debug({ agent: agent.name, err: msg }, 'agent-probe: not found or SSH failed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('agent-probe: scan complete');
|
||||||
|
}
|
||||||
384
apps/coder/src/services/dispatcher.ts
Normal file
384
apps/coder/src/services/dispatcher.ts
Normal file
@@ -0,0 +1,384 @@
|
|||||||
|
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 }[]>`
|
||||||
|
SELECT id, project_id, input, agent, model
|
||||||
|
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 }): 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 }[]>`
|
||||||
|
SELECT name, supports_acp FROM available_agents WHERE name = ${task.agent}
|
||||||
|
`;
|
||||||
|
if (agentRow) {
|
||||||
|
await runExternalAgent(task, agentRow.supports_acp);
|
||||||
|
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 }): 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 },
|
||||||
|
supportsAcp: boolean,
|
||||||
|
): 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<{ root_path: string | null }[]>`
|
||||||
|
SELECT root_path FROM projects WHERE id = ${task.project_id}
|
||||||
|
`;
|
||||||
|
const projectPath = project?.root_path;
|
||||||
|
if (!projectPath) {
|
||||||
|
await sql`
|
||||||
|
UPDATE tasks
|
||||||
|
SET state = 'failed', ended_at = clock_timestamp(), output_summary = 'Project has no root_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}
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Create session + chat for this task (same as Path A — for output tracking)
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
const sessionId = session!.id;
|
||||||
|
|
||||||
|
const [chat] = await sql<{ id: string }[]>`
|
||||||
|
INSERT INTO chats (session_id, name, status)
|
||||||
|
VALUES (${sessionId}, 'External agent 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 for the task input
|
||||||
|
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,
|
||||||
|
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,
|
||||||
|
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
|
||||||
|
`;
|
||||||
|
}
|
||||||
139
apps/coder/src/services/pty-dispatch.ts
Normal file
139
apps/coder/src/services/pty-dispatch.ts
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
/**
|
||||||
|
* PTY dispatch — runs external agents on the host via SSH.
|
||||||
|
*
|
||||||
|
* For agents without ACP support (claude, pi), we pipe the task into their
|
||||||
|
* non-interactive mode and capture stdout/stderr. The agent runs in a git
|
||||||
|
* worktree so it can modify files freely.
|
||||||
|
*
|
||||||
|
* Supported agents:
|
||||||
|
* - claude: `claude -p --model <model>` (print mode, reads task from stdin)
|
||||||
|
* - opencode: `echo <task> | opencode` (stdin pipe — exact flags TBD)
|
||||||
|
* - goose: stub (not yet supported)
|
||||||
|
* - pi: stub (not yet supported)
|
||||||
|
*/
|
||||||
|
import type { FastifyBaseLogger } from 'fastify';
|
||||||
|
import { sshSpawnWithStdin } from './ssh.js';
|
||||||
|
|
||||||
|
export interface DispatchResult {
|
||||||
|
exitCode: number;
|
||||||
|
stdout: string;
|
||||||
|
stderr: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PtyDispatchOpts {
|
||||||
|
agent: string;
|
||||||
|
task: string;
|
||||||
|
worktreePath: string;
|
||||||
|
model?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
log: FastifyBaseLogger;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the shell command that runs the agent non-interactively.
|
||||||
|
* The command will be executed inside `cd <worktreePath> && ...`.
|
||||||
|
*/
|
||||||
|
function buildAgentCommand(agent: string, task: string, model?: string): string | null {
|
||||||
|
// Escape the task for embedding in a shell command
|
||||||
|
const escapedTask = task.replace(/'/g, "'\\''");
|
||||||
|
|
||||||
|
switch (agent) {
|
||||||
|
case 'claude':
|
||||||
|
// Claude Code's print mode: reads prompt from stdin, runs autonomously, prints result
|
||||||
|
return model
|
||||||
|
? `echo '${escapedTask}' | claude -p --model '${model}'`
|
||||||
|
: `echo '${escapedTask}' | claude -p`;
|
||||||
|
|
||||||
|
case 'opencode':
|
||||||
|
// opencode non-interactive: pipe task via stdin
|
||||||
|
// NOTE: exact flags may vary — opencode may need --non-interactive or --pipe
|
||||||
|
return model
|
||||||
|
? `echo '${escapedTask}' | opencode --model '${model}'`
|
||||||
|
: `echo '${escapedTask}' | opencode`;
|
||||||
|
|
||||||
|
case 'goose':
|
||||||
|
// Not yet verified for non-interactive use
|
||||||
|
return null;
|
||||||
|
|
||||||
|
case 'pi':
|
||||||
|
// Not yet verified for non-interactive use
|
||||||
|
return null;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dispatch a task to an external agent via SSH.
|
||||||
|
*
|
||||||
|
* The agent runs in the worktree directory on the host. stdout/stderr are
|
||||||
|
* captured in full and returned. The SSH process is killed on abort signal.
|
||||||
|
*/
|
||||||
|
export async function dispatchViaPty(opts: PtyDispatchOpts): Promise<DispatchResult> {
|
||||||
|
const { agent, task, worktreePath, model, signal, log } = opts;
|
||||||
|
|
||||||
|
const agentCmd = buildAgentCommand(agent, task, model);
|
||||||
|
if (!agentCmd) {
|
||||||
|
return {
|
||||||
|
exitCode: 1,
|
||||||
|
stdout: '',
|
||||||
|
stderr: `Agent '${agent}' is not yet supported for PTY dispatch.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrap in cd to the worktree
|
||||||
|
const fullCommand = `cd '${worktreePath.replace(/'/g, "'\\''")}' && ${agentCmd}`;
|
||||||
|
|
||||||
|
log.info({ agent, worktreePath }, 'pty-dispatch: starting');
|
||||||
|
|
||||||
|
return new Promise<DispatchResult>((resolve, reject) => {
|
||||||
|
const child = sshSpawnWithStdin(fullCommand, '');
|
||||||
|
// Note: sshSpawnWithStdin already closes stdin. For agents that read from
|
||||||
|
// stdin via echo piping, the command itself handles the piping on the remote
|
||||||
|
// side. We just need the SSH tunnel.
|
||||||
|
|
||||||
|
// Actually, re-think: sshSpawnWithStdin writes input and closes stdin on the
|
||||||
|
// LOCAL ssh process. But the remote command is `echo '...' | agent`, which
|
||||||
|
// provides its own stdin. So we should use sshSpawn (no local stdin needed)
|
||||||
|
// or just let the empty stdin close — the remote shell handles piping internally.
|
||||||
|
// This is fine as-is because the echo piping happens WITHIN the remote shell command.
|
||||||
|
|
||||||
|
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');
|
||||||
|
// Give it a moment then force-kill
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
126
apps/coder/src/services/ssh.ts
Normal file
126
apps/coder/src/services/ssh.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
/**
|
||||||
|
* 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,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"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": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
"build": "tsc && node -e \"import('node:fs').then(fs=>fs.copyFileSync('src/schema.sql','dist/schema.sql'))\"",
|
||||||
@@ -11,8 +28,11 @@
|
|||||||
"test": "vitest run"
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||||
"@fastify/static": "^7.0.4",
|
"@fastify/static": "^7.0.4",
|
||||||
"@fastify/websocket": "^10.0.1",
|
"@fastify/websocket": "^10.0.1",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||||
|
"ai": "^6.0.190",
|
||||||
"fastify": "^4.28.1",
|
"fastify": "^4.28.1",
|
||||||
"postgres": "^3.4.4",
|
"postgres": "^3.4.4",
|
||||||
"ws": "^8.18.0",
|
"ws": "^8.18.0",
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const ConfigSchema = z.object({
|
|||||||
GITEA_USER: z.string().default('indifferentketchup'),
|
GITEA_USER: z.string().default('indifferentketchup'),
|
||||||
GITEA_TOKEN: z.string().optional(),
|
GITEA_TOKEN: z.string().optional(),
|
||||||
GITEA_SSH_HOST: z.string().default('100.114.205.53:2222'),
|
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(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type Config = z.infer<typeof ConfigSchema>;
|
export type Config = z.infer<typeof ConfigSchema>;
|
||||||
|
|||||||
@@ -10,17 +10,24 @@ import { registerProjectRoutes } from './routes/projects.js';
|
|||||||
import { registerSessionRoutes } from './routes/sessions.js';
|
import { registerSessionRoutes } from './routes/sessions.js';
|
||||||
import { registerSettingsRoutes } from './routes/settings.js';
|
import { registerSettingsRoutes } from './routes/settings.js';
|
||||||
import { registerMessageRoutes } from './routes/messages.js';
|
import { registerMessageRoutes } from './routes/messages.js';
|
||||||
|
import { registerArtifactRoutes } from './routes/artifacts.js';
|
||||||
import { registerChatRoutes } from './routes/chats.js';
|
import { registerChatRoutes } from './routes/chats.js';
|
||||||
import { registerSidebarRoutes } from './routes/sidebar.js';
|
import { registerSidebarRoutes } from './routes/sidebar.js';
|
||||||
import { registerWebSocket } from './routes/ws.js';
|
import { registerWebSocket } from './routes/ws.js';
|
||||||
import { registerModelRoutes } from './routes/models.js';
|
import { registerModelRoutes } from './routes/models.js';
|
||||||
import { registerAgentRoutes } from './routes/agents.js';
|
import { registerAgentRoutes } from './routes/agents.js';
|
||||||
import { registerSkillsRoutes } from './routes/skills.js';
|
import { registerSkillsRoutes } from './routes/skills.js';
|
||||||
import { createInferenceRunner } from './services/inference.js';
|
import { registerToolsRoutes } from './routes/tools.js';
|
||||||
|
import { createInferenceRunner } from './services/inference/index.js';
|
||||||
import { createBroker } from './services/broker.js';
|
import { createBroker } from './services/broker.js';
|
||||||
import { listSkills } from './services/skills.js';
|
import { listSkills } from './services/skills.js';
|
||||||
import * as compaction from './services/compaction.js';
|
import * as compaction from './services/compaction.js';
|
||||||
import { configureModelContext } from './services/model-context.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() {
|
async function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
@@ -66,6 +73,23 @@ async function main() {
|
|||||||
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
// default_generation_settings.n_ctx — the value persisted as messages.ctx_max.
|
||||||
configureModelContext({ llamaSwapUrl: config.LLAMA_SWAP_URL });
|
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);
|
await app.register(fastifyWebsocket);
|
||||||
|
|
||||||
app.get('/api/health', async () => {
|
app.get('/api/health', async () => {
|
||||||
@@ -73,7 +97,7 @@ async function main() {
|
|||||||
return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
|
return { status: dbOk ? 'ok' : 'degraded', db: dbOk };
|
||||||
});
|
});
|
||||||
|
|
||||||
const broker = createBroker();
|
const broker = createBroker(app.log);
|
||||||
|
|
||||||
registerProjectRoutes(app, sql, config, broker);
|
registerProjectRoutes(app, sql, config, broker);
|
||||||
registerSessionRoutes(app, sql, config, broker);
|
registerSessionRoutes(app, sql, config, broker);
|
||||||
@@ -82,6 +106,7 @@ async function main() {
|
|||||||
registerAgentRoutes(app, sql);
|
registerAgentRoutes(app, sql);
|
||||||
registerSidebarRoutes(app, sql);
|
registerSidebarRoutes(app, sql);
|
||||||
registerChatRoutes(app, sql, broker);
|
registerChatRoutes(app, sql, broker);
|
||||||
|
registerToolsRoutes(app, sql);
|
||||||
|
|
||||||
// Batch 9.6: warm the skills cache at boot and surface the count. Empty or
|
// 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.
|
// missing /data/skills is non-fatal — the skill tools just return empty.
|
||||||
@@ -98,7 +123,9 @@ async function main() {
|
|||||||
config,
|
config,
|
||||||
log: app.log,
|
log: app.log,
|
||||||
publish: (sessionId, frame) => {
|
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'
|
// v1.11: broker handle for compaction.process to publish 'compacted'
|
||||||
// frames on the per-session channel. Inference's regular publish path
|
// frames on the per-session channel. Inference's regular publish path
|
||||||
@@ -107,10 +134,10 @@ async function main() {
|
|||||||
broker,
|
broker,
|
||||||
},
|
},
|
||||||
(user, frame) => {
|
(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) => {
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
},
|
},
|
||||||
@@ -126,64 +153,96 @@ async function main() {
|
|||||||
},
|
},
|
||||||
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
hasActiveInference: (chatId) => inference.hasActive(chatId),
|
||||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||||
broker.publish(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
message_id: userMessageId,
|
message_id: userMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
});
|
});
|
||||||
broker.publish(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'delta',
|
type: 'delta',
|
||||||
message_id: userMessageId,
|
message_id: userMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
broker.publish(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
message_id: userMessageId,
|
message_id: userMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
publishMessagesDeleted: (sessionId, chatId, messageIds) => {
|
publishMessagesDeleted: (sessionId, chatId, messageIds) => {
|
||||||
broker.publish(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'messages_deleted',
|
type: 'messages_deleted',
|
||||||
message_ids: messageIds,
|
message_ids: messageIds,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
publishSessionFrame: (sessionId, frame) => {
|
publishSessionFrame: (sessionId, frame) => {
|
||||||
broker.publish(sessionId, frame);
|
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
registerArtifactRoutes(app, sql);
|
||||||
registerSkillsRoutes(app, sql, {
|
registerSkillsRoutes(app, sql, {
|
||||||
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
enqueueInference: (sessionId, chatId, assistantId, user) => {
|
||||||
inference.enqueue(sessionId, chatId, assistantId, user);
|
inference.enqueue(sessionId, chatId, assistantId, user);
|
||||||
},
|
},
|
||||||
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
publishUserMessage: (sessionId, chatId, userMessageId, content) => {
|
||||||
broker.publish(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_started',
|
type: 'message_started',
|
||||||
message_id: userMessageId,
|
message_id: userMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
role: 'user',
|
role: 'user',
|
||||||
});
|
});
|
||||||
broker.publish(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'delta',
|
type: 'delta',
|
||||||
message_id: userMessageId,
|
message_id: userMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
content,
|
content,
|
||||||
});
|
});
|
||||||
broker.publish(sessionId, {
|
broker.publishFrame(sessionId, {
|
||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
message_id: userMessageId,
|
message_id: userMessageId,
|
||||||
chat_id: chatId,
|
chat_id: chatId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
publishSessionFrame: (sessionId, frame) => {
|
publishSessionFrame: (sessionId, frame) => {
|
||||||
broker.publish(sessionId, frame);
|
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
registerWebSocket(app, sql, broker);
|
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');
|
const webDist = process.env.WEB_DIST_PATH ?? resolve(process.cwd(), '../web/dist');
|
||||||
if (existsSync(webDist)) {
|
if (existsSync(webDist)) {
|
||||||
await app.register(fastifyStatic, {
|
await app.register(fastifyStatic, {
|
||||||
@@ -201,6 +260,52 @@ async function main() {
|
|||||||
app.log.info(`serving static frontend from ${webDist}`);
|
app.log.info(`serving static frontend from ${webDist}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// v1.13.3: periodic in-process sweeper for streaming rows orphaned by a
|
||||||
|
// mid-session crash. The boot sweep (above) only fires once at startup;
|
||||||
|
// this loop catches the in-flight case. 60s cadence + 5-min threshold
|
||||||
|
// matches the boot sweep so behavior is consistent. Publishes
|
||||||
|
// chat_status='idle' on the user channel so the UI dot drops without a
|
||||||
|
// refresh — same pattern as handleAbortOrError.
|
||||||
|
const SWEEP_INTERVAL_MS = 60_000;
|
||||||
|
const sweepStaleStreaming = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const rows = await sql<{ id: string; chat_id: string }[]>`
|
||||||
|
UPDATE messages
|
||||||
|
SET status = 'failed', finished_at = clock_timestamp()
|
||||||
|
WHERE status = 'streaming'
|
||||||
|
AND created_at < NOW() - INTERVAL '5 minutes'
|
||||||
|
RETURNING id, chat_id
|
||||||
|
`;
|
||||||
|
if (rows.length === 0) return;
|
||||||
|
app.log.warn(
|
||||||
|
{ swept: rows.length, ids: rows.map((r) => r.id) },
|
||||||
|
'swept stale streaming rows',
|
||||||
|
);
|
||||||
|
const seenChats = new Set<string>();
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
for (const row of rows) {
|
||||||
|
if (seenChats.has(row.chat_id)) continue;
|
||||||
|
seenChats.add(row.chat_id);
|
||||||
|
broker.publishUserFrame('default', {
|
||||||
|
type: 'chat_status',
|
||||||
|
chat_id: row.chat_id,
|
||||||
|
status: 'idle',
|
||||||
|
at: now,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
app.log.error({ err }, 'stuck-row sweeper failed');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// 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) => {
|
const shutdown = async (signal: string) => {
|
||||||
app.log.info(`received ${signal}, shutting down`);
|
app.log.info(`received ${signal}, shutting down`);
|
||||||
try {
|
try {
|
||||||
|
|||||||
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')
|
VALUES (${req.params.id}, ${parsed.data.name ?? null}, 'open')
|
||||||
RETURNING id, session_id, name, status, created_at, updated_at
|
RETURNING id, session_id, name, status, created_at, updated_at
|
||||||
`;
|
`;
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'chat_created',
|
type: 'chat_created',
|
||||||
chat: chat!,
|
chat: chat!,
|
||||||
session_id: req.params.id,
|
session_id: req.params.id,
|
||||||
@@ -132,7 +132,7 @@ export function registerChatRoutes(
|
|||||||
return { error: 'chat not found' };
|
return { error: 'chat not found' };
|
||||||
}
|
}
|
||||||
const chat = rows[0]!;
|
const chat = rows[0]!;
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'chat_updated',
|
type: 'chat_updated',
|
||||||
chat_id: chat.id,
|
chat_id: chat.id,
|
||||||
session_id: chat.session_id,
|
session_id: chat.session_id,
|
||||||
@@ -162,7 +162,7 @@ export function registerChatRoutes(
|
|||||||
`;
|
`;
|
||||||
const ids = rows.map((r) => r.id);
|
const ids = rows.map((r) => r.id);
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'chat_archived',
|
type: 'chat_archived',
|
||||||
chat_id: id,
|
chat_id: id,
|
||||||
session_id: req.params.id,
|
session_id: req.params.id,
|
||||||
@@ -203,7 +203,7 @@ export function registerChatRoutes(
|
|||||||
return { error: 'chat not found or already archived' };
|
return { error: 'chat not found or already archived' };
|
||||||
}
|
}
|
||||||
const row = rows[0]!;
|
const row = rows[0]!;
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'chat_archived',
|
type: 'chat_archived',
|
||||||
chat_id: row.id,
|
chat_id: row.id,
|
||||||
session_id: row.session_id,
|
session_id: row.session_id,
|
||||||
@@ -226,7 +226,7 @@ export function registerChatRoutes(
|
|||||||
return { error: 'chat not found or not archived' };
|
return { error: 'chat not found or not archived' };
|
||||||
}
|
}
|
||||||
const chat = rows[0]!;
|
const chat = rows[0]!;
|
||||||
broker.publishUser('default', { type: 'chat_unarchived', chat });
|
broker.publishUserFrame('default', { type: 'chat_unarchived', chat });
|
||||||
return chat;
|
return chat;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
@@ -243,7 +243,7 @@ export function registerChatRoutes(
|
|||||||
return { error: 'chat not found' };
|
return { error: 'chat not found' };
|
||||||
}
|
}
|
||||||
const row = result[0]!;
|
const row = result[0]!;
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'chat_deleted',
|
type: 'chat_deleted',
|
||||||
chat_id: row.id,
|
chat_id: row.id,
|
||||||
session_id: row.session_id,
|
session_id: row.session_id,
|
||||||
@@ -296,13 +296,13 @@ export function registerChatRoutes(
|
|||||||
`;
|
`;
|
||||||
await tx`
|
await tx`
|
||||||
INSERT INTO messages (
|
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,
|
status, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||||
created_at, metadata
|
created_at, metadata
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
${source.session_id}, ${chat!.id}, role, content, kind,
|
${source.session_id}, ${chat!.id}, role, content, kind,
|
||||||
tool_calls, tool_results, status,
|
status,
|
||||||
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
||||||
clock_timestamp() + (
|
clock_timestamp() + (
|
||||||
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) * INTERVAL '1 microsecond'
|
||||||
@@ -313,10 +313,32 @@ export function registerChatRoutes(
|
|||||||
AND created_at <= ${target.created_at}::timestamptz
|
AND created_at <= ${target.created_at}::timestamptz
|
||||||
AND status = 'complete'
|
AND status = 'complete'
|
||||||
`;
|
`;
|
||||||
|
// v1.13.0: clone message_parts for the forked messages. Source and
|
||||||
|
// destination preserve ordering (the INSERT above orders by created_at,
|
||||||
|
// id) so a ROW_NUMBER pairing maps source.id → dest.id deterministically.
|
||||||
|
await tx`
|
||||||
|
WITH src AS (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS rn
|
||||||
|
FROM messages
|
||||||
|
WHERE chat_id = ${source.id}
|
||||||
|
AND created_at <= ${target.created_at}::timestamptz
|
||||||
|
AND status = 'complete'
|
||||||
|
),
|
||||||
|
dst AS (
|
||||||
|
SELECT id, ROW_NUMBER() OVER (ORDER BY created_at ASC, id ASC) AS rn
|
||||||
|
FROM messages
|
||||||
|
WHERE chat_id = ${chat!.id}
|
||||||
|
)
|
||||||
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
|
SELECT dst.id, p.sequence, p.kind, p.payload
|
||||||
|
FROM message_parts p
|
||||||
|
JOIN src ON p.message_id = src.id
|
||||||
|
JOIN dst ON dst.rn = src.rn
|
||||||
|
`;
|
||||||
return chat!;
|
return chat!;
|
||||||
});
|
});
|
||||||
|
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'chat_created',
|
type: 'chat_created',
|
||||||
chat: newChat,
|
chat: newChat,
|
||||||
session_id: source.session_id,
|
session_id: source.session_id,
|
||||||
@@ -363,33 +385,37 @@ export function registerChatRoutes(
|
|||||||
reply.code(409);
|
reply.code(409);
|
||||||
return { error: 'message is not stale yet', age_seconds: msg.age_seconds };
|
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
|
UPDATE messages
|
||||||
SET status = 'failed',
|
SET status = 'failed',
|
||||||
content = COALESCE(content, ''),
|
content = COALESCE(content, ''),
|
||||||
finished_at = clock_timestamp()
|
finished_at = clock_timestamp()
|
||||||
WHERE id = ${msg.id} AND status = 'streaming'
|
WHERE id = ${msg.id} AND status = 'streaming'
|
||||||
RETURNING id, session_id, chat_id, role, content, kind, tool_calls, tool_results,
|
RETURNING id
|
||||||
status, last_seq, tokens_used, ctx_used, ctx_max, started_at, finished_at,
|
|
||||||
created_at, metadata, summary, tail_start_id, compacted_at
|
|
||||||
`;
|
`;
|
||||||
if (updated.length === 0) {
|
if (updated.length === 0) {
|
||||||
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
|
// Race: the row flipped out of 'streaming' between our SELECT and UPDATE.
|
||||||
reply.code(409);
|
reply.code(409);
|
||||||
return { error: 'message status changed mid-request' };
|
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',
|
type: 'chat_status',
|
||||||
chat_id: msg.chat_id,
|
chat_id: msg.chat_id,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
at: new Date().toISOString(),
|
at: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
broker.publish(msg.session_id, {
|
broker.publishFrame(msg.session_id, {
|
||||||
type: 'message_complete',
|
type: 'message_complete',
|
||||||
message_id: msg.id,
|
message_id: msg.id,
|
||||||
chat_id: msg.chat_id,
|
chat_id: msg.chat_id,
|
||||||
});
|
});
|
||||||
return updated[0];
|
return refreshed[0];
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -401,11 +427,12 @@ export function registerChatRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'chat not found' };
|
return { error: 'chat not found' };
|
||||||
}
|
}
|
||||||
|
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
|
||||||
const rows = await sql<Message[]>`
|
const rows = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
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,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
summary, tail_start_id, compacted_at
|
summary, tail_start_id, compacted_at
|
||||||
FROM messages
|
FROM messages_with_parts
|
||||||
WHERE chat_id = ${req.params.id}
|
WHERE chat_id = ${req.params.id}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import type { FastifyInstance } from 'fastify';
|
import type { FastifyInstance } from 'fastify';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import type { Sql } from '../db.js';
|
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';
|
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({
|
const SendBody = z.object({
|
||||||
content: z.string().min(1).max(64_000),
|
content: z.string().min(1).max(64_000),
|
||||||
@@ -47,6 +53,21 @@ const AskUserInputArgs = z.object({
|
|||||||
.max(3),
|
.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 {
|
interface MessageHandlers {
|
||||||
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
enqueueInference: (sessionId: string, chatId: string, assistantMessageId: string, user: string) => void;
|
||||||
// v1.11: returns a promise that resolves after compaction.process finishes
|
// v1.11: returns a promise that resolves after compaction.process finishes
|
||||||
@@ -76,6 +97,8 @@ interface MessageHandlers {
|
|||||||
export function registerMessageRoutes(
|
export function registerMessageRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
|
config: Config,
|
||||||
|
broker: Broker,
|
||||||
handlers: MessageHandlers
|
handlers: MessageHandlers
|
||||||
): void {
|
): void {
|
||||||
app.get<{ Params: { id: string } }>(
|
app.get<{ Params: { id: string } }>(
|
||||||
@@ -91,11 +114,12 @@ export function registerMessageRoutes(
|
|||||||
// SummaryCard) and shows compacted_at-stamped rows inline for context.
|
// SummaryCard) and shows compacted_at-stamped rows inline for context.
|
||||||
// Internal inference assembly filters compacted_at IS NULL separately —
|
// Internal inference assembly filters compacted_at IS NULL separately —
|
||||||
// see services/inference.ts loadContext + services/compaction.ts.
|
// see services/inference.ts loadContext + services/compaction.ts.
|
||||||
|
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
|
||||||
const rows = await sql<Message[]>`
|
const rows = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
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,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
summary, tail_start_id, compacted_at
|
summary, tail_start_id, compacted_at
|
||||||
FROM messages
|
FROM messages_with_parts
|
||||||
WHERE session_id = ${req.params.id}
|
WHERE session_id = ${req.params.id}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
`;
|
`;
|
||||||
@@ -469,30 +493,36 @@ export function registerMessageRoutes(
|
|||||||
const chat = chatRows[0]!;
|
const chat = chatRows[0]!;
|
||||||
const sessionId = chat.session_id;
|
const sessionId = chat.session_id;
|
||||||
|
|
||||||
// Find the assistant message that emitted this tool_call. Scoped by
|
// v1.13.1-C: find the assistant's tool_call by indexing message_parts
|
||||||
// chat_id + role to avoid cross-chat lookups; ordered by created_at DESC
|
// directly on payload->>'id'. Scoped by chat_id + role via the JOIN.
|
||||||
// because the most recent issuance wins when an LLM reuses call IDs
|
// Pre-v1.13.0 history has no parts rows — those tool_calls become
|
||||||
// across turns (the older, already-answered one is a different row with
|
// unreachable here (404). Acceptable per the dispatch decision: any
|
||||||
// populated tool_results downstream).
|
// pending elicitation from before v1.13.0 is long timed out by now;
|
||||||
const callerRows = await sql<{ id: string; tool_calls: ToolCall[] | null }[]>`
|
// promote to a hotfix with a JSON-column fallback if it ever surfaces.
|
||||||
SELECT id, tool_calls FROM messages
|
const callerRows = await sql<{
|
||||||
WHERE chat_id = ${chat.id}
|
message_id: string;
|
||||||
AND role = 'assistant'
|
payload: { id: string; name: string; args: Record<string, unknown> };
|
||||||
AND tool_calls IS NOT NULL
|
}[]>`
|
||||||
ORDER BY created_at DESC
|
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
|
||||||
`;
|
`;
|
||||||
let foundCall: ToolCall | null = null;
|
const callerRow = callerRows[0];
|
||||||
for (const row of callerRows) {
|
if (!callerRow) {
|
||||||
const match = row.tool_calls?.find((tc) => tc.id === tool_call_id);
|
|
||||||
if (match) {
|
|
||||||
foundCall = match;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!foundCall) {
|
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'unknown_tool_call_id' };
|
return { error: 'unknown_tool_call_id' };
|
||||||
}
|
}
|
||||||
|
const foundCall: ToolCall = {
|
||||||
|
id: callerRow.payload.id,
|
||||||
|
name: callerRow.payload.name,
|
||||||
|
args: callerRow.payload.args,
|
||||||
|
};
|
||||||
if (foundCall.name !== 'ask_user_input') {
|
if (foundCall.name !== 'ask_user_input') {
|
||||||
reply.code(400);
|
reply.code(400);
|
||||||
return { error: 'tool_call_not_ask_user_input' };
|
return { error: 'tool_call_not_ask_user_input' };
|
||||||
@@ -539,18 +569,21 @@ export function registerMessageRoutes(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the pending tool row. ORDER BY created_at DESC + LIMIT 1 picks
|
// v1.13.1-C: find the pending tool row via message_parts on
|
||||||
// the most recent row with this tool_call_id; the already-answered
|
// payload->>'tool_call_id'. Same fallback caveat as the caller lookup
|
||||||
// check below guards against UPDATE-ing a stale answer.
|
// above — pre-v1.13.0 rows are unreachable here.
|
||||||
const toolRows = await sql<{
|
const toolRows = await sql<{
|
||||||
id: string;
|
message_id: string;
|
||||||
tool_results: { tool_call_id: string; output: unknown } | null;
|
payload: { tool_call_id: string; output: unknown };
|
||||||
}[]>`
|
}[]>`
|
||||||
SELECT id, tool_results FROM messages
|
SELECT p.message_id, p.payload
|
||||||
WHERE chat_id = ${chat.id}
|
FROM message_parts p
|
||||||
AND role = 'tool'
|
JOIN messages m ON m.id = p.message_id
|
||||||
AND tool_results->>'tool_call_id' = ${tool_call_id}
|
WHERE m.chat_id = ${chat.id}
|
||||||
ORDER BY created_at DESC
|
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
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
const toolRow = toolRows[0];
|
const toolRow = toolRows[0];
|
||||||
@@ -558,7 +591,7 @@ export function registerMessageRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
return { error: 'unknown_tool_call_id', detail: 'tool message not found' };
|
||||||
}
|
}
|
||||||
if (toolRow.tool_results && toolRow.tool_results.output !== null) {
|
if (toolRow.payload && toolRow.payload.output !== null) {
|
||||||
reply.code(409);
|
reply.code(409);
|
||||||
return { error: 'tool_call_already_answered' };
|
return { error: 'tool_call_already_answered' };
|
||||||
}
|
}
|
||||||
@@ -570,11 +603,17 @@ export function registerMessageRoutes(
|
|||||||
truncated: false,
|
truncated: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toolMessageId = toolRow.message_id;
|
||||||
const result = await sql.begin(async (tx) => {
|
const result = await sql.begin(async (tx) => {
|
||||||
|
// 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`
|
await tx`
|
||||||
UPDATE messages
|
INSERT INTO message_parts (message_id, sequence, kind, payload)
|
||||||
SET tool_results = ${tx.json(newToolResults as never)}
|
VALUES (${toolMessageId}, 0, 'tool_result', ${tx.json(newToolResults as never)})
|
||||||
WHERE id = ${toolRow.id}
|
|
||||||
`;
|
`;
|
||||||
const [assistantMsg] = await tx<{ id: string }[]>`
|
const [assistantMsg] = await tx<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
@@ -584,7 +623,7 @@ export function registerMessageRoutes(
|
|||||||
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
await tx`UPDATE sessions SET updated_at = clock_timestamp() WHERE id = ${sessionId}`;
|
||||||
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
await tx`UPDATE chats SET updated_at = clock_timestamp() WHERE id = ${chat.id}`;
|
||||||
return {
|
return {
|
||||||
tool_message_id: toolRow.id,
|
tool_message_id: toolMessageId,
|
||||||
assistant_message_id: assistantMsg!.id,
|
assistant_message_id: assistantMsg!.id,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -606,4 +645,230 @@ export function registerMessageRoutes(
|
|||||||
return result;
|
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,
|
RETURNING id, name, path, added_at, last_session_id, status, gitea_remote,
|
||||||
default_system_prompt, default_web_search_enabled
|
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);
|
reply.code(201);
|
||||||
return {
|
return {
|
||||||
project: row,
|
project: row,
|
||||||
@@ -186,11 +186,11 @@ export function registerProjectRoutes(
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
if (existing.length === 0) {
|
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);
|
reply.code(201);
|
||||||
} else {
|
} else {
|
||||||
// existing.status was 'archived' — row has been restored.
|
// 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);
|
reply.code(200);
|
||||||
}
|
}
|
||||||
return row;
|
return row;
|
||||||
@@ -243,7 +243,7 @@ export function registerProjectRoutes(
|
|||||||
// v1.9: the project_updated frame still only carries id + name. Clients
|
// v1.9: the project_updated frame still only carries id + name. Clients
|
||||||
// that need the new fields refetch via api.projects.list() — keeps the
|
// that need the new fields refetch via api.projects.list() — keeps the
|
||||||
// frame payload lean, per the locked recon decision (d).
|
// frame payload lean, per the locked recon decision (d).
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'project_updated',
|
type: 'project_updated',
|
||||||
project_id: project.id,
|
project_id: project.id,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
@@ -260,7 +260,7 @@ export function registerProjectRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'not found or already archived' };
|
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);
|
reply.code(204);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -277,7 +277,7 @@ export function registerProjectRoutes(
|
|||||||
return { error: 'not found or not archived' };
|
return { error: 'not found or not archived' };
|
||||||
}
|
}
|
||||||
const project = rows[0]!;
|
const project = rows[0]!;
|
||||||
broker.publishUser('default', { type: 'project_unarchived', project });
|
broker.publishUserFrame('default', { type: 'project_unarchived', project });
|
||||||
return project;
|
return project;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -288,7 +288,7 @@ export function registerProjectRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'not found' };
|
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);
|
reply.code(204);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,12 +13,37 @@ const CreateBody = z.object({
|
|||||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
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({
|
const WorkspacePaneZ = z.object({
|
||||||
id: z.string().min(1).max(200),
|
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(),
|
chatId: z.string().min(1).max(200).optional(),
|
||||||
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
chatIds: z.array(z.string().min(1).max(200)).max(50),
|
||||||
activeChatIdx: z.number().int(),
|
activeChatIdx: z.number().int(),
|
||||||
|
markdown_artifact_state: MarkdownArtifactStateZ.optional(),
|
||||||
|
html_artifact_state: HtmlArtifactStateZ.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const WorkspacePanesBody = z.object({
|
const WorkspacePanesBody = z.object({
|
||||||
@@ -32,6 +57,29 @@ const PatchBody = z.object({
|
|||||||
agent_id: z.string().min(1).max(200).nullable().optional(),
|
agent_id: z.string().min(1).max(200).nullable().optional(),
|
||||||
// v1.9: null = inherit from project default; true/false = explicit override.
|
// v1.9: null = inherit from project default; true/false = explicit override.
|
||||||
web_search_enabled: z.boolean().nullable().optional(),
|
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> {
|
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;
|
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(
|
export function registerSessionRoutes(
|
||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
sql: Sql,
|
sql: Sql,
|
||||||
@@ -56,7 +117,7 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
const status = req.query.status === 'archived' ? 'archived' : 'open';
|
||||||
const rows = await sql<Session[]>`
|
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
|
FROM sessions
|
||||||
WHERE project_id = ${req.params.id} AND status = ${status}
|
WHERE project_id = ${req.params.id} AND status = ${status}
|
||||||
ORDER BY updated_at DESC
|
ORDER BY updated_at DESC
|
||||||
@@ -112,7 +173,7 @@ export function registerSessionRoutes(
|
|||||||
`;
|
`;
|
||||||
return session!;
|
return session!;
|
||||||
});
|
});
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'session_created',
|
type: 'session_created',
|
||||||
session: row,
|
session: row,
|
||||||
project_id: row.project_id,
|
project_id: row.project_id,
|
||||||
@@ -124,7 +185,7 @@ export function registerSessionRoutes(
|
|||||||
|
|
||||||
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
app.get<{ Params: { id: string } }>('/api/sessions/:id', async (req, reply) => {
|
||||||
const rows = await sql<Session[]>`
|
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}
|
FROM sessions WHERE id = ${req.params.id}
|
||||||
`;
|
`;
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -150,15 +211,53 @@ export function registerSessionRoutes(
|
|||||||
const newAgentId = parsed.data.agent_id ?? null;
|
const newAgentId = parsed.data.agent_id ?? null;
|
||||||
const wseProvided = parsed.data.web_search_enabled !== undefined;
|
const wseProvided = parsed.data.web_search_enabled !== undefined;
|
||||||
const newWse = parsed.data.web_search_enabled ?? null;
|
const newWse = parsed.data.web_search_enabled ?? null;
|
||||||
// Read the prior name so the post-update publish can skip no-op renames
|
// v1.13.17-cross-repo-reads: tri-state on the wire (undefined = no
|
||||||
// (PATCH { name: "Foo" } where the session is already "Foo"). The window
|
// change, [] = clear). Frontend currently uses this PATCH only for
|
||||||
// between SELECT and UPDATE is sub-millisecond in the same request handler;
|
// revocation (delete a single entry from the existing array, send
|
||||||
// a concurrent rename in that gap would just mean one stale publish, which
|
// shortened result). Append-style grants go through the dedicated
|
||||||
// existing clients dedup by id.
|
// grant_read_access endpoint inside the inference loop.
|
||||||
const before = await sql<{ name: string }[]>`
|
const arpProvided = parsed.data.allowed_read_paths !== undefined;
|
||||||
SELECT name FROM sessions WHERE id = ${req.params.id}
|
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 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[]>`
|
const rows = await sql<Session[]>`
|
||||||
UPDATE sessions
|
UPDATE sessions
|
||||||
SET
|
SET
|
||||||
@@ -167,10 +266,11 @@ export function registerSessionRoutes(
|
|||||||
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
system_prompt = COALESCE(${system_prompt ?? null}, system_prompt),
|
||||||
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
|
agent_id = CASE WHEN ${agentIdProvided} THEN ${newAgentId} ELSE agent_id END,
|
||||||
web_search_enabled = CASE WHEN ${wseProvided} THEN ${newWse} ELSE web_search_enabled 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()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
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) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
@@ -178,7 +278,7 @@ export function registerSessionRoutes(
|
|||||||
}
|
}
|
||||||
const session = rows[0]!;
|
const session = rows[0]!;
|
||||||
if (name !== undefined && session.name !== priorName) {
|
if (name !== undefined && session.name !== priorName) {
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'session_renamed',
|
type: 'session_renamed',
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
name: session.name,
|
name: session.name,
|
||||||
@@ -188,7 +288,7 @@ export function registerSessionRoutes(
|
|||||||
// (notably the SettingsPane open in another tab) can refetch and pick
|
// (notably the SettingsPane open in another tab) can refetch and pick
|
||||||
// up the new fields. Frame stays lean (decision d) — payload is just
|
// up the new fields. Frame stays lean (decision d) — payload is just
|
||||||
// ids + name + updated_at, the client refetches via api.sessions.get.
|
// ids + name + updated_at, the client refetches via api.sessions.get.
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'session_updated',
|
type: 'session_updated',
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
project_id: session.project_id,
|
project_id: session.project_id,
|
||||||
@@ -213,14 +313,14 @@ export function registerSessionRoutes(
|
|||||||
updated_at = clock_timestamp()
|
updated_at = clock_timestamp()
|
||||||
WHERE id = ${req.params.id}
|
WHERE id = ${req.params.id}
|
||||||
RETURNING id, project_id, name, model, system_prompt, status, created_at, updated_at,
|
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) {
|
if (rows.length === 0) {
|
||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'session not found' };
|
return { error: 'session not found' };
|
||||||
}
|
}
|
||||||
const session = rows[0]!;
|
const session = rows[0]!;
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'session_workspace_updated',
|
type: 'session_workspace_updated',
|
||||||
session_id: session.id,
|
session_id: session.id,
|
||||||
workspace_panes: session.workspace_panes,
|
workspace_panes: session.workspace_panes,
|
||||||
@@ -248,7 +348,7 @@ export function registerSessionRoutes(
|
|||||||
`;
|
`;
|
||||||
const ids = rows.map((r) => r.id);
|
const ids = rows.map((r) => r.id);
|
||||||
for (const id of ids) {
|
for (const id of ids) {
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'session_archived',
|
type: 'session_archived',
|
||||||
session_id: id,
|
session_id: id,
|
||||||
project_id: req.params.id,
|
project_id: req.params.id,
|
||||||
@@ -289,7 +389,7 @@ export function registerSessionRoutes(
|
|||||||
reply.code(404);
|
reply.code(404);
|
||||||
return { error: 'session not found or already archived' };
|
return { error: 'session not found or already archived' };
|
||||||
}
|
}
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'session_archived',
|
type: 'session_archived',
|
||||||
session_id: rows[0]!.id,
|
session_id: rows[0]!.id,
|
||||||
project_id: rows[0]!.project_id,
|
project_id: rows[0]!.project_id,
|
||||||
@@ -312,7 +412,7 @@ export function registerSessionRoutes(
|
|||||||
return { error: 'session not found or not archived' };
|
return { error: 'session not found or not archived' };
|
||||||
}
|
}
|
||||||
const session = rows[0]!;
|
const session = rows[0]!;
|
||||||
broker.publishUser('default', {
|
broker.publishUserFrame('default', {
|
||||||
type: 'session_created',
|
type: 'session_created',
|
||||||
session: session,
|
session: session,
|
||||||
project_id: session.project_id,
|
project_id: session.project_id,
|
||||||
@@ -334,7 +434,7 @@ export function registerSessionRoutes(
|
|||||||
return { error: 'not found' };
|
return { error: 'not found' };
|
||||||
}
|
}
|
||||||
const project_id = deleted[0]!.project_id;
|
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);
|
reply.code(204);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,15 +86,30 @@ export function registerSkillsRoutes(
|
|||||||
|
|
||||||
const result = await sql.begin(async (tx) => {
|
const result = await sql.begin(async (tx) => {
|
||||||
const [synthAssistant] = await tx<{ id: string }[]>`
|
const [synthAssistant] = await tx<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, tool_calls, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chat.id}, 'assistant', '', ${sql.json(toolCalls as never)}, 'complete', clock_timestamp())
|
VALUES (${sessionId}, ${chat.id}, 'assistant', '', 'complete', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
// 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({
|
||||||
|
id: toolCallId,
|
||||||
|
name: 'skill_use',
|
||||||
|
args: { name: skill_name },
|
||||||
|
} as never)})
|
||||||
|
`;
|
||||||
const [toolMsg] = await tx<{ id: string }[]>`
|
const [toolMsg] = await tx<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, tool_results, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chat.id}, 'tool', '', ${sql.json(toolResults as never)}, 'complete', clock_timestamp())
|
VALUES (${sessionId}, ${chat.id}, 'tool', '', 'complete', clock_timestamp())
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`;
|
`;
|
||||||
|
// 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)})
|
||||||
|
`;
|
||||||
const [userMsg] = await tx<{ id: string }[]>`
|
const [userMsg] = await tx<{ id: string }[]>`
|
||||||
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
INSERT INTO messages (session_id, chat_id, role, content, status, created_at)
|
||||||
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
|
VALUES (${sessionId}, ${chat.id}, 'user', ${userText}, 'complete', clock_timestamp())
|
||||||
|
|||||||
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 };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -23,11 +23,12 @@ export function registerWebSocket(
|
|||||||
|
|
||||||
// v1.11: snapshot includes compaction fields so MessageBubble can
|
// v1.11: snapshot includes compaction fields so MessageBubble can
|
||||||
// render the SummaryCard for summary=true rows on first connect.
|
// render the SummaryCard for summary=true rows on first connect.
|
||||||
|
// v1.13.1-B: reads tool_calls/tool_results via the parts-merged view.
|
||||||
const messages = await sql<Message[]>`
|
const messages = await sql<Message[]>`
|
||||||
SELECT id, session_id, chat_id, role, content, kind, tool_calls, tool_results, status, last_seq,
|
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,
|
tokens_used, ctx_used, ctx_max, started_at, finished_at, created_at, metadata,
|
||||||
summary, tail_start_id, compacted_at
|
summary, tail_start_id, compacted_at
|
||||||
FROM messages
|
FROM messages_with_parts
|
||||||
WHERE session_id = ${sessionId}
|
WHERE session_id = ${sessionId}
|
||||||
ORDER BY created_at ASC, id ASC
|
ORDER BY created_at ASC, id ASC
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -1,3 +1,10 @@
|
|||||||
|
-- v1.13.3: statement_timeout is set at database level via:
|
||||||
|
-- ALTER DATABASE boocode SET statement_timeout = '30s';
|
||||||
|
-- ALTER DATABASE can't run inside a DO block, so this is an operational
|
||||||
|
-- step rather than schema. Re-apply after a volume reset (the setting
|
||||||
|
-- lives in pg_db which survives `docker compose up --build` but NOT a
|
||||||
|
-- `docker volume rm boocode_pgdata`).
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS projects (
|
CREATE TABLE IF NOT EXISTS projects (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
name TEXT NOT NULL,
|
name TEXT NOT NULL,
|
||||||
@@ -32,6 +39,162 @@ CREATE TABLE IF NOT EXISTS messages (
|
|||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_id, created_at);
|
||||||
|
|
||||||
|
-- v1.13.0: granular message parts table for AI SDK migration. Old
|
||||||
|
-- messages.content / tool_calls / tool_results columns stay authoritative
|
||||||
|
-- for reads in v1.13.0; this table is dual-written so the swap can happen
|
||||||
|
-- in a later dispatch without a backfill window. ON DELETE CASCADE means
|
||||||
|
-- removing a message removes its parts in one go.
|
||||||
|
CREATE TABLE IF NOT EXISTS message_parts (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
message_id uuid NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
|
||||||
|
sequence int NOT NULL,
|
||||||
|
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', '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);
|
||||||
|
|
||||||
|
-- v1.13.4: prune support. hidden_at marks parts that have been pruned out
|
||||||
|
-- of the model payload by the two-tier compaction prune (services/inference/
|
||||||
|
-- prune.ts). Rows stay in the DB so frontend can still display them with a
|
||||||
|
-- "hidden" indicator (out of scope this dispatch). messages_with_parts
|
||||||
|
-- view filters these out — see below. Partial index speeds the common
|
||||||
|
-- "visible parts only" filter.
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'message_parts' AND column_name = 'hidden_at'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE message_parts ADD COLUMN hidden_at timestamptz NULL;
|
||||||
|
END IF;
|
||||||
|
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.
|
||||||
|
-- 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,
|
||||||
|
(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 tokens_used INTEGER;
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_used INTEGER;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_used INTEGER;
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_max INTEGER;
|
ALTER TABLE messages ADD COLUMN IF NOT EXISTS ctx_max INTEGER;
|
||||||
@@ -120,19 +283,6 @@ BEGIN
|
|||||||
END IF;
|
END IF;
|
||||||
END $$;
|
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
|
-- v1.2-project-ux: projects.status + projects.gitea_remote
|
||||||
-- KEEP IN SYNC: apps/server/src/types/api.ts PROJECT_STATUSES
|
-- 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';
|
ALTER TABLE projects ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open';
|
||||||
@@ -164,6 +314,16 @@ END $$;
|
|||||||
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
|
-- agent_id is the slugified agent name. NULL means "use BooCode defaults".
|
||||||
ALTER TABLE sessions ADD COLUMN IF NOT EXISTS agent_id TEXT;
|
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
|
-- v1.8.2: per-message metadata for sentinels (cap-hit) and structured error
|
||||||
-- reasons. JSONB so future kinds can extend without further schema churn.
|
-- reasons. JSONB so future kinds can extend without further schema churn.
|
||||||
-- Shape for cap_hit: { kind: 'cap_hit', used: number, limit: number,
|
-- 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 { 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 { join } from 'node:path';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { callCodecontext } from '../codecontext_client.js';
|
import { callCodecontext } from '../codecontext_client.js';
|
||||||
@@ -203,3 +203,197 @@ describe('callCodecontext — error paths', () => {
|
|||||||
).rejects.toThrow(/timed out after 30000ms/);
|
).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);
|
const { url, body } = parsePOST(fetcher);
|
||||||
expect(url).toMatch(/\/v1\/get_file_analysis$/);
|
expect(url).toMatch(/\/v1\/get_file_analysis$/);
|
||||||
expect(body).toMatchObject({
|
expect(body).toMatchObject({
|
||||||
file_path: 'apps/server/src/index.ts',
|
file_path: join(projectDir, 'apps/server/src/index.ts'),
|
||||||
target_dir: projectDir,
|
target_dir: projectDir,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
turns,
|
turns,
|
||||||
select,
|
select,
|
||||||
buildPrompt,
|
buildPrompt,
|
||||||
|
buildHeadPayload,
|
||||||
type CompactionMessage,
|
type CompactionMessage,
|
||||||
} from '../compaction.js';
|
} from '../compaction.js';
|
||||||
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
import { SUMMARY_TEMPLATE } from '../compaction-prompt.js';
|
||||||
@@ -31,6 +32,7 @@ function mkMsg(
|
|||||||
status: 'complete',
|
status: 'complete',
|
||||||
tool_calls: null,
|
tool_calls: null,
|
||||||
tool_results: null,
|
tool_results: null,
|
||||||
|
reasoning_parts: null,
|
||||||
metadata: null,
|
metadata: null,
|
||||||
created_at: new Date(counter * 1000).toISOString(),
|
created_at: new Date(counter * 1000).toISOString(),
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -39,49 +41,58 @@ function mkMsg(
|
|||||||
|
|
||||||
// ---- usable -----------------------------------------------------------------
|
// ---- usable -----------------------------------------------------------------
|
||||||
|
|
||||||
describe('usable', () => {
|
// v1.13.9: ratio-only early trigger at 0.85 × contextLimit. Replaces the
|
||||||
it('returns 0 when contextLimit is 0', () => {
|
// 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);
|
expect(usable(0)).toBe(0);
|
||||||
});
|
expect(usable(-1)).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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- isOverflow -------------------------------------------------------------
|
// ---- isOverflow -------------------------------------------------------------
|
||||||
|
|
||||||
describe('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: 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', () => {
|
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);
|
expect(isOverflow({ prompt_tokens: 30_000, completion_tokens: 10_000 }, 100_000)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns false just under usable', () => {
|
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 >)', () => {
|
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', () => {
|
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);
|
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 u = mkMsg('user', 'oversized');
|
||||||
const a = mkMsg('assistant', 'Y'.repeat(40_000));
|
const a = mkMsg('assistant', 'Y'.repeat(40_000));
|
||||||
const result = select([u, a], 30_000, 1);
|
const result = select([u, a], 30_000, 1);
|
||||||
// usable(30k) = 10k → budget = min(8k, max(2k, floor(10k*0.25))) =
|
// v1.13.9: usable(30k) = floor(0.85*30k) = 25500 → budget =
|
||||||
// min(8k, max(2k, 2500)) = 2500. 40k chars ≈ 10k tokens. Can't fit.
|
// 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.tail_start_id).toBeUndefined();
|
||||||
expect(result.head).toEqual([u, a]);
|
expect(result.head).toEqual([u, a]);
|
||||||
});
|
});
|
||||||
@@ -256,3 +268,56 @@ describe('buildPrompt', () => {
|
|||||||
expect(out.endsWith('extra-context-line')).toBe(true);
|
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>');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { DOOM_LOOP_THRESHOLD, detectDoomLoop } from '../inference.js';
|
import { DOOM_LOOP_THRESHOLD, detectDoomLoop } from '../inference/index.js';
|
||||||
import type { ToolCall } from '../../types/api.js';
|
import type { ToolCall } from '../../types/api.js';
|
||||||
|
|
||||||
// ---- fixture ----------------------------------------------------------------
|
// ---- fixture ----------------------------------------------------------------
|
||||||
|
|||||||
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(() => {});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from 'vitest';
|
||||||
import { buildMessagesPayload } from '../inference.js';
|
import { buildMessagesPayload } from '../inference/index.js';
|
||||||
import type {
|
import type {
|
||||||
Message,
|
Message,
|
||||||
MessageRole,
|
MessageRole,
|
||||||
|
|||||||
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
121
apps/server/src/services/__tests__/parts.test.ts
Normal file
121
apps/server/src/services/__tests__/parts.test.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { partsFromAssistantMessage, partsFromToolMessage } from '../inference/parts.js';
|
||||||
|
import type { ToolCall, ToolResult } from '../../types/api.js';
|
||||||
|
|
||||||
|
describe('partsFromAssistantMessage', () => {
|
||||||
|
it('emits one text part for content-only assistant', () => {
|
||||||
|
const parts = partsFromAssistantMessage({ content: 'hello world', tool_calls: null });
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
expect(parts[0]).toEqual({
|
||||||
|
sequence: 0,
|
||||||
|
kind: 'text',
|
||||||
|
payload: { text: 'hello world' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits one tool_call part for empty-content + single tool_call', () => {
|
||||||
|
const tc: ToolCall = { id: 'call_1', name: 'view_file', args: { path: 'src/a.ts' } };
|
||||||
|
const parts = partsFromAssistantMessage({ content: '', tool_calls: [tc] });
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
expect(parts[0]).toEqual({
|
||||||
|
sequence: 0,
|
||||||
|
kind: 'tool_call',
|
||||||
|
payload: { id: 'call_1', name: 'view_file', args: { path: 'src/a.ts' } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('emits text then tool_call parts in order when both present', () => {
|
||||||
|
const tc: ToolCall = { id: 'call_2', name: 'grep', args: { pattern: 'foo' } };
|
||||||
|
const parts = partsFromAssistantMessage({ content: 'let me search', tool_calls: [tc] });
|
||||||
|
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
|
||||||
|
[0, 'text'],
|
||||||
|
[1, 'tool_call'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves tool_call order with multiple calls', () => {
|
||||||
|
const calls: ToolCall[] = [
|
||||||
|
{ id: 'a', name: 'list_dir', args: { path: '.' } },
|
||||||
|
{ id: 'b', name: 'view_file', args: { path: 'x.ts' } },
|
||||||
|
{ id: 'c', name: 'grep', args: { pattern: 'y' } },
|
||||||
|
];
|
||||||
|
const parts = partsFromAssistantMessage({ content: '', tool_calls: calls });
|
||||||
|
expect(parts).toHaveLength(3);
|
||||||
|
expect(parts.map((p) => p.payload)).toEqual([
|
||||||
|
{ id: 'a', name: 'list_dir', args: { path: '.' } },
|
||||||
|
{ id: 'b', name: 'view_file', args: { path: 'x.ts' } },
|
||||||
|
{ id: 'c', name: 'grep', args: { pattern: 'y' } },
|
||||||
|
]);
|
||||||
|
expect(parts.map((p) => p.sequence)).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array for empty content + null tool_calls', () => {
|
||||||
|
expect(partsFromAssistantMessage({ content: '', tool_calls: null })).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('v1.13.1-C: reasoning lands at sequence 0 before text + tool_calls', () => {
|
||||||
|
const tc: ToolCall = { id: 'call_r', name: 'view_file', args: { path: 'x.ts' } };
|
||||||
|
const parts = partsFromAssistantMessage({
|
||||||
|
content: 'inspecting now',
|
||||||
|
tool_calls: [tc],
|
||||||
|
reasoning: 'user asked about x.ts; I should view it',
|
||||||
|
});
|
||||||
|
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
|
||||||
|
[0, 'reasoning'],
|
||||||
|
[1, 'text'],
|
||||||
|
[2, 'tool_call'],
|
||||||
|
]);
|
||||||
|
expect(parts[0]!.payload).toEqual({
|
||||||
|
text: 'user asked about x.ts; I should view it',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('v1.13.1-C: reasoning + empty content + tool_calls preserves seq 0 reasoning', () => {
|
||||||
|
const tc: ToolCall = { id: 'call_r2', name: 'grep', args: { pattern: 'foo' } };
|
||||||
|
const parts = partsFromAssistantMessage({
|
||||||
|
content: '',
|
||||||
|
tool_calls: [tc],
|
||||||
|
reasoning: 'jumping straight to grep',
|
||||||
|
});
|
||||||
|
expect(parts.map((p) => [p.sequence, p.kind])).toEqual([
|
||||||
|
[0, 'reasoning'],
|
||||||
|
[1, 'tool_call'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('partsFromToolMessage', () => {
|
||||||
|
it('emits a single tool_result part at sequence 0', () => {
|
||||||
|
const tr: ToolResult = {
|
||||||
|
tool_call_id: 'call_1',
|
||||||
|
output: { contents: 'console.log(1)' },
|
||||||
|
truncated: false,
|
||||||
|
};
|
||||||
|
const parts = partsFromToolMessage({ tool_results: tr });
|
||||||
|
expect(parts).toHaveLength(1);
|
||||||
|
expect(parts[0]).toEqual({
|
||||||
|
sequence: 0,
|
||||||
|
kind: 'tool_result',
|
||||||
|
payload: {
|
||||||
|
tool_call_id: 'call_1',
|
||||||
|
output: { contents: 'console.log(1)' },
|
||||||
|
truncated: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes error in payload when present', () => {
|
||||||
|
const tr: ToolResult = {
|
||||||
|
tool_call_id: 'call_2',
|
||||||
|
output: null,
|
||||||
|
truncated: false,
|
||||||
|
error: 'permission denied',
|
||||||
|
};
|
||||||
|
const parts = partsFromToolMessage({ tool_results: tr });
|
||||||
|
expect(parts[0]!.payload).toMatchObject({ error: 'permission denied' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty array when tool_results is null', () => {
|
||||||
|
expect(partsFromToolMessage({ tool_results: null })).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
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'));
|
||||||
|
});
|
||||||
|
});
|
||||||
96
apps/server/src/services/__tests__/prune.test.ts
Normal file
96
apps/server/src/services/__tests__/prune.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import {
|
||||||
|
selectPruneTargets,
|
||||||
|
PROTECTED_TOKENS,
|
||||||
|
PRUNE_TRIGGER_TOKENS,
|
||||||
|
type PartForPrune,
|
||||||
|
} from '../inference/prune.js';
|
||||||
|
|
||||||
|
// Test fixture: build a tool_result part whose payload size yields a known
|
||||||
|
// token estimate (chars/4). The decision logic only cares about
|
||||||
|
// JSON.stringify(payload).length, so a string payload of `4n` chars
|
||||||
|
// produces exactly `n` tokens.
|
||||||
|
let seq = 0;
|
||||||
|
function part(tokens: number, createdAt: Date): PartForPrune {
|
||||||
|
seq += 1;
|
||||||
|
// JSON.stringify("xxx...") wraps in quotes (adds 2 chars), so subtract 2
|
||||||
|
// before multiplying. Math.ceil((len+2)/4) needs len ≈ 4*tokens - 2 so the
|
||||||
|
// total stringified length is 4*tokens. Approximate by padding 4 chars per
|
||||||
|
// token; the off-by-one from quotes is small and tests check totals, not
|
||||||
|
// exact per-part counts.
|
||||||
|
const text = 'x'.repeat(tokens * 4 - 2);
|
||||||
|
return { id: `p${seq}`, payload: text, created_at: createdAt };
|
||||||
|
}
|
||||||
|
|
||||||
|
const T_NOW = new Date('2026-05-22T12:00:00Z');
|
||||||
|
function ago(secondsBack: number): Date {
|
||||||
|
return new Date(T_NOW.getTime() - secondsBack * 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectPruneTargets', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
seq = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when there are no parts', () => {
|
||||||
|
expect(selectPruneTargets([], null)).toEqual({ ids: [], freedTokens: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when total tokens are under the protection window', () => {
|
||||||
|
const parts: PartForPrune[] = [
|
||||||
|
part(10_000, ago(10)),
|
||||||
|
part(10_000, ago(20)),
|
||||||
|
]; // 20k total, all protected
|
||||||
|
expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns nothing when candidate total is below the prune trigger', () => {
|
||||||
|
// Protection fills with ~40k newest, candidates only ~5k. Below 20k trigger.
|
||||||
|
const parts: PartForPrune[] = [
|
||||||
|
part(20_000, ago(10)),
|
||||||
|
part(20_000, ago(20)),
|
||||||
|
// Past protection; total ~5k won't trigger.
|
||||||
|
part(5_000, ago(30)),
|
||||||
|
];
|
||||||
|
const result = selectPruneTargets(parts, null);
|
||||||
|
expect(result.ids).toEqual([]);
|
||||||
|
expect(result.freedTokens).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides candidates past protection when their total clears the trigger', () => {
|
||||||
|
// Newest 40k protected; older 30k cleanly above the 20k trigger.
|
||||||
|
const parts: PartForPrune[] = [
|
||||||
|
part(20_000, ago(10)),
|
||||||
|
part(20_000, ago(20)),
|
||||||
|
// Past protection, total ~30k freed.
|
||||||
|
part(15_000, ago(30)),
|
||||||
|
part(15_000, ago(40)),
|
||||||
|
];
|
||||||
|
const result = selectPruneTargets(parts, null);
|
||||||
|
expect(result.ids).toEqual(['p3', 'p4']);
|
||||||
|
expect(result.freedTokens).toBeGreaterThanOrEqual(PRUNE_TRIGGER_TOKENS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops at the compaction summary boundary', () => {
|
||||||
|
// Newest 30k protected (just under PROTECTED_TOKENS=40k); then 30k of
|
||||||
|
// older parts. Boundary sits at ago(35), so the ago(40) part is
|
||||||
|
// beyond it and gets skipped.
|
||||||
|
const parts: PartForPrune[] = [
|
||||||
|
part(15_000, ago(10)),
|
||||||
|
part(15_000, ago(20)),
|
||||||
|
part(15_000, ago(30)), // crosses protection threshold; candidate
|
||||||
|
part(15_000, ago(40)), // beyond summary boundary; skipped
|
||||||
|
];
|
||||||
|
const tailStart = ago(35);
|
||||||
|
const result = selectPruneTargets(parts, tailStart);
|
||||||
|
// ago(30) is the only candidate inside the window; 15k is below the
|
||||||
|
// 20k trigger so we expect no hides.
|
||||||
|
expect(result.ids).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not prune when only protected parts exist (no candidates)', () => {
|
||||||
|
// Exactly PROTECTED_TOKENS of newest parts; no older candidates.
|
||||||
|
const parts: PartForPrune[] = [part(PROTECTED_TOKENS, ago(10))];
|
||||||
|
expect(selectPruneTargets(parts, null)).toEqual({ ids: [], freedTokens: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,7 +6,9 @@ import {
|
|||||||
loadContainerGuidance,
|
loadContainerGuidance,
|
||||||
getContainerGuidance,
|
getContainerGuidance,
|
||||||
buildSystemPrompt,
|
buildSystemPrompt,
|
||||||
|
buildSystemPromptWithFingerprint,
|
||||||
_resetContainerGuidanceCacheForTests,
|
_resetContainerGuidanceCacheForTests,
|
||||||
|
_resetPrefixObserverForTests,
|
||||||
} from '../system-prompt.js';
|
} from '../system-prompt.js';
|
||||||
import type { Agent, Project, Session } from '../../types/api.js';
|
import type { Agent, Project, Session } from '../../types/api.js';
|
||||||
|
|
||||||
@@ -17,12 +19,14 @@ let tmpDir: string;
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tmpDir = await mkdtemp(join(tmpdir(), 'system-prompt-test-'));
|
tmpDir = await mkdtemp(join(tmpdir(), 'system-prompt-test-'));
|
||||||
_resetContainerGuidanceCacheForTests();
|
_resetContainerGuidanceCacheForTests();
|
||||||
|
_resetPrefixObserverForTests();
|
||||||
delete process.env['CONTAINER_GUIDANCE_FILE'];
|
delete process.env['CONTAINER_GUIDANCE_FILE'];
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
delete process.env['CONTAINER_GUIDANCE_FILE'];
|
delete process.env['CONTAINER_GUIDANCE_FILE'];
|
||||||
_resetContainerGuidanceCacheForTests();
|
_resetContainerGuidanceCacheForTests();
|
||||||
|
_resetPrefixObserverForTests();
|
||||||
await rm(tmpDir, { recursive: true, force: true });
|
await rm(tmpDir, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -176,3 +180,75 @@ describe('buildSystemPrompt', () => {
|
|||||||
expect(prompt).not.toContain('--- end container guidance ---');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
76
apps/server/src/services/__tests__/tools.test.ts
Normal file
76
apps/server/src/services/__tests__/tools.test.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
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
|
||||||
|
// cache hits on byte-identical prefixes; the tool list lives near the
|
||||||
|
// top of the system prompt, so any order drift invalidates every cached
|
||||||
|
// turn. The registry sort is the single source of truth; downstream
|
||||||
|
// helpers (toolJsonSchemas, TOOLS_BY_NAME, buildAiTools) inherit it.
|
||||||
|
it('exports tools in alphabetical order by name', () => {
|
||||||
|
const names = ALL_TOOLS.map((t) => t.name);
|
||||||
|
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 { promises as fs } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import type { Agent, AgentsResponse, AgentParseError } from '../types/api.js';
|
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
|
// 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
|
// (./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
|
// hand-maintained list drifted (web_search/web_fetch from v1.11.8 + the 8
|
||||||
// codecontext tools were missing), silently filtering valid tool names out
|
// codecontext tools were missing), silently filtering valid tool names out
|
||||||
// of agents that opted in. Single source of truth is tools.ts now.
|
// 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);
|
let ALL_TOOL_NAMES: readonly string[] = ALL_TOOLS.map((t) => t.name);
|
||||||
const DEFAULT_TOOLS: string[] = [...ALL_TOOL_NAMES];
|
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;
|
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 {
|
export function slugify(name: string): string {
|
||||||
return name
|
return name
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
@@ -37,6 +89,10 @@ interface ParsedFrontmatter {
|
|||||||
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
|
// v1.8.2: optional per-agent tool-loop budget. Absent → inference resolves
|
||||||
// from the agent's toolset at runtime.
|
// from the agent's toolset at runtime.
|
||||||
max_tool_calls?: number;
|
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 {
|
function stripQuotes(s: string): string {
|
||||||
@@ -112,6 +168,21 @@ function parseFrontmatter(yaml: string): { data: ParsedFrontmatter; errors: stri
|
|||||||
} else {
|
} else {
|
||||||
errors.push(`max_tool_calls must be an integer 1-100 (got "${valueRaw}")`);
|
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.
|
// Unknown keys silently ignored — forward-compat.
|
||||||
}
|
}
|
||||||
@@ -186,11 +257,18 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
|||||||
throw new Error(fmErrors.join('; '));
|
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)
|
const filteredTools = Array.isArray(fm.tools)
|
||||||
? fm.tools.filter((t): t is string =>
|
? 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 {
|
return {
|
||||||
id: slugify(section.name),
|
id: slugify(section.name),
|
||||||
@@ -201,6 +279,7 @@ function parseAgentSection(section: RawSection): Omit<Agent, 'source'> {
|
|||||||
tools: filteredTools,
|
tools: filteredTools,
|
||||||
model: typeof fm.model === 'string' && fm.model.length > 0 ? fm.model : null,
|
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,
|
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> {
|
async function safeStat(path: string): Promise<number | null> {
|
||||||
try {
|
try {
|
||||||
const s = await fs.stat(path);
|
const s = await fs.stat(path);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user