Compare commits
13 Commits
v2.7.12-au
...
pre-futuri
| Author | SHA1 | Date | |
|---|---|---|---|
| d10d79399b | |||
| aeb2777ad4 | |||
| 2c58f2b3d3 | |||
| d8bb2dabfe | |||
| ca028a4024 | |||
| 3e7115afad | |||
| f32fd928b3 | |||
| 9a139633b8 | |||
| 2c4ff2063d | |||
| ae3f10b19d | |||
| cc4bd04aa4 | |||
| 649ce71eff | |||
| 2a05d2f9fe |
@@ -28,6 +28,11 @@
|
||||
- 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.
|
||||
|
||||
## Recovery and context (v2.7)
|
||||
|
||||
- **Heed the recovery nudge.** Native inference tracks consecutive tool **failures** (`mistake-tracker.ts`): after 3 in a row with no successful step between, a `mistake_recovery` sentinel is injected telling you to re-read tool schemas, verify a path exists before acting, and try a *different* approach — not retry variations of the same failing call. Ignoring it (a second failure run with the nudge still outstanding) **escalates and stops the turn** to protect the step budget. This complements the doom-loop guard, which only catches *identical* repeats.
|
||||
- **Files-read provenance survives compaction.** Paths you read via `view_file` / `grep` / `find_files` / `list_dir` are accumulated and merged into a cumulative `## Files Read` ledger in the rolling summary, so a file read long ago stays in context across compactions. You don't manage this — but it means you usually don't need to re-read a file just because the raw turn scrolled out of the window.
|
||||
|
||||
## Output format
|
||||
|
||||
- Stay in Markdown by default for every reply, short or long.
|
||||
|
||||
36
BOOCODER.md
36
BOOCODER.md
@@ -23,6 +23,8 @@ You are BooCoder, a write-capable coding agent. You can read AND modify files wi
|
||||
|
||||
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.
|
||||
|
||||
`edit_file`'s `old_string` match is **fuzzy** (`fuzzy-match.ts`, v2.7.1): an exact → per-line-whitespace → unicode-canonicalization (curly quotes/dashes/nbsp) → Levenshtein-≥0.66 ladder, so minor whitespace/indentation/unicode drift in `old_string` still lands on the right span. Two consequences: a near-miss `old_string` may still apply (verify the queued diff is what you intended), and an `old_string` matching **more than one** place is rejected as **ambiguous** rather than editing the first — add surrounding context to disambiguate. A genuine non-match returns a clear failure, not a thrown error.
|
||||
|
||||
## Behavior
|
||||
|
||||
- Show diffs clearly. Explain what you're changing and why.
|
||||
@@ -102,7 +104,7 @@ Either way, **adding to config does NOT install the binary.** Until the CLI is o
|
||||
### Deploy + smoke
|
||||
|
||||
Two deploy targets:
|
||||
- **Routes (host service):** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
- **Routes (host service):** `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
- **Web UI (container):** `docker compose up --build -d boocode`
|
||||
|
||||
Green gate (verified across phases 1–5): `pnpm -C apps/coder test` (134 passing) `&& pnpm -C apps/coder build`.
|
||||
@@ -115,3 +117,35 @@ curl http://100.114.205.53:9500/api/coder/providers/config # raw config, throu
|
||||
# Settings → Providers: disable goose → it leaves the composer picker, stays in the tab
|
||||
# POST refresh → models repopulate; Add a catalog entry → it appears after refresh (unavailable until its CLI is installed)
|
||||
```
|
||||
|
||||
## Persistent agent sessions (v2.6)
|
||||
|
||||
When you `dispatch_external_agent` to a chat-tab provider, BooCoder keeps that agent **warm and resumable** instead of spawning a fresh process per turn. This is mostly transparent — but the model below explains why turn 2 is fast, why an external agent remembers earlier turns, and how edits flow.
|
||||
|
||||
### Backends and keying
|
||||
|
||||
- One live backend per **`(chat_id, agent)`** pair, owned by the `agent-pool` (`agent-pool.ts`). State lives in `agent_sessions` (the resumable session id) and `worktrees` (the per-chat working copy).
|
||||
- **opencode** runs a long-lived `opencode serve` (`backends/opencode-server.ts`) with per-session SSE; turns after the first reuse the same session (memory intact, ~9× faster).
|
||||
- **goose / qwen** run a warm ACP connection (`backends/warm-acp.ts`) — `initialize` + `session/new` once per `(chat,agent)`, then `session/prompt` per turn. Interrupt cancels the prompt (`session/cancel`), never the child.
|
||||
- **claude** runs the Claude Agent SDK backend (`backends/claude-sdk.ts`) over a clean-room Postgres session store.
|
||||
- Arena, MCP `new_task`, and one-shot dispatches still use the cold `runExternalAgent` path — warm reuse needs both a `session_id` and a `chat_id`.
|
||||
|
||||
### Worktrees
|
||||
|
||||
- External agents write **directly into a persistent per-chat worktree** (`/tmp/booworktrees/sess-<id>`), not into the project root via `pending_changes`. The worktree is created once, base commit captured, and **reused across turns and across agents in the same chat** — so opencode and goose in one chat share one worktree.
|
||||
- Each turn's worktree diff supersedes the prior `pending_changes` row for that `(chat,agent)` (latest-wins) and is badged with the authoring agent in the DiffPanel.
|
||||
- **Staging boundary:** a provider only sees another agent's edits once they are **applied**. Unapplied worktree edits from a different agent are invisible to you — the DiffPanel shows a muted hint when that's the case.
|
||||
|
||||
### Lifecycle (v2.6.10–v2.6.11)
|
||||
|
||||
- **Idle eviction:** a backend idle past `AGENT_POOL_IDLE_TTL_MS` (default 30 min) is disposed; an LRU cap of `AGENT_POOL_MAX_LIVE` (default 10) bounds live backends. A busy backend is never evicted, and the next turn transparently re-attaches or re-creates from `agent_sessions`/`worktrees`.
|
||||
- **Crash recovery:** a health monitor restarts a crashed server (opencode → fresh sessions; ACP → re-`session/new`) and reclaims its port.
|
||||
- **Close cleanup:** closing/deleting a chat or session evicts its backends, archives the `worktrees` row, and removes the worktree. An hourly reaper sweeps orphaned worktrees (dirty/unpushed preflight before removal).
|
||||
|
||||
### Checkpoints (v2.7.1)
|
||||
|
||||
Because external agents write the worktree directly (outside `pending_changes`), a worktree **checkpoint** is shadow-committed before each external-agent turn (tracked + untracked, into `refs/boocode/checkpoints/<id>`), anchored to that turn's assistant message. The per-message **"Restore to here"** affordance resets the worktree (`reset --hard` + `clean -fd`), trims the transcript past that message, and resets the `(chat,agent)` backend session — so files, transcript, and agent context land consistent at the restore point. `rewind` still only reverses BooCoder's own applied `pending_changes`; checkpoints are what cover external-agent worktree edits.
|
||||
|
||||
### Normalized status (v2.6 / v2.7.6)
|
||||
|
||||
Turn boundaries publish a normalized per-`(chat,agent)` status — `working | blocked | idle | error` — to the UI (`agent_status_updated` frame), so blocked-on-permission and crash/idle are visible, not just WS liveness.
|
||||
|
||||
16
CHANGELOG.md
16
CHANGELOG.md
@@ -2,6 +2,22 @@
|
||||
|
||||
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.7.16-container-git-safedir — 2026-06-03
|
||||
|
||||
Hotfix that makes the `v2.7.15-git-diff-panel` work in production. The `boocode` container runs as root but bind-mounts host project repos owned by uid 1000, so git rejected them with "detected dubious ownership" and the diff route reported every project as not-a-repo — which hid the Git tab entirely (and had been silently nulling the existing branch indicator too). Adds `git config --system --add safe.directory '*'` to the Dockerfile runtime stage so the container's git trusts the mounted repos; applied live to the running container and baked into the image for future rebuilds. Surfaced by a live smoke immediately after the v2.7.14/v2.7.15 deploy.
|
||||
|
||||
## v2.7.15-git-diff-panel — 2026-06-03
|
||||
|
||||
A Files / Git tab in the right-side file panel (the file-browser sidebar) that shows the project repository's git diff and lets the user stage, unstage, commit, and discard whole files in-session — modeled on Paseo's diff view, scoped and planned through the `plan-a-feature` → `plan-implementation` skills, then built and audited via `paseo-epic` in an isolated worktree. Two comparison modes (Uncommitted vs HEAD, and the current branch vs its base — the upstream tracking branch else `origin/HEAD`), auto-selected by repo dirty-state on first open and pinned after an explicit choice; per-file expand/collapse with lazy Shiki `lang:'diff'` highlighting, +/- stats, and binary/too-large placeholders. All git read and write logic lives in `apps/server` (new `git_diff.ts` + routes on `projects.ts`) — the read-only-server posture governs the assistant's tools, not the user's own actions, and the container already mounts `/opt` read-write while `project_bootstrap` already commits via `execFile`. Every write uses the safe `execFile` argv pattern (never a shell string) with `--` operand separators, per-file `pathGuard` + realpath symlink-escape validation, server-derived `-c` commit identity (the request body is `.strict()` and carries no author fields), and the write endpoints are deliberately absent from the assistant tool registry. Reads are bounded (30s deadline, 10MB); an index lock or an in-progress merge/rebase/cherry-pick/bisect surfaces as "repository busy" and disables writes. The panel stays current via a client `git_diff_refresh` sessionEvent (no new wire contract) coalesced across tab-open, mutations, turn completion, and pending-change apply; discard is an irrecoverable hard-delete behind a plain confirm distinguishing a tracked revert from an untracked delete. New `git_diff` pure-helper + temp-repo integration tests (59 cases); server 630 tests green, web tsc clean. Pairs with `v2.7.14-backlog-hardening` (shipped together).
|
||||
|
||||
## v2.7.14-backlog-hardening — 2026-06-03
|
||||
|
||||
Five independent items from the second external-code-review backlog (`boocode_code_review_v2.md`), each built and audited as its own phase via `paseo-epic`. **External task-cancel** now actually works: Stop on an opencode/goose/qwen/claude task aborts the running child via a per-task `AbortController` registry reachable from the cancel route and finalizes the assistant message as `cancelled` — fixing two latent bugs (catch blocks left the message `streaming`; warm success-paths wrote `complete` on an aborted turn); warm pools/worktrees are preserved (abort the prompt only, never the pooled process) and the native boocode path is unchanged. **Parser prune**: the tool-call parser drops to its two load-bearing exports (eight zero-caller symbols unexported, a gate test added for the `<invoke>`-as-text fallback) with no live-path behavior change, and placeholder-rejection logging moves to pino. **BooChat stall-timeout**: a 90s per-chunk deadline wraps native inference's `fullStream` via `AbortSignal.any` so a hung local stream finalizes the message instead of hanging — no retry, since re-running re-emits already-streamed deltas (a pure `classifyStreamError` helper is added). **view_session_history**: a read-only MCP tool returning the newest-N transcript (role≠system) in chronological order. **Retire :9502**: the unused `apps/coder/web` fallback SPA is removed (package, static-serve block, build step, Dockerfile copy, `@fastify/static`), keeping every API/WS/health/MCP route. F1 added an optional `status` field to the shared `message_complete` contracts frame (so a deploy rebuilds `@boocode/contracts` first, as the sequence already does). Server 630 / coder 360 tests green.
|
||||
|
||||
## v2.7.13-contracts-ssot — 2026-06-02
|
||||
|
||||
Creates `@boocode/contracts` (`packages/contracts`), a new workspace package that becomes the single source of truth for every cross-app wire contract — reversing the decision recorded in `v2.5.12-provider-lifecycle-phase4` that declined a shared types package as not worth the Docker/build-order risk at solo scale; a live `AgentSessionConfig` drift that had since appeared between `apps/coder` and `apps/web` justified the investment. Six contracts are now defined exactly once: the `WsFrameSchema` Zod runtime schema, the provider snapshot types (`ProviderSnapshotEntry` and family), the Zod provider-config schemas, `MessageMetadata` + `ErrorReason`, `AgentSessionConfig`, and `WorktreeRiskReport`; both Zod-backed contracts use `z.infer` so validator and type derive from the same definition and cannot drift independently. All four consumers — `apps/server`, `apps/web`, `apps/coder`, and the fallback SPA `apps/coder/web` — import via `workspace:*` through a per-subpath exports map consuming built dist only (no tsconfig project references); the hand-synced copies and their parity tests (`provider-types-parity.test.ts`; the ws-frames byte-parity assertion) are deleted while the KNOWN_FRAME_TYPES drift test and broker fail-closed tests are preserved. Build order is inverted in the root build script, Dockerfile, and coder deploy docs; `apps/coder/web`'s migration also removed dead `pending_change_*` reducer arms (no frame publisher exists for these — pending changes are HTTP-delivered), closing a latent missing-default-arm crash, and reconciled field-type conflicts with the canonical `WsFrame`; zod is pinned to a single version across the workspace. Server 543 / coder 293 / contracts 11 tests passing; human smoke verified on the live stack 2026-06-02.
|
||||
|
||||
## v2.7.12-audit-cleanup — 2026-06-02
|
||||
|
||||
A repo-wide audit and aggressive cleanup pass, run as a multi-agent orchestration (five read-only Opus auditors over server/web/coder/booterm + cross-cutting deps/build/parity + a structural-architecture lens) followed by phased, behavior-preserving implementation — every change gated on the per-app test suites and delivered behind a strict DEFER discipline that never touched the files in flight for `v2.7.9`–`v2.7.11` (`mcp-config`, the `ws-frames` pair, `dispatcher`, `claude-sdk-map`, `AgentComposerBar`/`CoderMessageList`/`CoderPane`), so the branch rebased onto current main with zero conflicts. **Dead code/deps/schema**: removed ~9 dead files and a swathe of dead exports/write-only state across all four apps, dropped dead deps (`next-themes`, `@xterm/addon-webgl`, booterm `tslib`; `shadcn`→devDep), and idempotently dropped dead schema columns/tables (`sessions.tags`, `tasks.worktree_path`/`feature_values`, `available_agents.supports_mcp_client`, the superseded `session_worktrees` table, the always-empty `list_worktrees` MCP tool) — chat/session/message DATA untouched, only never-read columns. **Server dedup + reshapes**: collapsed the dead `budget.ts` tier system (surfacing a latent `READ_ONLY_TOOL_NAMES` drift, then deleted), extracted shared `MESSAGE_COLUMNS`/`selectProject`/`stripQuotes`/`SENTINEL_KINDS`/`samplerOptsFromAgent`/`createContentFlusher`/`insertSentinel`/a `makeCodecontextTool` factory/a pending-tool-call resolver, split `tools.ts` (799→46 barrel + `tools/{types,fs-tools,misc-tools,registry,tiers}`, register-through registry preserved so coder's import contract stays byte-stable), and decomposed the inference pipeline (`sentinel-summaries`→`runWrapUpSummary`, `turn.ts`→`turn-config`+`step-decision`, a pure `stream-phase-adapter`, shared finalize atoms — stopping short of fusing synthesis to preserve frame timing). **Coder reshapes**: split the 1062-line `opencode-server.ts` god-class into supervisor / sse-loop / pure event-map / port-utils + extracted `buildAcpClient`/`makeFrameEmitter`/`worktree-risk`, plus happy-path-safe concurrency hardening (reconnect backoff, double-spawn guard; a defensive busy-assert + ensureSession coalescing flagged for review). **Web**: `React.memo` on `MessageBubble`/`MarkdownRenderer` + module-hoisted markdown components (the streaming re-parse was the biggest perf cost), shared `linkifyPaths`/artifact/tab dedup, two latent bug fixes (`ChatPane` index-keys → stable ids; `FileViewerOverlay` blank-line line-number desync), and decomposed the 1298-line `TerminalPane.tsx` into fit/socket/selection hooks + presentational pieces (verbatim move, all ~30 listeners/timers inventoried; the label-dep fix stops a live terminal tearing down on pane renumber). +78 parity/unit tests (server 597, coder 328 green; `apps/web` has no harness, so its changes are typecheck + manual/device QA). Net ≈ −4,600 LOC. Deferred (designed; blueprints in the audit reports): the `tasks` dual-CREATE / `project_id` FK (a cross-service deploy-ordering decision, not a data migration), web structural decomposition of `useWorkspacePanes`/`MessageBubble` (needs a web test harness first), a `@boocode/contracts` shared package, and the `dispatcher.ts` split — the last two now unblocked since their in-flight files shipped in `v2.7.9`–`v2.7.11`. Rebased clean onto `v2.7.11-coder-model-snapshot`.
|
||||
|
||||
26
CLAUDE.md
26
CLAUDE.md
@@ -23,13 +23,12 @@ pnpm -C apps/server build # server only (tsc + copy schema.sql)
|
||||
pnpm -C apps/web build # web only (vite)
|
||||
|
||||
# Type checking (no emit)
|
||||
npx tsc --noEmit # project references (root)
|
||||
npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
|
||||
|
||||
# IMPORTANT: root tsc --noEmit uses project references and can miss errors
|
||||
# that the per-app tsconfig catches. Always verify with the per-app command
|
||||
# when editing web code. The server build (pnpm -C apps/server build) is
|
||||
# authoritative for server code.
|
||||
# Per-app is authoritative. There is NO root tsconfig.json (only tsconfig.base.json),
|
||||
# so a bare `npx tsc --noEmit` at root compiles nothing.
|
||||
npx tsc -p apps/web/tsconfig.app.json --noEmit # web (authoritative)
|
||||
pnpm -C apps/server build # server typecheck (tsc + copy schema)
|
||||
pnpm -C apps/coder build # coder typecheck
|
||||
pnpm -C apps/booterm typecheck # booterm typecheck
|
||||
|
||||
# Production
|
||||
docker compose build --no-cache boocode && docker compose up -d
|
||||
@@ -39,7 +38,7 @@ Tests: `pnpm -C apps/server test` (vitest); `apps/coder` has its own suite — `
|
||||
|
||||
## Architecture
|
||||
|
||||
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres), `apps/web` (React + Vite), `apps/booterm` (Fastify + node-pty + tmux), `apps/coder` (BooCoder, host service).
|
||||
**Monorepo**: pnpm workspaces with `apps/server` (Fastify + postgres), `apps/web` (React + Vite), `apps/booterm` (Fastify + node-pty + tmux), `apps/coder` (BooCoder, host service), `packages/contracts` (`@boocode/contracts`, cross-app wire-contract SSOT — builds FIRST).
|
||||
|
||||
### Per-app deep references
|
||||
|
||||
@@ -69,11 +68,13 @@ Schema CHECK migration order when renaming allowed values: (1) `ALTER TABLE ...
|
||||
|
||||
**`CREATE OR REPLACE VIEW` can't reorder/rename columns** (Postgres `42P16`): append a new `messages_with_parts` column at the END of the SELECT — a mid-list insert shifts an existing column → crash-loops boot. Add it to each explicit read SELECT too (`routes/messages.ts`/`chats.ts`/`ws.ts`).
|
||||
|
||||
**A `SELECT *` view pins every column** (`2BP01`): `DROP COLUMN` on the table fails while such a view exists. `human_inbox` is `SELECT * FROM tasks` — to drop a `tasks` column, `DROP VIEW IF EXISTS human_inbox` first, drop the column(s), then recreate the view (idempotent). Bites existing DBs only; a fresh DB never had the column, so fresh-DB testing misses it.
|
||||
|
||||
## Environment
|
||||
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only add-existing scope), `BOOTSTRAP_ROOT` (/opt/projects, writable bootstrap mkdir target — host must `mkdir -p` it before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale; the public host is behind Authelia, unusable from server context), `BOOCODE_TOOLS` (`core`|`standard`|`all`, default `all`; a ceiling, never expands an agent's whitelist), `MCP_CONFIG_PATH` (default `/data/mcp.json`, opencode `mcpServers` shape; missing = no MCP), `CONTEXT7_API_KEY` (the Context7 MCP key, referenced from `data/mcp.json` as `"{env:CONTEXT7_API_KEY}"`). `data/mcp.json` is **gitignored** but no longer holds secrets — string values support opencode-style `{env:VAR}` substitution (`mcp-config.ts:substituteEnvVars`, applied before Zod validation; unset var → `''` + warn), so real keys live in `.env`; template `data/mcp.example.json`. A config-only edit there needs only `docker compose restart boocode` (data/ is bind-mounted); changing a referenced secret edits `.env`. MCP loads at server startup with per-server graceful degradation; the coder does NOT load MCP (BooChat only).
|
||||
|
||||
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
|
||||
BooCoder at port 9502: `curl http://100.114.205.53:9502/api/health`. Runs as `boocoder.service` on the host (not Docker). Deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Health reports tool count: `{"ok":true,"db":true,"tools":33}`.
|
||||
|
||||
- `FAST_MODEL` (optional) — cheaper model for titles, summaries, labeling (auto_name.ts, tool-summaries.ts). Falls back to session model or DEFAULT_MODEL. Set to a small llama-swap model (e.g. `nemotron-nano-4b`) to avoid loading the 35B for 20-token calls.
|
||||
- Qwen Code dispatch: `OPENAI_BASE_URL=http://100.101.41.16:8401/v1 OPENAI_API_KEY=dummy qwen -p "<task>" --output-format stream-json`. Install: `npm install -g @qwen-code/qwen-code@latest`. Node ≥22 on host (container stays Node 20; BooCoder dispatches via direct spawn on host). No `--yolo` flag — `-p` runs autonomously without prompts. ACP bridge is an HTTP daemon (not stdio); use PTY dispatch.
|
||||
@@ -113,9 +114,10 @@ Cross-cutting only. Per-app conventions live in the matching `apps/*/CLAUDE.md`.
|
||||
- No app-layer auth. Authelia handles auth at the reverse proxy. All `broker.publishUser`/`subscribeUser` calls use `'default'` as the user key.
|
||||
- TypeScript strict mode. Both apps share `tsconfig.base.json`. Server + coder use NodeNext module resolution (`.js` extensions in imports).
|
||||
- Discriminated unions for type narrowing: `Pane` (by `kind`), `SessionEvent` (by `type`), `InferenceFrame` (by `type`).
|
||||
- **Adding a new WS frame type** (cross-app) 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 — missing the web side silently drops the frame at JSON-parse.
|
||||
- **Sentinels** (cross-app) are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
||||
- **Coder↔web provider-type parity** (`apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`): enforced by runtime `provider-types-parity.test.ts` (compile-time cross-import is blocked by TS6307 on web's composite tsconfig). Mirror of the ws-frames parity pattern — edit both copies together.
|
||||
- **Adding a new WS frame type** (cross-app): add it to `WsFrameSchema` in `packages/contracts/src/ws-frames.ts` (single source of truth; rebuild with `pnpm -C packages/contracts build`). The server's `InferenceFrame` loose union (`services/inference/turn.ts`) and the web's strict `WsFrame` discriminated union (`apps/web/src/api/types.ts`) still exist separately and also need updating. Server publish is permissive; the frontend type is the wire-format gate — missing the web side silently drops the frame at JSON-parse.
|
||||
- **Sentinels** (cross-app) are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. `MessageMetadata` is single-sourced in `@boocode/contracts` (`packages/contracts/src/message-metadata.ts`). A new kind requires updating that file and rebuilding the package, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
||||
- **Provider snapshot types** (`ProviderSnapshotEntry`, `ProviderModel`, `ProviderMode`, `ThinkingOption`, `AgentCommand`, `ProviderSnapshotStatus`) are single-sourced in `@boocode/contracts` (`packages/contracts/src/provider-snapshot.ts`); `apps/coder/src/services/provider-types.ts` re-exports them. Edit the package source; there is no hand-synced web copy to update.
|
||||
- **`@boocode/contracts`** single-sources cross-app wire contracts via per-subpath built-dist exports, consumed by all four apps (incl. `apps/coder/web`): `./ws-frames`, `./provider-snapshot`, `./provider-config` (Zod schemas), `./message-metadata` (`MessageMetadata`/`ErrorReason`/`AgentSessionConfig`), `./worktree-risk`. It builds BEFORE every consumer (root build, Dockerfile, coder deploy). Its `WsFrame` is the loose `z.infer` of `WsFrameSchema` (payloads `unknown`); the web's richer strict `WsFrame` union is **deliberately web-local** (`apps/web/src/api/types.ts`), bridged to the validated frame by a cast — don't move it into the package. Consume built `dist` via the exports map; never add the package to a tsconfig `references` array.
|
||||
- **JSONB columns**: use `sql.json(value as never)` — NOT `${JSON.stringify(value)}::jsonb` which double-serializes (stores a JSON string instead of an object/array). Pattern in `parts.ts`, `settings.ts`.
|
||||
- Skills live in `data/skills/<vendor>/`; Sam's own namespace is `boocode/` (`committing-changes`, `using-worktrees`, `improving-boocode-guidance`, `systematic-debugging`) — `SKILL.md` + optional `eval.yaml` (gerund names; eval = `skill:` + `tasks:` of `prompt`+`grader`, incl. a negative-trigger task). `data/skills/` is canonical; a divergent mirror at `/opt/skills/` exists.
|
||||
|
||||
|
||||
@@ -5,11 +5,15 @@ RUN corepack enable
|
||||
WORKDIR /build
|
||||
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml tsconfig.base.json ./
|
||||
COPY packages/contracts/package.json ./packages/contracts/
|
||||
COPY apps/server/package.json ./apps/server/
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
# @boocode/contracts must be present before `pnpm build`, which builds it FIRST
|
||||
# (root build script) so apps/web can resolve its compiled dist via the exports map.
|
||||
COPY packages/contracts ./packages/contracts
|
||||
COPY apps/server ./apps/server
|
||||
COPY apps/web ./apps/web
|
||||
|
||||
@@ -20,6 +24,9 @@ RUN pnpm deploy --filter=@boocode/server --prod --legacy /out/server
|
||||
|
||||
FROM node:20-alpine AS runtime
|
||||
RUN apk add --no-cache ripgrep git openssh-client
|
||||
# The container runs as root but bind-mounts host project repos owned by uid 1000;
|
||||
# trust them so git read/write tools (git_status, the git diff panel) work over the mount.
|
||||
RUN git config --system --add safe.directory '*'
|
||||
RUN mkdir -p /root/.ssh && ssh-keyscan -p 2222 -H 100.114.205.53 git.indifferentketchup.com >> /root/.ssh/known_hosts && chmod 700 /root/.ssh && chmod 600 /root/.ssh/known_hosts
|
||||
WORKDIR /app
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ upstream and inject `Remote-User`. Postgres binds loopback only.
|
||||
BooCoder runs as a **host systemd service** (`boocoder.service`, port `:9502`), not in Docker:
|
||||
|
||||
```bash
|
||||
pnpm -C apps/server build && pnpm -C apps/coder build
|
||||
pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build
|
||||
sudo systemctl restart boocoder
|
||||
curl http://100.114.205.53:9502/api/health
|
||||
```
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
## Build, deploy, dispatch
|
||||
|
||||
- **Workspace dependency on `@boocode/server`**: imports `createInferenceRunner`, `createBroker`, `ALL_TOOLS`, `appendMcpTools` from the server's compiled `dist/`. apps/server's `package.json` has an `exports` map with `types` conditions for NodeNext resolution. **apps/server must build FIRST.**
|
||||
- Build + deploy: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- Build + deploy: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. Env file at `apps/coder/.env.host`. Service file at `/etc/systemd/system/boocoder.service`.
|
||||
- After `pnpm -C apps/coder build` the host service keeps running the OLD process until `sudo systemctl restart boocoder` — a stale process shows **new routes 404 with `{error:'not found'}` while old routes still 200** (the `/api` not-found handler shape). Restart, don't re-debug.
|
||||
- `:9502/api/health` is down ~15–20s after a boocoder restart while the startup agent-probe scan runs — retry; an early connection-refused is not a failed deploy.
|
||||
- Agent dispatch spawns binaries directly using `install_path` from `available_agents` — no `spawn('sh', ['-c', ...])` (fails under systemd). Paseo's pattern: `spawn(fullBinaryPath, argsArray, { cwd })`.
|
||||
|
||||
@@ -7,7 +7,6 @@ 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
|
||||
|
||||
@@ -16,7 +15,6 @@ 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
|
||||
@@ -27,7 +25,6 @@ RUN apt-get update && apt-get install -y --no-install-recommends ripgrep git ope
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /out/coder ./
|
||||
COPY --from=builder /build/apps/coder/web/dist ./web
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 3000
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boocode/contracts": "workspace:*",
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"@anthropic-ai/claude-agent-sdk": "^0.3.159",
|
||||
"@boocode/server": "workspace:*",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@opencode-ai/sdk": "~1.15.0",
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
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';
|
||||
@@ -16,7 +9,7 @@ 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';
|
||||
import type { WsFrame } from '@boocode/contracts/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';
|
||||
@@ -257,7 +250,7 @@ async function main() {
|
||||
registerPendingRoutes(app, sql);
|
||||
registerCheckpointRoutes(app, sql);
|
||||
registerAgentSessionRoutes(app, sql);
|
||||
registerTaskRoutes(app, sql, inferenceApi);
|
||||
registerTaskRoutes(app, sql, inferenceApi, dispatcher.cancelExternalTask);
|
||||
registerInboxRoutes(app, sql);
|
||||
registerStatsRoutes(app, sql);
|
||||
registerArenaRoutes(app, sql);
|
||||
@@ -266,28 +259,6 @@ async function main() {
|
||||
registerLifecycleRoutes(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');
|
||||
|
||||
138
apps/coder/src/routes/__tests__/tasks-cancel.test.ts
Normal file
138
apps/coder/src/routes/__tests__/tasks-cancel.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import Fastify, { type FastifyInstance } from 'fastify';
|
||||
import postgres from 'postgres';
|
||||
import { registerTaskRoutes } from '../tasks.js';
|
||||
|
||||
/**
|
||||
* F1 — POST /api/tasks/:id/cancel route wiring.
|
||||
*
|
||||
* The route's job: reach the in-flight external run via `cancelExternal(taskId)`
|
||||
* (the new abort hook), keep cancelling native inference for open chats unchanged,
|
||||
* and land the task row in 'cancelled'. The streaming assistant message is
|
||||
* finalized by the dispatcher's run-function, not here — that path is covered by
|
||||
* finalize-message.test.ts. This suite pins the route's behavior against a real DB.
|
||||
*/
|
||||
describe.runIf(!!process.env.DATABASE_URL)('POST /api/tasks/:id/cancel (route, F1)', () => {
|
||||
let sql: ReturnType<typeof postgres>;
|
||||
let app: FastifyInstance;
|
||||
let projectId: string;
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
|
||||
const externalCancelCalls: string[] = [];
|
||||
const inferenceCancelCalls: Array<[string, string]> = [];
|
||||
let externalReturns = true;
|
||||
|
||||
beforeAll(async () => {
|
||||
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
|
||||
const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql');
|
||||
const coderSchema = resolve(__dirname, '../../schema.sql');
|
||||
await sql.unsafe(readFileSync(serverSchema, 'utf8'));
|
||||
await sql.unsafe(readFileSync(coderSchema, 'utf8'));
|
||||
|
||||
const [p] = await sql<{ id: string }[]>`
|
||||
INSERT INTO projects (name, path, status) VALUES ('f1-cancel-route', '/tmp/f1-cancel-route', 'open') RETURNING id
|
||||
`;
|
||||
projectId = p!.id;
|
||||
const [s] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status) VALUES (${projectId}, 'f1', 'm', 'open') RETURNING id
|
||||
`;
|
||||
sessionId = s!.id;
|
||||
const [c] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id
|
||||
`;
|
||||
chatId = c!.id;
|
||||
|
||||
app = Fastify();
|
||||
registerTaskRoutes(
|
||||
app,
|
||||
sql,
|
||||
{
|
||||
cancel: async (sid: string, cid: string) => {
|
||||
inferenceCancelCalls.push([sid, cid]);
|
||||
return false;
|
||||
},
|
||||
},
|
||||
(taskId: string) => {
|
||||
externalCancelCalls.push(taskId);
|
||||
return externalReturns;
|
||||
},
|
||||
);
|
||||
await app.ready();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (app) await app.close();
|
||||
if (!sql) return;
|
||||
await sql`DELETE FROM messages WHERE session_id = ${sessionId}`.catch(() => {});
|
||||
await sql`DELETE FROM tasks WHERE project_id = ${projectId}`.catch(() => {});
|
||||
await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {});
|
||||
await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {});
|
||||
await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {});
|
||||
await sql.end({ timeout: 5 });
|
||||
});
|
||||
|
||||
async function insertTask(agent: string | null, state: string): Promise<string> {
|
||||
const [t] = await sql<{ id: string }[]>`
|
||||
INSERT INTO tasks (project_id, input, agent, session_id, state, started_at)
|
||||
VALUES (${projectId}, 'do a thing', ${agent}, ${sessionId}, ${state}, clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
return t!.id;
|
||||
}
|
||||
|
||||
it('reaches cancelExternal and lands the task cancelled for a running external task', async () => {
|
||||
externalReturns = true;
|
||||
externalCancelCalls.length = 0;
|
||||
const taskId = await insertTask('opencode', 'running');
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ cancelled: true });
|
||||
|
||||
expect(externalCancelCalls).toContain(taskId);
|
||||
|
||||
const [row] = await sql<{ state: string; ended_at: Date | null }[]>`
|
||||
SELECT state, ended_at FROM tasks WHERE id = ${taskId}
|
||||
`;
|
||||
expect(row!.state).toBe('cancelled');
|
||||
expect(row!.ended_at).not.toBeNull();
|
||||
});
|
||||
|
||||
it('still cancels a native boocode task (cancelExternal returns false → inference.cancel path unchanged)', async () => {
|
||||
externalReturns = false; // native task: no controller registered
|
||||
externalCancelCalls.length = 0;
|
||||
inferenceCancelCalls.length = 0;
|
||||
const taskId = await insertTask(null, 'running');
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
|
||||
// The route calls cancelExternal unconditionally (cheap, returns false here)...
|
||||
expect(externalCancelCalls).toContain(taskId);
|
||||
// ...and the native inference.cancel path still fires for the open chat.
|
||||
expect(inferenceCancelCalls).toContainEqual([sessionId, chatId]);
|
||||
|
||||
const [row] = await sql<{ state: string }[]>`SELECT state FROM tasks WHERE id = ${taskId}`;
|
||||
expect(row!.state).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('rejects cancelling an already-terminal task with 409 and never touches the abort hook', async () => {
|
||||
externalCancelCalls.length = 0;
|
||||
const taskId = await insertTask('opencode', 'completed');
|
||||
|
||||
const res = await app.inject({ method: 'POST', url: `/api/tasks/${taskId}/cancel` });
|
||||
expect(res.statusCode).toBe(409);
|
||||
expect(externalCancelCalls).not.toContain(taskId);
|
||||
});
|
||||
|
||||
it('returns 404 for an unknown task', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/tasks/00000000-0000-0000-0000-000000000000/cancel`,
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { resolveChatId } from './chat-resolve.js';
|
||||
|
||||
const AnswerUserInputBody = z.object({
|
||||
|
||||
@@ -2,7 +2,7 @@ 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';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { getSkillBody } from '@boocode/server/skills';
|
||||
import {
|
||||
buildSkillInvokeSyntheticFrames,
|
||||
|
||||
@@ -8,6 +8,12 @@ interface InferenceApi {
|
||||
cancel: (sessionId: string, chatId: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// F1: the dispatcher's reach into an in-flight external-agent run. Narrow by
|
||||
// design (not the whole dispatcher) — the route only needs to fire the abort.
|
||||
// Returns true when a controller was registered for the task (an external run was
|
||||
// in flight), false otherwise (native boocode task, or already finished).
|
||||
export type ExternalCancelFn = (taskId: string) => boolean;
|
||||
|
||||
const CreateBody = z.object({
|
||||
project_id: z.string().uuid(),
|
||||
input: z.string().min(1).max(64_000),
|
||||
@@ -27,7 +33,12 @@ const ListQuery = z.object({
|
||||
project_id: z.string().uuid().optional(),
|
||||
});
|
||||
|
||||
export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: InferenceApi): void {
|
||||
export function registerTaskRoutes(
|
||||
app: FastifyInstance,
|
||||
sql: Sql,
|
||||
inference: InferenceApi,
|
||||
cancelExternal: ExternalCancelFn,
|
||||
): void {
|
||||
// POST /api/tasks — create a new task
|
||||
app.post('/api/tasks', async (req, reply) => {
|
||||
const parsed = CreateBody.safeParse(req.body);
|
||||
@@ -127,7 +138,14 @@ export function registerTaskRoutes(app: FastifyInstance, sql: Sql, inference: In
|
||||
|
||||
cancelPendingPermission(taskId);
|
||||
|
||||
// If running, try to cancel inference
|
||||
// F1: abort the in-flight external-agent run (opencode / goose / qwen / claude).
|
||||
// Idempotent — a double-Stop re-aborts harmlessly; a native boocode task is not
|
||||
// registered, so this returns false and the inference.cancel path below handles
|
||||
// it unchanged. The dispatcher's run-function finalizes the streaming assistant
|
||||
// message as 'cancelled' once the backend honors the signal.
|
||||
cancelExternal(taskId);
|
||||
|
||||
// If running, try to cancel inference (native boocode path — unchanged).
|
||||
if ((task.state === 'running' || task.state === 'blocked') && task.session_id) {
|
||||
// Find active chat in the task's session
|
||||
const chats = await sql<{ id: string }[]>`
|
||||
|
||||
@@ -75,8 +75,14 @@ ALTER TABLE tasks ADD COLUMN IF NOT EXISTS mode_id TEXT;
|
||||
ALTER TABLE tasks ADD COLUMN IF NOT EXISTS thinking_option_id TEXT;
|
||||
-- tasks.feature_values and tasks.worktree_path were never read or written by any
|
||||
-- code path; drop them from existing DBs (fresh DBs never had them in the CREATE).
|
||||
-- human_inbox is `SELECT *` over tasks, so it pins every task column — dropping a
|
||||
-- column while the view exists fails (2BP01). Drop the view, drop the columns, then
|
||||
-- recreate it with the current column set (idempotent on fresh + existing DBs).
|
||||
DROP VIEW IF EXISTS human_inbox;
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS feature_values;
|
||||
ALTER TABLE tasks DROP COLUMN IF EXISTS worktree_path;
|
||||
CREATE OR REPLACE VIEW human_inbox AS
|
||||
SELECT * FROM tasks WHERE state IN ('blocked', 'failed');
|
||||
|
||||
-- v2.6: one backend session per (session, agent); resumed on switch-back.
|
||||
CREATE TABLE IF NOT EXISTS agent_sessions (
|
||||
|
||||
51
apps/coder/src/services/__tests__/cancel-registry.test.ts
Normal file
51
apps/coder/src/services/__tests__/cancel-registry.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { createCancelRegistry } from '../cancel-registry.js';
|
||||
|
||||
/**
|
||||
* F1 — per-task abort wiring. The registry is the missing link between the Stop
|
||||
* route and the in-flight external run: register an AbortController per task id,
|
||||
* cancel(taskId) aborts its signal, the run's .finally deletes it. Pure (no DB /
|
||||
* child / IO) so the abort + idempotency contract is unit-testable in isolation.
|
||||
*/
|
||||
describe('CancelRegistry (F1 abort wiring)', () => {
|
||||
it('register hands back a fresh controller; cancel aborts its signal', () => {
|
||||
const reg = createCancelRegistry();
|
||||
const ac = reg.register('t1');
|
||||
expect(ac.signal.aborted).toBe(false);
|
||||
expect(reg.has('t1')).toBe(true);
|
||||
|
||||
expect(reg.cancel('t1')).toBe(true);
|
||||
expect(ac.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('cancel on an unknown task returns false (native task / cancel-before-register)', () => {
|
||||
const reg = createCancelRegistry();
|
||||
expect(reg.has('nope')).toBe(false);
|
||||
expect(reg.cancel('nope')).toBe(false);
|
||||
});
|
||||
|
||||
it('double-Stop is idempotent: a second cancel never throws and the signal stays aborted', () => {
|
||||
const reg = createCancelRegistry();
|
||||
const ac = reg.register('t1');
|
||||
|
||||
expect(reg.cancel('t1')).toBe(true);
|
||||
// The run-function has not hit its .finally yet, so the entry is still
|
||||
// present — a rapid second Stop re-aborts (abort() no-ops) without throwing.
|
||||
expect(() => reg.cancel('t1')).not.toThrow();
|
||||
expect(reg.cancel('t1')).toBe(true);
|
||||
expect(ac.signal.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it('cancel after delete returns false (cancel-after-natural-exit is safe)', () => {
|
||||
const reg = createCancelRegistry();
|
||||
reg.register('t1');
|
||||
reg.delete('t1');
|
||||
expect(reg.has('t1')).toBe(false);
|
||||
expect(reg.cancel('t1')).toBe(false);
|
||||
});
|
||||
|
||||
it('delete of an unknown id is a no-op (never throws)', () => {
|
||||
const reg = createCancelRegistry();
|
||||
expect(() => reg.delete('ghost')).not.toThrow();
|
||||
});
|
||||
});
|
||||
163
apps/coder/src/services/__tests__/finalize-message.test.ts
Normal file
163
apps/coder/src/services/__tests__/finalize-message.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import postgres from 'postgres';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import { classifyTerminalStatus, finalizeStreamingMessage } from '../finalize-message.js';
|
||||
|
||||
/**
|
||||
* F1 (D-7 / OCE-001 / OCE-002) — finalizing a Stop'd or errored external turn.
|
||||
*
|
||||
* `classifyTerminalStatus` is the pure D-7 decision (user Stop / AbortError →
|
||||
* cancelled, genuine error → failed). `finalizeStreamingMessage` writes that
|
||||
* terminal state onto the streaming assistant row and publishes the matching
|
||||
* message_complete frame — idempotently, guarded by `WHERE status='streaming'`,
|
||||
* so a double-Stop or an abort-then-catch settles the message exactly once and
|
||||
* never clobbers a row that already finished cleanly.
|
||||
*/
|
||||
describe('classifyTerminalStatus (pure, D-7)', () => {
|
||||
it('maps a fired abort signal to cancelled (user Stop)', () => {
|
||||
expect(classifyTerminalStatus({ aborted: true })).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('maps a thrown AbortError to cancelled', () => {
|
||||
const e = new Error('the operation was aborted');
|
||||
e.name = 'AbortError';
|
||||
expect(classifyTerminalStatus({ aborted: false, error: e })).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('maps a genuine thrown error to failed', () => {
|
||||
expect(classifyTerminalStatus({ aborted: false, error: new Error('boom') })).toBe('failed');
|
||||
});
|
||||
|
||||
it('defaults a no-abort / no-error catch to failed', () => {
|
||||
expect(classifyTerminalStatus({ aborted: false })).toBe('failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(!!process.env.DATABASE_URL)('finalizeStreamingMessage (DB)', () => {
|
||||
let sql: ReturnType<typeof postgres>;
|
||||
let projectId: string;
|
||||
let sessionId: string;
|
||||
let chatId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
sql = postgres(process.env.DATABASE_URL!, { max: 3 });
|
||||
// Server schema owns messages/sessions/chats (FK targets); coder schema after.
|
||||
const serverSchema = resolve(__dirname, '../../../../server/src/schema.sql');
|
||||
const coderSchema = resolve(__dirname, '../../schema.sql');
|
||||
await sql.unsafe(readFileSync(serverSchema, 'utf8'));
|
||||
await sql.unsafe(readFileSync(coderSchema, 'utf8'));
|
||||
|
||||
const [p] = await sql<{ id: string }[]>`
|
||||
INSERT INTO projects (name, path, status) VALUES ('f1-finalize', '/tmp/f1-finalize', 'open') RETURNING id
|
||||
`;
|
||||
projectId = p!.id;
|
||||
const [s] = await sql<{ id: string }[]>`
|
||||
INSERT INTO sessions (project_id, name, model, status) VALUES (${projectId}, 'f1', 'm', 'open') RETURNING id
|
||||
`;
|
||||
sessionId = s!.id;
|
||||
const [c] = await sql<{ id: string }[]>`
|
||||
INSERT INTO chats (session_id, name, status) VALUES (${sessionId}, 'tab', 'open') RETURNING id
|
||||
`;
|
||||
chatId = c!.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (!sql) return;
|
||||
await sql`DELETE FROM messages WHERE session_id = ${sessionId}`.catch(() => {});
|
||||
await sql`DELETE FROM chats WHERE id = ${chatId}`.catch(() => {});
|
||||
await sql`DELETE FROM sessions WHERE id = ${sessionId}`.catch(() => {});
|
||||
await sql`DELETE FROM projects WHERE id = ${projectId}`.catch(() => {});
|
||||
await sql.end({ timeout: 5 });
|
||||
});
|
||||
|
||||
async function insertStreaming(): Promise<string> {
|
||||
const [m] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming') RETURNING id
|
||||
`;
|
||||
return m!.id;
|
||||
}
|
||||
|
||||
it('finalizes a streaming row to cancelled, persists partial content, publishes one frame', async () => {
|
||||
const id = await insertStreaming();
|
||||
const frames: WsFrame[] = [];
|
||||
const did = await finalizeStreamingMessage(sql, (_s, f) => frames.push(f), {
|
||||
sessionId,
|
||||
chatId,
|
||||
assistantId: id,
|
||||
status: 'cancelled',
|
||||
model: 'qwen',
|
||||
content: 'partial answer',
|
||||
});
|
||||
|
||||
expect(did).toBe(true);
|
||||
const [row] = await sql<{ status: string; content: string; finished_at: Date | null }[]>`
|
||||
SELECT status, content, finished_at FROM messages WHERE id = ${id}
|
||||
`;
|
||||
expect(row!.status).toBe('cancelled');
|
||||
expect(row!.content).toBe('partial answer');
|
||||
expect(row!.finished_at).not.toBeNull();
|
||||
expect(frames).toHaveLength(1);
|
||||
expect(frames[0]!.type).toBe('message_complete');
|
||||
expect((frames[0] as { status?: string }).status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('is idempotent for a double-Stop: second call updates nothing and re-publishes nothing', async () => {
|
||||
const id = await insertStreaming();
|
||||
const frames: WsFrame[] = [];
|
||||
const push = (_s: string, f: WsFrame): void => {
|
||||
frames.push(f);
|
||||
};
|
||||
|
||||
expect(
|
||||
await finalizeStreamingMessage(sql, push, { sessionId, chatId, assistantId: id, status: 'cancelled', model: null }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
await finalizeStreamingMessage(sql, push, { sessionId, chatId, assistantId: id, status: 'cancelled', model: null }),
|
||||
).toBe(false);
|
||||
|
||||
expect(frames).toHaveLength(1);
|
||||
const [row] = await sql<{ status: string }[]>`SELECT status FROM messages WHERE id = ${id}`;
|
||||
expect(row!.status).toBe('cancelled');
|
||||
});
|
||||
|
||||
it('never clobbers a row that already finished cleanly (abort raced a clean finish)', async () => {
|
||||
const [m] = await sql<{ id: string }[]>`
|
||||
INSERT INTO messages (session_id, chat_id, role, content, status)
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', 'done', 'complete') RETURNING id
|
||||
`;
|
||||
const id = m!.id;
|
||||
const frames: WsFrame[] = [];
|
||||
|
||||
const did = await finalizeStreamingMessage(sql, (_s, f) => frames.push(f), {
|
||||
sessionId,
|
||||
chatId,
|
||||
assistantId: id,
|
||||
status: 'cancelled',
|
||||
model: null,
|
||||
});
|
||||
|
||||
expect(did).toBe(false);
|
||||
expect(frames).toHaveLength(0);
|
||||
const [row] = await sql<{ status: string; content: string }[]>`
|
||||
SELECT status, content FROM messages WHERE id = ${id}
|
||||
`;
|
||||
expect(row!.status).toBe('complete');
|
||||
expect(row!.content).toBe('done');
|
||||
});
|
||||
|
||||
it('no-ops on an empty assistantId (throw happened before the row was created)', async () => {
|
||||
const frames: WsFrame[] = [];
|
||||
const did = await finalizeStreamingMessage(sql, (_s, f) => frames.push(f), {
|
||||
sessionId,
|
||||
chatId,
|
||||
assistantId: '',
|
||||
status: 'failed',
|
||||
model: null,
|
||||
});
|
||||
expect(did).toBe(false);
|
||||
expect(frames).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -1,64 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve, dirname } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
/**
|
||||
* Parity guard between the two copies of the provider snapshot types:
|
||||
* apps/coder/src/services/provider-types.ts (backend source of truth)
|
||||
* apps/web/src/api/types.ts (web wire copy)
|
||||
*
|
||||
* APPROACH: text-identity of each shared type block (mirrors the repo's existing
|
||||
* ws-frames.test.ts byte-parity convention). A compile-time bidirectional-
|
||||
* assignability check was attempted first (a web-side file importing coder's
|
||||
* import-free provider-types.ts), but apps/web/tsconfig.app.json is a composite
|
||||
* project and rejects out-of-include files with TS6307 — so cross-project type
|
||||
* import is structurally blocked. This runtime guard FAILS on any field
|
||||
* add/remove/rename/loosen in either copy, including the nested model/mode/
|
||||
* command types that ProviderSnapshotEntry references. Single-source-of-truth
|
||||
* (shared workspace package) is deferred as a Tier-2 follow-up.
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const coderSrc = readFileSync(resolve(here, '../provider-types.ts'), 'utf8');
|
||||
const webSrc = readFileSync(resolve(here, '../../../../web/src/api/types.ts'), 'utf8');
|
||||
|
||||
function extractBlock(src: string, name: string): string {
|
||||
const iface = src.match(new RegExp(`export interface ${name} \\{[\\s\\S]*?\\n\\}`));
|
||||
const alias = src.match(new RegExp(`export type ${name} =[^;]*;`));
|
||||
const block = iface?.[0] ?? alias?.[0];
|
||||
if (!block) throw new Error(`type block '${name}' not found`);
|
||||
// Normalize to type structure: drop blank + comment lines (//, /* */, *),
|
||||
// trim each line. Field add/remove/rename/loosen still changes a field line.
|
||||
return block
|
||||
.split('\n')
|
||||
.map((l) => l.trim())
|
||||
.filter(
|
||||
(l) =>
|
||||
l.length > 0 &&
|
||||
!l.startsWith('//') &&
|
||||
!l.startsWith('/*') &&
|
||||
!l.startsWith('*'),
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe('provider snapshot type parity (coder ↔ web)', () => {
|
||||
// Includes the nested types ProviderSnapshotEntry references, so structural
|
||||
// drift anywhere in the snapshot surface is caught.
|
||||
const names = [
|
||||
'ProviderSnapshotStatus',
|
||||
'ProviderSnapshotEntry',
|
||||
'ProviderModel',
|
||||
'ProviderMode',
|
||||
'ThinkingOption',
|
||||
'AgentCommand',
|
||||
];
|
||||
for (const name of names) {
|
||||
it(`${name} is identical in both copies`, () => {
|
||||
expect(
|
||||
extractBlock(webSrc, name),
|
||||
`${name} drifted between apps/coder/src/services/provider-types.ts and apps/web/src/api/types.ts`,
|
||||
).toBe(extractBlock(coderSrc, name));
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
type ClientSideConnection as ConnectionType,
|
||||
} from '@agentclientprotocol/sdk';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
|
||||
import { spawn } from 'node:child_process';
|
||||
import { findThoughtLevelConfigId } from './acp-derive.js';
|
||||
import { resolveLaunchSpec } from './acp-spawn.js';
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* (`AgentStatusUpdatedFrame`) and mirrored byte-identical in apps/web.
|
||||
*/
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { AgentStatus } from './normalize-agent-status.js';
|
||||
|
||||
// The exact slice of Broker we need — accepting just the bound method keeps call
|
||||
|
||||
50
apps/coder/src/services/cancel-registry.ts
Normal file
50
apps/coder/src/services/cancel-registry.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* F1 — per-task abort registry. A Stop on an external-agent task must reach the
|
||||
* in-flight run and abort its child / prompt. Each external run-function registers
|
||||
* its per-turn AbortController here keyed by task id; the cancel route calls
|
||||
* `cancel(taskId)` to fire it; the run-function's `.finally` deletes the entry.
|
||||
*
|
||||
* Idempotent by construction:
|
||||
* - `cancel()` on an already-aborted controller no-ops (AbortController.abort()
|
||||
* is idempotent) → a rapid double-Stop is safe.
|
||||
* - `cancel()` on an unknown / already-finished task returns false → a
|
||||
* cancel-after-natural-exit (entry already deleted) and a Stop on a native
|
||||
* boocode task (never registered) are both safe no-ops.
|
||||
*
|
||||
* Pure (no DB / child / IO) so the abort wiring + idempotency contract is
|
||||
* unit-testable in isolation — mirrors the turn-guard / lifecycle-decisions
|
||||
* pure-helper precedent.
|
||||
*/
|
||||
export interface CancelRegistry {
|
||||
/** Create + store an AbortController for this task, returning it for the run. */
|
||||
register(taskId: string): AbortController;
|
||||
/** Abort the task's in-flight run. Returns false when no controller is registered. */
|
||||
cancel(taskId: string): boolean;
|
||||
/** Drop the task's entry (called from the run's `.finally`). No-op if absent. */
|
||||
delete(taskId: string): void;
|
||||
/** Whether a controller is currently registered for this task. */
|
||||
has(taskId: string): boolean;
|
||||
}
|
||||
|
||||
export function createCancelRegistry(): CancelRegistry {
|
||||
const controllers = new Map<string, AbortController>();
|
||||
return {
|
||||
register(taskId) {
|
||||
const ac = new AbortController();
|
||||
controllers.set(taskId, ac);
|
||||
return ac;
|
||||
},
|
||||
cancel(taskId) {
|
||||
const ac = controllers.get(taskId);
|
||||
if (!ac) return false;
|
||||
ac.abort();
|
||||
return true;
|
||||
},
|
||||
delete(taskId) {
|
||||
controllers.delete(taskId);
|
||||
},
|
||||
has(taskId) {
|
||||
return controllers.has(taskId);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Sql } from '../db.js';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { Config } from '../config.js';
|
||||
import { createWorktree, diffWorktree, cleanupWorktree, ensureSessionWorktree } from './worktrees.js';
|
||||
import { createCheckpoint } from './checkpoints.js';
|
||||
@@ -22,6 +22,12 @@ import { shouldUseClaudeSdk } from './backends/claude-sdk-routing.js';
|
||||
import type { AgentBackend, AgentEvent } from './agent-backend.js';
|
||||
import { publishAgentStatus } from './agent-status-publish.js';
|
||||
import type { AgentStatus } from './normalize-agent-status.js';
|
||||
import { createCancelRegistry } from './cancel-registry.js';
|
||||
import {
|
||||
finalizeStreamingMessage,
|
||||
classifyTerminalStatus,
|
||||
type TerminalMessageStatus,
|
||||
} from './finalize-message.js';
|
||||
|
||||
interface InferenceRunner {
|
||||
enqueue: (sessionId: string, chatId: string, assistantId: string, user: string) => void;
|
||||
@@ -43,7 +49,11 @@ interface Deps {
|
||||
const POLL_INTERVAL_MS = 2_000;
|
||||
const COMPLETION_POLL_MS = 2_000;
|
||||
|
||||
export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<void> } {
|
||||
export function createDispatcher(deps: Deps): {
|
||||
cancelExternalTask(taskId: string): boolean;
|
||||
start(): void;
|
||||
stop(): Promise<void>;
|
||||
} {
|
||||
const { sql, inference, broker, log, config } = deps;
|
||||
let timer: ReturnType<typeof setInterval> | null = null;
|
||||
let listener: { unlisten: () => Promise<void> } | null = null;
|
||||
@@ -55,6 +65,13 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
// turn at a time.
|
||||
const inflight = new Map<string, Promise<void>>();
|
||||
|
||||
// F1: per-task abort registry. Each external run-function registers its per-turn
|
||||
// AbortController here (keyed by task id); the cancel route reaches it through the
|
||||
// exported `cancelExternalTask`; the run's `.finally` deletes the entry. Native
|
||||
// boocode tasks are never registered, so a Stop on one returns false and falls
|
||||
// through to the unchanged inference.cancel path.
|
||||
const taskControllers = createCancelRegistry();
|
||||
|
||||
// Shared entry point for both the poll timer and the NOTIFY listener. poll()'s
|
||||
// `polling`/`stopping` guard makes this safe to call concurrently — a notify
|
||||
// arriving mid-poll returns immediately and never double-dispatches.
|
||||
@@ -83,6 +100,40 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
publishAgentStatus(broker.publishFrame, sessionId, chatId, agent, status, reason);
|
||||
}
|
||||
|
||||
// F1 (OCE-001/OCE-002): finalize a streaming assistant message into a terminal
|
||||
// state and publish the matching message_complete frame. Best-effort + idempotent
|
||||
// (the helper's `WHERE status='streaming'` guard) — a failure here must never mask
|
||||
// the original abort/error, so it logs and swallows.
|
||||
function finalizeMessage(
|
||||
sessionId: string,
|
||||
chatId: string,
|
||||
assistantId: string,
|
||||
status: TerminalMessageStatus,
|
||||
model: string | null,
|
||||
content?: string,
|
||||
): Promise<boolean> {
|
||||
return finalizeStreamingMessage(sql, broker.publishFrame, {
|
||||
sessionId,
|
||||
chatId,
|
||||
assistantId,
|
||||
status,
|
||||
model,
|
||||
content,
|
||||
}).catch((err) => {
|
||||
log.error({ err: err instanceof Error ? err.message : String(err), assistantId }, 'dispatcher: finalizeStreamingMessage failed');
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
// F1: the cancel route's reach into an in-flight external run. Idempotent — a
|
||||
// double-Stop re-aborts an already-aborted controller (no-op) and a Stop on a
|
||||
// finished/native task returns false. Aborting only fires the backend's per-turn
|
||||
// cancel (session.abort / session/cancel / interrupt / child.kill); it never kills
|
||||
// a warm pool process, so persistent worktrees + pooled backends are preserved.
|
||||
function cancelExternalTask(taskId: string): boolean {
|
||||
return taskControllers.cancel(taskId);
|
||||
}
|
||||
|
||||
async function poll(): Promise<void> {
|
||||
// `polling` serializes poll() execution itself (timer + NOTIFY can fire
|
||||
// concurrently) so we never double-select a task. It does NOT serialize task
|
||||
@@ -116,6 +167,9 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
// with the same key is skipped and a concurrent poll can't re-pick it.
|
||||
const p = runTask(task).finally(() => {
|
||||
inflight.delete(key);
|
||||
// F1: drop the abort controller once the run settles. After this, a Stop
|
||||
// on the (now-finished) task returns false — cancel-after-exit is safe.
|
||||
taskControllers.delete(task.id);
|
||||
});
|
||||
inflight.set(key, p);
|
||||
}
|
||||
@@ -312,13 +366,16 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
// Create an abort controller for this task
|
||||
const ac = new AbortController();
|
||||
// F1: register the per-task abort controller so a Stop reaches this run.
|
||||
const ac = taskControllers.register(taskId);
|
||||
|
||||
// #10: hoisted above the try so the catch block can report `error` status with
|
||||
// the (chat, agent) key. Empty until resolved below; guarded before use.
|
||||
let sessionId = '';
|
||||
let chatId = '';
|
||||
// F1: hoisted so the catch / abort short-circuit can finalize the streaming
|
||||
// assistant row. Empty until the row is created; finalize no-ops on ''.
|
||||
let assistantId = '';
|
||||
|
||||
try {
|
||||
// Mark running
|
||||
@@ -384,7 +441,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
assistantId = assistantMsg!.id;
|
||||
|
||||
// write-edit-robustness #4: pre-turn worktree checkpoint (best-effort; a
|
||||
// failure logs and never breaks dispatch). This path uses a per-task worktree
|
||||
@@ -526,6 +583,20 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
}
|
||||
}
|
||||
|
||||
// F1: abort short-circuit BEFORE the unconditional 'complete' write. A Stop
|
||||
// (cancelExternalTask → ac.abort) or shutdown finalizes the streaming row as
|
||||
// 'cancelled' (keeping whatever streamed) instead of recording 'complete',
|
||||
// and skips the diff. This one-shot path owns a per-task worktree, so we DO
|
||||
// tear it down here (unlike the warm paths, which keep their persistent one).
|
||||
if (ac.signal.aborted || stopping) {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
clearTaskCommands(taskId);
|
||||
return;
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE messages
|
||||
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
||||
@@ -539,14 +610,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
model: task.model,
|
||||
} as WsFrame);
|
||||
|
||||
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 });
|
||||
@@ -587,18 +650,26 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const status = classifyTerminalStatus({ aborted: ac.signal.aborted, error: err });
|
||||
log.error({ taskId, agent, err: errMsg }, 'dispatcher: external agent error');
|
||||
|
||||
// Guard `NOT IN ('cancelled','completed')` so a genuine error in the catch
|
||||
// never overwrites a state the cancel route already wrote (user-Stop wins).
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
SET state = ${status}, ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId} AND state NOT IN ('cancelled', 'completed')
|
||||
`.catch(() => {});
|
||||
|
||||
// F1 (OCE-001): finalize the streaming assistant message — the catch
|
||||
// previously updated only `tasks` and left the message 'streaming' forever
|
||||
// (the BooChat 5-min sweep runs in a different process and can't reach it).
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
|
||||
// #10: external-agent turn failed/crashed. chatId may be unbound if the throw
|
||||
// preceded its assignment — guard so the status publish never masks the real
|
||||
// error.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'failed');
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'failed');
|
||||
|
||||
// Best-effort cleanup
|
||||
await cleanupWorktree(projectPath, taskId);
|
||||
@@ -652,11 +723,14 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
// F1: register the per-task abort controller so a Stop reaches this run.
|
||||
const ac = taskControllers.register(taskId);
|
||||
|
||||
// #10: hoisted so the catch can report `error` with the (chat, agent) key.
|
||||
let sessionId = '';
|
||||
let chatId = '';
|
||||
// F1: hoisted so the catch / abort short-circuit can finalize the streaming row.
|
||||
let assistantId = '';
|
||||
|
||||
try {
|
||||
// execution_path = 'acp' — the schema CHECK has no 'opencode_server' value
|
||||
@@ -728,7 +802,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
assistantId = assistantMsg!.id;
|
||||
|
||||
// write-edit-robustness #4: pre-turn checkpoint of the persistent session
|
||||
// worktree (best-effort; never breaks dispatch). worktreeId comes from the
|
||||
@@ -856,6 +930,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
|
||||
|
||||
// F1: abort short-circuit BEFORE the unconditional 'complete' write — fixes
|
||||
// the warm success-path recording 'complete' on a Stop'd turn. The abort fired
|
||||
// session.abort on the prompt only: the persistent session worktree is kept
|
||||
// (no cleanup) and the pooled opencode server stays warm for the next turn.
|
||||
if (ac.signal.aborted || stopping) {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE messages
|
||||
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
||||
@@ -868,11 +954,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
model: task.model,
|
||||
} as WsFrame);
|
||||
|
||||
if (stopping) {
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
|
||||
// 1.10: diff the persistent worktree against its captured baseline and
|
||||
// SUPERSEDE the session's prior pending row (latest-wins, one accumulating
|
||||
// diff) instead of stacking. Stamp agent for DiffPanel attribution.
|
||||
@@ -920,14 +1001,17 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const status = classifyTerminalStatus({ aborted: ac.signal.aborted, error: err });
|
||||
log.error({ taskId, agent, err: errMsg }, 'dispatcher: opencode server error');
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
SET state = ${status}, ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId} AND state NOT IN ('cancelled', 'completed')
|
||||
`.catch(() => {});
|
||||
// F1 (OCE-001): finalize the streaming message (was left 'streaming').
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||
if (chatId) emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -988,7 +1072,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
// F1: register the per-task abort controller so a Stop reaches this run.
|
||||
const ac = taskControllers.register(taskId);
|
||||
// F1: hoisted so the catch / abort short-circuit can finalize the streaming row.
|
||||
let assistantId = '';
|
||||
|
||||
try {
|
||||
await sql`
|
||||
@@ -1010,7 +1097,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
assistantId = assistantMsg!.id;
|
||||
|
||||
// write-edit-robustness #4: pre-turn checkpoint of the persistent session
|
||||
// worktree (best-effort; never breaks dispatch). Same worktree the opencode
|
||||
@@ -1121,6 +1208,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
|
||||
|
||||
// F1: abort short-circuit BEFORE the unconditional 'complete' write — fixes
|
||||
// the warm success-path recording 'complete' on a Stop'd turn. The abort fired
|
||||
// session/cancel on the warm connection only (never killed the child), so the
|
||||
// persistent worktree is kept and the pooled (chat,agent) backend stays warm.
|
||||
if (ac.signal.aborted || stopping) {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
|
||||
await sql`
|
||||
UPDATE messages
|
||||
SET content = ${assistantContent}, status = 'complete', finished_at = clock_timestamp()
|
||||
@@ -1133,11 +1232,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
model: task.model,
|
||||
} as WsFrame);
|
||||
|
||||
if (stopping) {
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
|
||||
// Diff the persistent worktree against its captured baseline and SUPERSEDE
|
||||
// the session's prior pending row (latest-wins) — identical to opencode.
|
||||
const diff = await diffWorktree(worktreePath, projectPath, {
|
||||
@@ -1184,14 +1278,17 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const status = classifyTerminalStatus({ aborted: ac.signal.aborted, error: err });
|
||||
log.error({ taskId, agent, err: errMsg }, 'dispatcher: warm ACP error');
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
SET state = ${status}, ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId} AND state NOT IN ('cancelled', 'completed')
|
||||
`.catch(() => {});
|
||||
// F1 (OCE-001): finalize the streaming message (was left 'streaming').
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1245,7 +1342,10 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
return;
|
||||
}
|
||||
|
||||
const ac = new AbortController();
|
||||
// F1: register the per-task abort controller so a Stop reaches this run.
|
||||
const ac = taskControllers.register(taskId);
|
||||
// F1: hoisted so the catch / abort short-circuit can finalize the streaming row.
|
||||
let assistantId = '';
|
||||
|
||||
try {
|
||||
await sql`
|
||||
@@ -1267,7 +1367,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
VALUES (${sessionId}, ${chatId}, 'assistant', '', 'streaming', ${task.model}, clock_timestamp())
|
||||
RETURNING id
|
||||
`;
|
||||
const assistantId = assistantMsg!.id;
|
||||
assistantId = assistantMsg!.id;
|
||||
|
||||
// write-edit-robustness #4: pre-turn checkpoint of the persistent session
|
||||
// worktree (best-effort; never breaks dispatch).
|
||||
@@ -1376,6 +1476,18 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
|
||||
await persistExternalAgentTurn(sql, assistantId, [...toolSnaps.values()], reasoningText);
|
||||
|
||||
// F1: abort short-circuit BEFORE the unconditional 'complete' write — fixes
|
||||
// the warm success-path recording 'complete' on a Stop'd turn. The abort fired
|
||||
// the SDK interrupt on the same query generator only (never killed the warm
|
||||
// process), so the persistent worktree is kept and the backend stays warm.
|
||||
if (ac.signal.aborted || stopping) {
|
||||
await finalizeMessage(sessionId, chatId, assistantId, 'cancelled', task.model, assistantContent);
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
emitAgentStatus(sessionId, chatId, agent, 'idle', stopping ? 'shutdown' : 'cancelled');
|
||||
clearTaskCommands(taskId);
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
|
||||
// ctx_used/ctx_max from the SDK result (1M-aware) → the assistant message, so
|
||||
// the ContextBar renders a real context-window fill for claude.
|
||||
await sql`
|
||||
@@ -1391,11 +1503,6 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
model: task.model,
|
||||
} as WsFrame);
|
||||
|
||||
if (stopping) {
|
||||
await sql`UPDATE tasks SET state = 'cancelled', ended_at = clock_timestamp() WHERE id = ${taskId}`;
|
||||
return; // worktree persists (no cleanup); backend stays warm
|
||||
}
|
||||
|
||||
// Diff the persistent worktree against its captured baseline and SUPERSEDE
|
||||
// the session's prior pending row (latest-wins) — identical to opencode/ACP.
|
||||
const diff = await diffWorktree(worktreePath, projectPath, {
|
||||
@@ -1442,14 +1549,17 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
clearTaskCommands(taskId);
|
||||
} catch (err) {
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const status = classifyTerminalStatus({ aborted: ac.signal.aborted, error: err });
|
||||
log.error({ taskId, agent, err: errMsg }, 'dispatcher: claude SDK error');
|
||||
await sql`
|
||||
UPDATE tasks
|
||||
SET state = 'failed', ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId}
|
||||
SET state = ${status}, ended_at = clock_timestamp(), output_summary = ${errMsg.slice(0, 500)}
|
||||
WHERE id = ${taskId} AND state NOT IN ('cancelled', 'completed')
|
||||
`.catch(() => {});
|
||||
// F1 (OCE-001): finalize the streaming message (was left 'streaming').
|
||||
await finalizeMessage(sessionId, chatId, assistantId, status, task.model);
|
||||
// #10: turn crashed.
|
||||
emitAgentStatus(sessionId, chatId, agent, 'error', 'crashed');
|
||||
emitAgentStatus(sessionId, chatId, agent, status === 'cancelled' ? 'idle' : 'error', status === 'cancelled' ? 'cancelled' : 'crashed');
|
||||
clearTaskCommands(taskId);
|
||||
// No worktree cleanup (persistent); backend stays warm for the next turn.
|
||||
}
|
||||
@@ -1476,6 +1586,7 @@ export function createDispatcher(deps: Deps): { start(): void; stop(): Promise<v
|
||||
}
|
||||
|
||||
return {
|
||||
cancelExternalTask,
|
||||
start() {
|
||||
log.info('dispatcher: starting poll loop + tasks_new listener');
|
||||
|
||||
|
||||
76
apps/coder/src/services/finalize-message.ts
Normal file
76
apps/coder/src/services/finalize-message.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import type { Sql } from '../db.js';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
|
||||
export type TerminalMessageStatus = 'cancelled' | 'failed';
|
||||
|
||||
/**
|
||||
* F1 (D-7) — decide the terminal status a Stop'd / errored external turn lands in.
|
||||
*
|
||||
* A user Stop (the per-task AbortController fired) or a thrown `AbortError` is a
|
||||
* deliberate, non-error outcome → `'cancelled'`. A genuine thrown error → `'failed'`.
|
||||
* Keeping the two distinct keeps the human-inbox / failure surfaces honest.
|
||||
*
|
||||
* Pure (no DB / IO) so the mapping is unit-testable in isolation.
|
||||
*/
|
||||
export function classifyTerminalStatus(opts: { aborted: boolean; error?: unknown }): TerminalMessageStatus {
|
||||
if (opts.aborted) return 'cancelled';
|
||||
if (opts.error instanceof Error && opts.error.name === 'AbortError') return 'cancelled';
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
/**
|
||||
* F1 (OCE-001 / OCE-002) — finalize a streaming assistant message into a terminal
|
||||
* state and publish the matching `message_complete` frame.
|
||||
*
|
||||
* Idempotent via `WHERE status = 'streaming'`: a second call (a double-Stop, or an
|
||||
* abort short-circuit followed by the catch block) updates zero rows and does NOT
|
||||
* re-publish, so the frontend reducer settles the message exactly once. It also
|
||||
* never clobbers a row that already finished cleanly (`complete`) — the abort that
|
||||
* raced a clean finish is a no-op.
|
||||
*
|
||||
* Returns `true` iff this call performed the finalization (the row was still
|
||||
* streaming); `false` if it was already terminal or the id is absent (the throw
|
||||
* preceded the row's creation).
|
||||
*/
|
||||
export async function finalizeStreamingMessage(
|
||||
sql: Sql,
|
||||
publishFrame: (sessionId: string, frame: WsFrame) => void,
|
||||
opts: {
|
||||
sessionId: string;
|
||||
chatId: string;
|
||||
assistantId: string;
|
||||
status: TerminalMessageStatus;
|
||||
model: string | null;
|
||||
/** Partial accumulated text to persist; omit to leave the row's content untouched. */
|
||||
content?: string;
|
||||
},
|
||||
): Promise<boolean> {
|
||||
const { sessionId, chatId, assistantId, status, model, content } = opts;
|
||||
if (!assistantId) return false;
|
||||
|
||||
const rows =
|
||||
content !== undefined
|
||||
? await sql<{ id: string }[]>`
|
||||
UPDATE messages
|
||||
SET content = ${content}, status = ${status}, finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantId} AND status = 'streaming'
|
||||
RETURNING id
|
||||
`
|
||||
: await sql<{ id: string }[]>`
|
||||
UPDATE messages
|
||||
SET status = ${status}, finished_at = clock_timestamp()
|
||||
WHERE id = ${assistantId} AND status = 'streaming'
|
||||
RETURNING id
|
||||
`;
|
||||
|
||||
if (rows.length === 0) return false;
|
||||
|
||||
publishFrame(sessionId, {
|
||||
type: 'message_complete',
|
||||
message_id: assistantId,
|
||||
chat_id: chatId,
|
||||
model,
|
||||
status,
|
||||
} as WsFrame);
|
||||
return true;
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
* but never publishes.
|
||||
*/
|
||||
import type { Broker } from '@boocode/server/broker';
|
||||
import type { WsFrame } from '@boocode/server/ws-frames';
|
||||
import type { WsFrame } from '@boocode/contracts/ws-frames';
|
||||
import type { AgentEvent } from './agent-backend.js';
|
||||
import { type AcpToolSnapshot, snapshotToWireToolCall } from './acp-tool-snapshot.js';
|
||||
import { mergeTaskCommands, getTaskCommands } from './agent-commands-cache.js';
|
||||
|
||||
@@ -29,6 +29,17 @@ interface ProjectPathRow {
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface MessageRow {
|
||||
id: string;
|
||||
session_id: string;
|
||||
chat_id: string | null;
|
||||
role: string;
|
||||
content: string;
|
||||
status: string;
|
||||
model: string | null;
|
||||
created_at: Date;
|
||||
}
|
||||
|
||||
function textResult(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
}
|
||||
@@ -189,6 +200,56 @@ export async function startMcpServer(sql: Sql): Promise<void> {
|
||||
},
|
||||
);
|
||||
|
||||
// 6. boocoder.view_session_history
|
||||
server.tool(
|
||||
'boocoder.view_session_history',
|
||||
'Retrieve the most-recent N messages of a session chat transcript (role != system) from messages_with_parts, returned in chronological (oldest→newest) order',
|
||||
{
|
||||
session_id: z.string().describe('Session UUID'),
|
||||
chat_id: z.string().optional().describe('Optional chat UUID — narrows to one chat tab'),
|
||||
limit: z
|
||||
.number()
|
||||
.int()
|
||||
.min(1)
|
||||
.max(200)
|
||||
.optional()
|
||||
.describe('Max messages to return (default 50, max 200)'),
|
||||
},
|
||||
async (args) => {
|
||||
const effectiveLimit = Math.min(args.limit ?? 50, 200);
|
||||
let rows: MessageRow[];
|
||||
if (args.chat_id) {
|
||||
rows = await sql<MessageRow[]>`
|
||||
SELECT id, session_id, chat_id, role, content, status, model, created_at
|
||||
FROM (
|
||||
SELECT id, session_id, chat_id, role, content, status, model, created_at
|
||||
FROM messages_with_parts
|
||||
WHERE session_id = ${args.session_id}
|
||||
AND chat_id = ${args.chat_id}
|
||||
AND role != 'system'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${effectiveLimit}
|
||||
) sub
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
} else {
|
||||
rows = await sql<MessageRow[]>`
|
||||
SELECT id, session_id, chat_id, role, content, status, model, created_at
|
||||
FROM (
|
||||
SELECT id, session_id, chat_id, role, content, status, model, created_at
|
||||
FROM messages_with_parts
|
||||
WHERE session_id = ${args.session_id}
|
||||
AND role != 'system'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ${effectiveLimit}
|
||||
) sub
|
||||
ORDER BY created_at ASC
|
||||
`;
|
||||
}
|
||||
return textResult({ session_id: args.session_id, count: rows.length, messages: rows });
|
||||
},
|
||||
);
|
||||
|
||||
// Connect via stdio
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
@@ -5,42 +5,28 @@
|
||||
* (see provider-config-registry.ts). Loading NEVER throws at startup (design.md
|
||||
* §2.1): a missing file, invalid JSON, or schema mismatch all fall back to
|
||||
* `{ providers: {} }` (built-ins only, all enabled).
|
||||
*
|
||||
* Schemas are defined once in @boocode/contracts/provider-config and re-exported
|
||||
* here so existing importers (routes, tests, registry) don't need path changes.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
ProviderOverrideSchema,
|
||||
CoderProvidersFileSchema,
|
||||
ProviderConfigPatchSchema,
|
||||
type ProviderOverride,
|
||||
type CoderProvidersFile,
|
||||
type ProviderConfigPatch,
|
||||
} from '@boocode/contracts/provider-config';
|
||||
|
||||
// Schemas verbatim from design.md §2.2.
|
||||
export const ProviderOverrideSchema = z.object({
|
||||
extends: z.enum(['acp']).optional(), // v2.3: only 'acp' for custom; built-ins omit extends
|
||||
label: z.string().min(1).optional(),
|
||||
description: z.string().optional(),
|
||||
command: z.array(z.string().min(1)).min(1).optional(), // [binary, ...args]
|
||||
env: z.record(z.string()).optional(),
|
||||
enabled: z.boolean().optional(), // default true
|
||||
order: z.number().int().optional(), // UI sort key
|
||||
models: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
additionalModels: z.array(z.object({ id: z.string(), label: z.string() })).optional(),
|
||||
});
|
||||
|
||||
export const CoderProvidersFileSchema = z.object({
|
||||
providers: z.record(ProviderOverrideSchema).default({}),
|
||||
});
|
||||
|
||||
export type ProviderOverride = z.infer<typeof ProviderOverrideSchema>;
|
||||
export type CoderProvidersFile = z.infer<typeof CoderProvidersFileSchema>;
|
||||
|
||||
/**
|
||||
* PATCH body schema (design.md §6.2). A partial providers map where each value
|
||||
* is either a full override object (REPLACES that id's override) or `null`
|
||||
* (DELETES the override → revert to the built-in default). Ids absent from the
|
||||
* patch are left untouched. The route validates the body against this first
|
||||
* (malformed → 422) so a bad shape can never reach the merge/save step.
|
||||
*/
|
||||
export const ProviderConfigPatchSchema = z.object({
|
||||
providers: z.record(ProviderOverrideSchema.nullable()).default({}),
|
||||
});
|
||||
|
||||
export type ProviderConfigPatch = z.infer<typeof ProviderConfigPatchSchema>;
|
||||
export {
|
||||
ProviderOverrideSchema,
|
||||
CoderProvidersFileSchema,
|
||||
ProviderConfigPatchSchema,
|
||||
type ProviderOverride,
|
||||
type CoderProvidersFile,
|
||||
type ProviderConfigPatch,
|
||||
};
|
||||
|
||||
/**
|
||||
* Shallow per-id merge (design.md §6.2 / Paseo `patchConfig`). Each key in
|
||||
|
||||
@@ -1,61 +1,10 @@
|
||||
/** Shared provider / snapshot types (Paseo-shaped, BooCoder-native). */
|
||||
/** Provider snapshot types — re-exported from @boocode/contracts for local consumers. */
|
||||
|
||||
export interface ProviderMode {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
/** Auto-approve tool permissions when this mode is selected. */
|
||||
isUnattended?: boolean;
|
||||
}
|
||||
|
||||
export interface ThinkingOption {
|
||||
id: string;
|
||||
label: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
thinkingOptions?: ThinkingOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
|
||||
// v2.3 phase 2: 'loading' (cache-miss, probe in flight) + 'unavailable'
|
||||
// (disabled or not installed) restored alongside the terminal 'ready' | 'error'.
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||
kind?: 'command' | 'skill';
|
||||
}
|
||||
|
||||
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity is
|
||||
// enforced by __tests__/provider-types-parity.test.ts (fails on any field drift).
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: string;
|
||||
model?: string;
|
||||
modeId?: string;
|
||||
thinkingOptionId?: string;
|
||||
}
|
||||
export type {
|
||||
ProviderMode,
|
||||
ThinkingOption,
|
||||
ProviderModel,
|
||||
ProviderSnapshotStatus,
|
||||
AgentCommand,
|
||||
ProviderSnapshotEntry,
|
||||
} from '@boocode/contracts/provider-snapshot';
|
||||
|
||||
@@ -9,24 +9,9 @@
|
||||
* server calls the routes that wrap these helpers. Behavior is unchanged from the
|
||||
* original worktrees.ts implementation.
|
||||
*/
|
||||
import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
|
||||
import { hostExec } from './host-exec.js';
|
||||
|
||||
/**
|
||||
* Risk report for a single worktree, returned by checkWorktreeWorkAtRisk.
|
||||
* `atRisk` is the gate the server reads before allowing a session delete.
|
||||
* A git error never silently passes — it forces `atRisk` true and surfaces
|
||||
* the message in `error` (fail-closed).
|
||||
*/
|
||||
export interface RiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean; // uncommitted working-tree changes (incl. untracked)
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream is set
|
||||
unmerged: number; // commits on this branch not in the project default branch
|
||||
atRisk: boolean; // dirty || unmerged > 0 || (upstream && unpushed > 0) || git error
|
||||
error?: string; // populated on a git failure; presence forces atRisk
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the project's default branch as a git-usable ref (e.g. "origin/main").
|
||||
*
|
||||
@@ -79,7 +64,7 @@ async function detectDefaultBranchRef(
|
||||
export async function checkWorktreeWorkAtRisk(
|
||||
worktreePath: string,
|
||||
opts?: { signal?: AbortSignal },
|
||||
): Promise<RiskReport> {
|
||||
): Promise<WorktreeRiskReport> {
|
||||
// Branch name — also doubles as the "is this still a git worktree?" probe.
|
||||
const br = await hostExec(
|
||||
`git -C ${shellEscape(worktreePath)} rev-parse --abbrev-ref HEAD`,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import type { Sql } from '../db.js';
|
||||
import { hostExec } from './host-exec.js';
|
||||
import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
|
||||
import { checkWorktreeWorkAtRisk } from './worktree-risk.js';
|
||||
|
||||
export const WORKTREE_BASE = '/tmp/booworktrees';
|
||||
@@ -379,6 +380,10 @@ export async function rebaselineWorktreeAfterApply(
|
||||
return { rebaselined: true, newBaseCommit: newBase };
|
||||
}
|
||||
|
||||
// ─── Session-delete work-loss guard ─────────────────────────────────────────
|
||||
// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here.
|
||||
export type { WorktreeRiskReport };
|
||||
|
||||
/** Minimal shell escape for paths (single-quote wrapping). */
|
||||
function shellEscape(s: string): string {
|
||||
// Replace single quotes with escaped version, wrap in single quotes
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<!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>
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import type { Project, Session, Chat, Message, PendingChange, AskUserAnswer } 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 ?? {}),
|
||||
}),
|
||||
answerUserInput: (chatId: string, toolCallId: string, answers: AskUserAnswer[]) =>
|
||||
request<{ tool_message_id: string; assistant_message_id: string }>(
|
||||
`/api/chats/${chatId}/answer_user_input`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ tool_call_id: toolCallId, answers }),
|
||||
},
|
||||
),
|
||||
},
|
||||
|
||||
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',
|
||||
}),
|
||||
},
|
||||
};
|
||||
@@ -1,110 +0,0 @@
|
||||
// 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;
|
||||
args: unknown;
|
||||
}
|
||||
|
||||
export interface ToolResult {
|
||||
tool_call_id: string;
|
||||
output: unknown;
|
||||
truncated?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
// Batch 9.7: ask_user_input shapes. The tool_call.args is { questions: AskUserQuestion[] }
|
||||
// (1-3 entries); the eventual tool_result.output is { answers: AskUserAnswer[] } in the
|
||||
// same order. AskUserInputCard renders questions and POSTs answers.
|
||||
export type AskUserQuestionType = 'single_select' | 'multi_select';
|
||||
|
||||
export interface AskUserQuestion {
|
||||
question: string;
|
||||
type: AskUserQuestionType;
|
||||
options: string[];
|
||||
}
|
||||
|
||||
export interface AskUserAnswer {
|
||||
question: string;
|
||||
selected_options: string[];
|
||||
free_text: string | null;
|
||||
}
|
||||
|
||||
export interface AskUserAnswerSet {
|
||||
answers: AskUserAnswer[];
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -1,323 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Check } from 'lucide-react';
|
||||
import { api } from '@/api/client';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type {
|
||||
AskUserAnswer,
|
||||
AskUserAnswerSet,
|
||||
AskUserQuestion,
|
||||
ToolCall,
|
||||
ToolResult,
|
||||
} from '@/api/types';
|
||||
|
||||
// Batch 9.7: Inline interactive picker. Renders inside MessageList in place of
|
||||
// the standard ToolCallLine when the assistant emits an ask_user_input tool
|
||||
// call. While the tool result is null (server pre-stamps a sentinel with
|
||||
// output=null), shows the form; once the WS tool_result frame arrives with a
|
||||
// real AnswerSet, flips to read-only review mode.
|
||||
|
||||
interface Props {
|
||||
toolCall: ToolCall;
|
||||
toolResult: ToolResult | null;
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
function parseQuestions(raw: unknown): AskUserQuestion[] {
|
||||
if (!raw || typeof raw !== 'object' || !('questions' in raw)) return [];
|
||||
const arr = (raw as { questions: unknown }).questions;
|
||||
if (!Array.isArray(arr)) return [];
|
||||
const out: AskUserQuestion[] = [];
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const q = item as { question?: unknown; type?: unknown; options?: unknown };
|
||||
if (typeof q.question !== 'string') continue;
|
||||
if (q.type !== 'single_select' && q.type !== 'multi_select') continue;
|
||||
if (!Array.isArray(q.options)) continue;
|
||||
const opts = q.options.filter((o): o is string => typeof o === 'string');
|
||||
if (opts.length < 2) continue;
|
||||
out.push({ question: q.question, type: q.type, options: opts });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseAnswerSet(raw: unknown): AskUserAnswerSet | null {
|
||||
if (!raw || typeof raw !== 'object' || !('answers' in raw)) return null;
|
||||
const arr = (raw as { answers: unknown }).answers;
|
||||
if (!Array.isArray(arr)) return null;
|
||||
const answers: AskUserAnswer[] = [];
|
||||
for (const item of arr) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const a = item as { question?: unknown; selected_options?: unknown; free_text?: unknown };
|
||||
if (typeof a.question !== 'string') continue;
|
||||
if (!Array.isArray(a.selected_options)) continue;
|
||||
if (a.free_text !== null && typeof a.free_text !== 'string') continue;
|
||||
const sel = a.selected_options.filter((s): s is string => typeof s === 'string');
|
||||
answers.push({
|
||||
question: a.question,
|
||||
selected_options: sel,
|
||||
free_text: (a.free_text as string | null) ?? null,
|
||||
});
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
|
||||
export function AskUserInputCard({ toolCall, toolResult, chatId }: Props) {
|
||||
const questions = useMemo(() => parseQuestions(toolCall.args), [toolCall.args]);
|
||||
|
||||
if (questions.length === 0) {
|
||||
return (
|
||||
<div className="rounded border border-destructive/40 bg-destructive/10 text-xs px-3 py-2 text-destructive">
|
||||
ask_user_input: malformed tool args
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Tool result with a non-null output means the answer is already submitted.
|
||||
// The pending sentinel uses output=null, so this branch only triggers after
|
||||
// the real WS tool_result frame lands.
|
||||
const answered = toolResult && toolResult.output !== null;
|
||||
if (answered) {
|
||||
const answerSet = parseAnswerSet(toolResult!.output);
|
||||
return <AnsweredView questions={questions} answers={answerSet} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<PendingView questions={questions} toolCallId={toolCall.id} chatId={chatId} />
|
||||
);
|
||||
}
|
||||
|
||||
function PendingView({
|
||||
questions,
|
||||
toolCallId,
|
||||
chatId,
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
toolCallId: string;
|
||||
chatId: string;
|
||||
}) {
|
||||
// Per-question selections + free text. Selections are option arrays so the
|
||||
// multi_select case is uniform; single_select just constrains to length 1.
|
||||
const [selections, setSelections] = useState<string[][]>(() => questions.map(() => []));
|
||||
const [freeTexts, setFreeTexts] = useState<string[]>(() => questions.map(() => ''));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const singleQuestion = questions.length === 1;
|
||||
const anyFreeText = freeTexts.some((t) => t.trim().length > 0);
|
||||
|
||||
// Submit button shows when:
|
||||
// - more than one question (always batched), OR
|
||||
// - one question and the user has typed free text (committing it needs an
|
||||
// explicit Submit so an accidental Tab/click doesn't lose it).
|
||||
// For one question with no free text, clicking an option submits inline.
|
||||
const showSubmitButton = !singleQuestion || anyFreeText;
|
||||
|
||||
// Every question must have at least one of (option, free text).
|
||||
const allComplete = questions.every((_, i) => {
|
||||
return selections[i]!.length > 0 || freeTexts[i]!.trim().length > 0;
|
||||
});
|
||||
|
||||
function buildAnswers(): AskUserAnswer[] {
|
||||
return questions.map((q, i) => {
|
||||
const freeText = freeTexts[i]!.trim();
|
||||
return {
|
||||
question: q.question,
|
||||
selected_options: selections[i]!,
|
||||
free_text: freeText.length > 0 ? freeText : null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(answers: AskUserAnswer[]) {
|
||||
if (submitting) return;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.chats.answerUserInput(chatId, toolCallId, answers);
|
||||
// Card stays mounted; the incoming WS tool_result frame will flip it
|
||||
// into AnsweredView via the parent prop change.
|
||||
} catch (err) {
|
||||
console.error('ask_user_input submit failed:', err instanceof Error ? err.message : err);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
function pickSingle(qIdx: number, option: string) {
|
||||
setSelections((prev) => prev.map((arr, i) => (i === qIdx ? [option] : arr)));
|
||||
// Immediate submit for the single-question single-select shortcut. Only
|
||||
// fires when no free text exists anywhere — once the user typed, the
|
||||
// Submit button takes over so the typed text isn't silently dropped.
|
||||
if (singleQuestion && !anyFreeText) {
|
||||
const answers: AskUserAnswer[] = [
|
||||
{
|
||||
question: questions[0]!.question,
|
||||
selected_options: [option],
|
||||
free_text: null,
|
||||
},
|
||||
];
|
||||
void submit(answers);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMulti(qIdx: number, option: string) {
|
||||
setSelections((prev) =>
|
||||
prev.map((arr, i) => {
|
||||
if (i !== qIdx) return arr;
|
||||
return arr.includes(option) ? arr.filter((o) => o !== option) : [...arr, option];
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function setFreeText(qIdx: number, value: string) {
|
||||
setFreeTexts((prev) => prev.map((t, i) => (i === qIdx ? value : t)));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 text-sm">
|
||||
<div className="px-4 py-3 space-y-4">
|
||||
{questions.map((q, i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
{questions.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Question {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
{q.type === 'single_select' ? (
|
||||
<RadioGroup
|
||||
value={selections[i]![0] ?? ''}
|
||||
onValueChange={(v) => pickSingle(i, v)}
|
||||
disabled={submitting}
|
||||
className="gap-1.5"
|
||||
>
|
||||
{q.options.map((opt, j) => {
|
||||
const id = `q${i}-opt${j}`;
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<RadioGroupItem id={id} value={opt} className="mt-0.5" />
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
) : (
|
||||
<div className="grid gap-1.5">
|
||||
{q.options.map((opt, j) => {
|
||||
const id = `q${i}-opt${j}`;
|
||||
const checked = selections[i]!.includes(opt);
|
||||
return (
|
||||
<label
|
||||
key={j}
|
||||
htmlFor={id}
|
||||
className="flex items-start gap-2 text-sm leading-snug cursor-pointer rounded px-1 py-0.5 hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={submitting}
|
||||
onChange={() => toggleMulti(i, opt)}
|
||||
className="mt-1 size-3.5 rounded border-input accent-primary"
|
||||
/>
|
||||
<span>{opt}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div className="pt-1 space-y-1">
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Or type a custom answer
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
value={freeTexts[i]}
|
||||
disabled={submitting}
|
||||
placeholder="Free text…"
|
||||
onChange={(e) => setFreeText(i, e.target.value)}
|
||||
className="w-full rounded border border-input bg-background px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:opacity-60"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{showSubmitButton && (
|
||||
<div className="flex justify-end gap-2 border-t px-4 py-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
disabled={!allComplete || submitting}
|
||||
onClick={() => void submit(buildAnswers())}
|
||||
>
|
||||
{submitting ? 'Submitting…' : 'Submit'}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AnsweredView({
|
||||
questions,
|
||||
answers,
|
||||
}: {
|
||||
questions: AskUserQuestion[];
|
||||
answers: AskUserAnswerSet | null;
|
||||
}) {
|
||||
if (!answers) {
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/20 text-xs px-4 py-3 text-muted-foreground">
|
||||
ask_user_input: answers unavailable
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border bg-muted/10 text-sm">
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
{questions.map((q, i) => {
|
||||
const a = answers.answers[i];
|
||||
if (!a) return null;
|
||||
return (
|
||||
<div key={i} className="space-y-1.5">
|
||||
{questions.length > 1 && (
|
||||
<div className="text-[10px] uppercase tracking-wide text-muted-foreground/70">
|
||||
Question {i + 1}
|
||||
</div>
|
||||
)}
|
||||
<div className="font-medium leading-snug">{q.question}</div>
|
||||
<div className="space-y-0.5">
|
||||
{q.options.map((opt, j) => {
|
||||
const selected = a.selected_options.includes(opt);
|
||||
return (
|
||||
<div
|
||||
key={j}
|
||||
className={
|
||||
selected
|
||||
? 'flex items-start gap-2 text-sm leading-snug text-foreground'
|
||||
: 'flex items-start gap-2 text-sm leading-snug text-muted-foreground/60 line-through'
|
||||
}
|
||||
>
|
||||
<span className="mt-0.5 size-3.5 shrink-0 inline-flex items-center justify-center">
|
||||
{selected && <Check className="size-3 text-primary" />}
|
||||
</span>
|
||||
<span>{opt}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{a.free_text && (
|
||||
<div className="rounded bg-background border px-2 py-1 text-xs font-mono whitespace-pre-wrap">
|
||||
{a.free_text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Send, Square } from 'lucide-react';
|
||||
import type { Message, ToolResult } 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');
|
||||
|
||||
// Build a lookup map from tool_call_id -> ToolResult for all messages
|
||||
const toolResultsMap: Record<string, ToolResult> = {};
|
||||
for (const msg of messages) {
|
||||
if (msg.tool_results) {
|
||||
toolResultsMap[msg.tool_results.tool_call_id] = msg.tool_results;
|
||||
}
|
||||
}
|
||||
|
||||
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} chatId={msg.chat_id} toolResultsMap={toolResultsMap} />
|
||||
))}
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,352 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
import Markdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import type { Message, ToolResult } from '@/api/types';
|
||||
import { Wrench, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { AskUserInputCard } from './AskUserInputCard';
|
||||
|
||||
interface Props {
|
||||
message: Message;
|
||||
chatId: string;
|
||||
toolResultsMap: Record<string, ToolResult>;
|
||||
}
|
||||
|
||||
export function MessageBubble({ message, chatId }: 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) => {
|
||||
if (tc.name === 'ask_user_input') {
|
||||
const result = message.tool_results ?? null;
|
||||
return (
|
||||
<AskUserInputCard
|
||||
key={tc.id}
|
||||
toolCall={tc}
|
||||
toolResult={result}
|
||||
chatId={chatId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<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.args)}
|
||||
</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 }: { message: Message }) {
|
||||
const result = message.tool_results;
|
||||
if (!result) return null;
|
||||
|
||||
const isError = result.error;
|
||||
const output = result.output != null ? String(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: unknown): string {
|
||||
if (!args) return '';
|
||||
try {
|
||||
if (typeof args === 'object' && args !== null) {
|
||||
const obj = args as Record<string, unknown>;
|
||||
const keys = Object.keys(obj);
|
||||
if (keys.length === 0) return '';
|
||||
const first = keys[0]!;
|
||||
const val = String(obj[first] ?? '');
|
||||
const display = val.length > 40 ? val.slice(0, 40) + '...' : val;
|
||||
return `${first}: ${display}`;
|
||||
}
|
||||
const str = String(args);
|
||||
return str.length > 50 ? str.slice(0, 50) + '...' : str;
|
||||
} catch {
|
||||
return String(args).length > 50 ? String(args).slice(0, 50) + '...' : String(args);
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||
size?: 'default' | 'sm' | 'lg' | 'icon';
|
||||
}
|
||||
|
||||
const variantClasses: Record<string, string> = {
|
||||
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline',
|
||||
};
|
||||
|
||||
const sizeClasses: Record<string, string> = {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
};
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant = 'default', size = 'default', ...props }, ref) => {
|
||||
const base =
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-60';
|
||||
const cls = [base, variantClasses[variant] ?? '', sizeClasses[size] ?? '', className ?? ''].join(' ');
|
||||
return <button className={cls} ref={ref} {...props} />;
|
||||
},
|
||||
);
|
||||
Button.displayName = 'Button';
|
||||
|
||||
export { Button };
|
||||
@@ -1,56 +0,0 @@
|
||||
import * as React from 'react';
|
||||
|
||||
const RadioGroupContext = React.createContext<{
|
||||
value: string | undefined;
|
||||
onValueChange: (v: string) => void;
|
||||
disabled?: boolean;
|
||||
} | null>(null);
|
||||
|
||||
interface RadioGroupProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const RadioGroup = React.forwardRef<HTMLDivElement, RadioGroupProps>(
|
||||
({ className, value, onValueChange, disabled, ...props }, ref) => {
|
||||
const ctx = React.useMemo(() => ({ value, onValueChange: onValueChange ?? (() => {}), disabled }), [value, onValueChange, disabled]);
|
||||
return (
|
||||
<RadioGroupContext.Provider value={ctx}>
|
||||
<div
|
||||
ref={ref}
|
||||
role="radiogroup"
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
</RadioGroupContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
RadioGroup.displayName = 'RadioGroup';
|
||||
|
||||
interface RadioGroupItemProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
||||
value: string;
|
||||
}
|
||||
|
||||
const RadioGroupItem = React.forwardRef<HTMLInputElement, RadioGroupItemProps>(
|
||||
({ className, value, ...props }, ref) => {
|
||||
const ctx = React.useContext(RadioGroupContext);
|
||||
if (!ctx) return <input ref={ref} type="radio" className={className} value={value} {...props} />;
|
||||
const checked = ctx.value === value;
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="radio"
|
||||
checked={checked}
|
||||
disabled={ctx.disabled}
|
||||
onChange={() => ctx.onValueChange(value)}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
RadioGroupItem.displayName = 'RadioGroupItem';
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -1,22 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,230 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
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>,
|
||||
);
|
||||
@@ -1,138 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
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
1
apps/coder/web/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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,
|
||||
},
|
||||
});
|
||||
@@ -21,7 +21,7 @@
|
||||
- **`experimental_repairToolCall`** wired into `streamText` to keep the stream alive when qwen3.6 emits malformed tool args. Pass-through: logs the bad call, returns it unmodified; `executeToolPhase`'s zod-reject path routes it back to the model next turn.
|
||||
- **`chat_status` frame** (via `broker.publishUser`) — `status: 'streaming' | 'tool_running' | 'waiting_for_input' | 'idle' | 'error'`. Frontend `useChatStatus` derives `idle_warm` (<30s since idle) vs `idle_cold`. `ChatThroughput` renders beside `StatusDot` only when streaming/tool_running, fed by 500ms-throttled `'usage'` frames (`completion_tokens` + `ctx_used` + `ctx_max`). `POST /api/chats/:id/discard_stale` marks a stuck-streaming row `failed` when the frontend's 60s no-token timer gives up.
|
||||
- **Stale-streaming sweeps** (`apps/server/src/index.ts`): a boot-time pass after `applySchema()` and a periodic 60s `setInterval` both flip `messages.status='streaming'` older than 5 min to `failed` (publishing `chat_status='idle'`); the interval also runs `cleanupTruncations` (TTL + orphan reap of tmpfs truncation files). `onClose` hook clears the timer. Recovers from a container restart mid-stream.
|
||||
- **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema duplicated byte-identical at `apps/web/src/api/ws-frames.ts`; `ws-frames.test.ts` enforces parity. Don't add raw `broker.publish()`/`publishUser()` calls.
|
||||
- **`services/broker.ts`** — In-memory pub/sub, two channel types: per-session (message streaming) and per-user (sidebar). No persistence; clients reconnect on restart. Every WS publish goes through `broker.publishFrame(sessionId, frame)` / `publishUserFrame(user, frame)` — both Zod-validate against `WsFrameSchema` (`types/ws-frames.ts`) and fail-closed (log + drop). Schema single-sourced in `@boocode/contracts` (`packages/contracts/src/ws-frames.ts`); the package's `ws-frames.test.ts` enforces schema correctness. Don't add 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) pass three guards: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). Web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (falls back to `project.default_web_search_enabled`) and filtered out of the LLM tool schema when false. Truncation: when a tool slice cuts content, `services/truncate.ts` stashes the full text on tmpfs (`BOOCODE_TRUNCATION_DIR`, default `/tmp/boocode-truncations`, 0o700) keyed by `tr_<12 base32>`; `view_truncated_output(id)` retrieves it. 5MB cap, 7-day TTL, reaped by the sweeper. Container restart loses retrieval — acceptable.
|
||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — Anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself each compaction). Triggered when `chats.needs_compaction` is set after a turn exceeds `usable(ctx_max) = floor(0.85 × ctx_max)`. **`ctx_max` comes from `model-context.getModelContext()` fetching `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx`. First inferences after boot may have `ctx_max=NULL` if llama-swap hasn't loaded the model; negative cache TTL 60s, recovers next turn. `buildHeadPayload` embeds `reasoning_parts` as a `<reasoning>...</reasoning>` prose prefix on assistant `content` (OpenAI wire shape has no structured reasoning field); standalone tag when content is empty. `buildHeadPayload` + `OpenAiMessage` exported for tests — keep them exported.
|
||||
- **`services/system-prompt.ts`** — `buildSystemPrompt` is the string shim; `buildSystemPromptWithFingerprint` is the canonical impl returning `{prompt, fingerprint, drift}`. SHA-256 of the assembled prefix is logged per `buildMessagesPayload` (`prefix-fingerprint`, info); a `Map<sessionId, lastHash>` fires `prefix-drift` (warn) on change with a `changed_inputs` diff. The prefix is byte-stable in steady-state, so prefix caching is left to the input-layer mtime caches (BOOCHAT.md + AGENTS.md global/per-project in `agents.ts:safeStat`).
|
||||
|
||||
@@ -53,10 +53,6 @@
|
||||
"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"
|
||||
@@ -81,6 +77,7 @@
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boocode/contracts": "workspace:*",
|
||||
"@ai-sdk/openai-compatible": "^2.0.47",
|
||||
"@fastify/static": "^7.0.4",
|
||||
"@fastify/websocket": "^10.0.1",
|
||||
|
||||
@@ -140,7 +140,7 @@ async function main() {
|
||||
publish: (sessionId, frame) => {
|
||||
// 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);
|
||||
broker.publishFrame(sessionId, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
|
||||
},
|
||||
// v1.11: broker handle for compaction.process to publish 'compacted'
|
||||
// frames on the per-session channel. Inference's regular publish path
|
||||
@@ -149,7 +149,7 @@ async function main() {
|
||||
broker,
|
||||
},
|
||||
(user, frame) => {
|
||||
broker.publishUserFrame(user, frame as unknown as import('./types/ws-frames.js').WsFrame);
|
||||
broker.publishUserFrame(user, frame as unknown as import('@boocode/contracts/ws-frames').WsFrame);
|
||||
}
|
||||
);
|
||||
registerMessageRoutes(app, sql, config, broker, {
|
||||
@@ -194,7 +194,7 @@ async function main() {
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
broker.publishFrame(sessionId, frame as import('@boocode/contracts/ws-frames').WsFrame);
|
||||
},
|
||||
});
|
||||
registerArtifactRoutes(app, sql);
|
||||
@@ -222,7 +222,7 @@ async function main() {
|
||||
});
|
||||
},
|
||||
publishSessionFrame: (sessionId, frame) => {
|
||||
broker.publishFrame(sessionId, frame as import('./types/ws-frames.js').WsFrame);
|
||||
broker.publishFrame(sessionId, frame as import('@boocode/contracts/ws-frames').WsFrame);
|
||||
},
|
||||
});
|
||||
registerWebSocket(app, sql, broker);
|
||||
|
||||
@@ -10,6 +10,18 @@ import { resolveProjectRoot, PathScopeError } from '../services/path_guard.js';
|
||||
import { listDir, viewFile } from '../services/file_ops.js';
|
||||
import { getProjectFiles } from '../services/file_index.js';
|
||||
import { getGitMeta } from '../services/git_meta.js';
|
||||
import {
|
||||
getGitDiff,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
commitFiles,
|
||||
discardFiles,
|
||||
detectInProgress,
|
||||
isRepoDirty,
|
||||
autoSelectMode,
|
||||
GitWriteError,
|
||||
} from '../services/git_diff.js';
|
||||
import type { GitDiffMode } from '../services/git_diff.js';
|
||||
import {
|
||||
bootstrapProject,
|
||||
BootstrapNameError,
|
||||
@@ -453,6 +465,178 @@ export function registerProjectRoutes(
|
||||
}
|
||||
);
|
||||
|
||||
// GET /api/projects/:id/git/diff?mode=uncommitted|committed
|
||||
// Returns the structured diff payload for the project repository. mode param
|
||||
// selects the comparison: uncommitted (working tree vs HEAD) or committed
|
||||
// (branch vs its upstream/default-branch base). When mode is absent the server
|
||||
// auto-selects based on dirty state (FIX 1: dirty → uncommitted, clean → committed).
|
||||
// Always includes auto_mode (the dirty-state-derived mode) so the client can
|
||||
// show a suggestion when a pinned mode diverges from what would be auto-selected.
|
||||
// Returns { git_repo: false } when the path is not a git repository.
|
||||
app.get<{ Params: { id: string }; Querystring: { mode?: string } }>(
|
||||
'/api/projects/:id/git/diff',
|
||||
async (req, reply) => {
|
||||
const { id } = req.params;
|
||||
const rawMode = req.query.mode;
|
||||
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (projectPath === null) {
|
||||
reply.code(404);
|
||||
return { error: 'not found' };
|
||||
}
|
||||
let projectRoot: string;
|
||||
try {
|
||||
projectRoot = await resolveProjectRoot(projectPath);
|
||||
} catch (err) {
|
||||
if (err instanceof PathScopeError) {
|
||||
reply.code(404);
|
||||
return { error: err.message };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Always detect dirty state: used for auto-select (FIX 1) and suggestion (FIX 4).
|
||||
const dirty = await isRepoDirty(projectRoot);
|
||||
const auto_mode = autoSelectMode(dirty);
|
||||
|
||||
const mode: GitDiffMode =
|
||||
rawMode === 'committed' ? 'committed' :
|
||||
rawMode === 'uncommitted' ? 'uncommitted' :
|
||||
auto_mode; // no mode param → auto-select (FIX 1)
|
||||
|
||||
const result = await getGitDiff(projectRoot, mode);
|
||||
if (result === null) {
|
||||
return { git_repo: false, mode, auto_mode, base_label: null, in_progress_op: null, files: [] };
|
||||
}
|
||||
return { git_repo: true, ...result, auto_mode };
|
||||
}
|
||||
);
|
||||
|
||||
// ── Git write routes (Phase 2) ─────────────────────────────────────────────
|
||||
// These are user UI actions — NOT registered in the assistant tool registry.
|
||||
// D-3: argv-safe runGit/execFile with -- separators (never shell strings).
|
||||
// D-4: per-file pathGuard validation via validateWritePath.
|
||||
// D-5: commit identity server-derived; request body .strict(), no author fields.
|
||||
// D-7: index-lock → 409; in-progress op → 409.
|
||||
// D-13: NOT in ALL_TOOLS.
|
||||
|
||||
const GitFilesBody = z.object({ files: z.array(z.string().min(1)).min(1) });
|
||||
|
||||
const GitCommitBody = z
|
||||
.object({
|
||||
message: z.string().min(1),
|
||||
files: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const GitDiscardBody = z.object({
|
||||
files: z.array(
|
||||
z
|
||||
.object({
|
||||
path: z.string().min(1),
|
||||
change_type: z.string().min(1),
|
||||
staged: z.boolean(),
|
||||
})
|
||||
.strict(),
|
||||
).min(1),
|
||||
});
|
||||
|
||||
// POST /api/projects/:id/git/stage — stage whole files
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/stage',
|
||||
async (req, reply) => {
|
||||
const body = GitFilesBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const inProg = await detectInProgress(root);
|
||||
if (inProg) { reply.code(409); return { error: `git ${inProg} in progress — write actions disabled` }; }
|
||||
try {
|
||||
await stageFiles(root, body.data.files);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof GitWriteError) { reply.code(err.busy ? 409 : 500); return { error: err.message }; }
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/projects/:id/git/unstage — unstage whole files
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/unstage',
|
||||
async (req, reply) => {
|
||||
const body = GitFilesBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const inProg = await detectInProgress(root);
|
||||
if (inProg) { reply.code(409); return { error: `git ${inProg} in progress — write actions disabled` }; }
|
||||
try {
|
||||
await unstageFiles(root, body.data.files);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof GitWriteError) { reply.code(err.busy ? 409 : 500); return { error: err.message }; }
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/projects/:id/git/commit — commit staged files (identity server-derived)
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/commit',
|
||||
async (req, reply) => {
|
||||
const body = GitCommitBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const inProg = await detectInProgress(root);
|
||||
if (inProg) { reply.code(409); return { error: `git ${inProg} in progress — write actions disabled` }; }
|
||||
try {
|
||||
await commitFiles(root, body.data.message, body.data.files);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof GitWriteError) { reply.code(err.busy ? 409 : 500); return { error: err.message }; }
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// POST /api/projects/:id/git/discard — discard file changes (irrecoverable)
|
||||
app.post<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/git/discard',
|
||||
async (req, reply) => {
|
||||
const body = GitDiscardBody.safeParse(req.body);
|
||||
if (!body.success) { reply.code(400); return { error: body.error.message }; }
|
||||
const { id } = req.params;
|
||||
const projectPath = await selectProjectPath(sql, id);
|
||||
if (!projectPath) { reply.code(404); return { error: 'not found' }; }
|
||||
let root: string;
|
||||
try { root = await resolveProjectRoot(projectPath); }
|
||||
catch (err) { if (err instanceof PathScopeError) { reply.code(404); return { error: (err as Error).message }; } throw err; }
|
||||
const inProg = await detectInProgress(root);
|
||||
if (inProg) { reply.code(409); return { error: `git ${inProg} in progress — write actions disabled` }; }
|
||||
try {
|
||||
await discardFiles(root, body.data.files);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
if (err instanceof GitWriteError) { reply.code(err.busy ? 409 : 500); return { error: err.message }; }
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// GET /api/projects/:id/files
|
||||
app.get<{ Params: { id: string } }>(
|
||||
'/api/projects/:id/files',
|
||||
|
||||
346
apps/server/src/services/__tests__/git_diff.test.ts
Normal file
346
apps/server/src/services/__tests__/git_diff.test.ts
Normal file
@@ -0,0 +1,346 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtemp, rm, mkdir, writeFile } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import {
|
||||
parseNameStatus,
|
||||
splitDiffByFile,
|
||||
classifyDiffBody,
|
||||
autoSelectMode,
|
||||
detectInProgress,
|
||||
resolveCommittedBase,
|
||||
canCommit,
|
||||
getGitDiff,
|
||||
} from '../git_diff.js';
|
||||
import type { GitDiffFile } from '../git_diff.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ── T1: parseNameStatus ────────────────────────────────────────────────────
|
||||
|
||||
describe('parseNameStatus', () => {
|
||||
it('parses modified file', () => {
|
||||
const files = parseNameStatus('M\tsrc/foo.ts\n');
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatchObject({ path: 'src/foo.ts', change_type: 'modified', old_path: null });
|
||||
});
|
||||
|
||||
it('parses added file', () => {
|
||||
const files = parseNameStatus('A\tnewfile.ts\n');
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatchObject({ path: 'newfile.ts', change_type: 'added' });
|
||||
});
|
||||
|
||||
it('parses deleted file', () => {
|
||||
const files = parseNameStatus('D\tremoved.ts\n');
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatchObject({ path: 'removed.ts', change_type: 'deleted' });
|
||||
});
|
||||
|
||||
it('parses renamed file with similarity score', () => {
|
||||
const files = parseNameStatus('R100\told.ts\tnew.ts\n');
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatchObject({ path: 'new.ts', old_path: 'old.ts', change_type: 'renamed' });
|
||||
});
|
||||
|
||||
it('parses type-changed file as modified', () => {
|
||||
const files = parseNameStatus('T\tsymlink.ts\n');
|
||||
expect(files).toHaveLength(1);
|
||||
expect(files[0]).toMatchObject({ path: 'symlink.ts', change_type: 'modified' });
|
||||
});
|
||||
|
||||
it('parses multiple files from multiline output', () => {
|
||||
const output = 'M\ta.ts\nA\tb.ts\nD\tc.ts\n';
|
||||
const files = parseNameStatus(output);
|
||||
expect(files).toHaveLength(3);
|
||||
expect(files.map((f) => f.change_type)).toEqual(['modified', 'added', 'deleted']);
|
||||
});
|
||||
|
||||
it('ignores blank lines', () => {
|
||||
const files = parseNameStatus('\n\nM\ta.ts\n\n');
|
||||
expect(files).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(parseNameStatus('')).toHaveLength(0);
|
||||
expect(parseNameStatus('\n')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── T2: splitDiffByFile ────────────────────────────────────────────────────
|
||||
|
||||
describe('splitDiffByFile', () => {
|
||||
const FIXTURE = `diff --git a/src/a.ts b/src/a.ts
|
||||
index abc1234..def5678 100644
|
||||
--- a/src/a.ts
|
||||
+++ b/src/a.ts
|
||||
@@ -1,3 +1,4 @@
|
||||
context
|
||||
-old line
|
||||
+new line
|
||||
more context
|
||||
diff --git a/src/b.ts b/src/b.ts
|
||||
index 1111111..2222222 100644
|
||||
--- a/src/b.ts
|
||||
+++ b/src/b.ts
|
||||
@@ -10,2 +10,3 @@
|
||||
ctx
|
||||
+added
|
||||
`;
|
||||
|
||||
it('splits two-file diff into two entries', () => {
|
||||
const map = splitDiffByFile(FIXTURE);
|
||||
expect(map.size).toBe(2);
|
||||
expect(map.has('src/a.ts')).toBe(true);
|
||||
expect(map.has('src/b.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('each segment starts with diff --git header', () => {
|
||||
const map = splitDiffByFile(FIXTURE);
|
||||
expect(map.get('src/a.ts')).toMatch(/^diff --git a\/src\/a\.ts/);
|
||||
expect(map.get('src/b.ts')).toMatch(/^diff --git a\/src\/b\.ts/);
|
||||
});
|
||||
|
||||
it('handles deleted file (no +++ b/ line)', () => {
|
||||
const deleted = `diff --git a/gone.ts b/gone.ts
|
||||
deleted file mode 100644
|
||||
--- a/gone.ts
|
||||
+++ /dev/null
|
||||
@@ -1,2 +0,0 @@
|
||||
-line1
|
||||
-line2
|
||||
`;
|
||||
const map = splitDiffByFile(deleted);
|
||||
expect(map.size).toBe(1);
|
||||
expect(map.has('gone.ts')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty map for empty input', () => {
|
||||
expect(splitDiffByFile('').size).toBe(0);
|
||||
expect(splitDiffByFile('\n').size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ── T3: resolveCommittedBase (integration with temp git repo) ──────────────
|
||||
|
||||
describe('resolveCommittedBase', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gitdiff-base-')));
|
||||
await execFileAsync('git', ['init'], { cwd: tmp });
|
||||
await execFileAsync('git', ['-c', 'user.email=test@test.com', '-c', 'user.name=Test',
|
||||
'commit', '--allow-empty', '-m', 'init'], { cwd: tmp });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null base when no upstream and no origin', async () => {
|
||||
const { base, label } = await resolveCommittedBase(tmp);
|
||||
expect(base).toBeNull();
|
||||
expect(label).toBeTruthy(); // still has a descriptive label
|
||||
});
|
||||
});
|
||||
|
||||
// ── T4: autoSelectMode ────────────────────────────────────────────────────
|
||||
|
||||
describe('autoSelectMode', () => {
|
||||
it('returns uncommitted when dirty', () => {
|
||||
expect(autoSelectMode(true)).toBe('uncommitted');
|
||||
});
|
||||
|
||||
it('returns committed when clean', () => {
|
||||
expect(autoSelectMode(false)).toBe('committed');
|
||||
});
|
||||
});
|
||||
|
||||
// ── T5: classifyDiffBody ──────────────────────────────────────────────────
|
||||
|
||||
describe('classifyDiffBody', () => {
|
||||
it('classifies a normal diff as diff', () => {
|
||||
const body = `diff --git a/foo b/foo
|
||||
--- a/foo
|
||||
+++ b/foo
|
||||
@@ -1 +1 @@
|
||||
-old
|
||||
+new
|
||||
`;
|
||||
expect(classifyDiffBody(body)).toBe('diff');
|
||||
});
|
||||
|
||||
it('classifies binary diff as binary', () => {
|
||||
const body = `diff --git a/image.png b/image.png
|
||||
index abc..def 100644
|
||||
Binary files a/image.png and b/image.png differ
|
||||
`;
|
||||
expect(classifyDiffBody(body)).toBe('binary');
|
||||
});
|
||||
|
||||
it('classifies oversized diff as too_large', () => {
|
||||
const big = 'a'.repeat(600 * 1024); // 600KB > default cap
|
||||
expect(classifyDiffBody(big)).toBe('too_large');
|
||||
});
|
||||
|
||||
it('respects custom cap', () => {
|
||||
const body = 'a'.repeat(100);
|
||||
expect(classifyDiffBody(body, 50)).toBe('too_large');
|
||||
expect(classifyDiffBody(body, 200)).toBe('diff');
|
||||
});
|
||||
});
|
||||
|
||||
// ── T6: detectInProgress ──────────────────────────────────────────────────
|
||||
|
||||
describe('detectInProgress', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-inprogress-')));
|
||||
await mkdir(join(tmp, '.git'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('returns null when no sentinel files present', async () => {
|
||||
expect(await detectInProgress(tmp)).toBeNull();
|
||||
});
|
||||
|
||||
it('detects merge via MERGE_HEAD', async () => {
|
||||
await writeFile(join(tmp, '.git', 'MERGE_HEAD'), 'abc');
|
||||
expect(await detectInProgress(tmp)).toBe('merge');
|
||||
await rm(join(tmp, '.git', 'MERGE_HEAD'));
|
||||
});
|
||||
|
||||
it('detects cherry-pick via CHERRY_PICK_HEAD', async () => {
|
||||
await writeFile(join(tmp, '.git', 'CHERRY_PICK_HEAD'), 'abc');
|
||||
expect(await detectInProgress(tmp)).toBe('cherry-pick');
|
||||
await rm(join(tmp, '.git', 'CHERRY_PICK_HEAD'));
|
||||
});
|
||||
|
||||
it('detects bisect via BISECT_LOG', async () => {
|
||||
await writeFile(join(tmp, '.git', 'BISECT_LOG'), 'abc');
|
||||
expect(await detectInProgress(tmp)).toBe('bisect');
|
||||
await rm(join(tmp, '.git', 'BISECT_LOG'));
|
||||
});
|
||||
|
||||
it('detects rebase via rebase-merge directory', async () => {
|
||||
await mkdir(join(tmp, '.git', 'rebase-merge'));
|
||||
expect(await detectInProgress(tmp)).toBe('rebase');
|
||||
await rm(join(tmp, '.git', 'rebase-merge'), { recursive: true });
|
||||
});
|
||||
|
||||
it('detects rebase via rebase-apply directory', async () => {
|
||||
await mkdir(join(tmp, '.git', 'rebase-apply'));
|
||||
expect(await detectInProgress(tmp)).toBe('rebase');
|
||||
await rm(join(tmp, '.git', 'rebase-apply'), { recursive: true });
|
||||
});
|
||||
});
|
||||
|
||||
// ── T7: canCommit ─────────────────────────────────────────────────────────
|
||||
|
||||
describe('canCommit', () => {
|
||||
const stagedFile: GitDiffFile = {
|
||||
path: 'a.ts',
|
||||
old_path: null,
|
||||
change_type: 'modified',
|
||||
added_lines: 1,
|
||||
removed_lines: 0,
|
||||
staged: true,
|
||||
diff_body: '+new',
|
||||
is_binary: false,
|
||||
is_too_large: false,
|
||||
};
|
||||
const unstagedFile: GitDiffFile = { ...stagedFile, staged: false };
|
||||
|
||||
it('returns true when at least one file is staged', () => {
|
||||
expect(canCommit([stagedFile, unstagedFile])).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when no files are staged', () => {
|
||||
expect(canCommit([unstagedFile])).toBe(false);
|
||||
expect(canCommit([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── T8: getGitDiff integration test ───────────────────────────────────────
|
||||
|
||||
describe('getGitDiff integration (temp repo)', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-gitdiff-int-')));
|
||||
|
||||
// Init repo + initial commit
|
||||
await execFileAsync('git', ['init'], { cwd: tmp });
|
||||
await execFileAsync('git', ['config', 'user.email', 'test@test.com'], { cwd: tmp });
|
||||
await execFileAsync('git', ['config', 'user.name', 'Test'], { cwd: tmp });
|
||||
await writeFile(join(tmp, 'existing.ts'), 'const x = 1;\n');
|
||||
await execFileAsync('git', ['add', '.'], { cwd: tmp });
|
||||
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
|
||||
|
||||
// Modify existing file (unstaged)
|
||||
await writeFile(join(tmp, 'existing.ts'), 'const x = 2;\n');
|
||||
// Add new untracked file
|
||||
await writeFile(join(tmp, 'untracked.ts'), 'export {};\n');
|
||||
// Stage a new file
|
||||
await writeFile(join(tmp, 'staged.ts'), 'export const y = 1;\n');
|
||||
await execFileAsync('git', ['add', 'staged.ts'], { cwd: tmp });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('getGitDiff returns git_repo true for a git repo', async () => {
|
||||
const result = await getGitDiff(tmp, 'uncommitted');
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.mode).toBe('uncommitted');
|
||||
});
|
||||
|
||||
it('includes modified file in uncommitted mode', async () => {
|
||||
const result = await getGitDiff(tmp, 'uncommitted');
|
||||
const paths = result!.files.map((f: GitDiffFile) => f.path);
|
||||
expect(paths).toContain('existing.ts');
|
||||
});
|
||||
|
||||
it('includes staged file with staged=true', async () => {
|
||||
const result = await getGitDiff(tmp, 'uncommitted');
|
||||
const staged = result!.files.find((f: GitDiffFile) => f.path === 'staged.ts');
|
||||
expect(staged).toBeDefined();
|
||||
expect(staged!.staged).toBe(true);
|
||||
expect(staged!.change_type).toBe('added');
|
||||
});
|
||||
|
||||
it('includes untracked file with change_type=untracked', async () => {
|
||||
const result = await getGitDiff(tmp, 'uncommitted');
|
||||
const untracked = result!.files.find((f: GitDiffFile) => f.path === 'untracked.ts');
|
||||
expect(untracked).toBeDefined();
|
||||
expect(untracked!.change_type).toBe('untracked');
|
||||
});
|
||||
|
||||
it('returns null for a non-git directory', async () => {
|
||||
const nonGit = await realpath(await mkdtemp(join(tmpdir(), 'boocode-nongit-')));
|
||||
try {
|
||||
const result = await getGitDiff(nonGit, 'uncommitted');
|
||||
expect(result).toBeNull();
|
||||
} finally {
|
||||
await rm(nonGit, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('returns in_progress_op when MERGE_HEAD exists', async () => {
|
||||
await writeFile(join(tmp, '.git', 'MERGE_HEAD'), 'abc\n');
|
||||
try {
|
||||
const result = await getGitDiff(tmp, 'uncommitted');
|
||||
expect(result!.in_progress_op).toBe('merge');
|
||||
} finally {
|
||||
await rm(join(tmp, '.git', 'MERGE_HEAD'));
|
||||
}
|
||||
});
|
||||
});
|
||||
379
apps/server/src/services/__tests__/git_diff_write.test.ts
Normal file
379
apps/server/src/services/__tests__/git_diff_write.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { mkdtemp, rm, writeFile, mkdir, access, symlink } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { realpath } from 'node:fs/promises';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import {
|
||||
validateWritePath,
|
||||
checkSymlinkEscape,
|
||||
stageFiles,
|
||||
unstageFiles,
|
||||
commitFiles,
|
||||
discardFiles,
|
||||
deriveCommitIdentity,
|
||||
GitWriteError,
|
||||
getGitDiff,
|
||||
} from '../git_diff.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// ── T12: validateWritePath — pure validation ──────────────────────────────
|
||||
|
||||
describe('validateWritePath', () => {
|
||||
const root = '/repo/root';
|
||||
|
||||
it('accepts a simple relative path', () => {
|
||||
expect(() => validateWritePath(root, 'src/foo.ts')).not.toThrow();
|
||||
});
|
||||
|
||||
it('accepts a nested path', () => {
|
||||
expect(() => validateWritePath(root, 'a/b/c.ts')).not.toThrow();
|
||||
});
|
||||
|
||||
it('rejects empty string', () => {
|
||||
expect(() => validateWritePath(root, '')).toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('rejects path starting with - (flag injection)', () => {
|
||||
expect(() => validateWritePath(root, '-flag')).toThrow(GitWriteError);
|
||||
expect(() => validateWritePath(root, '--option')).toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('rejects "." (repo root discard)', () => {
|
||||
expect(() => validateWritePath(root, '.')).toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('rejects absolute paths', () => {
|
||||
expect(() => validateWritePath(root, '/etc/passwd')).toThrow(GitWriteError);
|
||||
expect(() => validateWritePath(root, '/repo/root/file.ts')).toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('rejects ".." traversal escaping root', () => {
|
||||
expect(() => validateWritePath(root, '../outside/file.ts')).toThrow(GitWriteError);
|
||||
expect(() => validateWritePath(root, 'a/../../outside')).toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('rejects path resolving exactly to root', () => {
|
||||
// e.g. "a/.." resolves to /repo/root which is the root itself
|
||||
expect(() => validateWritePath(root, 'a/..')).toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('throws GitWriteError not just Error', () => {
|
||||
try {
|
||||
validateWritePath(root, '-bad');
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(GitWriteError);
|
||||
expect((err as GitWriteError).busy).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Integration tests (temp git repo) ─────────────────────────────────────
|
||||
|
||||
async function initRepo(dir: string) {
|
||||
await execFileAsync('git', ['init'], { cwd: dir });
|
||||
await execFileAsync('git', ['config', 'user.email', 'test@test.com'], { cwd: dir });
|
||||
await execFileAsync('git', ['config', 'user.name', 'Test User'], { cwd: dir });
|
||||
}
|
||||
|
||||
async function fileExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── T9: stage / unstage round-trip ────────────────────────────────────────
|
||||
|
||||
describe('stageFiles / unstageFiles round-trip (temp repo)', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-stage-')));
|
||||
await initRepo(tmp);
|
||||
await writeFile(join(tmp, 'initial.ts'), 'const a = 1;\n');
|
||||
await execFileAsync('git', ['add', '.'], { cwd: tmp });
|
||||
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('staging an untracked file shows it as staged in diff', async () => {
|
||||
await writeFile(join(tmp, 'new.ts'), 'export const x = 1;\n');
|
||||
// Before staging
|
||||
const before = await getGitDiff(tmp, 'uncommitted');
|
||||
const untrackedBefore = before!.files.find((f) => f.path === 'new.ts');
|
||||
expect(untrackedBefore?.change_type).toBe('untracked');
|
||||
expect(untrackedBefore?.staged).toBe(false);
|
||||
|
||||
// Stage
|
||||
await stageFiles(tmp, ['new.ts']);
|
||||
|
||||
// After staging
|
||||
const after = await getGitDiff(tmp, 'uncommitted');
|
||||
const stagedAfter = after!.files.find((f) => f.path === 'new.ts');
|
||||
expect(stagedAfter?.staged).toBe(true);
|
||||
expect(stagedAfter?.change_type).toBe('added');
|
||||
});
|
||||
|
||||
it('unstaging removes file from staged set', async () => {
|
||||
// new.ts is currently staged from the previous test
|
||||
await unstageFiles(tmp, ['new.ts']);
|
||||
const after = await getGitDiff(tmp, 'uncommitted');
|
||||
const f = after!.files.find((f) => f.path === 'new.ts');
|
||||
expect(f?.staged).toBe(false);
|
||||
expect(f?.change_type).toBe('untracked');
|
||||
});
|
||||
|
||||
it('stageFiles rejects a path starting with -', async () => {
|
||||
await expect(stageFiles(tmp, ['-bad'])).rejects.toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('stageFiles rejects path traversal', async () => {
|
||||
await expect(stageFiles(tmp, ['../outside.ts'])).rejects.toThrow(GitWriteError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── T10: commit with server-derived identity ──────────────────────────────
|
||||
|
||||
describe('commitFiles with server-derived identity (temp repo)', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-commit-')));
|
||||
await initRepo(tmp);
|
||||
await writeFile(join(tmp, 'base.ts'), 'export const a = 1;\n');
|
||||
await execFileAsync('git', ['add', '.'], { cwd: tmp });
|
||||
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('deriveCommitIdentity falls back when no git config set', async () => {
|
||||
// New repo initialized without global user config — may or may not have local config.
|
||||
// The function should always return a non-empty name and email.
|
||||
const identity = await deriveCommitIdentity(tmp);
|
||||
expect(identity.name).toBeTruthy();
|
||||
expect(identity.email).toBeTruthy();
|
||||
});
|
||||
|
||||
it('deriveCommitIdentity uses git config when set', async () => {
|
||||
const identity = await deriveCommitIdentity(tmp);
|
||||
// We set user.email/name in initRepo above
|
||||
expect(identity.name).toBe('Test User');
|
||||
expect(identity.email).toBe('test@test.com');
|
||||
});
|
||||
|
||||
it('commit creates a new commit and the staged file is no longer in diff', async () => {
|
||||
await writeFile(join(tmp, 'newfile.ts'), 'export const b = 2;\n');
|
||||
await stageFiles(tmp, ['newfile.ts']);
|
||||
|
||||
const before = await getGitDiff(tmp, 'uncommitted');
|
||||
expect(before!.files.find((f) => f.path === 'newfile.ts')).toBeDefined();
|
||||
|
||||
await commitFiles(tmp, 'add newfile');
|
||||
|
||||
const after = await getGitDiff(tmp, 'uncommitted');
|
||||
expect(after!.files.find((f) => f.path === 'newfile.ts')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('commit with specific files only commits those files', async () => {
|
||||
await writeFile(join(tmp, 'a.ts'), 'const a = 1;\n');
|
||||
await writeFile(join(tmp, 'b.ts'), 'const b = 2;\n');
|
||||
await stageFiles(tmp, ['a.ts', 'b.ts']);
|
||||
|
||||
await commitFiles(tmp, 'partial commit', ['a.ts']);
|
||||
|
||||
const after = await getGitDiff(tmp, 'uncommitted');
|
||||
const aFile = after!.files.find((f) => f.path === 'a.ts');
|
||||
const bFile = after!.files.find((f) => f.path === 'b.ts');
|
||||
// a.ts was committed — should not appear in uncommitted diff
|
||||
expect(aFile).toBeUndefined();
|
||||
// b.ts is still staged
|
||||
expect(bFile?.staged).toBe(true);
|
||||
});
|
||||
|
||||
it('commit rejects a path starting with - in files list', async () => {
|
||||
await expect(commitFiles(tmp, 'msg', ['-bad'])).rejects.toThrow(GitWriteError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── T11: discard tracked vs untracked ─────────────────────────────────────
|
||||
|
||||
describe('discardFiles (temp repo)', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-discard-')));
|
||||
await initRepo(tmp);
|
||||
await writeFile(join(tmp, 'tracked.ts'), 'const orig = 1;\n');
|
||||
await execFileAsync('git', ['add', '.'], { cwd: tmp });
|
||||
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('discarding a modified tracked file reverts its content', async () => {
|
||||
await writeFile(join(tmp, 'tracked.ts'), 'const modified = 99;\n');
|
||||
const before = await getGitDiff(tmp, 'uncommitted');
|
||||
expect(before!.files.find((f) => f.path === 'tracked.ts')).toBeDefined();
|
||||
|
||||
await discardFiles(tmp, [{ path: 'tracked.ts', change_type: 'modified', staged: false }]);
|
||||
|
||||
const after = await getGitDiff(tmp, 'uncommitted');
|
||||
expect(after!.files.find((f) => f.path === 'tracked.ts')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('discarding an untracked file removes it from disk', async () => {
|
||||
await writeFile(join(tmp, 'untracked.ts'), 'orphan\n');
|
||||
const exists = await fileExists(join(tmp, 'untracked.ts'));
|
||||
expect(exists).toBe(true);
|
||||
|
||||
await discardFiles(tmp, [{ path: 'untracked.ts', change_type: 'untracked', staged: false }]);
|
||||
|
||||
expect(await fileExists(join(tmp, 'untracked.ts'))).toBe(false);
|
||||
});
|
||||
|
||||
it('discarding a staged-addition file removes it from index and disk', async () => {
|
||||
await writeFile(join(tmp, 'staged-add.ts'), 'new file\n');
|
||||
await stageFiles(tmp, ['staged-add.ts']);
|
||||
|
||||
const before = await getGitDiff(tmp, 'uncommitted');
|
||||
expect(before!.files.find((f) => f.path === 'staged-add.ts')?.staged).toBe(true);
|
||||
|
||||
await discardFiles(tmp, [{ path: 'staged-add.ts', change_type: 'added', staged: true }]);
|
||||
|
||||
const after = await getGitDiff(tmp, 'uncommitted');
|
||||
expect(after!.files.find((f) => f.path === 'staged-add.ts')).toBeUndefined();
|
||||
expect(await fileExists(join(tmp, 'staged-add.ts'))).toBe(false);
|
||||
});
|
||||
|
||||
it('discardFiles rejects "." (repo root)', async () => {
|
||||
await expect(
|
||||
discardFiles(tmp, [{ path: '.', change_type: 'modified', staged: false }]),
|
||||
).rejects.toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('discardFiles rejects path traversal', async () => {
|
||||
await expect(
|
||||
discardFiles(tmp, [{ path: '../outside', change_type: 'untracked', staged: false }]),
|
||||
).rejects.toThrow(GitWriteError);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Index-lock → busy error ────────────────────────────────────────────────
|
||||
|
||||
describe('index-lock detection', () => {
|
||||
let tmp: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmp = await realpath(await mkdtemp(join(tmpdir(), 'boocode-write-lock-')));
|
||||
await initRepo(tmp);
|
||||
await writeFile(join(tmp, 'file.ts'), 'const x = 1;\n');
|
||||
await execFileAsync('git', ['add', '.'], { cwd: tmp });
|
||||
await execFileAsync('git', ['commit', '-m', 'init'], { cwd: tmp });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(tmp, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('stageFiles throws GitWriteError with busy=true when index.lock exists', async () => {
|
||||
await writeFile(join(tmp, 'new.ts'), 'export {};\n');
|
||||
// Simulate a lock by creating .git/index.lock
|
||||
await mkdir(join(tmp, '.git'), { recursive: true });
|
||||
await writeFile(join(tmp, '.git', 'index.lock'), '');
|
||||
try {
|
||||
await stageFiles(tmp, ['new.ts']);
|
||||
// Should not reach here
|
||||
expect(true).toBe(false);
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(GitWriteError);
|
||||
expect((err as GitWriteError).busy).toBe(true);
|
||||
} finally {
|
||||
try { await rm(join(tmp, '.git', 'index.lock')); } catch { /* already gone */ }
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// ── Commit request schema: reject unknown author fields ───────────────────
|
||||
|
||||
describe('GitCommitBody schema strictness (unit)', () => {
|
||||
it('rejects extra author/email fields via Zod strict', () => {
|
||||
// We import Zod inline to mirror the route's schema
|
||||
const { z } = require('zod');
|
||||
const GitCommitBody = z
|
||||
.object({
|
||||
message: z.string().min(1),
|
||||
files: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const result = GitCommitBody.safeParse({
|
||||
message: 'test commit',
|
||||
author: 'Evil <evil@hack.com>',
|
||||
email: 'evil@hack.com',
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts valid commit body with message only', () => {
|
||||
const { z } = require('zod');
|
||||
const GitCommitBody = z
|
||||
.object({
|
||||
message: z.string().min(1),
|
||||
files: z.array(z.string().min(1)).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const result = GitCommitBody.safeParse({ message: 'add feature' });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── T13: checkSymlinkEscape (FIX 3) ──────────────────────────────────────────
|
||||
|
||||
describe('checkSymlinkEscape', () => {
|
||||
let repoDir: string;
|
||||
let outsideDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
repoDir = await realpath(await mkdtemp(join(tmpdir(), 'boocode-symlink-repo-')));
|
||||
outsideDir = await realpath(await mkdtemp(join(tmpdir(), 'boocode-symlink-outside-')));
|
||||
await writeFile(join(outsideDir, 'secret.ts'), 'secret data\n');
|
||||
// Symlink inside repo pointing to outside dir
|
||||
await symlink(outsideDir, join(repoDir, 'evil'));
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await rm(repoDir, { recursive: true, force: true });
|
||||
await rm(outsideDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('rejects a path that escapes via a directory symlink', async () => {
|
||||
await expect(checkSymlinkEscape(repoDir, 'evil/secret.ts')).rejects.toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('rejects a path that resolves to the symlink itself (outside)', async () => {
|
||||
await expect(checkSymlinkEscape(repoDir, 'evil')).rejects.toThrow(GitWriteError);
|
||||
});
|
||||
|
||||
it('accepts a path that resolves within the repo', async () => {
|
||||
await writeFile(join(repoDir, 'legit.ts'), 'export {};\n');
|
||||
await expect(checkSymlinkEscape(repoDir, 'legit.ts')).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a non-existent path (new file being staged)', async () => {
|
||||
await expect(checkSymlinkEscape(repoDir, 'brand-new-file-not-yet-created.ts')).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { classifyStreamError } from '../inference/stream-error-classifier.js';
|
||||
|
||||
describe('classifyStreamError', () => {
|
||||
it("classifies AbortError as 'stall'", () => {
|
||||
const err = new Error('aborted');
|
||||
err.name = 'AbortError';
|
||||
expect(classifyStreamError(err)).toBe('stall');
|
||||
});
|
||||
|
||||
it("classifies a 503 HTTP error as 'transient'", () => {
|
||||
const err = Object.assign(new Error('Service Unavailable'), { status: 503 });
|
||||
expect(classifyStreamError(err)).toBe('transient');
|
||||
});
|
||||
|
||||
it("classifies a 500 HTTP error as 'transient'", () => {
|
||||
const err = Object.assign(new Error('Internal Server Error'), { status: 500 });
|
||||
expect(classifyStreamError(err)).toBe('transient');
|
||||
});
|
||||
|
||||
it("classifies a 4xx HTTP error as 'non-retryable'", () => {
|
||||
const err = Object.assign(new Error('Bad Request'), { status: 400 });
|
||||
expect(classifyStreamError(err)).toBe('non-retryable');
|
||||
});
|
||||
|
||||
it("classifies a generic Error as 'non-retryable'", () => {
|
||||
expect(classifyStreamError(new Error('something went wrong'))).toBe('non-retryable');
|
||||
});
|
||||
});
|
||||
153
apps/server/src/services/__tests__/stream-phase-adapter.test.ts
Normal file
153
apps/server/src/services/__tests__/stream-phase-adapter.test.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// Gate test: pins the <invoke>-as-text fallback in the stream-phase text-delta
|
||||
// path. This test will fail if extractToolCallBlocks is ever removed from the
|
||||
// text-delta branch of streamCompletion, which is the only guard for models
|
||||
// that emit tool calls as inline XML rather than structured tool_calls.
|
||||
import { describe, expect, it, vi, afterEach } from 'vitest';
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
|
||||
// vi.mock is hoisted before all module imports. Spread the original so all
|
||||
// other ai exports (tool, jsonSchema, types, …) remain real; only streamText
|
||||
// is replaced with a controllable spy.
|
||||
vi.mock('ai', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('ai')>();
|
||||
return { ...actual, streamText: vi.fn() };
|
||||
});
|
||||
|
||||
import { streamText } from 'ai';
|
||||
import { streamCompletion, STALL_TIMEOUT_MS } from '../inference/stream-phase-adapter.js';
|
||||
import type { StreamAdapterContext } from '../inference/stream-phase-adapter.js';
|
||||
|
||||
const INVOKE_BLOCK =
|
||||
'<invoke name="view_file"><parameter name="path">/tmp/test.ts</parameter></invoke>';
|
||||
|
||||
// One-shot async generator that yields a single text-delta carrying a complete
|
||||
// <invoke> block, simulating a model that emits its tool call as plain XML text.
|
||||
async function* makeInvokeTextDeltaStream() {
|
||||
yield { type: 'text-delta' as const, text: INVOKE_BLOCK };
|
||||
}
|
||||
|
||||
const fakeLog = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
fatal: vi.fn(),
|
||||
trace: vi.fn(),
|
||||
child: vi.fn(),
|
||||
} as unknown as FastifyBaseLogger;
|
||||
|
||||
const fakeCtx: StreamAdapterContext = {
|
||||
config: { LLAMA_SWAP_URL: 'http://localhost:11434' } as StreamAdapterContext['config'],
|
||||
log: fakeLog,
|
||||
};
|
||||
|
||||
describe('<invoke>-as-text fallback gate (stream-phase text-delta path)', () => {
|
||||
it('surfaces a plain-text <invoke> block as a toolCall and strips markup from content and deltas', async () => {
|
||||
vi.mocked(streamText).mockReturnValue({
|
||||
fullStream: makeInvokeTextDeltaStream(),
|
||||
usage: Promise.resolve({ inputTokens: 1, outputTokens: 1 }),
|
||||
} as unknown as ReturnType<typeof streamText>);
|
||||
|
||||
const deltas: string[] = [];
|
||||
const result = await streamCompletion(
|
||||
fakeCtx,
|
||||
'test-model',
|
||||
[{ role: 'user', content: 'call a tool' }],
|
||||
{ tools: null },
|
||||
(d) => deltas.push(d),
|
||||
undefined,
|
||||
);
|
||||
|
||||
// The <invoke> block must surface as a structured tool call
|
||||
expect(result.toolCalls).toHaveLength(1);
|
||||
expect(result.toolCalls[0]).toMatchObject({
|
||||
id: 'xml_call_0',
|
||||
name: 'view_file',
|
||||
args: { path: '/tmp/test.ts' },
|
||||
});
|
||||
|
||||
// The XML markup must not appear in the saved content or any flushed delta
|
||||
expect(result.content).not.toContain('<invoke');
|
||||
expect(result.content).not.toContain('</invoke>');
|
||||
expect(deltas.join('')).not.toContain('<invoke');
|
||||
});
|
||||
});
|
||||
|
||||
// T9: stall timeout — fake hanging stream fires AbortError after STALL_TIMEOUT_MS.
|
||||
describe('stall timeout (F6)', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it(`aborts the stream after ${STALL_TIMEOUT_MS}ms with no chunks (stall path)`, async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
// Capture the effectiveSignal the adapter passes to streamText so the fake
|
||||
// generator can unblock when the stall fires (matching real ReadableStream
|
||||
// abort behavior: the stream ends rather than throwing into the generator).
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
vi.mocked(streamText).mockImplementation((opts: Parameters<typeof streamText>[0]) => {
|
||||
capturedSignal = opts.abortSignal as AbortSignal | undefined;
|
||||
return {
|
||||
// Hang until the effective signal fires, then return without emitting
|
||||
// any parts — mirrors how a real fetch stream ends when aborted.
|
||||
fullStream: (async function* () {
|
||||
await new Promise<void>((resolve) => {
|
||||
if (capturedSignal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
capturedSignal?.addEventListener('abort', () => resolve(), { once: true });
|
||||
});
|
||||
})(),
|
||||
// Never resolves; the stall throw happens before usage is awaited.
|
||||
usage: new Promise<never>(() => {}),
|
||||
} as unknown as ReturnType<typeof streamText>;
|
||||
});
|
||||
|
||||
const streamPromise = streamCompletion(
|
||||
fakeCtx,
|
||||
'test-model',
|
||||
[{ role: 'user', content: 'hang' }],
|
||||
{ tools: null },
|
||||
() => {},
|
||||
undefined,
|
||||
);
|
||||
|
||||
// Attach the rejection handler BEFORE advancing timers so the rejection is
|
||||
// never unhandled (Node emits PromiseRejectionHandledWarning otherwise).
|
||||
const assertion = expect(streamPromise).rejects.toMatchObject({ name: 'AbortError' });
|
||||
|
||||
// Advance past the stall deadline — the stallAc fires, the hanging generator
|
||||
// resolves, the post-loop check sees stallAc.signal.aborted and throws.
|
||||
await vi.advanceTimersByTimeAsync(STALL_TIMEOUT_MS);
|
||||
|
||||
await assertion;
|
||||
});
|
||||
|
||||
// T10: regression pin — the original post-loop signal check for user-initiated
|
||||
// abort must still fire correctly after the stall logic was added.
|
||||
it('throws AbortError when the inbound signal is aborted (user-abort regression pin)', async () => {
|
||||
const ac = new AbortController();
|
||||
ac.abort();
|
||||
|
||||
vi.mocked(streamText).mockReturnValue({
|
||||
fullStream: (async function* () {
|
||||
// Yield nothing — stream ends immediately after user abort is already set
|
||||
})(),
|
||||
usage: Promise.resolve({ inputTokens: 0, outputTokens: 0 }),
|
||||
} as unknown as ReturnType<typeof streamText>);
|
||||
|
||||
await expect(
|
||||
streamCompletion(
|
||||
fakeCtx,
|
||||
'test-model',
|
||||
[{ role: 'user', content: 'aborted' }],
|
||||
{ tools: null },
|
||||
() => {},
|
||||
undefined,
|
||||
ac.signal,
|
||||
),
|
||||
).rejects.toMatchObject({ name: 'AbortError' });
|
||||
});
|
||||
});
|
||||
@@ -1,179 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
parseXmlToolCall,
|
||||
parseInvokeToolCall,
|
||||
partialXmlOpenerStart,
|
||||
extractToolCallBlocks,
|
||||
stripToolMarkup,
|
||||
XML_TOOL_OPEN,
|
||||
XML_TOOL_CLOSE,
|
||||
INVOKE_TOOL_OPEN,
|
||||
INVOKE_TOOL_CLOSE,
|
||||
} from '../inference/tool-call-parser.js';
|
||||
|
||||
// ── Ported from xml-parser.test.ts ───────────────────────────────────────
|
||||
|
||||
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', () => {
|
||||
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' },
|
||||
});
|
||||
});
|
||||
|
||||
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/' },
|
||||
});
|
||||
});
|
||||
|
||||
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' },
|
||||
});
|
||||
});
|
||||
|
||||
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)', () => {
|
||||
it('extracts a single <invoke> block (spec case 1)', () => {
|
||||
const input = '<invoke name="view_file"><parameter name="path">/tmp/foo</parameter></invoke>';
|
||||
@@ -341,11 +171,3 @@ describe('stripToolMarkup', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delimiter constants', () => {
|
||||
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>');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,159 +1,13 @@
|
||||
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';
|
||||
} from '@boocode/contracts/ws-frames';
|
||||
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('accepts a message_complete frame with a null model (external coder, no model selected)', () => {
|
||||
// Regression guard: the dispatcher publishes `model: task.model` (string |
|
||||
// null). When null, this MUST validate or publishFrame fail-closes and drops
|
||||
// the whole frame, incl. the status:'complete' transition.
|
||||
const result = WsFrameSchema.safeParse({
|
||||
type: 'message_complete',
|
||||
message_id: VALID_UUID_A,
|
||||
chat_id: VALID_UUID_B,
|
||||
model: null,
|
||||
});
|
||||
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];
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyBaseLogger } from 'fastify';
|
||||
import { WsFrameSchema, type WsFrame } from '../types/ws-frames.js';
|
||||
import { WsFrameSchema, type WsFrame } from '@boocode/contracts/ws-frames';
|
||||
|
||||
export type Frame = Record<string, unknown> & { type: string };
|
||||
export type Listener = (frame: Frame) => void;
|
||||
|
||||
554
apps/server/src/services/git_diff.ts
Normal file
554
apps/server/src/services/git_diff.ts
Normal file
@@ -0,0 +1,554 @@
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { stat, realpath } from 'node:fs/promises';
|
||||
import { isAbsolute, join, resolve, sep } from 'node:path';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const GIT_TIMEOUT_MS = 30_000;
|
||||
const GIT_MAX_BUFFER = 10 * 1024 * 1024; // 10MB
|
||||
const FILE_DIFF_CAP = 512 * 1024; // 512KB per-file display cap
|
||||
|
||||
export type GitDiffMode = 'uncommitted' | 'committed';
|
||||
export type ChangeType = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked';
|
||||
|
||||
export interface GitDiffFile {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
change_type: ChangeType;
|
||||
added_lines: number;
|
||||
removed_lines: number;
|
||||
staged: boolean;
|
||||
diff_body: string | null; // null when is_binary or is_too_large
|
||||
is_binary: boolean;
|
||||
is_too_large: boolean;
|
||||
}
|
||||
|
||||
export interface GitDiffResult {
|
||||
mode: GitDiffMode;
|
||||
base_label: string | null;
|
||||
in_progress_op: string | null;
|
||||
files: GitDiffFile[];
|
||||
}
|
||||
|
||||
// runGit with 30s deadline and 10MB buffer for diff payloads. Returns null on
|
||||
// any failure so callers can degrade gracefully without surfacing git errors.
|
||||
async function runGit(args: string[], cwd: string): Promise<string | null> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
timeout: GIT_TIMEOUT_MS,
|
||||
windowsHide: true,
|
||||
maxBuffer: GIT_MAX_BUFFER,
|
||||
});
|
||||
return stdout.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Pure helpers (unit-testable without spawning git) ──────────────────────
|
||||
|
||||
/** Parses a single `git diff --name-status` output line. Returns null on garbage. */
|
||||
function parseNameStatusLine(line: string): {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
change_type: ChangeType;
|
||||
} | null {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return null;
|
||||
const parts = trimmed.split('\t');
|
||||
if (parts.length < 2) return null;
|
||||
const code = parts[0] ?? '';
|
||||
// Rename: R<score>\told\tnew Copy: C<score>\told\tnew
|
||||
if (code.startsWith('R') || code.startsWith('C')) {
|
||||
if (parts.length < 3) return null;
|
||||
return { path: parts[2] ?? '', old_path: parts[1] ?? null, change_type: 'renamed' };
|
||||
}
|
||||
const path = parts[1] ?? '';
|
||||
if (!path) return null;
|
||||
switch (code[0]) {
|
||||
case 'A': return { path, old_path: null, change_type: 'added' };
|
||||
case 'M':
|
||||
case 'T': // type changed
|
||||
case 'U': // unmerged
|
||||
return { path, old_path: null, change_type: 'modified' };
|
||||
case 'D': return { path, old_path: null, change_type: 'deleted' };
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses multi-line `git diff --name-status` output into a file list. */
|
||||
export function parseNameStatus(output: string): {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
change_type: ChangeType;
|
||||
}[] {
|
||||
return output
|
||||
.split('\n')
|
||||
.map((l) => parseNameStatusLine(l))
|
||||
.filter((x): x is NonNullable<typeof x> => x !== null);
|
||||
}
|
||||
|
||||
/** Parses a single `git diff --numstat` output line. */
|
||||
export function parseNumStatLine(line: string): {
|
||||
path: string;
|
||||
added: number;
|
||||
removed: number;
|
||||
binary: boolean;
|
||||
} | null {
|
||||
const parts = line.trim().split('\t');
|
||||
if (parts.length < 3) return null;
|
||||
const [added, removed, path] = parts;
|
||||
if (!path) return null;
|
||||
if (added === '-' && removed === '-') {
|
||||
return { path, added: 0, removed: 0, binary: true };
|
||||
}
|
||||
const a = parseInt(added ?? '', 10);
|
||||
const r = parseInt(removed ?? '', 10);
|
||||
if (isNaN(a) || isNaN(r)) return null;
|
||||
return { path, added: a, removed: r, binary: false };
|
||||
}
|
||||
|
||||
/** Splits a unified diff text into per-file bodies keyed by current path. */
|
||||
export function splitDiffByFile(diffText: string): Map<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
if (!diffText.trim()) return result;
|
||||
|
||||
// Split at each "diff --git" header (lookahead keeps the header with its section)
|
||||
const sections = diffText.split(/(?=^diff --git )/m);
|
||||
for (const section of sections) {
|
||||
if (!section.trim()) continue;
|
||||
|
||||
// Current path: prefer "+++ b/<path>" (absent for pure renames / deleted files)
|
||||
const pppMatch = section.match(/^\+{3} b\/(.+)$/m);
|
||||
if (pppMatch) {
|
||||
result.set((pppMatch[1] ?? '').trim(), section);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Deleted file: "--- a/<path>" with "+++ /dev/null"
|
||||
const mmmMatch = section.match(/^-{3} a\/(.+)$/m);
|
||||
if (mmmMatch) {
|
||||
const p = (mmmMatch[1] ?? '').trim();
|
||||
if (p && p !== '/dev/null') {
|
||||
result.set(p, section);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Pure rename with no content change: extract from "diff --git a/... b/..."
|
||||
// Take everything after the last " b/" on that line.
|
||||
const gitLineMatch = section.match(/^diff --git a\/.+ b\/(.+)$/m);
|
||||
if (gitLineMatch) {
|
||||
result.set((gitLineMatch[1] ?? '').trim(), section);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Classifies a diff body segment as diff | binary | too_large. */
|
||||
export function classifyDiffBody(body: string, cap = FILE_DIFF_CAP): 'diff' | 'binary' | 'too_large' {
|
||||
if (/^Binary files /m.test(body)) return 'binary';
|
||||
if (body.length > cap) return 'too_large';
|
||||
return 'diff';
|
||||
}
|
||||
|
||||
/** Returns the auto-selected diff mode based on dirty state. */
|
||||
export function autoSelectMode(isDirty: boolean): GitDiffMode {
|
||||
return isDirty ? 'uncommitted' : 'committed';
|
||||
}
|
||||
|
||||
/** Returns true when at least one file is staged (commit is possible). */
|
||||
export function canCommit(files: GitDiffFile[]): boolean {
|
||||
return files.some((f) => f.staged);
|
||||
}
|
||||
|
||||
/** Returns true when the working tree has uncommitted changes (staged or unstaged). */
|
||||
export async function isRepoDirty(cwd: string): Promise<boolean> {
|
||||
const gitRoot = await resolveGitRoot(cwd);
|
||||
if (!gitRoot) return false;
|
||||
const out = await runGit(['status', '--porcelain'], gitRoot);
|
||||
if (out === null) return true; // can't determine — assume dirty
|
||||
return out.trim().length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Async per-file symlink-escape guard (FIX 3 / D-4). Resolves the real path of
|
||||
* the target (if it already exists on disk) and rejects when it falls outside
|
||||
* the repo root. Non-existent paths (new files being staged) are allowed — there
|
||||
* is no symlink to follow when the file hasn't been created yet.
|
||||
*/
|
||||
export async function checkSymlinkEscape(repoRoot: string, filePath: string): Promise<void> {
|
||||
const resolved = resolve(repoRoot, filePath);
|
||||
let real: string;
|
||||
try {
|
||||
real = await realpath(resolved);
|
||||
} catch {
|
||||
// File doesn't exist yet — no symlink to resolve, safe to proceed.
|
||||
return;
|
||||
}
|
||||
if (real !== repoRoot && !real.startsWith(repoRoot + sep)) {
|
||||
throw new GitWriteError(`path escapes repository root via symlink: ${filePath}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Async helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolves the base ref for Committed mode with fallback chain. */
|
||||
export async function resolveCommittedBase(
|
||||
cwd: string,
|
||||
): Promise<{ base: string | null; label: string }> {
|
||||
// 1. Tracking branch (@{upstream})
|
||||
const upstream = await runGit(
|
||||
['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'],
|
||||
cwd,
|
||||
);
|
||||
if (upstream !== null) {
|
||||
const trimmed = upstream.trim();
|
||||
if (trimmed && !trimmed.includes('fatal')) {
|
||||
return { base: trimmed, label: trimmed };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. origin/HEAD (default branch)
|
||||
const originHead = await runGit(['rev-parse', '--abbrev-ref', 'origin/HEAD'], cwd);
|
||||
if (originHead !== null) {
|
||||
const trimmed = originHead.trim();
|
||||
if (trimmed && !trimmed.includes('fatal') && !trimmed.includes('unknown')) {
|
||||
return { base: trimmed, label: trimmed };
|
||||
}
|
||||
}
|
||||
|
||||
return { base: null, label: 'uncommitted (no base found)' };
|
||||
}
|
||||
|
||||
/** Detects in-progress git operations via .git sentinel files/dirs. */
|
||||
export async function detectInProgress(repoRoot: string): Promise<string | null> {
|
||||
const fileChecks: [string, string][] = [
|
||||
['MERGE_HEAD', 'merge'],
|
||||
['CHERRY_PICK_HEAD', 'cherry-pick'],
|
||||
['BISECT_LOG', 'bisect'],
|
||||
];
|
||||
for (const [file, op] of fileChecks) {
|
||||
try {
|
||||
await stat(join(repoRoot, '.git', file));
|
||||
return op;
|
||||
} catch {
|
||||
// sentinel not present — continue
|
||||
}
|
||||
}
|
||||
for (const dir of ['rebase-merge', 'rebase-apply']) {
|
||||
try {
|
||||
await stat(join(repoRoot, '.git', dir));
|
||||
return 'rebase';
|
||||
} catch {
|
||||
// not present — continue
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── Read logic ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolves the git work-tree root for the given path. Returns null if not a repo. */
|
||||
async function resolveGitRoot(cwd: string): Promise<string | null> {
|
||||
const out = await runGit(['rev-parse', '--show-toplevel'], cwd);
|
||||
return out !== null ? out.trim() : null;
|
||||
}
|
||||
|
||||
function buildNumstatMap(
|
||||
output: string,
|
||||
): Map<string, { added: number; removed: number; binary: boolean }> {
|
||||
const map = new Map<string, { added: number; removed: number; binary: boolean }>();
|
||||
for (const line of output.split('\n')) {
|
||||
const parsed = parseNumStatLine(line);
|
||||
if (parsed) map.set(parsed.path, { added: parsed.added, removed: parsed.removed, binary: parsed.binary });
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function getUncommittedDiff(
|
||||
gitRoot: string,
|
||||
inProgress: string | null,
|
||||
): Promise<GitDiffResult> {
|
||||
const hasCommits = (await runGit(['rev-parse', '--verify', 'HEAD'], gitRoot)) !== null;
|
||||
|
||||
const [nameStatusOut, cachedNameStatusOut, untrackedOut, numstatOut, diffOut, cachedDiffOut] =
|
||||
await Promise.all([
|
||||
hasCommits
|
||||
? runGit(['diff', '--name-status', 'HEAD'], gitRoot)
|
||||
: Promise.resolve(''),
|
||||
hasCommits
|
||||
? runGit(['diff', '--cached', '--name-status', 'HEAD'], gitRoot)
|
||||
: runGit(['diff', '--cached', '--name-status'], gitRoot),
|
||||
runGit(['ls-files', '--others', '--exclude-standard'], gitRoot),
|
||||
hasCommits ? runGit(['diff', '--numstat', 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||
hasCommits ? runGit(['diff', 'HEAD'], gitRoot) : Promise.resolve(''),
|
||||
hasCommits
|
||||
? runGit(['diff', '--cached', 'HEAD'], gitRoot)
|
||||
: runGit(['diff', '--cached'], gitRoot),
|
||||
]);
|
||||
|
||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||
const stagedSet = new Set(
|
||||
parseNameStatus(cachedNameStatusOut ?? '').map((f) => f.path),
|
||||
);
|
||||
const untracked = (untrackedOut ?? '').split('\n').filter(Boolean);
|
||||
|
||||
const numstatMap = buildNumstatMap(numstatOut ?? '');
|
||||
|
||||
// Merge unstaged and staged diff maps
|
||||
const diffMap = splitDiffByFile(diffOut ?? '');
|
||||
const cachedDiffMap = splitDiffByFile(cachedDiffOut ?? '');
|
||||
// Staged-only files won't be in diffOut; supplement from cachedDiffMap
|
||||
for (const [k, v] of cachedDiffMap) {
|
||||
if (!diffMap.has(k)) diffMap.set(k, v);
|
||||
}
|
||||
|
||||
const files: GitDiffFile[] = [];
|
||||
|
||||
for (const entry of allChanged) {
|
||||
const ns = numstatMap.get(entry.path);
|
||||
const body = diffMap.get(entry.path) ?? null;
|
||||
const kind = body !== null ? classifyDiffBody(body) : ns?.binary ? 'binary' : 'diff';
|
||||
files.push({
|
||||
path: entry.path,
|
||||
old_path: entry.old_path,
|
||||
change_type: entry.change_type,
|
||||
added_lines: ns?.added ?? 0,
|
||||
removed_lines: ns?.removed ?? 0,
|
||||
staged: stagedSet.has(entry.path),
|
||||
diff_body: kind === 'diff' ? body : null,
|
||||
is_binary: kind === 'binary',
|
||||
is_too_large: kind === 'too_large',
|
||||
});
|
||||
}
|
||||
|
||||
for (const p of untracked) {
|
||||
files.push({
|
||||
path: p,
|
||||
old_path: null,
|
||||
change_type: 'untracked',
|
||||
added_lines: 0,
|
||||
removed_lines: 0,
|
||||
staged: false,
|
||||
diff_body: null,
|
||||
is_binary: false,
|
||||
is_too_large: false,
|
||||
});
|
||||
}
|
||||
|
||||
return { mode: 'uncommitted', base_label: null, in_progress_op: inProgress, files };
|
||||
}
|
||||
|
||||
async function getCommittedDiff(
|
||||
gitRoot: string,
|
||||
base: string,
|
||||
label: string,
|
||||
inProgress: string | null,
|
||||
): Promise<GitDiffResult> {
|
||||
const [nameStatusOut, numstatOut, diffOut] = await Promise.all([
|
||||
runGit(['diff', '--name-status', base, 'HEAD'], gitRoot),
|
||||
runGit(['diff', '--numstat', base, 'HEAD'], gitRoot),
|
||||
runGit(['diff', base, 'HEAD'], gitRoot),
|
||||
]);
|
||||
|
||||
const allChanged = parseNameStatus(nameStatusOut ?? '');
|
||||
const numstatMap = buildNumstatMap(numstatOut ?? '');
|
||||
const diffMap = splitDiffByFile(diffOut ?? '');
|
||||
|
||||
const files: GitDiffFile[] = allChanged.map((entry) => {
|
||||
const ns = numstatMap.get(entry.path);
|
||||
const body = diffMap.get(entry.path) ?? null;
|
||||
const kind = body !== null ? classifyDiffBody(body) : ns?.binary ? 'binary' : 'diff';
|
||||
return {
|
||||
path: entry.path,
|
||||
old_path: entry.old_path,
|
||||
change_type: entry.change_type,
|
||||
added_lines: ns?.added ?? 0,
|
||||
removed_lines: ns?.removed ?? 0,
|
||||
staged: false, // staged concept does not apply in committed mode
|
||||
diff_body: kind === 'diff' ? body : null,
|
||||
is_binary: kind === 'binary',
|
||||
is_too_large: kind === 'too_large',
|
||||
};
|
||||
});
|
||||
|
||||
return { mode: 'committed', base_label: label, in_progress_op: inProgress, files };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the structured git diff for the given directory and mode, or null if
|
||||
* the directory is not a git repository. On a null committed-mode base, falls
|
||||
* back to uncommitted and labels the result accordingly.
|
||||
*/
|
||||
export async function getGitDiff(cwd: string, mode: GitDiffMode): Promise<GitDiffResult | null> {
|
||||
const gitRoot = await resolveGitRoot(cwd);
|
||||
if (!gitRoot) return null;
|
||||
|
||||
const inProgress = await detectInProgress(gitRoot);
|
||||
|
||||
if (mode === 'uncommitted') {
|
||||
return getUncommittedDiff(gitRoot, inProgress);
|
||||
}
|
||||
|
||||
const { base, label } = await resolveCommittedBase(gitRoot);
|
||||
if (!base) {
|
||||
// Fall back to uncommitted with a descriptive label
|
||||
const result = await getUncommittedDiff(gitRoot, inProgress);
|
||||
return { ...result, base_label: label };
|
||||
}
|
||||
return getCommittedDiff(gitRoot, base, label, inProgress);
|
||||
}
|
||||
|
||||
// ── Phase 2: Write helpers ─────────────────────────────────────────────────
|
||||
|
||||
// Fallback identity matching project_bootstrap.ts constants.
|
||||
const GIT_USER_NAME = 'indifferentketchup';
|
||||
const GIT_USER_EMAIL = 'samkintop@gmail.com';
|
||||
|
||||
export class GitWriteError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly busy: boolean,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'GitWriteError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a per-file path argument for write operations.
|
||||
* Rejects flag injection (leading `-`), repo-root discard (`.`), absolute
|
||||
* paths, and `..` traversal without requiring the file to exist on disk.
|
||||
*/
|
||||
export function validateWritePath(repoRoot: string, filePath: string): void {
|
||||
if (!filePath || typeof filePath !== 'string' || filePath.trim() === '') {
|
||||
throw new GitWriteError('path is required', false);
|
||||
}
|
||||
if (filePath.startsWith('-')) {
|
||||
throw new GitWriteError(`invalid path (flag injection): ${filePath}`, false);
|
||||
}
|
||||
if (filePath === '.') {
|
||||
throw new GitWriteError('cannot operate on repository root (.)', false);
|
||||
}
|
||||
if (isAbsolute(filePath)) {
|
||||
throw new GitWriteError(`path must be relative: ${filePath}`, false);
|
||||
}
|
||||
const resolved = resolve(repoRoot, filePath);
|
||||
if (resolved === repoRoot || !resolved.startsWith(repoRoot + sep)) {
|
||||
throw new GitWriteError(`path escapes repository root: ${filePath}`, false);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads git config user.name/email, falling back to bootstrap constants. */
|
||||
export async function deriveCommitIdentity(
|
||||
repoRoot: string,
|
||||
): Promise<{ name: string; email: string }> {
|
||||
const [nameOut, emailOut] = await Promise.all([
|
||||
runGit(['config', 'user.name'], repoRoot),
|
||||
runGit(['config', 'user.email'], repoRoot),
|
||||
]);
|
||||
return {
|
||||
name: nameOut?.trim() || GIT_USER_NAME,
|
||||
email: emailOut?.trim() || GIT_USER_EMAIL,
|
||||
};
|
||||
}
|
||||
|
||||
/** Runs a git write operation, propagating errors. Throws GitWriteError. */
|
||||
async function runGitWrite(args: string[], cwd: string): Promise<void> {
|
||||
try {
|
||||
await execFileAsync('git', args, { cwd, timeout: GIT_TIMEOUT_MS, windowsHide: true });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
const busy = msg.includes('index.lock') || msg.includes('Another git process');
|
||||
throw new GitWriteError(busy ? 'repository is busy, try again' : msg, busy);
|
||||
}
|
||||
}
|
||||
|
||||
/** Stages the given files (`git add -- <files>`). */
|
||||
export async function stageFiles(repoRoot: string, files: string[]): Promise<void> {
|
||||
for (const f of files) {
|
||||
validateWritePath(repoRoot, f);
|
||||
await checkSymlinkEscape(repoRoot, f);
|
||||
}
|
||||
await runGitWrite(['add', '--', ...files], repoRoot);
|
||||
}
|
||||
|
||||
/** Unstages the given files (`git restore --staged -- <files>`). */
|
||||
export async function unstageFiles(repoRoot: string, files: string[]): Promise<void> {
|
||||
for (const f of files) {
|
||||
validateWritePath(repoRoot, f);
|
||||
await checkSymlinkEscape(repoRoot, f);
|
||||
}
|
||||
await runGitWrite(['restore', '--staged', '--', ...files], repoRoot);
|
||||
}
|
||||
|
||||
/** Commits staged files with a server-derived identity. */
|
||||
export async function commitFiles(
|
||||
repoRoot: string,
|
||||
message: string,
|
||||
files?: string[],
|
||||
): Promise<void> {
|
||||
if (files && files.length > 0) {
|
||||
for (const f of files) {
|
||||
validateWritePath(repoRoot, f);
|
||||
await checkSymlinkEscape(repoRoot, f);
|
||||
}
|
||||
}
|
||||
const id = await deriveCommitIdentity(repoRoot);
|
||||
const args = ['-c', `user.name=${id.name}`, '-c', `user.email=${id.email}`, 'commit', '-m', message];
|
||||
if (files && files.length > 0) args.push('--', ...files);
|
||||
await runGitWrite(args, repoRoot);
|
||||
}
|
||||
|
||||
export interface DiscardFileInfo {
|
||||
path: string;
|
||||
change_type: string;
|
||||
staged: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Discards changes for the given files.
|
||||
* - Untracked files: `git clean -f -- <path>`
|
||||
* - Staged additions (new file staged, no HEAD version): unstage then clean
|
||||
* - All other tracked files: `git restore HEAD -- <path>` (undoes staged + unstaged)
|
||||
*/
|
||||
export async function discardFiles(repoRoot: string, files: DiscardFileInfo[]): Promise<void> {
|
||||
for (const { path } of files) {
|
||||
validateWritePath(repoRoot, path);
|
||||
await checkSymlinkEscape(repoRoot, path);
|
||||
}
|
||||
|
||||
const untracked: string[] = [];
|
||||
const stagedAdditions: string[] = [];
|
||||
const tracked: string[] = [];
|
||||
|
||||
for (const f of files) {
|
||||
if (f.change_type === 'untracked') {
|
||||
untracked.push(f.path);
|
||||
} else if (f.change_type === 'added' && f.staged) {
|
||||
stagedAdditions.push(f.path);
|
||||
} else {
|
||||
tracked.push(f.path);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore tracked files from HEAD (handles staged + unstaged modifications/deletions).
|
||||
// git checkout HEAD -- <file> is the most portable form: resets index + worktree.
|
||||
if (tracked.length > 0) {
|
||||
await runGitWrite(['checkout', 'HEAD', '--', ...tracked], repoRoot);
|
||||
}
|
||||
|
||||
// Staged additions: unstage first, then remove from working tree.
|
||||
for (const p of stagedAdditions) {
|
||||
await runGitWrite(['restore', '--staged', '--', p], repoRoot);
|
||||
await runGitWrite(['clean', '-f', '--', p], repoRoot);
|
||||
}
|
||||
|
||||
// Untracked files: clean (hard delete).
|
||||
if (untracked.length > 0) {
|
||||
await runGitWrite(['clean', '-f', '--', ...untracked], repoRoot);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// Pure classifier for errors thrown from the fullStream loop. Establishes the
|
||||
// retry seam for when llama-swap gains restart-in-place-with-clear-partial
|
||||
// semantics. No retry is performed today (partial-stream re-emit is
|
||||
// non-idempotent at single-local-instance scale).
|
||||
export type StreamErrorKind = 'stall' | 'transient' | 'non-retryable';
|
||||
|
||||
export function classifyStreamError(err: unknown): StreamErrorKind {
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return 'stall';
|
||||
}
|
||||
if (err != null && typeof err === 'object') {
|
||||
const status = (err as Record<string, unknown>).status;
|
||||
if (typeof status === 'number' && status >= 500 && status < 600) {
|
||||
return 'transient';
|
||||
}
|
||||
}
|
||||
return 'non-retryable';
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { Agent, ToolCall } from '../../types/api.js';
|
||||
import type { ToolJsonSchema } from '../tools.js';
|
||||
import type { OpenAiMessage } from './payload.js';
|
||||
import { extractToolCallBlocks } from './tool-call-parser.js';
|
||||
import { classifyStreamError } from './stream-error-classifier.js';
|
||||
import type { StreamResult } from './types.js';
|
||||
import { upstreamModel } from './provider.js';
|
||||
import {
|
||||
@@ -193,6 +194,10 @@ function buildAiTools(schemas: ToolJsonSchema[]): Record<string, ReturnType<type
|
||||
return out;
|
||||
}
|
||||
|
||||
// F6: per-chunk stall deadline. Exported so tests can advance fake timers by
|
||||
// exactly this value without hardcoding a magic number.
|
||||
export const STALL_TIMEOUT_MS = 90_000;
|
||||
|
||||
// v1.10.5 Qwen-coder XML fallback. Some local models (notably qwen3-coder via
|
||||
// llama-swap) emit tool calls as inline XML inside delta.content rather than
|
||||
// the structured tool_calls field. We extract them out of the streamed text
|
||||
@@ -267,6 +272,22 @@ export async function streamCompletion(
|
||||
// before this. They now go through the same extraBody path as the new params.
|
||||
const samplerBody = buildSamplerProviderOptions(opts);
|
||||
|
||||
// F6: per-chunk stall deadline. If the model stops emitting chunks for
|
||||
// STALL_TIMEOUT_MS the stallAc fires through AbortSignal.any; the post-loop
|
||||
// abort check below then throws AbortError → handleAbortOrError writes
|
||||
// 'cancelled'. Timer is bumped on every chunk and cleared in the finally.
|
||||
// NO retry: partial-stream re-emit is non-idempotent at single-local-instance
|
||||
// scale; see stream-error-classifier.ts for the future retry seam.
|
||||
const stallAc = new AbortController();
|
||||
let stallTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const bumpStallTimer = () => {
|
||||
if (stallTimer !== null) clearTimeout(stallTimer);
|
||||
stallTimer = setTimeout(() => stallAc.abort(), STALL_TIMEOUT_MS);
|
||||
};
|
||||
const effectiveSignal = signal
|
||||
? AbortSignal.any([signal, stallAc.signal])
|
||||
: stallAc.signal;
|
||||
|
||||
const result = streamText({
|
||||
model: upstreamModel(ctx.config, model, agent ?? null),
|
||||
messages: aiMessages,
|
||||
@@ -277,7 +298,7 @@ export async function streamCompletion(
|
||||
...(typeof opts.top_p === 'number' ? { topP: opts.top_p } : {}),
|
||||
...(typeof opts.presence_penalty === 'number' ? { presencePenalty: opts.presence_penalty } : {}),
|
||||
...(samplerBody ? { providerOptions: { openaiCompatible: samplerBody } } : {}),
|
||||
abortSignal: signal,
|
||||
abortSignal: effectiveSignal,
|
||||
});
|
||||
|
||||
let content = '';
|
||||
@@ -289,7 +310,11 @@ export async function streamCompletion(
|
||||
// same flat list and keep the v1.10.5 synthetic id convention.
|
||||
const toolCalls: ToolCall[] = [];
|
||||
|
||||
bumpStallTimer();
|
||||
|
||||
try {
|
||||
for await (const part of result.fullStream) {
|
||||
bumpStallTimer();
|
||||
switch (part.type) {
|
||||
case 'text-delta': {
|
||||
pendingBuffer += part.text;
|
||||
@@ -297,7 +322,7 @@ export async function streamCompletion(
|
||||
// complete <tool_call> or <invoke> block, flushes prose between/around
|
||||
// them, holds any partial opener for the next chunk, and silently
|
||||
// drops blocks that fail to parse (matches pre-v1.13.16 behavior).
|
||||
const extracted = extractToolCallBlocks(pendingBuffer);
|
||||
const extracted = extractToolCallBlocks(pendingBuffer, ctx.log);
|
||||
if (extracted.flushed.length > 0) {
|
||||
content += extracted.flushed;
|
||||
onDelta(extracted.flushed);
|
||||
@@ -339,7 +364,9 @@ export async function streamCompletion(
|
||||
}
|
||||
case 'error': {
|
||||
const err = part.error;
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
const actualErr = err instanceof Error ? err : new Error(String(err));
|
||||
ctx.log.warn({ kind: classifyStreamError(actualErr) }, 'stream error part');
|
||||
throw actualErr;
|
||||
}
|
||||
// Intentional no-op: start, start-step, text-start, text-end,
|
||||
// reasoning-start, reasoning-end, source, file, tool-input-start,
|
||||
@@ -365,7 +392,8 @@ export async function streamCompletion(
|
||||
// Without this throw the row would land as status='complete' with partial
|
||||
// content instead of going through handleAbortOrError → status='cancelled'.
|
||||
// Smoke D caught this in v1.13.1-A — don't refactor it away.
|
||||
if (signal?.aborted) {
|
||||
// F6: also catch the stall timeout arm (stallAc.signal.aborted).
|
||||
if (signal?.aborted || stallAc.signal.aborted) {
|
||||
const abortErr = new Error('aborted');
|
||||
abortErr.name = 'AbortError';
|
||||
throw abortErr;
|
||||
@@ -402,4 +430,12 @@ export async function streamCompletion(
|
||||
completionTokens,
|
||||
reasoning: reasoningAccumulated,
|
||||
};
|
||||
} finally {
|
||||
// Clear the stall timer whether the stream completes normally, throws, or
|
||||
// is aborted — prevents a dangling timer from firing after the turn ends.
|
||||
if (stallTimer !== null) {
|
||||
clearTimeout(stallTimer);
|
||||
stallTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,10 @@
|
||||
|
||||
// ── Constants ────────────────────────────────────────────────────────────
|
||||
|
||||
export const XML_TOOL_OPEN = '<tool_call>';
|
||||
export const XML_TOOL_CLOSE = '</tool_call>';
|
||||
export const INVOKE_TOOL_OPEN = '<invoke';
|
||||
export const INVOKE_TOOL_CLOSE = '</invoke>';
|
||||
const XML_TOOL_OPEN = '<tool_call>';
|
||||
const XML_TOOL_CLOSE = '</tool_call>';
|
||||
const INVOKE_TOOL_OPEN = '<invoke';
|
||||
const INVOKE_TOOL_CLOSE = '</invoke>';
|
||||
|
||||
// ── Strip patterns ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -45,7 +45,7 @@ export interface ParsedCall {
|
||||
const PLACEHOLDER_LITERALS = new Set(['...', 'placeholder', '<path>', '<file>']);
|
||||
const ANGLE_BRACKET_SENTINEL_RE = /^<[^>]+>$/;
|
||||
|
||||
export function isPlaceholderArgValue(value: unknown): boolean {
|
||||
function isPlaceholderArgValue(value: unknown): boolean {
|
||||
if (typeof value !== 'string') return false;
|
||||
const trimmed = value.trim();
|
||||
if (trimmed === '') return true;
|
||||
@@ -61,17 +61,21 @@ function hasPlaceholderArgs(args: Record<string, unknown>): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function logRejectedPlaceholder(parsed: ParsedCall): void {
|
||||
console.debug(
|
||||
{ toolName: parsed.name, args: parsed.args },
|
||||
'rejected placeholder tool call at parse time',
|
||||
);
|
||||
type MinLogger = { debug(obj: object, msg: string): void };
|
||||
|
||||
function logRejectedPlaceholder(parsed: ParsedCall, log?: MinLogger): void {
|
||||
if (log) {
|
||||
log.debug(
|
||||
{ toolName: parsed.name, args: parsed.args },
|
||||
'rejected placeholder tool call at parse time',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const QWEN_FUNCTION_RE = /<function\s*=\s*([^>\s]+)\s*>/;
|
||||
const QWEN_PARAM_RE = /<parameter\s*=\s*([^>\s]+)\s*>([\s\S]*?)<\/parameter>/g;
|
||||
|
||||
export function parseXmlToolCall(block: string): ParsedCall | null {
|
||||
function parseXmlToolCall(block: string): ParsedCall | null {
|
||||
const nameMatch = block.match(QWEN_FUNCTION_RE);
|
||||
if (!nameMatch || !nameMatch[1]) return null;
|
||||
const name = nameMatch[1].trim();
|
||||
@@ -95,7 +99,7 @@ const INVOKE_NAME_RE =
|
||||
const INVOKE_PARAM_RE =
|
||||
/<parameter\s+name\s*=\s*("([^"]*)"|'([^']*)')\s*>([\s\S]*?)<\/parameter>/g;
|
||||
|
||||
export function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||
function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||
const nameMatch = block.match(INVOKE_NAME_RE);
|
||||
if (!nameMatch) return null;
|
||||
const name = (nameMatch[2] ?? nameMatch[3] ?? '').trim();
|
||||
@@ -116,7 +120,7 @@ export function parseInvokeToolCall(block: string): ParsedCall | null {
|
||||
|
||||
const ALL_OPENERS = [XML_TOOL_OPEN, INVOKE_TOOL_OPEN] as const;
|
||||
|
||||
export function partialXmlOpenerStart(s: string): number {
|
||||
function partialXmlOpenerStart(s: string): number {
|
||||
let earliest = -1;
|
||||
for (const op of ALL_OPENERS) {
|
||||
const idx = s.indexOf(op);
|
||||
@@ -150,7 +154,7 @@ const OPENER_SPECS: ReadonlyArray<OpenerSpec> = [
|
||||
{ open: INVOKE_TOOL_OPEN, close: INVOKE_TOOL_CLOSE, parse: parseInvokeToolCall },
|
||||
];
|
||||
|
||||
export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
||||
export function extractToolCallBlocks(buffer: string, log?: MinLogger): ToolCallExtraction {
|
||||
let flushed = '';
|
||||
const calls: ParsedCall[] = [];
|
||||
let pos = 0;
|
||||
@@ -176,7 +180,7 @@ export function extractToolCallBlocks(buffer: string): ToolCallExtraction {
|
||||
const parsed = next.spec.parse(block);
|
||||
if (parsed) {
|
||||
if (hasPlaceholderArgs(parsed.args)) {
|
||||
logRejectedPlaceholder(parsed);
|
||||
logRejectedPlaceholder(parsed, log);
|
||||
flushed += block;
|
||||
} else {
|
||||
calls.push(parsed);
|
||||
|
||||
@@ -25,19 +25,9 @@ export interface AvailableProject {
|
||||
|
||||
export type SessionStatus = 'open' | 'archived';
|
||||
|
||||
// Session-delete work-loss guard. Returned (as `reports`) in the 409 body when
|
||||
// a delete is blocked because the session's worktree holds work at risk. The
|
||||
// shape is produced by BooCoder's checkWorktreeWorkAtRisk and passed through
|
||||
// verbatim; mirrored byte-for-byte in apps/web/src/api/types.ts for the dialog.
|
||||
export interface WorktreeRiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean;
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream
|
||||
unmerged: number; // commits not in the project default branch
|
||||
atRisk: boolean;
|
||||
error?: string;
|
||||
}
|
||||
// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here.
|
||||
import type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
|
||||
export type { WorktreeRiskReport };
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
@@ -197,51 +187,10 @@ export interface ToolResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// v1.8.2: structured reason codes for failed inferences. `error` carries the
|
||||
// human text; `reason` is the machine-readable discriminator the UI matches
|
||||
// on (with `error` as fallback when reason is absent or unrecognized).
|
||||
export type ErrorReason =
|
||||
| 'llm_provider_error'
|
||||
| 'tool_execution_failed'
|
||||
| 'summary_after_cap_failed';
|
||||
|
||||
// v1.8.2 / v1.11.6: shapes stored in messages.metadata. Discriminated on `kind`.
|
||||
// cap_hit — system sentinel emitted when tool budget is exhausted
|
||||
// doom_loop — system sentinel emitted when the model called the same
|
||||
// tool with the same args DOOM_LOOP_THRESHOLD times in a row
|
||||
// mistake_recovery — system sentinel emitted when a run of consecutive
|
||||
// *heterogeneous* tool failures is detected (#12). A nudge
|
||||
// (escalated:false) injects model-facing recovery guidance
|
||||
// and continues; an escalate (escalated:true) stops the
|
||||
// turn after the nudge failed to break the failure run.
|
||||
// error — attached to a failed assistant message so UI can show reason
|
||||
export type MessageMetadata =
|
||||
| {
|
||||
kind: 'cap_hit';
|
||||
used: number;
|
||||
limit: number;
|
||||
agent_name: string | null;
|
||||
can_continue: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'doom_loop';
|
||||
tool_name: string;
|
||||
args: Record<string, unknown>;
|
||||
threshold: number;
|
||||
}
|
||||
| {
|
||||
// PINNED CONTRACT (#12) — mirrored byte-for-byte in apps/web/src/api/types.ts.
|
||||
kind: 'mistake_recovery';
|
||||
failure_kinds: string[];
|
||||
count: number;
|
||||
escalated: boolean;
|
||||
can_continue?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
error_reason: ErrorReason;
|
||||
error_text: string;
|
||||
};
|
||||
// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in
|
||||
// @boocode/contracts — edit the package, not here.
|
||||
import type { ErrorReason, MessageMetadata } from '@boocode/contracts/message-metadata';
|
||||
export type { ErrorReason, MessageMetadata };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
// v1.13.11-a: Zod schemas for every WebSocket frame published by the server.
|
||||
// Validation runs both on send (broker.publishFrame / publishUserFrame) and
|
||||
// on receive (apps/web/src/hooks/useSessionStream + useUserEvents). Catches
|
||||
// silent protocol drift between publisher and consumer.
|
||||
//
|
||||
// IMPORTANT: This file is duplicated byte-identical at
|
||||
// apps/web/src/api/ws-frames.ts. The two apps have separate tsconfigs and
|
||||
// no path alias; the duplication is sync-by-hand. A test asserts the two
|
||||
// files match. If you change one, change the other.
|
||||
//
|
||||
// Per-kind payload schemas (tool_call args, message_parts payloads, etc.)
|
||||
// stay z.unknown() in v1.13.11. Frame-level drift detection is the goal;
|
||||
// deep payload validation is follow-up work.
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
// ---- shared primitives -----------------------------------------------------
|
||||
|
||||
const Uuid = z.string().uuid();
|
||||
// Tool call IDs are model-emitted (e.g. "call_abc123") — not UUIDs.
|
||||
const ToolCallId = z.string().min(1);
|
||||
// v1.13.12 fix: postgres returns timestamp columns as JS Date objects, not
|
||||
// strings. The publish sites pass them through unchanged, so the schema must
|
||||
// tolerate both. preprocess converts Date → ISO string before string-validation;
|
||||
// on the web side (where frames arrive via JSON.parse) it's a no-op. Before
|
||||
// this fix, every message_complete / session_updated / chat_updated frame
|
||||
// failed validation and got dropped — symptoms: token tracking blank in UI,
|
||||
// status stuck at 'streaming' tripping the 60s stale-stream banner.
|
||||
const IsoTimestamp = z.preprocess(
|
||||
(v) => (v instanceof Date ? v.toISOString() : v),
|
||||
z.string().min(1),
|
||||
);
|
||||
|
||||
const ChatStatusValue = z.enum([
|
||||
'streaming',
|
||||
'tool_running',
|
||||
'waiting_for_input',
|
||||
'idle',
|
||||
'error',
|
||||
]);
|
||||
|
||||
// agent-status-normalize (#10): normalized per-(chat,agent) lifecycle status for
|
||||
// external coding agents (warm-acp / opencode / claude-sdk / pty). Distinct from
|
||||
// ChatStatusValue (native-inference chat lifecycle) — published by BooCoder's
|
||||
// dispatcher + permission flow on the per-session channel.
|
||||
const AgentStatusValue = z.enum(['working', 'blocked', 'idle', 'error']);
|
||||
|
||||
const ErrorReasonValue = z.enum([
|
||||
'llm_provider_error',
|
||||
'doom_loop',
|
||||
'doom_loop_summary_failed',
|
||||
'cap_hit',
|
||||
'cap_hit_summary_failed',
|
||||
]);
|
||||
|
||||
const MessageRoleValue = z.enum(['user', 'assistant', 'system', 'tool']);
|
||||
|
||||
const ToolCallShape = z.object({
|
||||
id: ToolCallId,
|
||||
name: z.string().min(1),
|
||||
args: z.record(z.string(), z.unknown()),
|
||||
});
|
||||
|
||||
// Free-form bags: opaque to the frame schema; deep validation is out of
|
||||
// scope for v1.13.11 (frame-level drift detection is the goal; per-kind
|
||||
// payload narrowing is follow-up work). z.unknown() means the consumer
|
||||
// must narrow before reading — TypeScript-side this is fine because every
|
||||
// consumer already operates on the hand-maintained Project / Chat / Session
|
||||
// / WorkspacePane types (the brief's "Don't strip existing types yet"
|
||||
// rule), and the Zod-typed shape is only used at the publishFrame boundary.
|
||||
const OpaqueObject = z.unknown();
|
||||
|
||||
// ---- per-session channel frames --------------------------------------------
|
||||
|
||||
export const SnapshotFrame = z.object({
|
||||
type: z.literal('snapshot'),
|
||||
messages: z.array(OpaqueObject),
|
||||
});
|
||||
|
||||
export const MessageStartedFrame = z.object({
|
||||
type: z.literal('message_started'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
role: MessageRoleValue,
|
||||
});
|
||||
|
||||
export const DeltaFrame = z.object({
|
||||
type: z.literal('delta'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ReasoningDeltaFrame = z.object({
|
||||
type: z.literal('reasoning_delta'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
content: z.string(),
|
||||
});
|
||||
|
||||
export const ToolCallFrame = z.object({
|
||||
type: z.literal('tool_call'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call: ToolCallShape,
|
||||
});
|
||||
|
||||
export const ToolResultFrame = z.object({
|
||||
type: z.literal('tool_result'),
|
||||
tool_message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tool_call_id: ToolCallId,
|
||||
output: z.unknown(),
|
||||
truncated: z.boolean(),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
|
||||
export const MessageCompleteFrame = z.object({
|
||||
type: z.literal('message_complete'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
tokens_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_used: z.number().int().nonnegative().nullable().optional(),
|
||||
ctx_max: z.number().int().positive().nullable().optional(),
|
||||
started_at: IsoTimestamp.nullable().optional(),
|
||||
finished_at: IsoTimestamp.nullable().optional(),
|
||||
// nullable: external-coder turns carry task.model, which is null when no
|
||||
// model was selected. This frame is published through the same fail-closed
|
||||
// publishFrame, so null MUST validate or the entire frame (incl. the
|
||||
// status:'complete' transition) is dropped.
|
||||
model: z.string().nullable().optional(),
|
||||
metadata: OpaqueObject.nullable().optional(),
|
||||
});
|
||||
|
||||
export const UsageFrame = z.object({
|
||||
type: z.literal('usage'),
|
||||
message_id: Uuid,
|
||||
chat_id: Uuid.optional(),
|
||||
completion_tokens: z.number().int().nonnegative().nullable(),
|
||||
ctx_used: z.number().int().nonnegative().nullable(),
|
||||
ctx_max: z.number().int().positive().nullable(),
|
||||
});
|
||||
|
||||
export const MessagesDeletedFrame = z.object({
|
||||
type: z.literal('messages_deleted'),
|
||||
message_ids: z.array(Uuid),
|
||||
chat_id: Uuid.optional(),
|
||||
});
|
||||
|
||||
export const ChatRenamedFrame = z.object({
|
||||
type: z.literal('chat_renamed'),
|
||||
chat_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const CompactedFrame = z.object({
|
||||
type: z.literal('compacted'),
|
||||
session_id: Uuid,
|
||||
chat_id: Uuid,
|
||||
summary_message_id: Uuid,
|
||||
});
|
||||
|
||||
export const ErrorFrame = z.object({
|
||||
type: z.literal('error'),
|
||||
message_id: Uuid.optional(),
|
||||
chat_id: Uuid.optional(),
|
||||
error: z.string(),
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
// ---- per-user channel frames (sidebar refresh) -----------------------------
|
||||
|
||||
export const ChatStatusFrame = z.object({
|
||||
type: z.literal('chat_status'),
|
||||
chat_id: Uuid,
|
||||
status: ChatStatusValue,
|
||||
at: IsoTimestamp,
|
||||
reason: ErrorReasonValue.optional(),
|
||||
});
|
||||
|
||||
export const SessionUpdatedFrame = z.object({
|
||||
type: z.literal('session_updated'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const SessionRenamedFrame = z.object({
|
||||
type: z.literal('session_renamed'),
|
||||
session_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const SessionCreatedFrame = z.object({
|
||||
type: z.literal('session_created'),
|
||||
session: OpaqueObject,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionArchivedFrame = z.object({
|
||||
type: z.literal('session_archived'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionDeletedFrame = z.object({
|
||||
type: z.literal('session_deleted'),
|
||||
session_id: Uuid,
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const SessionWorkspaceUpdatedFrame = z.object({
|
||||
type: z.literal('session_workspace_updated'),
|
||||
session_id: Uuid,
|
||||
// v2.6.x: widened from z.array — the payload is now either the legacy bare
|
||||
// WorkspacePane[] OR the WorkspaceState envelope object (panes + tabNumbers +
|
||||
// nextTabNumber + closedPaneStack). z.array alone would fail-closed and drop
|
||||
// every envelope frame at validation. MUST be mirrored in the server's
|
||||
// byte-identical copy (parity test).
|
||||
workspace_panes: z.union([z.array(OpaqueObject), z.record(z.unknown())]),
|
||||
});
|
||||
|
||||
export const ChatCreatedFrame = z.object({
|
||||
type: z.literal('chat_created'),
|
||||
chat: OpaqueObject,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUpdatedFrame = z.object({
|
||||
type: z.literal('chat_updated'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
name: z.string().nullable(),
|
||||
updated_at: IsoTimestamp,
|
||||
});
|
||||
|
||||
export const ChatArchivedFrame = z.object({
|
||||
type: z.literal('chat_archived'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ChatUnarchivedFrame = z.object({
|
||||
type: z.literal('chat_unarchived'),
|
||||
chat: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ChatDeletedFrame = z.object({
|
||||
type: z.literal('chat_deleted'),
|
||||
chat_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectCreatedFrame = z.object({
|
||||
type: z.literal('project_created'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectArchivedFrame = z.object({
|
||||
type: z.literal('project_archived'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
export const ProjectUnarchivedFrame = z.object({
|
||||
type: z.literal('project_unarchived'),
|
||||
project: OpaqueObject,
|
||||
});
|
||||
|
||||
export const ProjectUpdatedFrame = z.object({
|
||||
type: z.literal('project_updated'),
|
||||
project_id: Uuid,
|
||||
name: z.string(),
|
||||
});
|
||||
|
||||
export const ProjectDeletedFrame = z.object({
|
||||
type: z.literal('project_deleted'),
|
||||
project_id: Uuid,
|
||||
});
|
||||
|
||||
const PermissionOptionShape = z.object({
|
||||
option_id: z.string(),
|
||||
label: z.string(),
|
||||
});
|
||||
|
||||
export const PermissionRequestedFrame = z.object({
|
||||
type: z.literal('permission_requested'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
kind: z.enum(['tool', 'question', 'plan', 'elicitation']).optional(),
|
||||
tool_title: z.string().optional(),
|
||||
input: z.record(z.unknown()).optional(),
|
||||
options: z.array(PermissionOptionShape),
|
||||
});
|
||||
|
||||
export const PermissionResolvedFrame = z.object({
|
||||
type: z.literal('permission_resolved'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
});
|
||||
|
||||
const AgentCommandShape = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
});
|
||||
|
||||
export const AgentCommandsFrame = z.object({
|
||||
type: z.literal('agent_commands'),
|
||||
task_id: Uuid,
|
||||
session_id: Uuid,
|
||||
commands: z.array(AgentCommandShape),
|
||||
});
|
||||
|
||||
// agent-status-normalize (#10): published by BooCoder on the per-session channel
|
||||
// when an external agent's normalized status changes (turn start/end, permission
|
||||
// block/unblock). Keyed per (chat_id, agent); the frontend tracks the latest per
|
||||
// pair and resets on chat switch. `reason` is a free-form discriminator
|
||||
// (turn_start / turn_complete / failed / crashed / permission_request /
|
||||
// permission_resolved).
|
||||
export const AgentStatusUpdatedFrame = z.object({
|
||||
type: z.literal('agent_status_updated'),
|
||||
chat_id: Uuid,
|
||||
agent: z.string().min(1),
|
||||
status: AgentStatusValue,
|
||||
reason: z.string().optional(),
|
||||
at: IsoTimestamp,
|
||||
});
|
||||
|
||||
// ---- discriminated union ---------------------------------------------------
|
||||
|
||||
export const WsFrameSchema = z.discriminatedUnion('type', [
|
||||
// per-session
|
||||
SnapshotFrame,
|
||||
MessageStartedFrame,
|
||||
DeltaFrame,
|
||||
ReasoningDeltaFrame,
|
||||
ToolCallFrame,
|
||||
ToolResultFrame,
|
||||
MessageCompleteFrame,
|
||||
UsageFrame,
|
||||
MessagesDeletedFrame,
|
||||
ChatRenamedFrame,
|
||||
CompactedFrame,
|
||||
ErrorFrame,
|
||||
PermissionRequestedFrame,
|
||||
PermissionResolvedFrame,
|
||||
AgentCommandsFrame,
|
||||
AgentStatusUpdatedFrame,
|
||||
// per-user
|
||||
ChatStatusFrame,
|
||||
SessionUpdatedFrame,
|
||||
SessionRenamedFrame,
|
||||
SessionCreatedFrame,
|
||||
SessionArchivedFrame,
|
||||
SessionDeletedFrame,
|
||||
SessionWorkspaceUpdatedFrame,
|
||||
ChatCreatedFrame,
|
||||
ChatUpdatedFrame,
|
||||
ChatArchivedFrame,
|
||||
ChatUnarchivedFrame,
|
||||
ChatDeletedFrame,
|
||||
ProjectCreatedFrame,
|
||||
ProjectArchivedFrame,
|
||||
ProjectUnarchivedFrame,
|
||||
ProjectUpdatedFrame,
|
||||
ProjectDeletedFrame,
|
||||
]);
|
||||
|
||||
export type WsFrame = z.infer<typeof WsFrameSchema>;
|
||||
|
||||
// Convenience: the set of known frame types. Useful for the publishFrame
|
||||
// helper to log the offending type name when validation fails. Kept in sync
|
||||
// by hand with the discriminated union above.
|
||||
export const KNOWN_FRAME_TYPES: readonly WsFrame['type'][] = [
|
||||
'snapshot',
|
||||
'message_started',
|
||||
'delta',
|
||||
'reasoning_delta',
|
||||
'tool_call',
|
||||
'tool_result',
|
||||
'message_complete',
|
||||
'usage',
|
||||
'messages_deleted',
|
||||
'chat_renamed',
|
||||
'compacted',
|
||||
'error',
|
||||
'permission_requested',
|
||||
'permission_resolved',
|
||||
'agent_commands',
|
||||
'agent_status_updated',
|
||||
'chat_status',
|
||||
'session_updated',
|
||||
'session_renamed',
|
||||
'session_created',
|
||||
'session_archived',
|
||||
'session_deleted',
|
||||
'session_workspace_updated',
|
||||
'chat_created',
|
||||
'chat_updated',
|
||||
'chat_archived',
|
||||
'chat_unarchived',
|
||||
'chat_deleted',
|
||||
'project_created',
|
||||
'project_archived',
|
||||
'project_unarchived',
|
||||
'project_updated',
|
||||
'project_deleted',
|
||||
] as const;
|
||||
@@ -10,6 +10,7 @@
|
||||
"typecheck": "tsc -b --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@boocode/contracts": "workspace:*",
|
||||
"@fontsource-variable/inter": "^5.2.8",
|
||||
"@fontsource-variable/jetbrains-mono": "^5.2.8",
|
||||
"@xterm/addon-fit": "0.10.0",
|
||||
|
||||
@@ -10,6 +10,9 @@ import type {
|
||||
ViewFileResult,
|
||||
AgentsResponse,
|
||||
GitMeta,
|
||||
GitDiffMode,
|
||||
GitDiffResult,
|
||||
GitDiscardFileInfo,
|
||||
Skill,
|
||||
ToolCostStat,
|
||||
ProviderSnapshotEntry,
|
||||
@@ -151,6 +154,32 @@ export const api = {
|
||||
request<{ files: string[] }>(`/api/projects/${id}/files`),
|
||||
git: (id: string) =>
|
||||
request<GitMeta>(`/api/projects/${id}/git`),
|
||||
gitDiff: (id: string, mode: GitDiffMode | null) =>
|
||||
request<GitDiffResult>(
|
||||
mode !== null
|
||||
? `/api/projects/${id}/git/diff?mode=${mode}`
|
||||
: `/api/projects/${id}/git/diff`,
|
||||
),
|
||||
gitStage: (id: string, files: string[]) =>
|
||||
request<{ ok: boolean }>(`/api/projects/${id}/git/stage`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ files }),
|
||||
}),
|
||||
gitUnstage: (id: string, files: string[]) =>
|
||||
request<{ ok: boolean }>(`/api/projects/${id}/git/unstage`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ files }),
|
||||
}),
|
||||
gitCommit: (id: string, body: { message: string; files?: string[] }) =>
|
||||
request<{ ok: boolean }>(`/api/projects/${id}/git/commit`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
gitDiscard: (id: string, files: GitDiscardFileInfo[]) =>
|
||||
request<{ ok: boolean }>(`/api/projects/${id}/git/discard`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ files }),
|
||||
}),
|
||||
},
|
||||
|
||||
sessions: {
|
||||
|
||||
@@ -34,18 +34,8 @@ export interface AvailableProject {
|
||||
|
||||
export type SessionStatus = 'open' | 'archived';
|
||||
|
||||
// Session-delete work-loss guard. Mirror of WorktreeRiskReport in
|
||||
// apps/server/src/types/api.ts — edit both copies together. Arrives as the
|
||||
// `reports` field of the 409 body when a delete is blocked.
|
||||
export interface WorktreeRiskReport {
|
||||
worktreePath: string;
|
||||
branch: string;
|
||||
dirty: boolean;
|
||||
unpushed: number; // commits ahead of upstream, or -1 if no upstream
|
||||
unmerged: number; // commits not in the project default branch
|
||||
atRisk: boolean;
|
||||
error?: string;
|
||||
}
|
||||
// WorktreeRiskReport single-sourced in @boocode/contracts — edit the package, not here.
|
||||
export type { WorktreeRiskReport } from '@boocode/contracts/worktree-risk';
|
||||
|
||||
export interface Session {
|
||||
id: string;
|
||||
@@ -143,49 +133,10 @@ export interface ToolResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// v1.8.2: structured reason codes that flow through error frames / metadata.
|
||||
// `error` text stays human; `reason` is the discriminator the UI matches on.
|
||||
export type ErrorReason =
|
||||
| 'llm_provider_error'
|
||||
| 'tool_execution_failed'
|
||||
| 'summary_after_cap_failed';
|
||||
|
||||
// v1.8.2 / v1.11.6: shapes stored in Message.metadata. Discriminated on `kind`.
|
||||
// cap_hit — sentinel emitted when the tool budget is hit; carries the
|
||||
// budget + agent name + whether Continue is still allowed.
|
||||
// doom_loop — sentinel emitted when the model called the same tool with
|
||||
// the same arguments threshold times in a row.
|
||||
// mistake_recovery — sentinel emitted when the model hit repeated *different*
|
||||
// errors; non-escalated means recovery guidance was injected and
|
||||
// the turn continues, escalated means the turn was stopped.
|
||||
// error — attached to a failed assistant message so the bubble can show
|
||||
// a specific reason on reload (WS error frame is one-shot).
|
||||
export type MessageMetadata =
|
||||
| {
|
||||
kind: 'cap_hit';
|
||||
used: number;
|
||||
limit: number;
|
||||
agent_name: string | null;
|
||||
can_continue: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'doom_loop';
|
||||
tool_name: string;
|
||||
args: Record<string, unknown>;
|
||||
threshold: number;
|
||||
}
|
||||
| {
|
||||
kind: 'mistake_recovery';
|
||||
failure_kinds: string[];
|
||||
count: number;
|
||||
escalated: boolean;
|
||||
can_continue?: boolean;
|
||||
}
|
||||
| {
|
||||
kind: 'error';
|
||||
error_reason: ErrorReason;
|
||||
error_text: string;
|
||||
};
|
||||
// v1.8.2 / v1.11.6: ErrorReason + MessageMetadata single-sourced in
|
||||
// @boocode/contracts — edit the package, not here.
|
||||
import type { ErrorReason, MessageMetadata } from '@boocode/contracts/message-metadata';
|
||||
export type { ErrorReason, MessageMetadata };
|
||||
|
||||
export interface Message {
|
||||
id: string;
|
||||
@@ -239,80 +190,23 @@ export interface ModelInfo {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isDefault?: boolean;
|
||||
thinkingOptions?: ThinkingOption[];
|
||||
defaultThinkingOptionId?: string;
|
||||
}
|
||||
export type {
|
||||
ProviderModel,
|
||||
ProviderMode,
|
||||
ThinkingOption,
|
||||
ProviderSnapshotStatus,
|
||||
AgentCommand,
|
||||
ProviderSnapshotEntry,
|
||||
} from '@boocode/contracts/provider-snapshot';
|
||||
|
||||
export interface ProviderMode {
|
||||
id: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
isUnattended?: boolean;
|
||||
}
|
||||
export type {
|
||||
ProviderOverride,
|
||||
CoderProvidersFile,
|
||||
ProviderConfigPatch,
|
||||
} from '@boocode/contracts/provider-config';
|
||||
|
||||
export interface ThinkingOption {
|
||||
id: string;
|
||||
label: string;
|
||||
isDefault?: boolean;
|
||||
}
|
||||
|
||||
// v2.3 phase 2: 'loading' + 'unavailable' restored alongside 'ready' | 'error'.
|
||||
export type ProviderSnapshotStatus = 'loading' | 'ready' | 'unavailable' | 'error';
|
||||
|
||||
// KEEP IN SYNC with apps/coder/src/services/provider-types.ts ProviderSnapshotEntry
|
||||
// — parity is enforced by coder __tests__/provider-types-parity.test.ts (field drift fails it).
|
||||
export interface ProviderSnapshotEntry {
|
||||
name: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
transport: string;
|
||||
status: ProviderSnapshotStatus;
|
||||
enabled: boolean;
|
||||
installed: boolean;
|
||||
models: ProviderModel[];
|
||||
modes: ProviderMode[];
|
||||
defaultModeId: string | null;
|
||||
commands: AgentCommand[];
|
||||
error?: string;
|
||||
fetchedAt?: string;
|
||||
}
|
||||
|
||||
// v2.3 Phase 4: provider config file wire types. Mirror of the Zod-inferred
|
||||
// ProviderOverride / CoderProvidersFile in apps/coder/src/services/provider-config.ts
|
||||
// (web can't cross-import the coder package — TS6307 on the composite project).
|
||||
export interface ProviderOverride {
|
||||
extends?: 'acp';
|
||||
label?: string;
|
||||
description?: string;
|
||||
command?: string[];
|
||||
env?: Record<string, string>;
|
||||
enabled?: boolean;
|
||||
order?: number;
|
||||
models?: Array<{ id: string; label: string }>;
|
||||
additionalModels?: Array<{ id: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface CoderProvidersFile {
|
||||
providers: Record<string, ProviderOverride>;
|
||||
}
|
||||
|
||||
// PATCH body: a partial providers map. A `null` value deletes that id's
|
||||
// override (revert to built-in default); an object replaces it wholesale.
|
||||
export interface ProviderConfigPatch {
|
||||
providers: Record<string, ProviderOverride | null>;
|
||||
}
|
||||
|
||||
export interface AgentSessionConfig {
|
||||
provider: string;
|
||||
model: string;
|
||||
modeId: string | null;
|
||||
thinkingOptionId: string | null;
|
||||
}
|
||||
// AgentSessionConfig single-sourced in @boocode/contracts — edit the package, not here.
|
||||
export type { AgentSessionConfig } from '@boocode/contracts/message-metadata';
|
||||
|
||||
export type PermissionKind = 'tool' | 'question' | 'plan' | 'elicitation';
|
||||
|
||||
@@ -324,14 +218,6 @@ export interface PermissionPrompt {
|
||||
options: Array<{ optionId: string; label: string }>;
|
||||
}
|
||||
|
||||
export interface AgentCommand {
|
||||
name: string;
|
||||
description?: string;
|
||||
// v2.5.11: 'skill' (plugin skill) vs 'command' (native/CLI slash command).
|
||||
// Drives the icon split in the coder slash menu. Undefined → command.
|
||||
kind?: 'command' | 'skill';
|
||||
}
|
||||
|
||||
export interface CoderSendMessageBody {
|
||||
content: string;
|
||||
pane_id: string;
|
||||
@@ -426,6 +312,39 @@ export interface GitMeta {
|
||||
behind: number;
|
||||
}
|
||||
|
||||
// git-diff-panel Phase 1: shapes returned by GET /api/projects/:id/git/diff.
|
||||
export type GitDiffMode = 'uncommitted' | 'committed';
|
||||
export type GitDiffChangeType = 'added' | 'modified' | 'deleted' | 'renamed' | 'untracked';
|
||||
|
||||
export interface GitDiffFile {
|
||||
path: string;
|
||||
old_path: string | null;
|
||||
change_type: GitDiffChangeType;
|
||||
added_lines: number;
|
||||
removed_lines: number;
|
||||
staged: boolean;
|
||||
diff_body: string | null;
|
||||
is_binary: boolean;
|
||||
is_too_large: boolean;
|
||||
}
|
||||
|
||||
export interface GitDiffResult {
|
||||
git_repo: boolean;
|
||||
mode: GitDiffMode;
|
||||
/** Server-computed mode based on dirty state — used for auto-select (FIX 1) and mode suggestion (FIX 4). */
|
||||
auto_mode?: GitDiffMode;
|
||||
base_label: string | null;
|
||||
in_progress_op: string | null;
|
||||
files: GitDiffFile[];
|
||||
}
|
||||
|
||||
// git-diff-panel Phase 2: per-file info for the discard endpoint.
|
||||
export interface GitDiscardFileInfo {
|
||||
path: string;
|
||||
change_type: GitDiffChangeType;
|
||||
staged: boolean;
|
||||
}
|
||||
|
||||
// Batch 9.6: skill catalog row. Returned by GET /api/skills and consumed by
|
||||
// the slash-command dropdown. `path` and `mtime` are exposed for debug surface
|
||||
// (/api/skills) but the dropdown only renders name + description.
|
||||
@@ -556,6 +475,11 @@ export type WsFrame =
|
||||
// cap-hit sentinels (and any future stamped-on-complete metadata) flow
|
||||
// to the client without a refetch.
|
||||
metadata?: MessageMetadata | null;
|
||||
// F1 (D-8): terminal status of the assistant message. Absent on the normal
|
||||
// path (reducer defaults to 'complete'); the BooCoder dispatcher stamps it
|
||||
// 'cancelled' on a user Stop / stall and 'failed' on a thrown error so the
|
||||
// reducer renders a muted "Stopped" / failed state — no new frame type.
|
||||
status?: 'complete' | 'cancelled' | 'failed';
|
||||
}
|
||||
// v1.12.2: live throughput frame, published mid-stream every ~500ms with
|
||||
// the latest token + ctx counts so ChatThroughput can render tok/s and
|
||||
|
||||
@@ -49,6 +49,9 @@ interface Props {
|
||||
// queues while busy). Omitting onStop falls back to a (disabled) Send button.
|
||||
generating?: boolean;
|
||||
onStop?: () => void | Promise<void>;
|
||||
// F1: disable the Stop button while a cancel request is already in flight, so a
|
||||
// rapid second click can't fire a duplicate Stop. Optional — BooChat omits it.
|
||||
stopDisabled?: boolean;
|
||||
// Batch 9.6: slash-command dispatch. When the input parses to a known skill,
|
||||
// ChatInput calls this with the skill name + the post-name args (possibly
|
||||
// empty). Callers wire this to api.chats.skillInvoke. Omitting the prop
|
||||
@@ -76,7 +79,7 @@ interface Props {
|
||||
modelContextLimit?: number | null;
|
||||
}
|
||||
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
export function ChatInput({ disabled, projectId, agentId, onAgentChange, sessionId, webSearchEnabled, onSend, onForceSend, generating, onStop, stopDisabled, onSlashCommand, slashGroups, chatId, chatLabel, messages, modelContextLimit }: Props) {
|
||||
const { isMobile } = useViewport();
|
||||
const [value, setValue] = useState('');
|
||||
const [busy, setBusy] = useState(false);
|
||||
@@ -701,10 +704,11 @@ export function ChatInput({ disabled, projectId, agentId, onAgentChange, session
|
||||
return (
|
||||
<Button
|
||||
onClick={() => void onStop()}
|
||||
disabled={stopDisabled}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
aria-label="Stop generating"
|
||||
title="Stop generating"
|
||||
title={stopDisabled ? 'Stopping…' : 'Stop generating'}
|
||||
>
|
||||
<Square className="fill-current size-3.5" />
|
||||
</Button>
|
||||
|
||||
493
apps/web/src/components/GitDiffView.tsx
Normal file
493
apps/web/src/components/GitDiffView.tsx
Normal file
@@ -0,0 +1,493 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, GitBranch, RefreshCw, Trash2 } from 'lucide-react';
|
||||
import { codeToHtml } from 'shiki';
|
||||
import type { GitDiffFile, GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface WriteProps {
|
||||
mutating: boolean;
|
||||
mutateError: string | null;
|
||||
onStage: (files: string[]) => Promise<boolean>;
|
||||
onUnstage: (files: string[]) => Promise<boolean>;
|
||||
onCommit: (message: string, files?: string[]) => Promise<boolean>;
|
||||
onDiscard: (files: GitDiscardFileInfo[]) => Promise<boolean>;
|
||||
}
|
||||
|
||||
interface Props extends WriteProps {
|
||||
result: GitDiffResult | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
mode: GitDiffMode;
|
||||
onSelectMode: (m: GitDiffMode) => void;
|
||||
onRefresh: () => void;
|
||||
/** FIX 4: non-null when the repo's dirty state suggests a different mode than the pinned one. */
|
||||
modeSuggestion?: GitDiffMode | null;
|
||||
/** FIX 5: pending-changes count from the Coder pane — shown in empty state as a hint. */
|
||||
pendingCount?: number;
|
||||
}
|
||||
|
||||
const CHANGE_TYPE_LABELS: Record<string, string> = {
|
||||
added: 'A',
|
||||
modified: 'M',
|
||||
deleted: 'D',
|
||||
renamed: 'R',
|
||||
untracked: '?',
|
||||
};
|
||||
|
||||
const CHANGE_TYPE_COLORS: Record<string, string> = {
|
||||
added: 'text-green-500',
|
||||
modified: 'text-yellow-500',
|
||||
deleted: 'text-red-500',
|
||||
renamed: 'text-blue-500',
|
||||
untracked: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
interface DiscardConfirmState {
|
||||
file: GitDiffFile;
|
||||
}
|
||||
|
||||
function DiscardConfirmDialog({
|
||||
state,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
}: {
|
||||
state: DiscardConfirmState;
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const isUntracked = state.file.change_type === 'untracked';
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
|
||||
>
|
||||
<div className="bg-popover border rounded-lg shadow-lg max-w-sm w-full p-4 flex flex-col gap-3">
|
||||
<p className="text-sm font-medium">
|
||||
{isUntracked ? 'Permanently delete file?' : 'Discard changes?'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isUntracked
|
||||
? `${state.file.path} will be permanently deleted. This cannot be undone.`
|
||||
: `Changes to ${state.file.path} will be reverted to the last commit. This cannot be undone.`}
|
||||
</p>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="text-xs px-3 py-1.5 rounded border hover:bg-muted max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onConfirm}
|
||||
className="text-xs px-3 py-1.5 rounded bg-destructive text-destructive-foreground hover:bg-destructive/90 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
>
|
||||
{isUntracked ? 'Delete' : 'Discard'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FileDiffRow({
|
||||
file,
|
||||
uncommitted,
|
||||
disabled,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscardRequest,
|
||||
}: {
|
||||
file: GitDiffFile;
|
||||
uncommitted: boolean;
|
||||
disabled: boolean;
|
||||
onStage: (path: string) => void;
|
||||
onUnstage: (path: string) => void;
|
||||
onDiscardRequest: (file: GitDiffFile) => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [html, setHtml] = useState<string | null>(null);
|
||||
const [highlighting, setHighlighting] = useState(false);
|
||||
const highlightRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!expanded || !file.diff_body) return;
|
||||
if (html !== null) return;
|
||||
let cancelled = false;
|
||||
setHighlighting(true);
|
||||
void codeToHtml(file.diff_body, { lang: 'diff', theme: 'github-dark' })
|
||||
.then((result) => { if (!cancelled) setHtml(result); })
|
||||
.catch(() => { if (!cancelled) setHtml(null); })
|
||||
.finally(() => { if (!cancelled) setHighlighting(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [expanded, file.diff_body, html]);
|
||||
|
||||
useEffect(() => {
|
||||
if (highlightRef.current && html !== null) {
|
||||
// Shiki generates sanitized HTML — not user-supplied content.
|
||||
// eslint-disable-next-line no-unsanitized/property
|
||||
highlightRef.current.innerHTML = html;
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
const typeLabel = CHANGE_TYPE_LABELS[file.change_type] ?? '?';
|
||||
const typeColor = CHANGE_TYPE_COLORS[file.change_type] ?? 'text-muted-foreground';
|
||||
const displayPath = file.old_path ? `${file.old_path} → ${file.path}` : file.path;
|
||||
|
||||
return (
|
||||
<li className="border-b border-border/30 last:border-0">
|
||||
<div className="flex items-center group">
|
||||
<button
|
||||
type="button"
|
||||
className="flex-1 flex items-center gap-1.5 px-2 py-1.5 text-xs hover:bg-muted/40 text-left max-md:min-h-[44px] min-w-0"
|
||||
onClick={() => setExpanded((p) => !p)}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={10} className="shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight size={10} className="shrink-0 text-muted-foreground" />}
|
||||
<span className={cn('font-mono font-bold w-3 shrink-0', typeColor)}>{typeLabel}</span>
|
||||
<span className="truncate flex-1">{displayPath}</span>
|
||||
{(file.added_lines > 0 || file.removed_lines > 0) && (
|
||||
<span className="shrink-0 text-muted-foreground/70 font-mono text-[10px]">
|
||||
{file.added_lines > 0 && <span className="text-green-500">+{file.added_lines}</span>}
|
||||
{file.added_lines > 0 && file.removed_lines > 0 && <span className="mx-0.5">/</span>}
|
||||
{file.removed_lines > 0 && <span className="text-red-500">-{file.removed_lines}</span>}
|
||||
</span>
|
||||
)}
|
||||
{file.staged && (
|
||||
<span className="shrink-0 text-[10px] bg-blue-500/15 text-blue-400 px-1 rounded">staged</span>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Write affordances — Uncommitted mode only */}
|
||||
{uncommitted && (
|
||||
<div className="flex items-center gap-0.5 px-1 shrink-0">
|
||||
{/* Stage / Unstage toggle */}
|
||||
{file.change_type !== 'deleted' && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => file.staged ? onUnstage(file.path) : onStage(file.path)}
|
||||
className="text-[10px] px-1.5 py-0.5 rounded border border-border/50 hover:bg-muted disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
title={file.staged ? 'Unstage' : 'Stage'}
|
||||
>
|
||||
{file.staged ? '−' : '+'}
|
||||
</button>
|
||||
)}
|
||||
{/* Discard — separated secondary affordance */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => onDiscardRequest(file)}
|
||||
className="p-1 rounded hover:bg-destructive/15 hover:text-destructive text-muted-foreground/50 disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
title={file.change_type === 'untracked' ? 'Delete file' : 'Discard changes'}
|
||||
>
|
||||
<Trash2 size={10} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="px-2 pb-2">
|
||||
{file.is_binary && (
|
||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Binary file</p>
|
||||
)}
|
||||
{file.is_too_large && (
|
||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Diff too large to display</p>
|
||||
)}
|
||||
{file.change_type === 'untracked' && (
|
||||
<p className="text-xs text-muted-foreground italic px-2 py-1">Untracked — not yet staged</p>
|
||||
)}
|
||||
{!file.is_binary && !file.is_too_large && file.diff_body && (
|
||||
<>
|
||||
{highlighting && (
|
||||
<p className="text-xs text-muted-foreground px-2 py-1">Highlighting…</p>
|
||||
)}
|
||||
{!highlighting && html !== null ? (
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="text-[11px] overflow-x-auto rounded bg-[#0d1117] [&_pre]:!p-2 [&_pre]:!m-0 [&_pre]:overflow-x-auto"
|
||||
/>
|
||||
) : (
|
||||
!highlighting && (
|
||||
<pre className="text-[11px] overflow-x-auto rounded bg-muted/30 p-2 whitespace-pre">
|
||||
{file.diff_body}
|
||||
</pre>
|
||||
)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitDiffView({
|
||||
result,
|
||||
loading,
|
||||
error,
|
||||
mode,
|
||||
onSelectMode,
|
||||
onRefresh,
|
||||
mutating,
|
||||
mutateError,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onCommit,
|
||||
onDiscard,
|
||||
modeSuggestion,
|
||||
pendingCount,
|
||||
}: Props) {
|
||||
const [commitMessage, setCommitMessage] = useState('');
|
||||
const [discardTarget, setDiscardTarget] = useState<DiscardConfirmState | null>(null);
|
||||
const [lastAction, setLastAction] = useState<string | null>(null);
|
||||
const lastActionTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
function flashAction(msg: string) {
|
||||
setLastAction(msg);
|
||||
if (lastActionTimer.current) clearTimeout(lastActionTimer.current);
|
||||
lastActionTimer.current = setTimeout(() => setLastAction(null), 2000);
|
||||
}
|
||||
|
||||
const uncommitted = mode === 'uncommitted';
|
||||
const inProgress = result?.in_progress_op ?? null;
|
||||
const writeDisabled = mutating || !!inProgress;
|
||||
const stagedFiles = result?.files.filter((f) => f.staged) ?? [];
|
||||
const canDoCommit = uncommitted && stagedFiles.length > 0 && commitMessage.trim().length > 0 && !writeDisabled;
|
||||
|
||||
async function handleStage(path: string) {
|
||||
const ok = await onStage([path]);
|
||||
if (ok) flashAction('Staged');
|
||||
}
|
||||
|
||||
async function handleUnstage(path: string) {
|
||||
const ok = await onUnstage([path]);
|
||||
if (ok) flashAction('Unstaged');
|
||||
}
|
||||
|
||||
function handleDiscardRequest(file: GitDiffFile) {
|
||||
setDiscardTarget({ file });
|
||||
}
|
||||
|
||||
async function handleDiscardConfirm() {
|
||||
if (!discardTarget) return;
|
||||
const { file } = discardTarget;
|
||||
setDiscardTarget(null);
|
||||
const info: GitDiscardFileInfo = {
|
||||
path: file.path,
|
||||
change_type: file.change_type,
|
||||
staged: file.staged,
|
||||
};
|
||||
const ok = await onDiscard([info]);
|
||||
if (ok) flashAction(file.change_type === 'untracked' ? 'Deleted' : 'Discarded');
|
||||
}
|
||||
|
||||
async function handleCommit() {
|
||||
const msg = commitMessage.trim();
|
||||
if (!msg) return;
|
||||
const ok = await onCommit(msg);
|
||||
if (ok) {
|
||||
setCommitMessage('');
|
||||
flashAction('Committed');
|
||||
}
|
||||
}
|
||||
|
||||
if (loading && !result) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground">
|
||||
Loading diff…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2 px-4 text-center">
|
||||
<p className="text-xs text-destructive">{error}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 max-md:min-h-[44px]"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!result || !result.git_repo) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center text-xs text-muted-foreground px-4 text-center">
|
||||
Not a git repository
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { files, base_label } = result;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
{/* Mode selector */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectMode('uncommitted')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
|
||||
mode === 'uncommitted'
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Uncommitted
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectMode('committed')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
|
||||
mode === 'committed'
|
||||
? 'bg-muted text-foreground font-medium'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Committed
|
||||
</button>
|
||||
<div className="flex-1" />
|
||||
{(loading || mutating) && (
|
||||
<span className="text-[10px] text-muted-foreground">{mutating ? 'Working…' : 'Refreshing…'}</span>
|
||||
)}
|
||||
{lastAction && !mutating && (
|
||||
<span className="text-[10px] text-green-500">{lastAction}</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRefresh}
|
||||
disabled={loading || mutating}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground disabled:opacity-40 max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="Refresh diff"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw size={12} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Committed-mode base label */}
|
||||
{result.mode === 'committed' && base_label && (
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b flex items-center gap-1 shrink-0">
|
||||
<GitBranch size={10} />
|
||||
<span className="truncate">vs {base_label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FIX 2: Fallback label — committed was requested but no base branch found */}
|
||||
{result.mode === 'uncommitted' && result.base_label && (
|
||||
<div className="px-2 py-1 text-[10px] text-amber-600 dark:text-amber-400 border-b flex items-center gap-1 shrink-0">
|
||||
<GitBranch size={10} />
|
||||
<span className="truncate">{result.base_label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FIX 4: Mode suggestion — shown when pinned mode diverges from auto-selected mode */}
|
||||
{modeSuggestion && (
|
||||
<div className="px-2 py-1 text-[10px] text-muted-foreground border-b shrink-0 flex items-center gap-1">
|
||||
<span>Repo is now {modeSuggestion === 'uncommitted' ? 'dirty' : 'clean'} —</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectMode(modeSuggestion)}
|
||||
className="underline hover:text-foreground"
|
||||
>
|
||||
switch to {modeSuggestion === 'uncommitted' ? 'Uncommitted' : 'Committed'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* In-progress op banner */}
|
||||
{inProgress && (
|
||||
<div className="px-2 py-1 text-[10px] text-yellow-500 bg-yellow-500/10 border-b shrink-0">
|
||||
{inProgress} in progress — write actions disabled
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mutation error */}
|
||||
{mutateError && (
|
||||
<div className="px-2 py-1 text-[10px] text-destructive bg-destructive/10 border-b shrink-0 truncate">
|
||||
{mutateError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{files.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center px-4 py-8 text-xs text-muted-foreground text-center gap-1.5">
|
||||
<span>{mode === 'uncommitted' ? 'No uncommitted changes' : 'No changes vs. the base branch'}</span>
|
||||
{/* FIX 5: hint when pending changes exist in the Coder pane */}
|
||||
{!!pendingCount && (
|
||||
<span className="text-[10px]">
|
||||
{pendingCount} pending {pendingCount === 1 ? 'change' : 'changes'} visible in the Coder pane
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ul className="list-none">
|
||||
{files.map((file) => (
|
||||
<FileDiffRow
|
||||
key={file.path}
|
||||
file={file}
|
||||
uncommitted={uncommitted}
|
||||
disabled={writeDisabled}
|
||||
onStage={handleStage}
|
||||
onUnstage={handleUnstage}
|
||||
onDiscardRequest={handleDiscardRequest}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commit panel — Uncommitted mode only */}
|
||||
{uncommitted && (
|
||||
<div className="shrink-0 border-t px-2 py-2 flex flex-col gap-1.5">
|
||||
<textarea
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
disabled={writeDisabled}
|
||||
placeholder="Commit message…"
|
||||
rows={2}
|
||||
className="w-full text-xs rounded border bg-background px-2 py-1 resize-none focus:outline-none focus:ring-1 focus:ring-ring disabled:opacity-40 placeholder:text-muted-foreground"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[10px] text-muted-foreground flex-1">
|
||||
{stagedFiles.length > 0
|
||||
? `${stagedFiles.length} file${stagedFiles.length > 1 ? 's' : ''} staged`
|
||||
: 'No files staged'}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!canDoCommit}
|
||||
onClick={handleCommit}
|
||||
className="text-xs px-3 py-1 rounded bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 max-md:min-h-[44px]"
|
||||
>
|
||||
Commit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Discard confirmation dialog */}
|
||||
{discardTarget && (
|
||||
<DiscardConfirmDialog
|
||||
state={discardTarget}
|
||||
onConfirm={handleDiscardConfirm}
|
||||
onCancel={() => setDiscardTarget(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -780,6 +780,10 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
|
||||
const isStreaming = message.status === 'streaming';
|
||||
const failed = message.status === 'failed';
|
||||
// F1 (D-10): a user Stop finalizes the turn as 'cancelled' — surface a muted
|
||||
// "Stopped" label (not the red "message failed" — a deliberate Stop is not a
|
||||
// failure), keeping whatever streamed before the abort.
|
||||
const cancelled = message.status === 'cancelled';
|
||||
// v1.13.7: match the MessageList.flatten trim guard so a whitespace-only
|
||||
// assistant turn doesn't render an empty bubble + dangling ActionRow.
|
||||
const hasContent = message.content.trim().length > 0;
|
||||
@@ -826,6 +830,7 @@ export const MessageBubble = memo(function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{cancelled && <div className="text-xs text-muted-foreground">Stopped</div>}
|
||||
{!isStreaming && (modelLabel || null) && (
|
||||
<span
|
||||
className="inline-flex w-fit items-center rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-[10px] font-mono text-primary/90"
|
||||
|
||||
@@ -6,7 +6,10 @@ import { inferLanguage } from '@/lib/attachments';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
import { useRightRailDrawer } from '@/hooks/useRightRailDrawer';
|
||||
import { useViewport } from '@/hooks/useViewport';
|
||||
import { useProjectGit } from '@/hooks/useProjectGit';
|
||||
import { useGitDiff } from '@/hooks/useGitDiff';
|
||||
import { FileViewerOverlay } from '@/components/FileViewerOverlay';
|
||||
import { GitDiffView } from '@/components/GitDiffView';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -21,6 +24,8 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type RailTab = 'files' | 'git';
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
sessionId: string;
|
||||
@@ -45,12 +50,38 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
const [open, setOpen] = useState(() => {
|
||||
try { return localStorage.getItem(`${STORAGE_KEY}.open`) !== 'false'; } catch { return true; }
|
||||
});
|
||||
const [tab, setTab] = useState<RailTab>('files');
|
||||
const [filter, setFilter] = useState('');
|
||||
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set());
|
||||
const [cache, setCache] = useState<Map<string, FileEntry[]>>(new Map());
|
||||
const [fullFileList, setFullFileList] = useState<string[] | null>(null);
|
||||
const [viewerFile, setViewerFile] = useState<{ path: string; content: string } | null>(null);
|
||||
|
||||
// Git metadata: dirty dot on the Git tab (no new fetch — reuses the 30s poll).
|
||||
const git = useProjectGit(projectId);
|
||||
const isDirty = git?.is_dirty ?? false;
|
||||
|
||||
// Git diff view state (Phase 2: includes write callbacks).
|
||||
const { result: gitDiff, loading: gitLoading, error: gitError, mode: gitMode, selectMode, refresh: refreshDiff, mutating: gitMutating, mutateError: gitMutateError, stage: gitStage, unstage: gitUnstage, commit: gitCommit, discard: gitDiscard, modeSuggestion: gitModeSuggestion } = useGitDiff(projectId);
|
||||
const showGitTab = gitDiff === null || gitDiff.git_repo;
|
||||
|
||||
// FIX 5: pending-changes count — fetched when git tab is active so the empty state
|
||||
// can hint that unapplied pending changes exist in the Coder pane.
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
useEffect(() => {
|
||||
if (tab !== 'git') return;
|
||||
const check = () => {
|
||||
fetch(`/api/coder/sessions/${sessionId}/pending`)
|
||||
.then((r) => r.ok ? r.json() as Promise<Array<{ status: string }>> : [])
|
||||
.then((data) => setPendingCount(data.filter((c) => c.status === 'pending').length))
|
||||
.catch(() => {});
|
||||
};
|
||||
check();
|
||||
return sessionEvents.subscribe((e) => {
|
||||
if (e.type === 'git_diff_refresh') check();
|
||||
});
|
||||
}, [tab, sessionId]);
|
||||
|
||||
// New-file-from-pasted-text modal. Queues a pending_changes create via
|
||||
// BooCoder; it then shows in the CoderPane DiffPanel for explicit apply.
|
||||
const [newFileOpen, setNewFileOpen] = useState(false);
|
||||
@@ -167,6 +198,11 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
return [];
|
||||
}, [filterActive, trimmed, fullFileList]);
|
||||
|
||||
// Trigger a git diff refresh whenever the Git tab becomes active.
|
||||
useEffect(() => {
|
||||
if (tab === 'git') sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}, [tab]);
|
||||
|
||||
// Listen for open_file_in_browser events
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
@@ -206,17 +242,45 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
return (
|
||||
<>
|
||||
<aside className={asideCls}>
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b shrink-0">
|
||||
<span className="text-xs font-medium flex-1">Files</span>
|
||||
{/* Header: Files / Git tab strip, FilePlus (Files only), close */}
|
||||
<div className="flex items-center gap-1 px-2 py-1.5 border-b shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewFile}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="New file from pasted text"
|
||||
title="New file"
|
||||
onClick={() => setTab('files')}
|
||||
className={cn(
|
||||
'text-xs px-2 py-0.5 rounded max-md:min-h-[44px]',
|
||||
tab === 'files' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
Files
|
||||
</button>
|
||||
{showGitTab && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTab('git')}
|
||||
className={cn(
|
||||
'relative text-xs px-2 py-0.5 rounded max-md:min-h-[44px] flex items-center gap-1',
|
||||
tab === 'git' ? 'bg-muted text-foreground font-medium' : 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
Git
|
||||
{isDirty && (
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-yellow-400 shrink-0" aria-label="dirty" />
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
{tab === 'files' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openNewFile}
|
||||
className="p-1 rounded hover:bg-muted text-muted-foreground max-md:min-h-[44px] max-md:min-w-[44px]"
|
||||
aria-label="New file from pasted text"
|
||||
title="New file"
|
||||
>
|
||||
<FilePlus size={14} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeRail}
|
||||
@@ -226,47 +290,73 @@ export function RightRail({ projectId, sessionId }: Props) {
|
||||
<PanelRightClose size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-2 py-1.5 shrink-0">
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter files..."
|
||||
className="h-7 text-xs"
|
||||
|
||||
{/* Files tab content */}
|
||||
{tab === 'files' && (
|
||||
<>
|
||||
<div className="px-2 py-1.5 shrink-0">
|
||||
<Input
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
placeholder="Filter files..."
|
||||
className="h-7 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-1 py-1">
|
||||
{filterActive ? (
|
||||
filterResults.length > 0 ? (
|
||||
<ul className="list-none space-y-0.5">
|
||||
{filterResults.map((r) => (
|
||||
<li key={r.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
|
||||
onClick={() => void openFile(r.path)}
|
||||
>
|
||||
<FileText size={12} className="text-muted-foreground shrink-0" />
|
||||
<span className="font-bold truncate">{r.name}</span>
|
||||
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
|
||||
)
|
||||
) : (
|
||||
<TreeLevel
|
||||
parentPath=""
|
||||
entries={rootEntries}
|
||||
cache={cache}
|
||||
expanded={expandedDirs}
|
||||
depth={0}
|
||||
onToggleDir={toggleDir}
|
||||
onSelectFile={(path) => void openFile(path)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Git tab content */}
|
||||
{tab === 'git' && (
|
||||
<GitDiffView
|
||||
result={gitDiff}
|
||||
loading={gitLoading}
|
||||
error={gitError}
|
||||
mode={gitMode}
|
||||
onSelectMode={selectMode}
|
||||
onRefresh={refreshDiff}
|
||||
mutating={gitMutating}
|
||||
mutateError={gitMutateError}
|
||||
onStage={gitStage}
|
||||
onUnstage={gitUnstage}
|
||||
onCommit={gitCommit}
|
||||
onDiscard={gitDiscard}
|
||||
modeSuggestion={gitModeSuggestion}
|
||||
pendingCount={pendingCount}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto px-1 py-1">
|
||||
{filterActive ? (
|
||||
filterResults.length > 0 ? (
|
||||
<ul className="list-none space-y-0.5">
|
||||
{filterResults.map((r) => (
|
||||
<li key={r.path}>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-1 px-2 py-1 text-xs rounded hover:bg-muted/60 text-left"
|
||||
onClick={() => void openFile(r.path)}
|
||||
>
|
||||
<FileText size={12} className="text-muted-foreground shrink-0" />
|
||||
<span className="font-bold truncate">{r.name}</span>
|
||||
<span className="text-muted-foreground ml-1 truncate">{r.path}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="text-xs text-muted-foreground px-2 py-4 text-center">No matches</div>
|
||||
)
|
||||
) : (
|
||||
<TreeLevel
|
||||
parentPath=""
|
||||
entries={rootEntries}
|
||||
cache={cache}
|
||||
expanded={expandedDirs}
|
||||
depth={0}
|
||||
onToggleDir={toggleDir}
|
||||
onSelectFile={(path) => void openFile(path)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
{viewerFile && (
|
||||
|
||||
@@ -10,7 +10,8 @@ export interface CoderMessageWire {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
// F1: 'cancelled' — a user Stop / stall finalized the turn (renders "Stopped").
|
||||
status?: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||
model?: string | null;
|
||||
reasoning_text?: string;
|
||||
tool_calls?: CoderToolCallWire[];
|
||||
|
||||
@@ -20,6 +20,7 @@ import { providerIcon, providerLabel } from '@/components/coder/providerIcons';
|
||||
import { refreshAgentSessions } from '@/hooks/useAgentSessions';
|
||||
import { useAgentStatus, type AgentStatus, type AgentStatusEntry } from '@/hooks/useAgentStatus';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { sessionEvents } from '@/hooks/sessionEvents';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -29,7 +30,8 @@ interface CoderMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
status?: 'streaming' | 'complete' | 'failed';
|
||||
// F1: 'cancelled' — a user Stop / stall finalized the turn (renders "Stopped").
|
||||
status?: 'streaming' | 'complete' | 'failed' | 'cancelled';
|
||||
// model-attribution: which model produced this assistant message (chip).
|
||||
model?: string | null;
|
||||
reasoning_text?: string;
|
||||
@@ -296,7 +298,10 @@ function useCoderMessages(sessionId: string, chatId: string | undefined, handler
|
||||
m.id === frame.message_id && m.role !== 'tool'
|
||||
? {
|
||||
...m,
|
||||
status: 'complete' as const,
|
||||
// F1 (D-8): the terminal frame carries an optional status —
|
||||
// 'cancelled' on a Stop/stall, 'failed' on error. Absent on the
|
||||
// normal path → defaults to 'complete'.
|
||||
status: ((frame as any).status ?? 'complete') as CoderMessage['status'],
|
||||
model: (frame as any).model ?? (m as any).model ?? null,
|
||||
ctx_used: (frame as any).ctx_used ?? (m as any).ctx_used ?? null,
|
||||
ctx_max: (frame as any).ctx_max ?? (m as any).ctx_max ?? null,
|
||||
@@ -433,6 +438,7 @@ function usePendingChanges(sessionId: string) {
|
||||
});
|
||||
if (res.ok) {
|
||||
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'approved' } : c));
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -442,6 +448,7 @@ function usePendingChanges(sessionId: string) {
|
||||
});
|
||||
if (res.ok) {
|
||||
setChanges((prev) => prev.map((c) => c.id === changeId ? { ...c, status: 'rejected' } : c));
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
}, [sessionId]);
|
||||
|
||||
@@ -669,6 +676,9 @@ export function CoderPane({
|
||||
onAgentLabelChange?.(parts.join(' · '));
|
||||
}, [agentConfig.provider, agentConfig.model, onAgentLabelChange]);
|
||||
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
|
||||
// F1: true while the Stop POST is in flight — disables the Stop button and makes
|
||||
// a rapid double-click a no-op (the abort is idempotent server-side regardless).
|
||||
const [stopping, setStopping] = useState(false);
|
||||
const [permissionPrompt, setPermissionPrompt] = useState<PermissionPrompt | null>(null);
|
||||
const [permissionBusy, setPermissionBusy] = useState(false);
|
||||
const [providerCommands, setProviderCommands] = useState<AgentCommand[]>([]);
|
||||
@@ -986,14 +996,17 @@ export function CoderPane({
|
||||
|
||||
const handleStop = useCallback(async () => {
|
||||
const taskId = activeTaskId;
|
||||
if (!taskId) return;
|
||||
if (!taskId || stopping) return; // ignore a second Stop while the POST is in flight
|
||||
setStopping(true);
|
||||
try {
|
||||
await api.coder.cancelTask(taskId);
|
||||
setActiveTaskId(null); // optimistic; WS/poll terminal-state also clears it
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'stop failed');
|
||||
} finally {
|
||||
setStopping(false);
|
||||
}
|
||||
}, [activeTaskId]);
|
||||
}, [activeTaskId, stopping]);
|
||||
|
||||
// write-edit-robustness #4: reset the worktree to a message's checkpoint and
|
||||
// trim the transcript past it. The confirm lives in MessageBubble's ActionRow
|
||||
@@ -1125,6 +1138,7 @@ export function CoderPane({
|
||||
onSend={handleChatInputSend}
|
||||
generating={generating}
|
||||
onStop={handleStop}
|
||||
stopDisabled={stopping}
|
||||
onSlashCommand={handleChatInputSlash}
|
||||
slashGroups={slashGroups}
|
||||
chatId={chatId ?? undefined}
|
||||
|
||||
@@ -178,6 +178,12 @@ export interface RefetchMessagesEvent {
|
||||
type: 'refetch_messages';
|
||||
}
|
||||
|
||||
// git-diff-panel Phase 1: emitted client-side to trigger a panel refresh.
|
||||
// Not a WS frame — no @boocode/contracts change required.
|
||||
export interface GitDiffRefreshEvent {
|
||||
type: 'git_diff_refresh';
|
||||
}
|
||||
|
||||
export type SessionEvent =
|
||||
| SessionRenamedEvent
|
||||
| ProjectCreatedEvent
|
||||
@@ -204,7 +210,8 @@ export type SessionEvent =
|
||||
| ProjectUnarchivedEvent
|
||||
| ProjectUpdatedEvent
|
||||
| ChatStatusEvent
|
||||
| RefetchMessagesEvent;
|
||||
| RefetchMessagesEvent
|
||||
| GitDiffRefreshEvent;
|
||||
type Listener = (event: SessionEvent) => void;
|
||||
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
114
apps/web/src/hooks/useGitDiff.ts
Normal file
114
apps/web/src/hooks/useGitDiff.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { api } from '@/api/client';
|
||||
import type { GitDiffMode, GitDiffResult, GitDiscardFileInfo } from '@/api/types';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
|
||||
export function useGitDiff(projectId: string | null | undefined) {
|
||||
const [mode, setMode] = useState<GitDiffMode>('uncommitted');
|
||||
const [pinned, setPinned] = useState(false);
|
||||
const [result, setResult] = useState<GitDiffResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// FIX 4: non-null when user has pinned a mode that differs from the server's auto-selected mode.
|
||||
const [modeSuggestion, setModeSuggestion] = useState<GitDiffMode | null>(null);
|
||||
|
||||
// Coalescence guard: absorb concurrent refresh triggers into the running request.
|
||||
const inFlightRef = useRef(false);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
if (!projectId || inFlightRef.current) return;
|
||||
inFlightRef.current = true;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
// FIX 1: when not pinned, omit mode param so the server auto-selects based on
|
||||
// dirty state (dirty → uncommitted, clean → committed).
|
||||
api.projects
|
||||
.gitDiff(projectId, pinned ? mode : null)
|
||||
.then((r) => {
|
||||
if (!pinned) {
|
||||
setMode(r.mode);
|
||||
}
|
||||
// FIX 4: if pinned and the server's auto-selected mode differs, surface a suggestion.
|
||||
if (pinned && r.auto_mode && r.auto_mode !== mode) {
|
||||
setModeSuggestion(r.auto_mode);
|
||||
} else {
|
||||
setModeSuggestion(null);
|
||||
}
|
||||
setResult(r);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load diff');
|
||||
})
|
||||
.finally(() => {
|
||||
inFlightRef.current = false;
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId, mode, pinned]);
|
||||
|
||||
// Re-run refresh when mode changes (user pinned a new mode).
|
||||
useEffect(() => {
|
||||
if (!projectId) {
|
||||
setResult(null);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
}, [projectId, mode]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Subscribe to git_diff_refresh events (tab open, message_complete, manual).
|
||||
useEffect(() => {
|
||||
return sessionEvents.subscribe((event) => {
|
||||
if (event.type === 'git_diff_refresh') refresh();
|
||||
});
|
||||
}, [refresh]);
|
||||
|
||||
const selectMode = useCallback((m: GitDiffMode) => {
|
||||
setPinned(true);
|
||||
setMode(m);
|
||||
setModeSuggestion(null); // FIX 4: clear suggestion on explicit mode pick
|
||||
}, []);
|
||||
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [mutateError, setMutateError] = useState<string | null>(null);
|
||||
|
||||
const runMutation = useCallback(
|
||||
async (fn: () => Promise<unknown>): Promise<boolean> => {
|
||||
if (!projectId) return false;
|
||||
setMutating(true);
|
||||
setMutateError(null);
|
||||
try {
|
||||
await fn();
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
return true;
|
||||
} catch (err) {
|
||||
setMutateError(err instanceof Error ? err.message : 'Operation failed');
|
||||
return false;
|
||||
} finally {
|
||||
setMutating(false);
|
||||
}
|
||||
},
|
||||
[projectId],
|
||||
);
|
||||
|
||||
const stage = useCallback(
|
||||
(files: string[]) => runMutation(() => api.projects.gitStage(projectId!, files)),
|
||||
[projectId, runMutation],
|
||||
);
|
||||
|
||||
const unstage = useCallback(
|
||||
(files: string[]) => runMutation(() => api.projects.gitUnstage(projectId!, files)),
|
||||
[projectId, runMutation],
|
||||
);
|
||||
|
||||
const commit = useCallback(
|
||||
(message: string, files?: string[]) =>
|
||||
runMutation(() => api.projects.gitCommit(projectId!, { message, files })),
|
||||
[projectId, runMutation],
|
||||
);
|
||||
|
||||
const discard = useCallback(
|
||||
(files: GitDiscardFileInfo[]) => runMutation(() => api.projects.gitDiscard(projectId!, files)),
|
||||
[projectId, runMutation],
|
||||
);
|
||||
|
||||
return { result, loading, error, mode, selectMode, refresh, mutating, mutateError, stage, unstage, commit, discard, modeSuggestion };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import type { Message, WsFrame } from '@/api/types';
|
||||
import { WsFrameSchema } from '@/api/ws-frames';
|
||||
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
||||
import { api } from '@/api/client';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { recordUsage } from './useChatThroughput';
|
||||
@@ -273,6 +273,10 @@ export function useSessionStream(sessionId: string | undefined) {
|
||||
return;
|
||||
}
|
||||
setState((s) => applyFrame(s, frame));
|
||||
// Trigger git diff refresh after each completed assistant turn.
|
||||
if (frame.type === 'message_complete') {
|
||||
sessionEvents.emit({ type: 'git_diff_refresh' });
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('bad ws frame', err);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,8 @@ function applyEvent(prev: SidebarResponse, event: import('./sessionEvents').Sess
|
||||
case 'chat_deleted':
|
||||
case 'chat_status':
|
||||
case 'refetch_messages':
|
||||
case 'git_diff_refresh':
|
||||
// Consumed by useGitDiff; no sidebar state change needed.
|
||||
return prev;
|
||||
case 'project_archived': {
|
||||
const next = prev.projects.filter((p) => p.id !== event.project_id);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect } from 'react';
|
||||
import { WsFrameSchema } from '@/api/ws-frames';
|
||||
import { WsFrameSchema } from '@boocode/contracts/ws-frames';
|
||||
import { sessionEvents } from './sessionEvents';
|
||||
import { createWsReconnectToast } from './wsReconnectToast';
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles/globals.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
|
||||
@@ -396,10 +396,13 @@ function SessionInner({ sessionId }: { sessionId: string }) {
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggleRightRail}
|
||||
className="inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
className="relative inline-flex items-center justify-center -mr-1 min-w-[44px] min-h-[44px] rounded text-muted-foreground hover:bg-muted hover:text-foreground shrink-0"
|
||||
aria-label="Toggle file browser"
|
||||
>
|
||||
<FolderTree className="size-5" />
|
||||
{git?.is_dirty && (
|
||||
<span className="absolute top-2 right-2 w-1.5 h-1.5 rounded-full bg-yellow-400" aria-hidden="true" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -399,25 +399,11 @@ All tags `vMAJOR.MINOR.PATCH-slug`, monotonic per minor, assigned at ship time (
|
||||
|
||||
-----
|
||||
|
||||
## v2.4 — BooCoder as ACP agent (driveable from external editors)
|
||||
## v2.4 — BooCoder as ACP agent (driveable from external editors) — DROPPED
|
||||
|
||||
**Status: not shipped.** This is a conceptual milestone, not yet built. The `v2.4.0`/`v2.4.1` *patch tags* shipped unrelated content (Unsloth Studio parser/HTML-to-md lift, llama-sidecar routing) — patch numbers are assigned at ship time and have outrun the milestone plan. The outbound ACP-agent surface below is still future work.
|
||||
**Status: DROPPED (Sam, 2026-06-02).** Will not be built. Sam only ever drives BooCoder through BooCoder's own surface (CoderPane) — never from an external editor (Zed/JetBrains/Avante/CodeCompanion). The outbound `boocoder acp` exposure solves a problem he doesn't have, so the milestone is closed as won't-do rather than deferred. (The `v2.4.0`/`v2.4.1` *patch tags* shipped unrelated content — Unsloth Studio parser/HTML-to-md lift, llama-sidecar routing — and never had anything to do with this milestone; patch numbers are assigned at ship time.)
|
||||
|
||||
**Goal:** expose `boocoder acp` so Zed, JetBrains, Avante.nvim, CodeCompanion.nvim can drive BooCoder as their agent. Outbound exposure of the BooCoder write-tool surface to ACP-compatible editors.
|
||||
|
||||
**Scope:**
|
||||
|
||||
1. New ACP server entry point: `boocoder acp` reads JSON-RPC over stdio, exposes BooCoder's task primitives as ACP sessions.
|
||||
1. BooCoder UI features remain optional: editor drives session via ACP; pending-changes queue still gates writes; user can approve/reject from either BooCoder's web UI or the editor's permission dialog (whichever responds first).
|
||||
1. Same auth model as the rest of BooCoder — editor must be reachable on the Tailscale mesh, or BooCoder is invoked with a short-lived token.
|
||||
|
||||
**Why v2.4, not v2.0:** outbound ACP-agent role is cheap once the inbound ACP-client side is implemented (same protocol library, server side), but it's a *different product surface* — driving BooCoder from external editors. Ship it after BooCoder's own surface stabilizes. (The v2.2 version number was used for the Paseo provider/dispatch batch shipped 2026-05-26.)
|
||||
|
||||
**Lift source:** `zed-industries/codex-acp` (Apache-2.0) as a server-side ACP reference implementation.
|
||||
|
||||
**Dependencies:** v2.0 + v2.2 (recommended; v2.1 runtime isolation optional).
|
||||
|
||||
**Estimated:** ~400 LoC.
|
||||
The original plan (kept for record): expose `boocoder acp` (JSON-RPC over stdio) so ACP-compatible editors could drive BooCoder's write-tool surface, pending-changes queue still gating writes; lift source `zed-industries/codex-acp` (Apache-2.0), ~400 LoC. If an external-editor use case ever appears, re-open from here.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -120,5 +120,5 @@ See [coder-backends.md](./coder-backends.md) for the full dispatch-backend refer
|
||||
## Deploy topology
|
||||
|
||||
- **BooChat + BooTerm + Postgres + codecontext:** `docker compose up --build -d` from `/opt/boocode`
|
||||
- **BooCoder:** `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
- **BooCoder:** `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`
|
||||
- **Ports bind to Tailscale IP** `100.114.205.53`, not `0.0.0.0` — use that IP for host smoke curls
|
||||
|
||||
@@ -152,16 +152,18 @@ These were explicitly scoped out of v2.3 (see `design.md` §11) and remain open:
|
||||
|
||||
- **`provider_snapshot_updated` WS frame** — the loading state uses a capped client poll / one-shot refetch instead of a server-pushed frame (design §4.4, §11; tasks O.1).
|
||||
- **`available_agents.enabled` DB column** — `enabled` is read from the in-memory resolved registry only; no DB mirror, so settings state after a coder restart re-derives from the JSON config rather than the DB (design §3.3; tasks O.2).
|
||||
- **Single-source-of-truth shared types package** — the provider snapshot types are duplicated across `apps/coder/.../provider-types.ts` and `apps/web/src/api/types.ts`, guarded by the text-identity `provider-types-parity.test.ts` rather than a shared package (see §3 below).
|
||||
- ~~**Single-source-of-truth shared types package**~~ — **shipped as `@boocode/contracts`** (branch `contracts-ssot-pkg`): all duplicated cross-app contracts (ws-frames schema, provider-snapshot types, provider-config schemas, `MessageMetadata`, `WorktreeRiskReport`) are now single-sourced in `packages/contracts/`; `provider-types-parity.test.ts` and the byte-parity test were deleted. See §3 below (now historical).
|
||||
- **MCP `list_providers` / `inspect_provider` tools** — provider introspection over MCP is not wired (design §11).
|
||||
|
||||
---
|
||||
|
||||
## 3. Unified `packages/types` for provider snapshot JSON
|
||||
## 3. ~~Unified `packages/types` for provider snapshot JSON~~ (resolved — shipped as `@boocode/contracts`)
|
||||
|
||||
### Current behavior
|
||||
> **Status: resolved.** All contracts described below are now single-sourced in `packages/contracts/` (`@boocode/contracts`), shipped on branch `contracts-ssot-pkg`. The rest of this section is historical.
|
||||
|
||||
Provider snapshot shapes are **duplicated** (not byte-identical exports):
|
||||
### Former behavior (pre-`@boocode/contracts`)
|
||||
|
||||
Provider snapshot shapes were **duplicated** (not byte-identical exports):
|
||||
|
||||
| Location | Types |
|
||||
|----------|-------|
|
||||
|
||||
@@ -74,4 +74,4 @@ _No open stale items from the 2026-05-26 review._
|
||||
|
||||
- **Task cancel → abort external ACP/PTY child** — `AbortController` in dispatcher not wired to cancel route
|
||||
- **Skip ACP cold probe when DB models fresh** — perf; changes snapshot semantics
|
||||
- **Unified `packages/types`** for provider snapshot JSON — Zod parity test may suffice
|
||||
- ~~**Unified `packages/types`** for provider snapshot JSON~~ — **shipped as `@boocode/contracts`** (`packages/contracts/`); all duplicated contracts are now single-sourced there
|
||||
|
||||
@@ -140,7 +140,7 @@ No matter which backend runs, the turn streams the same way. Each backend emits
|
||||
| `apps/coder/.env.host` | Production env (DATABASE_URL, LLAMA_SWAP_URL, CODER_PROVIDERS_PATH, CLAUDE_SDK_BACKEND, …) |
|
||||
| `data/coder-providers.json` | Live runtime provider overrides (gitignored); template is `data/coder-providers.example.json` |
|
||||
|
||||
**Build & deploy.** `apps/coder` imports the server's compiled `dist/` (`createInferenceRunner`, `createBroker`, `ALL_TOOLS`), so **`apps/server` must build first**: `pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. The server's `package.json` `exports` map needs both `types` and `default` conditions per subpath (and `declaration: true` in its tsconfig) or NodeNext can't find the `.d.ts` and tsc fails "Cannot find module" here. Agent dispatch spawns binaries **directly** — `spawn(fullBinaryPath, argsArray, { cwd })` using `install_path` — never `spawn('sh', ['-c', ...])`, which fails under systemd.
|
||||
**Build & deploy.** `apps/coder` imports from both `@boocode/contracts` and the server's compiled `dist/` (`createInferenceRunner`, `createBroker`, `ALL_TOOLS`), so **`packages/contracts` and `apps/server` must build first**: `pnpm -C packages/contracts build && pnpm -C apps/server build && pnpm -C apps/coder build && sudo systemctl restart boocoder`. The server's `package.json` `exports` map needs both `types` and `default` conditions per subpath (and `declaration: true` in its tsconfig) or NodeNext can't find the `.d.ts` and tsc fails "Cannot find module" here. Agent dispatch spawns binaries **directly** — `spawn(fullBinaryPath, argsArray, { cwd })` using `install_path` — never `spawn('sh', ['-c', ...])`, which fails under systemd.
|
||||
|
||||
## Configuration
|
||||
|
||||
@@ -267,7 +267,7 @@ interface AgentBackend {
|
||||
|
||||
`AcpToolSnapshot` (`apps/coder/src/services/acp-tool-snapshot.ts`) is the accumulating shape for a tool call — `{ toolCallId, title, kind?, status?, rawInput?, rawOutput? }` — merged incrementally and rendered via `snapshotToWireToolCall`.
|
||||
|
||||
The provider picker is driven by `ProviderSnapshotEntry` / `AgentCommand` in `apps/coder/src/services/provider-types.ts`, which must stay byte-identical to the web copy in `apps/web/src/api/types.ts` (see Testing).
|
||||
The provider picker is driven by `ProviderSnapshotEntry` / `AgentCommand` single-sourced in `@boocode/contracts` (`packages/contracts/src/provider-snapshot.ts`). `apps/coder/src/services/provider-types.ts` re-exports them; the web imports them directly. There is no hand-synced copy to keep in sync.
|
||||
|
||||
### Constants
|
||||
|
||||
@@ -332,7 +332,7 @@ The picker is built by a four-stage pipeline: `provider-config.ts` (never-throws
|
||||
- `services/backends/__tests__/turn-guard.test.ts` — abort orphan-terminal suppression
|
||||
- `services/backends/__tests__/lifecycle-decisions.test.ts` — idle/LRU/restart eviction
|
||||
- `services/__tests__/acp-event-map.test.ts` / `acp-tool-snapshot.test.ts` — ACP normalization + snapshot merge
|
||||
- `services/__tests__/provider-types-parity.test.ts` — text-identity parity between `provider-types.ts` and the web `api/types.ts` copy (compile-time cross-import is blocked by TS6307 on web's composite tsconfig)
|
||||
- `services/__tests__/provider-types-parity.test.ts` — **deleted**: provider snapshot types are now single-sourced in `@boocode/contracts/provider-snapshot`; parity is enforced by the single definition
|
||||
- `services/__tests__/write_guard.test.ts` (+ `_fuzz`) — path escape + secret-file blocking
|
||||
|
||||
### Adding a new backend
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
---
|
||||
paths:
|
||||
- "apps/server/src/types/ws-frames.ts"
|
||||
- "apps/web/src/api/ws-frames.ts"
|
||||
- "packages/contracts/src/ws-frames.ts"
|
||||
- "packages/contracts/src/provider-snapshot.ts"
|
||||
- "packages/contracts/src/message-metadata.ts"
|
||||
- "apps/server/src/types/api.ts"
|
||||
- "apps/web/src/api/types.ts"
|
||||
- "apps/coder/src/services/provider-types.ts"
|
||||
@@ -11,7 +12,7 @@ paths:
|
||||
|
||||
# Cross-App Contract Parity
|
||||
|
||||
*Reach for this when a parity test goes red (`ws-frames.test.ts`, `provider-types-parity.test.ts`), a reviewer flags a "half-synced" type, or a frame/sentinel "does nothing" at runtime — i.e. one copy of a duplicated cross-app contract drifted from the other. The fix-it path is [When to Apply](#when-to-apply) + its Verification step.*
|
||||
*Reach for this when adding a new WS frame type, a new sentinel `kind`, or touching the server's `InferenceFrame` loose union or the web's strict `WsFrame` union — contracts whose primary definition now lives in `@boocode/contracts` but that still have split secondary representations in the apps. The fix-it path is [When to Apply](#when-to-apply) + its Verification step.*
|
||||
|
||||
- **Status:** proposed
|
||||
- **Date Created:** 2026-06-02 00:00
|
||||
@@ -20,19 +21,19 @@ paths:
|
||||
- indifferentketchup (samkintop@gmail.com)
|
||||
- **Reviewers:**
|
||||
- **Applies To:**
|
||||
- Every hand-synced type/schema contract that crosses the `apps/server` ↔ `apps/web` ↔ `apps/coder` boundary in the files under `paths:`. The primary examples are the WS-frame Zod schema, the provider-snapshot types, and the sentinel `MessageMetadata` union plus its `MessageBubble` render arm — but the same rule governs the other duplicated pairs in these files (`WorktreeRiskReport`, the provider-config wire types, and the interface-typed `WsFrame` union that mirrors the Zod schema).
|
||||
- The remaining split contracts that cross app boundaries: the server's `InferenceFrame` loose publish union (`services/inference/turn.ts`), the web's strict `WsFrame` discriminated union (`apps/web/src/api/types.ts`), and the `MessageBubble` sentinel render arm (`apps/web/src/components/MessageBubble.tsx`). The primary contracts (WS-frame Zod schema, provider-snapshot types, `MessageMetadata`, `WorktreeRiskReport`, provider-config schemas) are now **single-sourced in `@boocode/contracts`** — those are governed by editing the package, not by this sync-both-copies rule.
|
||||
|
||||
## Introduction
|
||||
|
||||
Several wire contracts in BooCode exist as **two or three hand-synced copies** in different apps, because the apps have separate `tsconfig`s with no shared path alias and a composite-project restriction (TS6307) that structurally blocks importing one app's types from another. There is no shared workspace package for these types yet. This standard governs what you must do when you touch one of those copies: **change every copy in the same commit** — and, where the contract has no compile-time consumer guarantee (the sentinel render arm), the consumer too.
|
||||
The primary wire contracts (WS-frame Zod schema, provider-snapshot types, provider-config schemas, `MessageMetadata`, `WorktreeRiskReport`, `AgentSessionConfig`) are now **single-sourced in `@boocode/contracts`** — edit `packages/contracts/src/<subpath>.ts` and rebuild the package. There is no second copy to sync for those contracts.
|
||||
|
||||
The three families in [Coding Standard](#coding-standard) are the primary examples, but the rule applies to **every** hand-synced pair in the files under `paths:`, each of which carries its own in-code `edit both copies` / `Mirror of …` / `KEEP IN SYNC` marker. Beyond the three: `WorktreeRiskReport` (`apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`), the provider-config wire types (`ProviderOverride` / `CoderProvidersFile`, web mirror of the coder's Zod-inferred shapes), and — note this one — a **second** representation of the WS wire shape: the interface-typed `WsFrame` union in `apps/web/src/api/types.ts` plus the `*Frame` interfaces in `apps/server/src/types/api.ts`, which is distinct from the byte-identical Zod `ws-frames.ts` pair and is **not** covered by the byte-parity test. A WS frame's shape therefore lives in more than one place; treat all of them as one contract.
|
||||
What this standard still governs: adding a new WS frame type or a new sentinel `kind` touches contracts whose primary definition is in the package but that also have split secondary representations in the apps. The server's `InferenceFrame` loose publish union (`services/inference/turn.ts`) and the web's strict `WsFrame` discriminated union (`apps/web/src/api/types.ts`) both still exist separately from the canonical `WsFrameSchema` in the package, and must be updated together with it. The sentinel `MessageBubble` render arm (`apps/web/src/components/MessageBubble.tsx`) has no compile-time guard and still needs updating when a new `MessageMetadata` kind is added to the package.
|
||||
|
||||
### Purpose
|
||||
|
||||
- **Primary:** prevent *silent runtime* contract breakage. Nothing at compile time links the copies — each app type-checks against its own copy, so `tsc` stays green when they drift. The failure surfaces only at runtime, and silently: a WS frame whose `type` exists on one side but not the other is **dropped at JSON-parse** with no error; a sentinel `kind` added without a render arm shows nothing. Editing every copy in lockstep is the only thing that keeps the contract whole.
|
||||
- **Secondary:** two of the three contracts have runtime parity tests (`ws-frames.test.ts`, `provider-types-parity.test.ts`) that catch drift in the test run — but they are a backstop, not the mechanism, and the sentinel triple has no test at all.
|
||||
- **Side effect:** keeping the copies byte- or text-identical makes a contract change reviewable as a matched diff across files.
|
||||
- **Secondary:** the `@boocode/contracts` package's `ws-frames.test.ts` tests schema correctness (accept/reject behavior) and the `KNOWN_FRAME_TYPES` drift probe. `provider-types-parity.test.ts` was deleted when the provider-snapshot types moved to the package. The sentinel render arm still has no automated test.
|
||||
- **Side effect:** for the remaining split representations (server `InferenceFrame`, web `WsFrame`), updating them together with the package source in a single commit makes the change reviewable as a matched diff across files.
|
||||
|
||||
### Scope
|
||||
|
||||
@@ -43,33 +44,33 @@ The specific duplicated contracts listed in `paths:` above, inside the `apps/ser
|
||||
Walk this before editing a type, schema, enum, or metadata union:
|
||||
|
||||
1. **Does this shape exist as a copy in another app?** — Check: `grep -rn "<TypeOrFieldName>" apps/*/src`. If it appears under two or more of `apps/server`, `apps/web`, `apps/coder` → continue. If it lives in exactly one app → see "When NOT to Apply".
|
||||
2. **Are you changing its wire shape?** — adding, removing, renaming, or re-typing a field; adding/removing a frame `type`; adding an enum value or a sentinel `kind`. If yes → apply this standard: edit **every** copy, plus every consumer that switches on the shape, in the **same commit**. If no (a comment or formatting change that the contract's parity test normalizes away) → see "When NOT to Apply".
|
||||
2. **Are you changing its wire shape?** — adding, removing, renaming, or re-typing a field; adding/removing a frame `type`; adding an enum value or a sentinel `kind`. If yes → apply this standard: update the `@boocode/contracts` package source, rebuild, and also update every secondary app-side representation (the server `InferenceFrame` loose union, the web `WsFrame` strict union, and the `MessageBubble` render arm for sentinel kinds), in the **same commit**. If no (a comment or formatting change) → see "When NOT to Apply".
|
||||
|
||||
**Exception — the sentinel/consumer triple:** `MessageMetadata` (`apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`) has **no parity test**, and a new `kind` is inert until it gets a render branch in `apps/web/src/components/MessageBubble.tsx`. When the shape you are editing is `MessageMetadata`, "every copy" includes that render arm — there is no test to remind you.
|
||||
**Exception — the sentinel render arm:** `MessageMetadata` is single-sourced in `@boocode/contracts/message-metadata` (one definition, no second copy). However, a new `kind` is inert until it gets a render branch in `apps/web/src/components/MessageBubble.tsx`. There is no test to catch a missing render arm — when adding a `kind`, the render branch in `MessageBubble.tsx` is the one consumer you must update manually.
|
||||
|
||||
**Verification step:** run the guards that exist *now*, before you commit:
|
||||
|
||||
```bash
|
||||
# The trailing arg is a FILE-PATH substring filter for `vitest run` (not a test
|
||||
# name). A typo matches zero files and still exits 0 — a false green — so confirm
|
||||
# the run actually executed the file (look for "1 passed" on the named file).
|
||||
pnpm -C apps/server test ws-frames.test # WS-frame byte-parity + KNOWN_FRAME_TYPES drift
|
||||
pnpm -C apps/coder test provider-types-parity # provider-snapshot text-parity (incl. nested blocks)
|
||||
# Sentinel triple has no test — grep all copies for a NEW rendering kind:
|
||||
grep -rn "<new-kind>" apps/server/src/types/api.ts apps/web/src/api/types.ts apps/web/src/components/MessageBubble.tsx
|
||||
# Build the contracts package first, then run its tests.
|
||||
pnpm -C packages/contracts build
|
||||
pnpm -C packages/contracts test ws-frames.test # WS-frame schema correctness + KNOWN_FRAME_TYPES drift
|
||||
# provider-types-parity.test.ts was deleted — provider snapshot types are in @boocode/contracts
|
||||
|
||||
# Sentinel render arm has no test — grep for the new kind in the package definition and MessageBubble:
|
||||
grep -rn "<new-kind>" packages/contracts/src/message-metadata.ts apps/web/src/components/MessageBubble.tsx
|
||||
```
|
||||
|
||||
For a **rendering** sentinel kind (`cap_hit` / `doom_loop` / `mistake_recovery`) the new `kind` must appear in all three files. The non-rendering `error` arm of `MessageMetadata` lives in the two type copies only — it has no `MessageBubble` branch — so for it the grep should match the two `api.ts`/`types.ts` copies, not `MessageBubble.tsx`.
|
||||
For a **rendering** sentinel kind (`cap_hit` / `doom_loop` / `mistake_recovery`) the new `kind` must appear in `packages/contracts/src/message-metadata.ts` and have a render branch in `MessageBubble.tsx`. The non-rendering `error` arm has no `MessageBubble` branch — for it, only the package definition needs updating.
|
||||
|
||||
## When NOT to Apply
|
||||
|
||||
- **The type lives in a single app.** Internal server types, web-only view models, coder-only helpers — there is no second copy, so there is nothing to sync. Edit the one definition directly; do **not** manufacture a duplicate in another app "for symmetry." A new cross-app contract should prefer the eventual shared package or, at minimum, ship with its own parity test — not a third hand-synced copy.
|
||||
- **A comment- or whitespace-only edit to a *text-parity* file.** `provider-types-parity.test.ts` strips comments and blank lines before comparing, so a comment-only change to one provider-types copy is tolerated and you needn't chase the other. (This relief does **not** apply to `ws-frames.ts`, which is compared **byte-for-byte** — every character, including comments, must match.)
|
||||
- **The shared workspace package lands.** This standard exists *only* because the single source of truth was deferred (a Tier-2 follow-up noted in `provider-types-parity.test.ts`). Once these types move into one shared package, delete the hand-syncing rule rather than keep paying it — the SSOT supersedes this standard.
|
||||
- **A comment- or whitespace-only edit to a `@boocode/contracts` source file.** The package's `ws-frames.test.ts` tests behavior and structure, not formatting, so a comment-only change does not require secondary app-side updates.
|
||||
- **The type lives in `@boocode/contracts` and has no split secondary representations.** For the migrated contracts (WS-frame Zod schema, provider-snapshot, provider-config, `MessageMetadata`, `WorktreeRiskReport`) the package IS the single source of truth. Edit the package source; there is nothing to sync in the apps. The split representations that still require multi-file changes are the server `InferenceFrame` loose union, the web `WsFrame` strict union, and the `MessageBubble` render arm — all documented in [When to Apply](#when-to-apply).
|
||||
|
||||
## Background
|
||||
|
||||
The duplication is deliberate, not accidental. A compile-time bidirectional-assignability check was attempted first — a web-side file importing the coder's import-free `provider-types.ts` — but `apps/web/tsconfig.app.json` is a composite project and rejects out-of-include files with **TS6307**, so cross-project type import is structurally blocked. The team chose hand-synced copies guarded by runtime tests over a premature shared package. The WS-frame copies go further and are kept **byte-identical** so a single `readFileSync` equality test can guard them; the provider-snapshot copies are kept **text-identical per named type block** (comments normalized away) because they sit among unrelated types. The cost of this choice is exactly what this standard manages: a copy can drift, and because each app compiles independently, only a runtime test — or a runtime bug — reveals it.
|
||||
*The history below explains why the duplication existed, not the current state.* The primary contracts (WS-frame Zod schema, provider-snapshot types, provider-config schemas, `MessageMetadata`, `WorktreeRiskReport`) were formerly hand-synced copies guarded by runtime parity tests — because `apps/web/tsconfig.app.json` is a composite project and rejects out-of-include files with **TS6307**, blocking cross-project type import. The WS-frame copies were kept **byte-identical** so a single `readFileSync` equality test could guard them; provider-snapshot copies were kept **text-identical per named block**. These contracts have since moved to `@boocode/contracts` (the `packages/contracts` workspace package), the hand-sync discipline and the byte-parity test are retired, and drift is prevented by there being exactly one definition. The split representations that remain (server `InferenceFrame` loose union, web `WsFrame` strict union, `MessageBubble` render arm) still require lockstep edits when frame types or sentinel kinds are added — that is what this standard now governs.
|
||||
|
||||
## Coding Standard
|
||||
|
||||
@@ -77,41 +78,35 @@ The duplication is deliberate, not accidental. A compile-time bidirectional-assi
|
||||
|
||||
When you change one copy of a duplicated contract, change the others in the same commit. Each contract family has its own home files and its own (or no) guard.
|
||||
|
||||
**WS frame schema — `apps/server/src/types/ws-frames.ts` ↔ `apps/web/src/api/ws-frames.ts` (byte-identical):**
|
||||
**WS frame schema — single-sourced at `packages/contracts/src/ws-frames.ts` (imported as `@boocode/contracts/ws-frames`):**
|
||||
|
||||
```typescript
|
||||
// PRIMARY: no compile-time link exists across apps (separate tsconfigs, TS6307
|
||||
// blocks cross-import). A frame type added to one copy but not the other breaks
|
||||
// silently at runtime — the frontend drops the frame at JSON-parse. So this file
|
||||
// and apps/web/src/api/ws-frames.ts MUST stay byte-identical, in the same commit.
|
||||
//
|
||||
// IMPORTANT: This file is duplicated byte-identical at
|
||||
// apps/web/src/api/ws-frames.ts. ... If you change one, change the other.
|
||||
//
|
||||
// Adding a frame also means adding its `type` to KNOWN_FRAME_TYPES (a drift test
|
||||
// probes every entry for a discriminated branch).
|
||||
// Single source of truth: WsFrameSchema (Zod), WsFrame (z.infer), KNOWN_FRAME_TYPES.
|
||||
// No second copy to sync. When adding a frame type:
|
||||
// 1. Add it here (the canonical schema definition) and rebuild the package.
|
||||
// 2. Also add to the server's InferenceFrame loose union (services/inference/turn.ts).
|
||||
// 3. Also add to the web's strict WsFrame discriminated union (apps/web/src/api/types.ts).
|
||||
// Adding a frame also means adding its `type` to KNOWN_FRAME_TYPES — the package
|
||||
// test probes every entry for a discriminated branch.
|
||||
```
|
||||
|
||||
**Provider snapshot types — `apps/coder/src/services/provider-types.ts` ↔ `apps/web/src/api/types.ts`, text-identical per block.** By convention you author on the coder side and mirror to web (the in-code `KEEP IN SYNC` markers point that way), but the parity test is **symmetric** — it fails on drift in *either* file and names no authoritative copy, so "fix the red test" means re-sync the two, not edit one in particular:
|
||||
**Provider snapshot types — single-sourced at `packages/contracts/src/provider-snapshot.ts` (imported as `@boocode/contracts/provider-snapshot`):**
|
||||
|
||||
```typescript
|
||||
// PRIMARY: nothing links these two copies at compile time — a field added here
|
||||
// but not in apps/web/src/api/types.ts breaks silently at runtime (the web side
|
||||
// drops or mis-reads the snapshot). The in-file marker, with its test backstop:
|
||||
// KEEP IN SYNC with apps/web/src/api/types.ts ProviderSnapshotEntry — parity
|
||||
// is enforced by __tests__/provider-types-parity.test.ts (fails on field drift).
|
||||
// Applies to the nested ProviderModel / ProviderMode / ThinkingOption /
|
||||
// AgentCommand / ProviderSnapshotStatus blocks the entry references, too.
|
||||
// Single source of truth for ProviderSnapshotEntry, ProviderModel, ProviderMode,
|
||||
// ThinkingOption, AgentCommand, ProviderSnapshotStatus. No second copy to sync.
|
||||
// apps/coder/src/services/provider-types.ts re-exports from this package.
|
||||
// provider-types-parity.test.ts was deleted — drift is prevented by the single definition.
|
||||
export interface ProviderSnapshotEntry { /* ...fields... */ }
|
||||
```
|
||||
|
||||
**Sentinel metadata — `apps/server/src/types/api.ts` ↔ `apps/web/src/api/types.ts`, plus the render arm in `apps/web/src/components/MessageBubble.tsx` (no parity test):**
|
||||
**Sentinel metadata — `packages/contracts/src/message-metadata.ts` (single source) plus the render arm in `apps/web/src/components/MessageBubble.tsx` (no automated test):**
|
||||
|
||||
```typescript
|
||||
// A new *rendering* sentinel kind is a THREE-file change with NO test to catch a miss:
|
||||
// 1. apps/server/src/types/api.ts — add the arm to MessageMetadata
|
||||
// 2. apps/web/src/api/types.ts — add the identical arm
|
||||
// 3. MessageBubble.tsx — add the render branch, else it shows nothing
|
||||
// MessageMetadata is single-sourced in @boocode/contracts/message-metadata.
|
||||
// A new *rendering* sentinel kind is a TWO-step change with NO test to catch a miss:
|
||||
// 1. packages/contracts/src/message-metadata.ts — add the arm to MessageMetadata, rebuild
|
||||
// 2. MessageBubble.tsx — add the render branch, else it shows nothing
|
||||
// The real union has FOUR arms; show it whole so nobody reads two as the full set:
|
||||
export type MessageMetadata =
|
||||
| { kind: 'cap_hit'; /* used, limit, agent_name, can_continue */ }
|
||||
@@ -129,37 +124,37 @@ export type MessageMetadata =
|
||||
**What to avoid:**
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: editing one copy only.
|
||||
// Add a new frame type to apps/web/src/api/ws-frames.ts but not the server copy
|
||||
// (or vice versa): tsc stays green — they're separate projects — but the parity
|
||||
// test fails, and had it not existed, the server would publish a frame the
|
||||
// frontend silently discards at JSON-parse. A half-edited contract is invisible
|
||||
// to the type-checker; never land one.
|
||||
// ANTI-PATTERN: editing only the package schema without updating the app-side representations.
|
||||
// Add a new frame type to packages/contracts/src/ws-frames.ts but not to the
|
||||
// web's strict WsFrame union (apps/web/src/api/types.ts): tsc stays green because
|
||||
// they're separate projects, but the frontend silently discards the frame at
|
||||
// JSON-parse. A half-edited contract is invisible to the type-checker; never land one.
|
||||
```
|
||||
|
||||
**Project references:**
|
||||
- `apps/server/src/types/ws-frames.ts` — the byte-identical sync comment (top of file) and `KNOWN_FRAME_TYPES`.
|
||||
- `apps/web/src/api/ws-frames.ts` — the web copy that must match it byte-for-byte.
|
||||
- `apps/coder/src/services/provider-types.ts` — the `KEEP IN SYNC` comment above `ProviderSnapshotEntry`.
|
||||
- `apps/web/src/api/types.ts` — the provider-snapshot wire copy and the `MessageMetadata` copy.
|
||||
- `packages/contracts/src/ws-frames.ts` — `WsFrameSchema`, `WsFrame`, `KNOWN_FRAME_TYPES` (via `@boocode/contracts/ws-frames`). The two former app-level `ws-frames.ts` copies are deleted.
|
||||
- `packages/contracts/src/provider-snapshot.ts` — provider snapshot types (via `@boocode/contracts/provider-snapshot`). The former web mirror block in `apps/web/src/api/types.ts` and the former coder copy in `provider-types.ts` are deleted; `provider-types.ts` now re-exports from the package.
|
||||
- `packages/contracts/src/message-metadata.ts` — `MessageMetadata`, `ErrorReason` (via `@boocode/contracts/message-metadata`). The former copies in `apps/server/src/types/api.ts` and `apps/web/src/api/types.ts` are deleted.
|
||||
- `apps/web/src/api/types.ts` — the web-local strict `WsFrame` discriminated union (still maintained separately from the canonical schema in the package).
|
||||
- `apps/web/src/components/MessageBubble.tsx` — the sentinel render arms (`metadata?.kind` branches).
|
||||
|
||||
### A wire-shape change passes through the gate, then a consumer
|
||||
|
||||
A frame is published by the server's permissive `InferenceFrame` union (`apps/server/src/services/inference/turn.ts`) but only reaches the UI if the strict schema/union accepts it — permissive publish, strict receive. Keep the **type/schema copies** (this standard's scope) in lockstep so the frame survives validation; then make sure something consumes it.
|
||||
|
||||
> **Where consumer-wiring fits.** This standard governs the duplicated *type/schema* copies and the one consumer with no compile-time guard — the sentinel `MessageBubble` render arm. A new WS frame additionally needs a runtime handler to *do* anything: `applyFrame` in `apps/web/src/hooks/useSessionStream.ts` (per-session frames) and `useUserEvents` (user-channel frames), plus the sidebar reducer. That wiring — and the event-dedup discipline around it — is governed by `apps/web/CLAUDE.md`, not by this parity standard. A frame that passes the byte-parity test but has no reducer `case` validates and is then silently ignored.
|
||||
> **Where consumer-wiring fits.** This standard governs the duplicated *type/schema* copies and the one consumer with no compile-time guard — the sentinel `MessageBubble` render arm. A new WS frame additionally needs a runtime handler to *do* anything: `applyFrame` in `apps/web/src/hooks/useSessionStream.ts` (per-session frames) and `useUserEvents` (user-channel frames), plus the sidebar reducer. That wiring — and the event-dedup discipline around it — is governed by `apps/web/CLAUDE.md`, not by this parity standard. A frame that passes `WsFrameSchema` validation but has no reducer `case` is then silently ignored.
|
||||
|
||||
**Correct usage:**
|
||||
|
||||
```typescript
|
||||
// Adding a WS frame type, all in one commit:
|
||||
// - packages/contracts/src/ws-frames.ts — WsFrameSchema + WsFrame + KNOWN_FRAME_TYPES (rebuild package)
|
||||
// - apps/server/src/services/inference/turn.ts — loose InferenceFrame publish union (+ optional fields)
|
||||
// - apps/server/src/types/ws-frames.ts — strict WsFrameSchema + WsFrame + KNOWN_FRAME_TYPES
|
||||
// - apps/web/src/api/ws-frames.ts — byte-identical copy of the strict gate
|
||||
// The strict web-side type is the wire-format gate: a frame whose type isn't in
|
||||
// - apps/web/src/api/types.ts — strict WsFrame discriminated union (the web gate)
|
||||
// The web strict WsFrame is the wire-format gate: a frame whose type isn't in
|
||||
// it is dropped at JSON-parse. The loose publish union and the strict gate are
|
||||
// BOTH required — permissive publish, strict receive.
|
||||
// BOTH required — permissive publish, strict receive. The canonical schema in the
|
||||
// package is validated by the broker fail-closed (Zod) on every publish.
|
||||
```
|
||||
|
||||
**What to avoid:**
|
||||
@@ -172,25 +167,26 @@ A frame is published by the server's permissive `InferenceFrame` union (`apps/se
|
||||
```
|
||||
|
||||
**Project references:**
|
||||
- `packages/contracts/src/ws-frames.ts` — `WsFrameSchema` (the broker's fail-closed validation gate) + `WsFrame` + `KNOWN_FRAME_TYPES`.
|
||||
- `apps/server/src/services/inference/turn.ts` — the loose `InferenceFrame` publish union.
|
||||
- `apps/server/src/types/ws-frames.ts` — `WsFrameSchema` (the broker's fail-closed validation gate) + `KNOWN_FRAME_TYPES`.
|
||||
- `apps/web/src/api/types.ts` — the web-local strict `WsFrame` discriminated union.
|
||||
- `apps/web/src/components/MessageBubble.tsx` — the consumer for sentinel `MessageMetadata` kinds.
|
||||
|
||||
### Sync the copies; never weaken the parity test
|
||||
### Keep the package tests; never weaken them
|
||||
|
||||
When a parity test fails, the fix is to make the copies match — not to make the test stop checking. The corollary also holds: when you add a **new** nested type that `ProviderSnapshotEntry` references, add its name to the `names` array in `provider-types-parity.test.ts`, or the new type is hand-synced but **unguarded**.
|
||||
When a package test fails, the fix is to update the package source — not to delete the assertion or skip the test. When adding a **new** nested type to `ProviderSnapshotEntry`, add it to `packages/contracts/src/provider-snapshot.ts` — there is no `names` array to update since `provider-types-parity.test.ts` was deleted; the single definition is the guard.
|
||||
|
||||
**What to avoid:**
|
||||
|
||||
```typescript
|
||||
// ANTI-PATTERN: a red parity test "fixed" by deleting the assertion, skipping
|
||||
// the it(), or trimming a type out of the compared `names` list. That converts a
|
||||
// caught drift into a shipped, silent contract break. Re-sync the copies instead.
|
||||
// ANTI-PATTERN: "fixing" a red package test by deleting the assertion, skipping
|
||||
// the it(), or removing a frame type from the tested set. That converts caught
|
||||
// drift into a shipped, silent contract break. Fix the package source instead.
|
||||
```
|
||||
|
||||
**Project references:**
|
||||
- `apps/server/src/services/__tests__/ws-frames.test.ts` — `ws-frames.ts file mirror parity` (byte-identical) and the `KNOWN_FRAME_TYPES` drift probe.
|
||||
- `apps/coder/src/services/__tests__/provider-types-parity.test.ts` — text-identity of each shared block across the coder ↔ web copies.
|
||||
- `packages/contracts/src/__tests__/ws-frames.test.ts` — schema correctness (accept/reject) and the `KNOWN_FRAME_TYPES` drift probe. (The former server-side `ws-frames.ts file mirror parity` byte-identical test was deleted when the schema moved to the package.)
|
||||
- `apps/coder/src/services/__tests__/provider-types-parity.test.ts` — **deleted**: provider snapshot types moved to `@boocode/contracts/provider-snapshot`.
|
||||
|
||||
## Additional Resources
|
||||
|
||||
@@ -198,8 +194,8 @@ When a parity test fails, the fix is to make the copies match — not to make th
|
||||
|
||||
- [BooCoder Dispatch Backends](../coder-backends.md) — the provider-snapshot contract and the WS-frame mapping in their runtime context (see "Core Types" and the parity notes).
|
||||
- [Architecture overview](../ARCHITECTURE.md) — the three surfaces and the shared database the contracts cross.
|
||||
- Root `CLAUDE.md` → "Conventions" — the cross-app contract rules (WS frame, sentinels, provider-type parity, JSONB) this standard formalizes.
|
||||
- `apps/server/CLAUDE.md` (`services/broker.ts`) and `apps/coder/CLAUDE.md` — per-app notes on the broker validation and the provider-type mirror.
|
||||
- Root `CLAUDE.md` → "Conventions" — the cross-app contract rules (WS frame schema, sentinels, provider snapshot types, JSONB) this standard formalizes; updated to reflect `@boocode/contracts` SSOT.
|
||||
- `apps/server/CLAUDE.md` (`services/broker.ts`) — broker validation against the `@boocode/contracts` schema. `apps/coder/CLAUDE.md` — `provider-types.ts` re-exports from the package.
|
||||
|
||||
### External Resources
|
||||
|
||||
|
||||
97
docs/features/git-diff-panel/artifacts/.discovery-notes.md
Normal file
97
docs/features/git-diff-panel/artifacts/.discovery-notes.md
Normal file
@@ -0,0 +1,97 @@
|
||||
# Discovery notes — git-diff-panel implementation
|
||||
|
||||
Single source of truth for project context. Specialists: read this first; do NOT re-grep what is here.
|
||||
Source spec: `../feature-specification.md` (+ `decision-log.md` D1–D18, `team-findings.md` F1–F21). No
|
||||
`feature-technical-notes.md` (no load-bearing mechanic qualified at spec time).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- pnpm monorepo. `apps/web` (React 18 + Vite SPA), `apps/server` (BooChat — Fastify + postgres, native
|
||||
inference, **read-only** file/git tools), `apps/coder` (BooCoder — host systemd service, port 9502,
|
||||
write-capable, runs git writes today), `apps/booterm`. TS strict, NodeNext (`.js` import suffixes) on
|
||||
server + coder. `@boocode/contracts` package single-sources WS frames + provider/message types.
|
||||
- Tests: vitest ^3. server `pnpm -C apps/server test`; coder `pnpm -C apps/coder test` (`globals:false` —
|
||||
import describe/it/expect). Include glob `src/**/__tests__/**/*.test.ts`. No web test harness. DB-integration
|
||||
tests opt-in via `DATABASE_URL` + `describe.runIf`.
|
||||
- Deploy by surface: apps/coder change → `sudo systemctl restart boocoder`; apps/web|server change →
|
||||
`docker compose up --build -d boocode` (rebuilds web+server from working tree; web HMR live on dev only).
|
||||
- Shiki `^1.29.2` already in apps/web (`CodeBlock.tsx`, `FileViewerOverlay.tsx`); `lang:'diff'` is valid —
|
||||
the path of least resistance for rendering a unified diff. No react-diff-viewer / diff2html installed.
|
||||
|
||||
## ADRs / coding standards
|
||||
|
||||
- No `docs/adr/`. Decisions live in `boocode_roadmap.md` (Decisions log) + per-app `CLAUDE.md` (auto-loaded
|
||||
when editing that subtree) + `openspec/changes/archived/`.
|
||||
- Coding standards: `docs/coding-standards/` (canonical), surfaced via `.claude/rules/coding-standards/`
|
||||
path-scoped indexes. Cross-cutting conventions in root `CLAUDE.md`.
|
||||
|
||||
## Code touch points
|
||||
|
||||
### Git data sources today (read)
|
||||
- `apps/server/src/services/git_meta.ts:44` `getGitMeta(rootPath)` — runs `runGit([...argv], rootPath)`
|
||||
(SAFE: discrete argv, no shell), returns `{branch,is_dirty,ahead,behind}` only (NO diff text), 30s cache.
|
||||
This is the read-side precedent for the new read route and the F2 argv-safety bar. Uses
|
||||
`rev-list --left-right --count HEAD...@{upstream}` (the upstream-resolution precedent for D2/D13's base).
|
||||
- `apps/server/src/routes/projects.ts:426` `GET /api/projects/:id/git` — returns the GitMeta shape (no diff).
|
||||
`api.projects.git(id)` (`apps/web/src/api/client.ts:154`), polled 30s by `useProjectGit`. The new read
|
||||
route slots beside this.
|
||||
- `apps/coder/src/services/worktrees.ts:46` `diffWorktree(worktreePath, projectPath, {baseRef})` — produces
|
||||
a real unified `git diff <base>...<head>` but via `hostExec(shell string)` + `shellEscape`, and commits
|
||||
with per-invocation `-c user.email=boocoder@local -c user.name=BooCoder`. **Caution:** this is the SHELL
|
||||
pattern F2 warns against; the new git-write ops should follow `git_meta.ts`'s argv `runGit`, not this.
|
||||
But it IS the precedent that the coder/host can run git writes and inject identity per-invocation.
|
||||
- `pending_changes.diff` is unified only for external-agent edits; native BooCode edits store `{old,new}` JSON.
|
||||
The new Git panel is project-repo git state, complementary to the pending-changes panel (spec Coordinations).
|
||||
|
||||
### The "file browser" host (apps/web)
|
||||
- `apps/web/src/components/RightRail.tsx` — the right-side file panel (NOT a workspace pane; the legacy
|
||||
`file_browser` pane kind is dead). Header at ~:209 renders a static "Files" label + 2 icon buttons; desktop
|
||||
`w-64`, mobile drawer `w-[85vw] max-w-sm` via `useRightRailDrawer`. Already applies `max-md:min-h-[44px]`
|
||||
to header buttons (the 44px convention for D18). Fetches tree via `api.projects.files/listDir/viewFile`.
|
||||
**The Files / Git tab (D1, D16) is added to THIS header** — and must fit one line (toolbar-fit rule, D18).
|
||||
`open_file_in_browser` sessionEvent already opens the panel programmatically.
|
||||
- Rendered in `App.tsx:~89` for every `/session/:id` (so the panel — and D8's Git tab — appears in all
|
||||
session types). `Session.tsx:~397` mobile `FolderTree` toggle button (the dirty-indicator host for D17).
|
||||
|
||||
### Refresh-trigger plumbing (F20, D10)
|
||||
- `message_complete` WS frame = "agent turn complete" trigger. Coder pending-changes refresh precedent:
|
||||
`usePendingChanges` in `CoderPane.tsx:~786` refetches on message-complete. Pending-apply/discard has no
|
||||
named frame — driven by the `refresh()` callback. Adding a new event/frame requires the CLAUDE.md parity
|
||||
steps: a new WS frame → BOTH server/contracts `WsFrameSchema` AND web `WsFrame` (`apps/web/src/api/types.ts`);
|
||||
a new sessionEvent → a `case` in `useSidebar.ts` `applyEvent`.
|
||||
|
||||
### Security surfaces
|
||||
- `apps/server/src/services/path_guard.ts` `resolveProjectRoot(project.path)` — derives + scopes project
|
||||
paths from the DB project row, never from the request (the F2 "server-derived root + relative-arg
|
||||
validation" precedent). `secret_guard.ts` deny-list applies to the assistant's read tools (not the user's
|
||||
git panel — spec D8). HTML artifacts run in a sandboxed iframe with `connect-src 'none'` (BOOCHAT.md) — the
|
||||
evidence behind F1 (an artifact cannot POST to the new write endpoints). No app-layer auth (Authelia at the
|
||||
proxy; `'default'` user key).
|
||||
- BooCoder proxy: apps/server forwards `/api/coder/*` to apps/coder (`coder-proxy.ts`) — the route by which
|
||||
a web client reaches coder (host) endpoints.
|
||||
|
||||
## Recent activity / precedent
|
||||
|
||||
- HEAD ~v2.7.11. Pure-helper + TDD precedent: `backends/turn-guard.ts`, `lifecycle-decisions.ts`,
|
||||
`mistake-tracker.ts` (pure module + unit test, then wire). The diff-parse / base-resolution / mode-decision
|
||||
logic should follow it (testable pure helpers).
|
||||
- Sibling backlog plan at `docs/plans/post-review-backlog/` is the format precedent for the plan files.
|
||||
|
||||
## Enumerated gaps / open implementation questions for the team
|
||||
|
||||
1. **THE architecture decision (F18 / JD-005):** which service owns the new git operations? Options: (a) all
|
||||
in apps/server (read + write) — but apps/server is "read-only" by posture (the git-write would be a new
|
||||
write surface there); (b) read in apps/server (consistent with git_meta), write in apps/coder (the
|
||||
write-capable host service, already runs git writes) via the `/api/coder/*` proxy; (c) all in apps/coder.
|
||||
Note apps/coder runs on the host (can git-write to the project path); apps/server runs in Docker. The diff
|
||||
panel appears in ALL sessions incl. plain BooChat — does that constrain which service answers? software-architect to settle.
|
||||
2. No HTTP route returns a full working-tree git diff today — a new read route is needed regardless.
|
||||
3. The diff-parse + per-file expand/collapse + staged/unstaged grouping + Shiki `lang:'diff'` rendering is
|
||||
net-new UI in `RightRail.tsx`; no unified-diff renderer exists yet.
|
||||
4. F2 argv-safety: the new write path must use discrete-argv git (like `git_meta.runGit`), NOT the
|
||||
`hostExec(shell)` pattern `worktrees.ts` uses — concrete bar for the security/structural recommendation.
|
||||
5. Committer identity (D6/D12/F3): server-derived. Precedents differ — `worktrees.ts` injects
|
||||
`-c user.email=boocoder@local`; `project_bootstrap.ts` hardcodes `samkintop@gmail.com`. The plan must pick
|
||||
the source for a USER commit (host git config vs a configured value) — not request-supplied.
|
||||
6. Base resolution (D2/D13): `@{upstream}` precedent exists in git_meta; default-branch fallback unspecified
|
||||
in code (needs `git symbolic-ref refs/remotes/origin/HEAD` or `rev-parse --abbrev-ref origin/HEAD`).
|
||||
361
docs/features/git-diff-panel/artifacts/decision-log.md
Normal file
361
docs/features/git-diff-panel/artifacts/decision-log.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Decision log — Git diff panel
|
||||
|
||||
Decisions behind [`feature-specification.md`](../feature-specification.md). Full decisions carry rationale,
|
||||
evidence, and rejected alternatives; trivial decisions are one-liners. Shared D# counter.
|
||||
|
||||
## Full decisions
|
||||
|
||||
### D1 — Placement: a tab inside the file browser
|
||||
**Question:** Where does the diff view live in the workspace?
|
||||
**Decision:** The diff view lives in the right-side file panel as a Files / Git tab, occupying the same
|
||||
slot as the file tree, rather than as a new standalone workspace pane.
|
||||
**Rationale:** The request was "instead of the file browser," and the file browser is a right-side sidebar,
|
||||
not a workspace-grid pane. The reference design (Paseo) puts Changes / Files tabs in one sidebar slot. A
|
||||
new workspace pane would require new pane-kind plumbing for an affordance the user described as a
|
||||
replacement, not an addition.
|
||||
**Evidence:** User answer (2026-06-02). Codebase: the file browser is the right-rail sidebar; the legacy
|
||||
"file_browser" pane kind is unused. Paseo `explorer-sidebar.tsx` (Changes/Files tabs in one slot).
|
||||
**Rejected alternatives:**
|
||||
- A standalone git-diff workspace pane — rejected: it is an addition, not a replacement, and adds pane
|
||||
plumbing the user did not ask for.
|
||||
**Driven by findings:** —
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** D9, D10.
|
||||
**Referenced in spec:** Actors and triggers, Primary flow, User interactions.
|
||||
|
||||
### D2 — Scope: the project repository, with two comparison modes
|
||||
**Question:** What repository and comparison modes does the panel cover?
|
||||
**Decision:** The panel shows the **project repository's** changes, with a selector between **Uncommitted**
|
||||
(working tree vs. last commit) and **Committed** (current branch vs. its upstream tracking branch when
|
||||
set, otherwise the repository's default branch). It does not show the session agent's separate
|
||||
working-copy diff — that remains the pending-changes panel's job. In Committed mode the view labels the
|
||||
base it resolved ("Git — branch vs <base>"); when no base resolves the panel falls back to
|
||||
Uncommitted and labels the mode as a fallback.
|
||||
**Rationale:** The file browser is scoped to the project repository, so the diff "instead of" it should
|
||||
share that scope. The user chose both comparison modes (Paseo-style) over a single mode. The agent
|
||||
working-copy diff is already surfaced by the pending-changes panel; duplicating it here would create two
|
||||
overlapping surfaces. Labeling the base prevents silent ambiguity (F11).
|
||||
**Evidence:** User answer (2026-06-02, "Both, with a selector"). Codebase: the file browser and project
|
||||
git-metadata are scoped to the project path; agent worktree diffs already flow to the pending-changes
|
||||
panel. Paseo uncommitted/committed mode selector. F11 (base unlabeled finding).
|
||||
**Rejected alternatives:**
|
||||
- Project working tree only — rejected: user wanted both modes.
|
||||
- Session agent worktree — rejected: overlaps the pending-changes panel and is not the file browser's scope.
|
||||
- No base labeling — rejected (F11): the base is ambiguous between upstream tracking branch and default
|
||||
branch; unlabeled output invites confusion.
|
||||
**Driven by findings:** F11.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** D3, D4, D5, D11, D13.
|
||||
**Referenced in spec:** Outcome, Primary flow, Edge cases and failure modes, Coordinations.
|
||||
|
||||
### D3 — Mode auto-selection and session pinning
|
||||
**Question:** How is the initial mode chosen and how does it behave across refreshes?
|
||||
**Decision:** Auto mode-selection applies on first open only: Uncommitted when the working tree is dirty,
|
||||
Committed when it is clean. Once the user selects a mode explicitly it is pinned for the session;
|
||||
refreshes do not override it. If a refresh would change the auto-selected mode (e.g. the tree went clean
|
||||
while Uncommitted was pinned), the panel briefly notes the change rather than swapping silently.
|
||||
**Rationale:** Paseo's convention is auto-select by state. However, a refresh-triggered silent mode swap
|
||||
would dislocate the user's view without warning — they could be mid-review and suddenly see a different
|
||||
file list (F12). Pinning after explicit selection preserves the user's intent.
|
||||
**Evidence:** Paseo convention (auto-select by dirty state). F12 (silent-dislocation finding;
|
||||
design-judgment resolution).
|
||||
**Rejected alternatives:**
|
||||
- Always auto-select on every refresh — rejected (F12): silently dislocates the view when the tree state
|
||||
changes mid-session.
|
||||
- No auto-selection (always start in a fixed mode) — rejected: ignores the most useful starting point.
|
||||
**Driven by findings:** F12.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** D14.
|
||||
**Referenced in spec:** Primary flow, Alternate flows and states.
|
||||
|
||||
### D5 — Binary and large-file handling
|
||||
**Question:** What does the panel show for files it cannot diff or whose diff is too large?
|
||||
**Decision:** Binary files show a "Binary file" placeholder instead of a diff body. Files over a display-
|
||||
size threshold show "Diff too large to display" in place of the diff body. A git read that does not
|
||||
complete within a deadline exits the loading state, shows an error, and offers Refresh. The total
|
||||
response payload is bounded so a huge change set cannot stall the panel.
|
||||
**Rationale:** Paseo-style caps prevent the panel from hanging or overflowing on large repos. The read-
|
||||
deadline (F7) is a distinct concern from the large-result cap: a slow git process can stall the panel
|
||||
even when individual files are small.
|
||||
**Evidence:** Paseo codebase (display-size caps). F7 (hanging git-read finding; evidence-backed addition).
|
||||
**Rejected alternatives:**
|
||||
- No cap — rejected: a huge change set can stall or overflow the panel.
|
||||
- Combine deadline and size cap into one mechanism — rejected (F7): they address different failure modes
|
||||
(slow process vs. large output); both are needed.
|
||||
**Driven by findings:** F7.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** Edge cases and failure modes.
|
||||
|
||||
### D6 — v1 actions: stage, unstage, commit, discard (no push)
|
||||
**Question:** Which write actions are included in v1, and in which modes are they available?
|
||||
**Decision:** v1 includes staging/unstaging files, committing staged files with a message, and discarding a
|
||||
file's changes — all available only in Uncommitted mode. Committed mode is read-only review with no write
|
||||
actions. Pushing, pulling, PRs, and merges are excluded. Commit author/committer identity is derived
|
||||
server-side; the request cannot set or influence it.
|
||||
**Rationale:** The user chose to include stage/commit over a read-only view. Remote operations were not
|
||||
requested and the assistant-level rule already treats remote writes as out of band, so v1 stops at local
|
||||
history. Allowing write actions in Committed mode would mean reverting committed history (per-file resets
|
||||
of committed commits), which was not requested and creates a different risk profile. Committer identity
|
||||
must be server-derived to prevent the request body from spoofing authorship (F3).
|
||||
**Evidence:** User answer (2026-06-02, "Include stage/commit"). Convention: the assistant cannot push to
|
||||
remotes (project docs) — signals remotes are deliberately out of band. F3 (committer-identity
|
||||
finding). F14 (mode-scoping finding; design-judgment resolution).
|
||||
**Rejected alternatives:**
|
||||
- Read-only review — rejected by the user.
|
||||
- Full git actions incl. push/pull/PR (Paseo's set) — deferred (YAGNI): not requested.
|
||||
- Write actions in Committed mode too — rejected (F14): reverting committed history is a distinct,
|
||||
unrequested capability with a different risk profile.
|
||||
- Request-supplied commit identity — rejected (F3): allows spoofing; server-derived identity is the only
|
||||
safe source.
|
||||
**Driven by findings:** F3, F14.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** D7, D8, D12.
|
||||
**Referenced in spec:** Primary flow, User interactions.
|
||||
|
||||
### D7 — Discard requires a plain confirmation
|
||||
**Question:** What confirmation does discard require, and what wording?
|
||||
**Decision:** Discarding a file's changes prompts a plain Cancel / Discard confirmation with wording that
|
||||
distinguishes the two cases: "Discard changes to X?" for a tracked file (reverts to committed content)
|
||||
and "Delete X? It has never been committed and cannot be recovered" for an untracked file (permanently
|
||||
removed). Stage, unstage, and commit do not prompt.
|
||||
**Rationale:** Discard is the only irreversible action in the set; a confirmation guards an accidental tap,
|
||||
especially on mobile. The project's stated preference is plain confirm dialogs, never type-the-name
|
||||
patterns. A tracked revert and an untracked permanent delete are different losses — the user deserves to
|
||||
know which one they are confirming (F4).
|
||||
**Evidence:** Convention: destructive actions use plain Cancel/Confirm dialogs (no type-to-confirm).
|
||||
Stage/unstage/commit are reversible (commits can be amended/reset), so they need no prompt. F4
|
||||
(tracked-vs-untracked and affordance-separation finding; design-judgment resolution).
|
||||
**Rejected alternatives:**
|
||||
- No confirmation — rejected: irreversible data loss on a stray tap.
|
||||
- Type-the-filename-to-confirm — rejected: against the project's confirmation convention.
|
||||
- Single generic confirmation for tracked and untracked — rejected (F4): hides the difference in
|
||||
consequence (revert vs. permanent delete) from the user.
|
||||
**Driven by findings:** F4.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** D15.
|
||||
**Referenced in spec:** Alternate flows and states.
|
||||
|
||||
### D8 — Git write is a user action, not an assistant tool
|
||||
**Question:** Are the panel's write actions gated by session type (e.g. read-only-assistant sessions)?
|
||||
**Decision:** The diff panel's write actions (stage/commit/discard) are available wherever the file panel
|
||||
appears, including read-only-assistant sessions, because they are the human user's own UI actions, not
|
||||
the AI's. The git-write endpoints are never registered as assistant tools, and the artifact sandbox
|
||||
prevents a rendered artifact from invoking them.
|
||||
**Rationale:** The "read-only" rule constrains what the AI assistant's tools may do. A human committing
|
||||
their own repository through a panel is a different actor; gating the panel by session type would be a
|
||||
category error and produce inconsistent behavior across sessions. The artifact-sandbox commitment (F1)
|
||||
closes the indirect path an artifact might otherwise exploit.
|
||||
**Evidence:** Convention: the read-only invariant is defined over the assistant's tool surface; the file
|
||||
browser (also user-driven) already appears in all sessions. F1 (artifact-sandbox finding; evidenced by
|
||||
`connect-src 'none'` in the artifact iframe sandbox per BOOCHAT.md output-format section).
|
||||
**Rejected alternatives:**
|
||||
- Restrict the write actions to write-capable (coder) sessions only — rejected: conflates the assistant's
|
||||
tool permissions with the user's UI affordances; produces inconsistent behavior.
|
||||
**Driven by findings:** F1.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** D12.
|
||||
**Referenced in spec:** Actors and triggers, Coordinations.
|
||||
|
||||
### D10 — Refresh on open, on mutation, on turn completion, on demand, with coalescence
|
||||
**Question:** When does the panel re-read the repository, and how are concurrent triggers handled?
|
||||
**Decision:** The panel re-reads the repository state when the Git tab is opened, after any stage /
|
||||
unstage / commit / discard it performs, after an agent turn completes, after the user applies or discards
|
||||
a queued change in the pending-changes panel, and on an explicit Refresh control. Concurrent refresh
|
||||
triggers are coalesced — a refresh already in flight absorbs later triggers rather than spawning a
|
||||
second concurrent read, so the panel settles to a single final snapshot. Continuous file-watching is
|
||||
excluded.
|
||||
**Rationale:** These triggers cover every event that can change the project repository's state within the
|
||||
app's single-user workflow. Coalescence (F8) prevents a burst of triggers (e.g. multiple rapid mutations)
|
||||
from causing redundant concurrent reads or a stale intermediate result overwriting a fresher one. A
|
||||
continuous file watcher adds cost without a multi-user need.
|
||||
**Evidence:** Convention: event-driven refresh follows the session-event / broker model already used for
|
||||
other panels. F8 (concurrent-refresh finding; evidence-backed addition).
|
||||
**Rejected alternatives:**
|
||||
- Continuous file-watch stream — rejected (YAGNI): event- and demand-driven covers the single-user case;
|
||||
deferred under YAGNI.
|
||||
- No coalescence (each trigger spawns its own read) — rejected (F8): can produce concurrent reads and
|
||||
stale-snapshot overwrites.
|
||||
**Driven by findings:** F8.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** Alternate flows and states.
|
||||
|
||||
### D11 — All git operations scoped to the project repository path
|
||||
**Question:** How is the git operation target scoped and validated?
|
||||
**Decision:** Every read and write the panel performs is confined to the project's own repository. The
|
||||
repository root is derived server-side from the session's project record — never from the request. Per-
|
||||
file arguments are validated as repo-relative paths and rejected if they escape the repository root.
|
||||
User-supplied text (commit message, file arguments) is passed as discrete arguments and never
|
||||
interpolated into a shell string.
|
||||
**Rationale:** The panel acts on the project repo only. Deriving the root server-side and validating
|
||||
per-file arguments closes the path-escape and command-injection vectors (F2). Passing text as discrete
|
||||
arguments (not a shell string) ensures user-supplied content cannot be interpreted as git flags or shell
|
||||
syntax.
|
||||
**Evidence:** Convention: project file operations resolve and scope to the project path via the existing
|
||||
path-scoping guard; git-metadata reads already do this. F2 (derivation + argument-safety finding;
|
||||
evidenced by the existing path-scoping guard).
|
||||
**Rejected alternatives:**
|
||||
- Accept a caller-supplied repository path — rejected: needless write surface, no use case.
|
||||
- Validate path only at the root level (not per-file arguments) — rejected (F2): per-file arguments can
|
||||
escape the repo root via `../` traversal if not independently validated.
|
||||
- Build git invocations as a shell string — rejected (F2): user-supplied content (commit message, file
|
||||
names with special characters) can be interpreted as flags or shell syntax.
|
||||
**Driven by findings:** F2.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** D12.
|
||||
**Referenced in spec:** Coordinations.
|
||||
|
||||
### D12 — Git-write security posture
|
||||
**Question:** What are the combined security commitments for the git-write surface?
|
||||
**Decision:** The git-write surface (stage / commit / discard) has three security commitments: (1) these
|
||||
actions are user-initiated UI actions only and are never registered as assistant tools; the artifact
|
||||
sandbox prevents a rendered artifact from invoking them; (2) all git operations target only the project's
|
||||
own repository, with the root derived server-side and per-file paths validated inside it; (3) commit
|
||||
author/committer identity is derived from a server-side source (host git configuration) and cannot be set
|
||||
by the request.
|
||||
**Rationale:** F1, F2, and F3 each attacked a distinct vector — artifact-driven invocation, path/argument
|
||||
injection, and identity spoofing — that D8 and D11 individually did not close. D12 records all three
|
||||
commitments together as the complete security posture of the write surface.
|
||||
**Evidence:** F1 (artifact-sandbox; `connect-src 'none'` per BOOCHAT.md output-format section).
|
||||
F2 (path-scoping guard in the codebase; derivation and validation commitments). F3 (server-derived
|
||||
identity commitment; design-judgment that no request field should influence authorship).
|
||||
**Rejected alternatives:**
|
||||
- Trust the client-supplied repository path — rejected (F2): see D11.
|
||||
- Allow request-supplied commit identity — rejected (F3): allows spoofing; no legitimate use case in a
|
||||
single-user app.
|
||||
- Rely on session-type gating instead of endpoint-level exclusion from tool registry — rejected (F1):
|
||||
session type is the wrong layer; artifact-sandbox closes the actual indirect path.
|
||||
**Driven by findings:** F1, F2, F3.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** Actors and triggers, Coordinations.
|
||||
|
||||
### D13 — Committed-mode base resolution and labeling
|
||||
**Question:** What is the base for Committed mode, and how is it surfaced when resolution fails?
|
||||
**Decision:** Committed mode compares the current branch against its upstream tracking branch when one is
|
||||
set, falling back to the repository's default branch (main/master). The panel labels the base it used in
|
||||
the mode header ("Git — branch vs <base>"). When no base resolves (no tracking branch and no
|
||||
discoverable default branch), the panel falls back to showing uncommitted changes and labels the mode as
|
||||
a fallback, rather than erroring or silently swapping.
|
||||
**Rationale:** "Base" was undefined in D2, leaving the committed comparison ambiguous (F11). The tracking-
|
||||
branch-first resolution matches git's own upstream model and is the most useful default for contributors
|
||||
tracking a remote. Labeling the resolved base makes the comparison unambiguous to the user. A labeled
|
||||
fallback is more informative than an error and does not leave the panel empty.
|
||||
**Evidence:** F11 (base-unlabeled finding; UX-002, JD-002; design-judgment resolution). Git upstream
|
||||
model (tracking branch as natural "base" for a contributor's branch).
|
||||
**Rejected alternatives:**
|
||||
- Always compare against the default branch, ignoring tracking — rejected (F11): wrong for contributors
|
||||
whose tracking branch is a personal fork or a PR target branch, not the default.
|
||||
- Error when no base resolves — rejected: leaves the panel useless; an unlabeled fallback is more
|
||||
helpful.
|
||||
- Silently swap to uncommitted without a label — rejected (F11): the original spec's behavior; confusing
|
||||
because the mode selector still shows "Committed".
|
||||
**Driven by findings:** F11.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** Primary flow, Edge cases and failure modes, User interactions.
|
||||
|
||||
### D14 — Mode pinning and first-open auto-selection
|
||||
**Question:** Does auto mode-selection persist across refreshes after the user has acted?
|
||||
**Decision:** Auto mode-selection applies on first open only. Once the user selects a mode explicitly (via
|
||||
the selector), that choice is pinned for the session. Refreshes do not override a pinned mode. If a
|
||||
refresh would change the auto-selected mode (e.g. the tree transitioned from dirty to clean while
|
||||
Uncommitted was pinned), the panel briefly notes the change rather than swapping silently.
|
||||
**Rationale:** Auto-select on every refresh would dislocate the user mid-review without warning —
|
||||
illustrated by the scenario where the tree goes clean while the user is reading the uncommitted diff (F12).
|
||||
A brief note on a state change preserves awareness without overriding intent.
|
||||
**Evidence:** F12 (silent-dislocation finding; design-judgment resolution). D3 (auto-selection origin).
|
||||
**Rejected alternatives:**
|
||||
- Re-run auto-selection on every refresh — rejected (F12): dislocates the user's active view.
|
||||
- No notification on a would-be mode change — rejected: leaves the user unaware that the repository
|
||||
state changed in a way that would normally affect the view.
|
||||
**Driven by findings:** F12.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** Primary flow.
|
||||
|
||||
### D15 — Discard is irrecoverable; tracked vs. untracked confirmation; separated affordance
|
||||
**Question:** What are the full discard semantics and the UI placement of the discard control?
|
||||
**Decision:** Discard is hard-delete and irrecoverable. The confirmation dialog uses two distinct wordings:
|
||||
"Discard changes to X?" for a tracked file (which reverts to its committed content; the work is lost but
|
||||
the file remains in history) and "Delete X? It has never been committed and cannot be recovered" for an
|
||||
untracked file (permanent deletion with no recovery path). The Discard affordance is placed in an
|
||||
overflow or secondary position rather than as an equal-weight sibling of Stage/Unstage.
|
||||
**Rationale:** The spec previously called discard "irrecoverable" but left the git mechanic ambiguous.
|
||||
Owning the word and spelling out the two cases (F4) ensures the confirmation is honest. Separating the
|
||||
affordance from Stage/Unstage reduces the risk of an accidental tap on mobile (F4, UX concern).
|
||||
**Evidence:** F4 (discard-semantics and affordance-separation finding; on-call-engineer OCE-002,
|
||||
UX-005, adversarial-security-analyst; design-judgment resolution). Convention: plain
|
||||
Cancel/Confirm dialogs.
|
||||
**Rejected alternatives:**
|
||||
- Single generic confirmation for tracked and untracked cases — rejected (F4): obscures the difference
|
||||
in consequence.
|
||||
- Discard at equal weight alongside Stage/Unstage — rejected (F4): accidental-tap risk on mobile.
|
||||
**Driven by findings:** F4.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** Alternate flows and states, User interactions.
|
||||
|
||||
### D16 — Tab named "Git"
|
||||
**Question:** What is the new tab called?
|
||||
**Decision:** The new tab is named **Git**, giving a Files / Git tab pair. The existing "Pending Changes"
|
||||
panel is not renamed; that rename is out of scope.
|
||||
**Rationale:** "Changes" (the working name in the initial spec) collides with "Pending Changes" — the name
|
||||
of an existing distinct panel — creating discoverability confusion (F10, UX-001, UX-008, JD-001). "Git"
|
||||
is shorter, unambiguous, and describes the surface (the project's git state) without implying overlap
|
||||
with the pending-changes panel.
|
||||
**Evidence:** F10 (naming-collision finding; design-judgment resolution). Existing surface name: "Pending
|
||||
Changes" panel in the codebase.
|
||||
**Rejected alternatives:**
|
||||
- "Changes" — rejected (F10): collides with "Pending Changes"; confusion in context-switching.
|
||||
- Rename "Pending Changes" to disambiguate — rejected (F10): out of scope; would require changes to an
|
||||
existing surface the user did not ask to rename.
|
||||
**Driven by findings:** F10.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** Actors and triggers, User interactions, Out of scope.
|
||||
|
||||
### D17 — Ambient dirty indicator and empty-state hint
|
||||
**Question:** How does the user discover the Git tab when the panel defaults to Files?
|
||||
**Decision:** An ambient indicator on the file-panel toggle/header signals the repository is dirty (derived
|
||||
from the refresh data already gathered), making the Git tab findable without opening it. When the Git
|
||||
view is empty but the session has unapplied pending changes, the empty state hints that those live in the
|
||||
pending-changes panel.
|
||||
**Rationale:** Without a visual signal the Git tab is invisible until the user already knows to look for it
|
||||
(F10, UX-001). The indicator reuses state already gathered by the refresh cycle — no additional read
|
||||
needed. The empty-state hint prevents the user from concluding the panel is broken when what they are
|
||||
looking for is actually in the adjacent pending-changes panel.
|
||||
**Evidence:** F10 (discoverability finding; design-judgment resolution). Refresh cycle already produces
|
||||
dirty/clean state (D10).
|
||||
**Rejected alternatives:**
|
||||
- No ambient indicator (rely on the user knowing the tab exists) — rejected (F10): undiscoverable by
|
||||
new users.
|
||||
- Always show dirty indicator (not just when dirty) — rejected: misleading on clean repos.
|
||||
**Driven by findings:** F10.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** Actors and triggers, Alternate flows and states.
|
||||
|
||||
### D18 — Mobile tap-target and header-fit
|
||||
**Question:** What are the layout and accessibility constraints for the new tab and controls?
|
||||
**Decision:** All interactive controls in the diff panel follow the app's existing mobile tap-target
|
||||
minimum. The Files / Git tab strip and header fit on one line without horizontal scroll or wrapping;
|
||||
existing header elements are condensed if needed to maintain fit.
|
||||
**Rationale:** The app has an existing toolbar-fit rule (no scroll/wrap on crowded control bars) and a
|
||||
mobile-first posture. The new Git tab and its in-panel controls must not break either. Condensing
|
||||
existing elements rather than scrolling is the project's established pattern (F15, UX-009, JD-008).
|
||||
**Evidence:** F15 (mobile-fit finding; convention). Project convention: toolbars must fit one line (no
|
||||
scroll or wrapping); MEMORY.md toolbar-fit rule.
|
||||
**Rejected alternatives:**
|
||||
- Allow horizontal scroll if the header gets crowded — rejected: against the project's toolbar-fit rule.
|
||||
- Wrap the header to a second line — rejected: against the project's toolbar-fit rule.
|
||||
**Driven by findings:** F15.
|
||||
**Linked technical notes:** —
|
||||
**Dependent decisions:** —
|
||||
**Referenced in spec:** User interactions.
|
||||
|
||||
## Trivial decisions
|
||||
|
||||
- D4: Untracked files included in Uncommitted view — untracked files appear in the Uncommitted file list as additions (considered tracked-only; rejected because the user's new files are part of "what changed"). — Referenced in spec: Primary flow.
|
||||
- D9: Unified layout, syntax-highlighted — diffs render in a single-column unified layout reusing the existing code highlighter (considered side-by-side; deferred under YAGNI as a desktop-only enhancement). — Referenced in spec: User interactions.
|
||||
@@ -0,0 +1,195 @@
|
||||
# Implementation Decision Log: Git Diff Panel
|
||||
|
||||
<!--
|
||||
This file records every implementation decision committed while planning the Git Diff Panel.
|
||||
Behavioral and implementation statements live in [../feature-implementation-plan.md](../feature-implementation-plan.md) —
|
||||
this file captures the question, rationale, evidence, and rejected alternatives for each decision.
|
||||
Round-by-round history lives in [implementation-iteration-history.md](implementation-iteration-history.md).
|
||||
|
||||
Shared D-N counter across trivial and full sections. Decisions here are implementation HOW
|
||||
decisions; the spec WHAT decisions (D1–D18) live in [decision-log.md](decision-log.md) and are
|
||||
inherited, not restated.
|
||||
-->
|
||||
|
||||
## Trivial decisions
|
||||
|
||||
- D-9: No `@boocode/contracts` change — refresh is a client-side `sessionEvents` event, not a WS frame, so the contracts package (`packages/contracts/`) is not touched and not rebuilt. — Referenced in plan: Implementation Approach → External Interfaces, Operational Readiness.
|
||||
- D-10: No DB schema change, no migration, no new env var — the panel reads git state at request time and writes the project repo directly; nothing is persisted in Postgres. — Referenced in plan: Implementation Approach → Data Model and Persistence, Operational Readiness.
|
||||
- D-11: Per-file expand/collapse state is local React state inside `GitDiffView`, not a shared hook — one consumer exists. — Referenced in plan: Implementation Approach → Architecture and Integration Points, Deferred (YAGNI).
|
||||
- D-12: `autoSelectMode` and `canCommit` are inline pure helpers inside `git_diff.ts` (unit-tested), not separate modules. — Referenced in plan: Implementation Approach → Runtime Behavior, Testing Strategy.
|
||||
|
||||
## Full decisions
|
||||
|
||||
### D-1: Both git read AND git write live in apps/server
|
||||
|
||||
- **Question:** Which service owns the new git read and git write operations — apps/server (BooChat, Docker), apps/coder (BooCoder, host service), or a read-in-server / write-in-coder split?
|
||||
- **Decision:** Both the read route and the write routes (stage / unstage / commit / discard) live in **apps/server**. New `apps/server/src/services/git_diff.ts` holds the read logic and pure helpers plus the git-write helpers; new routes are added beside `GET /api/projects/:id/git` in `apps/server/src/routes/projects.ts`. No apps/coder changes. Single deploy surface: `docker compose up --build -d boocode`.
|
||||
- **Rationale:** Writing in apps/server reuses the existing safe argv `runGit` pattern and the proven `project_bootstrap.ts` git-write precedent, and avoids a cross-service `/api/coder/*` proxy hop per write, a second deploy surface (coder restart), and making the host coder service a dependency for a plain BooChat-session commit. The "read-only" posture that motivated the split governs the **assistant's tool surface**, not the container filesystem or the user's own UI actions.
|
||||
- **Evidence:**
|
||||
- `docker-compose.yml:16` mounts `/opt:/opt` **read-write** into the boocode container — apps/server can already write project paths (refutes the "apps/server is read-only by posture" premise).
|
||||
- `apps/server/src/services/project_bootstrap.ts` already runs git writes from apps/server: `git init` + commits via safe `execFile` with `-c user.name` / `-c user.email` (`GIT_USER_NAME='indifferentketchup'`, `GIT_USER_EMAIL='samkintop@gmail.com'` at :38-39, applied :122-123) — git-write from the server is an existing, proven pattern.
|
||||
- `apps/server/src/services/git_meta.ts:30` has the safe `runGit` (discrete-argv `execFile`, no shell); `apps/coder/src/services/worktrees.ts` has only the **unsafe** `hostExec(shell string)` — writing in coder would require building a new safe wrapper there.
|
||||
- Spec D8 ([decision-log.md](decision-log.md#d8--git-write-is-a-user-action-not-an-assistant-tool)): the read-only invariant is defined over the assistant's tool surface; the panel's actions are the human user's own UI actions.
|
||||
- **Rejected alternatives:**
|
||||
- Read-in-server / write-in-coder split (the Round-1 software-architect A1 recommendation) — rejected because its premise (apps/server is read-only and a write there is a new surface) is refuted by the `/opt` rw mount and the `project_bootstrap.ts` git-write precedent; the split adds a cross-service hop, a second deploy surface, and coder coupling for a BooChat-session commit.
|
||||
- All-in-coder — rejected because it requires building a new safe argv git wrapper in coder (which only has the unsafe `hostExec`) and adds the same coder coupling and second deploy surface.
|
||||
- **Specialist owner:** `software-architect`
|
||||
- **Revisit criterion:** If a third consumer (beyond this feature in apps/server) needs the same git ops, reopen the shared-`packages/` extraction (see Deferred YAGNI); if apps/server's container ever loses the `/opt` rw mount, reopen the service-owner question.
|
||||
- **Dissent (if any):** The Round-1 software-architect (A1) recommended the write-in-coder split; recorded under disagree-and-commit. Revisit if the refuting evidence (rw mount + bootstrap precedent) changes.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** D-2, D-3, D-4, D-5, D-8
|
||||
- **Referenced in plan:** Implementation Approach → Architecture and Integration Points, Decomposition and Sequencing, Operational Readiness, Summary
|
||||
|
||||
### D-2: Read route + `git_diff.ts` pure helpers, TDD-first
|
||||
|
||||
- **Question:** What is the read-side shape, and how is the diff-parse / mode / base logic structured and tested?
|
||||
- **Decision:** Add `GET /api/projects/:id/git/diff?mode=<uncommitted|committed>` beside `GET /api/projects/:id/git`. The route delegates to `apps/server/src/services/git_diff.ts`, which exposes pure helpers written test-first before wiring: `parseNameStatus` (porcelain → file list with change type), `splitDiffByFile` (unified diff text → per-file segments), `resolveCommittedBase` (base resolution, see D-6), `autoSelectMode`, the binary/large `classify`, and `detectInProgress` (in-progress git-state detection, see D-7). The route composes these over `runGit` argv calls.
|
||||
- **Rationale:** The pure-helper-then-wire pattern is the project's established TDD precedent and keeps the parse/mode/base/classify logic unit-testable without spawning git. Slotting beside the existing git-meta route reuses its derivation and request shape.
|
||||
- **Evidence:** `apps/server/src/services/git_meta.ts:44` `getGitMeta` + `apps/server/src/routes/projects.ts:426` `GET /api/projects/:id/git` (the read precedent the new route slots beside). Pure-helper TDD precedent: `apps/server/src/services/backends/turn-guard.ts`, `lifecycle-decisions.ts`, `mistake-tracker.ts` (pure module + unit test, then wire). test-engineer (T1–T12).
|
||||
- **Rejected alternatives:**
|
||||
- Inline all parse/mode/base logic in the route handler — rejected because it cannot be unit-tested without spawning git and breaks the project's pure-helper precedent.
|
||||
- A separate module per helper — rejected (see D-12); the helpers are small and cohesive in one tested module.
|
||||
- **Specialist owner:** `software-architect`
|
||||
- **Revisit criterion:** If `git_diff.ts` grows past a single cohesive module (e.g. distinct read vs write concerns each exceed a file), split along that seam.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** D-6, D-7
|
||||
- **Referenced in plan:** Implementation Approach → Architecture and Integration Points, Implementation Approach → Runtime Behavior, Decomposition and Sequencing, Testing Strategy
|
||||
|
||||
### D-3: Write ops via argv-safe `runGit`/`execFile` with `--` separators
|
||||
|
||||
- **Question:** How are the write operations (stage / unstage / commit / discard) invoked safely?
|
||||
- **Decision:** All write ops use the discrete-argv `runGit`/`execFile` pattern with explicit `--` separators between options and user-supplied paths, plus a flag-injection guard (reject path arguments beginning with `-`). They **never** use `hostExec(shell string)` or any shell-interpolated invocation. User-supplied text (commit message, file targets) is always passed as discrete argv, never built into a command string.
|
||||
- **Rationale:** Discrete argv with `--` separators closes the command-injection and flag-injection vectors that a shell string opens (F2). The server already has the safe pattern; the coder's shell pattern is exactly what the spec security posture warns against.
|
||||
- **Evidence:** `apps/server/src/services/git_meta.ts:30` `runGit` (safe discrete-argv `execFile`) is the bar. `apps/coder/src/services/worktrees.ts` `hostExec(shell)` + `shellEscape` is the anti-pattern F2 flags. Spec D11/D12 ([decision-log.md](decision-log.md#d11--all-git-operations-scoped-to-the-project-repository-path)), F2 ([team-findings.md](team-findings.md)).
|
||||
- **Rejected alternatives:**
|
||||
- Reuse the coder `hostExec(shell)` + `shellEscape` pattern — rejected because shell interpolation lets user-supplied content (commit message, filenames with special characters) be interpreted as flags or shell syntax; the argv pattern eliminates the class.
|
||||
- **Specialist owner:** `adversarial-security-analyst`
|
||||
- **Revisit criterion:** If a git operation genuinely cannot be expressed in discrete argv (none in the v1 set qualifies), reopen with a documented justification.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** D-4
|
||||
- **Referenced in plan:** Implementation Approach → Architecture and Integration Points, Security Posture, Decomposition and Sequencing
|
||||
|
||||
### D-4: Path validation — repo-relative `pathGuard`, reject escape and repo-root discard
|
||||
|
||||
- **Question:** How are per-file path arguments validated before a write?
|
||||
- **Decision:** Every per-file argument is validated by a `pathGuard(repoRoot, file)` that resolves the path and rejects it if it is absolute, contains `..` traversal, or resolves (including via symlink) outside the repository root. Additionally, discarding the repository root itself (`.`) is rejected. The repository root is derived server-side from the session's project record, never from the request.
|
||||
- **Rationale:** Per-file arguments can escape the repo root via `../` traversal or a symlink even when the root is correct, so each argument needs independent validation (F2). Rejecting a repo-root discard prevents a single confirmation from wiping the whole working tree.
|
||||
- **Evidence:** `apps/server/src/services/path_guard.ts` `resolveProjectRoot(project.path)` — derives + scopes project paths from the DB project row, never from the request (the F2 precedent). Spec D11/D12, F2 ([team-findings.md](team-findings.md)). Integration-test precedent: `path_guard.test.ts`.
|
||||
- **Rejected alternatives:**
|
||||
- Validate only the repository root, not per-file arguments — rejected (F2): a per-file `../` or symlink argument escapes a correctly-scoped root.
|
||||
- Accept a caller-supplied repository path — rejected: needless write surface, no use case; root is server-derived.
|
||||
- **Specialist owner:** `adversarial-security-analyst`
|
||||
- **Revisit criterion:** If a legitimate write target outside the resolved root ever appears (none in scope), reopen.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** —
|
||||
- **Referenced in plan:** Security Posture, Decomposition and Sequencing, Testing Strategy
|
||||
|
||||
### D-5: Commit identity server-derived; request schema `.strict()` with no author fields
|
||||
|
||||
- **Question:** Where does the commit author/committer identity come from, and what can the request set?
|
||||
- **Decision:** Commit identity is server-derived via `-c user.name=… -c user.email=…` read from the repository's git config at commit time, falling back to the `project_bootstrap.ts` constants (`indifferentketchup` / `samkintop@gmail.com`) when git config yields nothing. The commit request schema is Zod `.strict()` and carries `{message, files?}` only — no `author`, `email`, `date`, or any identity field; unknown keys are rejected.
|
||||
- **Rationale:** A server-derived identity passed via `-c` flags makes authorship un-spoofable from the request body (F3). `.strict()` ensures an attacker cannot smuggle an identity field. The `project_bootstrap.ts` `-c` precedent already establishes this exact mechanism for server-side commits.
|
||||
- **Evidence:** `apps/server/src/services/project_bootstrap.ts:38-39,122-123` (server-side commit with `-c user.name`/`-c user.email`). Spec D6/D12, F3 ([team-findings.md](team-findings.md)). Zod is the project's request-validation library (apps/server CLAUDE.md).
|
||||
- **Rejected alternatives:**
|
||||
- Request-supplied commit identity — rejected (F3): allows authorship spoofing; no legitimate use in a single-user app.
|
||||
- Hardcode the coder's `boocoder@local` identity — rejected: a user commit is not a coder commit; git config is the correct source for the human user, with the bootstrap constants as the fallback.
|
||||
- A non-strict request schema — rejected: an unknown `author`-style key could slip through and influence the commit.
|
||||
- **Specialist owner:** `adversarial-security-analyst`
|
||||
- **Revisit criterion:** If a multi-identity requirement appears (e.g. co-author selection), reopen with a server-validated identity source, never a free-form request field.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** —
|
||||
- **Referenced in plan:** Implementation Approach → Architecture and Integration Points, Security Posture, Decomposition and Sequencing, Testing Strategy
|
||||
|
||||
### D-6: Committed-mode base resolution via `@{upstream}` → `origin/HEAD` → null
|
||||
|
||||
- **Question:** How does Committed mode resolve the base it compares the current branch against?
|
||||
- **Decision:** The pure helper `resolveCommittedBase` resolves the base in order: (1) `git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` (the tracking branch; non-zero exit if none), then (2) `git rev-parse --abbrev-ref origin/HEAD` (the default branch), then (3) null. On null the panel falls back to Uncommitted and labels it as a fallback. The resolved base is returned in the read response so the header can label "Git — branch vs <base>".
|
||||
- **Rationale:** Tracking-branch-first matches git's own upstream model and is the right base for a contributor whose tracking branch is a fork or PR target; `origin/HEAD` is the right fallback for the default branch; a labeled Uncommitted fallback is more useful than an error or a silent swap (F11).
|
||||
- **Evidence:** `apps/server/src/services/git_meta.ts:58` already uses `HEAD...@{upstream}` (the upstream-resolution precedent). Discovery notes §6 (`rev-parse --abbrev-ref origin/HEAD` for the default-branch fallback). Spec D13, F11 ([team-findings.md](team-findings.md)).
|
||||
- **Rejected alternatives:**
|
||||
- Always compare against the default branch, ignoring the tracking branch — rejected (F11): wrong for contributors tracking a fork or PR target.
|
||||
- Error when no base resolves — rejected (D13): leaves the panel useless; a labeled Uncommitted fallback is more helpful.
|
||||
- `git symbolic-ref refs/remotes/origin/HEAD` — equivalent but `rev-parse --abbrev-ref origin/HEAD` is the simpler form already aligned with the git_meta precedent.
|
||||
- **Specialist owner:** `software-architect`
|
||||
- **Revisit criterion:** If repos without `origin/HEAD` set become common and the Uncommitted fallback is observed to confuse users, reopen the default-branch heuristic.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** —
|
||||
- **Referenced in plan:** Implementation Approach → Runtime Behavior, Decomposition and Sequencing, Testing Strategy
|
||||
|
||||
### D-7: Read deadline 30s + maxBuffer 10MB; index-lock → HTTP 409; in-progress detection disables writes
|
||||
|
||||
- **Question:** What resilience bounds and failure semantics does the git surface commit to?
|
||||
- **Decision:** (a) The read path uses a 30s execution deadline and a 10MB `maxBuffer` — both distinct from the D5 per-file display-size cap; a read that exceeds the deadline exits loading, shows an error, and offers Refresh. (b) A write that fails because the index is locked returns **HTTP 409 "repository busy"** with no server-side retry (retry is user-driven). (c) `detectInProgress` reads `.git` sentinel stats (`MERGE_HEAD`, `rebase-merge`/`rebase-apply`, `CHERRY_PICK_HEAD`, `BISECT_LOG`) and folds an in-progress flag into the read response; the client disables write affordances when set. (d) Untracked-file discard reports honest partial failure on refresh rather than claiming an unenforceable "state unchanged."
|
||||
- **Rationale:** A slow git process can stall the panel even with small files, so a deadline distinct from the size cap is needed (F7). A server retry on a lock hides the busy state and adds timer state; a 409 lets the user retry (F5). Detecting in-progress states up front prevents raw git errors on stage/commit/discard (F9). Partial-failure honesty matches what the git layer can actually guarantee (F6).
|
||||
- **Evidence:** `git_meta.ts:6-10` (the existing 2s timeout + 1MB buffer pattern this scales up for diff payloads). Spec D5/F7 (deadline distinct from size cap), F5 (index-lock), F9 (in-progress states), F6 (partial-failure wording) ([team-findings.md](team-findings.md)).
|
||||
- **Rejected alternatives:**
|
||||
- Server-side retry on index-lock — rejected (F5, YAGNI): "try again" is user-driven; a server retry hides the busy state and adds timer state. Reopen if lock contention is observed frequent.
|
||||
- Combine the deadline and the display-size cap into one mechanism — rejected (F7/D5): they address different failure modes (slow process vs large output).
|
||||
- Streaming diff reader that caps mid-stream — rejected (YAGNI): `execFile` maxBuffer 10MB covers any realistic single-user working-tree diff.
|
||||
- **Specialist owner:** `on-call-engineer`
|
||||
- **Revisit criterion:** Reopen the buffer/deadline if working-tree diffs routinely exceed 10MB or reads routinely exceed 30s; reopen the retry decision if index-lock contention is observed frequent in practice.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** —
|
||||
- **Referenced in plan:** Implementation Approach → Runtime Behavior, On-Call Resilience Posture, Decomposition and Sequencing, Testing Strategy
|
||||
|
||||
### D-8: Refresh via client `git_diff_refresh` sessionEvent, coalesced — not a WS frame
|
||||
|
||||
- **Question:** How is the panel's refresh wired across its five triggers, and does it need a new WS frame?
|
||||
- **Decision:** Refresh is a client-side `sessionEvents` event named `git_diff_refresh`, **not** a new WS frame. All five triggers are client-side: tab open, post-mutation, message_complete (from the existing WS frame), pending-change apply/discard, and the explicit Refresh control. The event is emitted from the `message_complete` handler and the CoderPane apply/discard callbacks; `useGitDiff` subscribes. A no-op `case 'git_diff_refresh'` is added to `useSidebar.ts` `applyEvent` per the apps/web parity rule. The client holds an in-flight coalescence ref so a refresh already running absorbs later triggers (no second concurrent read, no debounce).
|
||||
- **Rationale:** No trigger requires a server push — `message_complete` already arrives as a WS frame and the rest are local UI events — so a new WS frame and a `@boocode/contracts` rebuild are unnecessary. A client coalescence ref settles the panel to a single final snapshot (F8).
|
||||
- **Evidence:** Discovery notes §"Refresh-trigger plumbing": `message_complete` WS frame = turn-complete trigger; `usePendingChanges` in `CoderPane.tsx:~786` refetches on message-complete; new sessionEvent → a `case` in `useSidebar.ts` `applyEvent` (apps/web parity rule). Spec D10/F8 (coalescence), F20 (parity steps) ([team-findings.md](team-findings.md)).
|
||||
- **Rejected alternatives:**
|
||||
- A new WS frame for refresh — rejected (YAGNI): replaced by the simpler client `sessionEvents` event; no server push is needed, avoiding a `@boocode/contracts` rebuild and the dual server/web frame-type update.
|
||||
- Per-trigger debounce instead of an in-flight coalescence ref — rejected (F8): a debounce delays the first read and can still spawn overlapping reads under bursts.
|
||||
- **Specialist owner:** `software-architect`
|
||||
- **Revisit criterion:** If a future trigger genuinely originates server-side (e.g. an out-of-band repo mutation the client cannot observe), reopen the WS-frame option.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** —
|
||||
- **Referenced in plan:** Implementation Approach → Runtime Behavior, Implementation Approach → External Interfaces, Decomposition and Sequencing, On-Call Resilience Posture
|
||||
|
||||
### D-13: Write endpoints excluded from the assistant tool registry; artifact sandbox closes the indirect path
|
||||
|
||||
- **Question:** What prevents the assistant (directly or via a rendered artifact) from reaching the git-write endpoints?
|
||||
- **Decision:** The git-write endpoints are HTTP routes only and are **never** registered in the assistant tool registry (`ALL_TOOLS` in `services/tools.ts`). The indirect path — an AI-emitted HTML artifact POSTing to the endpoints with the user's cookie — is already closed by the artifact iframe's `connect-src 'none'` sandbox. No new mitigation is built for the artifact path; the plan records the existing control as the evidence.
|
||||
- **Rationale:** The panel's writes are the user's own UI actions (spec D8); keeping them out of `ALL_TOOLS` means no assistant tool surface exists for them, and the existing artifact sandbox closes the only indirect path (F1).
|
||||
- **Evidence:** `apps/server/src/services/tools.ts` `ALL_TOOLS` (tool registry the endpoints are kept out of). Artifact iframe `connect-src 'none'` per BOOCHAT.md output-format section (the F1 evidence). Spec D8/D12, F1 ([team-findings.md](team-findings.md)).
|
||||
- **Rejected alternatives:**
|
||||
- Session-type gating (restrict the panel to write-capable sessions) — rejected (F1/D8): session type is the wrong layer; the artifact sandbox closes the actual indirect path, and the user's UI actions should not be gated by the assistant's permissions.
|
||||
- A custom CSRF header on the write routes — rejected (YAGNI): `connect-src 'none'` on artifacts + the SameSite=Lax Authelia cookie + a same-origin SPA cover it at single-user scale. Reopen if the routes are exposed to a less-controlled origin.
|
||||
- **Specialist owner:** `adversarial-security-analyst`
|
||||
- **Revisit criterion:** If the write routes are ever exposed to an origin outside the same-origin SPA, reopen the CSRF-header decision.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** —
|
||||
- **Referenced in plan:** Security Posture, Decomposition and Sequencing
|
||||
|
||||
### D-14: Two-phase build — read/display (Phase 1) then write actions (Phase 2)
|
||||
|
||||
- **Question:** How is v1 sequenced, given the write surface is the larger half?
|
||||
- **Decision:** Two phases on the same deploy surface. **Phase 1 (read + display):** `git_diff.ts` pure helpers TDD-first → read route → client `useGitDiff` + RightRail Files/Git tab + `GitDiffView` read-only + the `git_diff_refresh` refresh wiring. **Phase 2 (write actions):** write helpers + write routes + stage/unstage/commit/discard affordances + in-progress disable. Both phases ship via `docker compose up --build -d boocode` (no coder restart).
|
||||
- **Rationale:** The write surface is the larger, higher-risk half (F19); landing read/display first delivers reviewable value and de-risks the write phase. Because both read and write live in apps/server (D-1), the two phases share one deploy surface, so the split is sequencing, not two deploy targets.
|
||||
- **Evidence:** Spec F19 (sequence diff-display before write actions within v1). D-1 (both in apps/server → single surface). junior-developer (coupling flag corrected to single-surface).
|
||||
- **Rejected alternatives:**
|
||||
- Ship read and write together as one slice — rejected (F19): the write surface is the larger half and benefits from landing display first for review.
|
||||
- Phase the split across deploy surfaces — rejected (D-1): both halves are in apps/server, so there is one surface.
|
||||
- **Specialist owner:** `software-architect`
|
||||
- **Revisit criterion:** If Phase 1 review surfaces a read-path change that blocks the write design, fold the affected piece forward; otherwise the order holds.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** —
|
||||
- **Referenced in plan:** Decomposition and Sequencing, Definition of Done
|
||||
|
||||
### D-15: Frontend — RightRail Files/Git tab, `GitDiffView` with lazy Shiki, dirty dot from `useProjectGit`
|
||||
|
||||
- **Question:** How does the panel fit into the existing right-rail file browser, and how are diffs rendered?
|
||||
- **Decision:** The Files/Git tab strip is added to the existing `RightRail.tsx` header (replacing the static "Files" label), fitting one line (D18); the FilePlus button shows only on the Files tab. `GitDiffView` occupies the same slot as the file tree and renders unified diffs via Shiki `lang:'diff'`, highlighting a file's diff lazily only when expanded (per-file loading state; "expand all" lazily highlights per file as rendered). The dirty dot on the Git tab button and the `Session.tsx` FolderTree toggle is fed by `useProjectGit`'s existing `is_dirty` (no new fetch). D14's "briefly notes the change" is a non-blocking inline line inside the panel, not a toast (avoids mobile-drawer z-index collision).
|
||||
- **Rationale:** The right rail is the file-browser host the spec scopes the panel to (D1); reusing its header and the existing `useProjectGit` dirty signal avoids new plumbing and a new fetch (D17). Lazy Shiki avoids highlighting unopened diffs (D9, perf). An inline note avoids the toast/drawer z-index collision on mobile.
|
||||
- **Evidence:** `apps/web/src/components/RightRail.tsx` (~:209 header with static "Files" label + 2 icon buttons; `max-md:min-h-[44px]` already applied — the D18 44px convention). `Session.tsx:~397` mobile FolderTree toggle (D17 dirty host). `useProjectGit` polls `GET /api/projects/:id/git` 30s and already returns `is_dirty` (D17 reuse). Shiki `^1.29.2` already in apps/web (`CodeBlock.tsx`, `FileViewerOverlay.tsx`); `lang:'diff'` valid (discovery notes). Spec D1, D9, D16, D17, D18.
|
||||
- **Rejected alternatives:**
|
||||
- Eager-highlight all diffs on load — rejected (D9, perf): highlights diffs the user never expands.
|
||||
- A new fetch for the dirty indicator — rejected (D17): `useProjectGit` already produces `is_dirty`.
|
||||
- A toast for D14's mode-change note — rejected: collides with the mobile-drawer z-index; an inline panel line is non-blocking and in-context.
|
||||
- A separate per-file expand-state hook — rejected (see D-11): one consumer; local state suffices.
|
||||
- **Specialist owner:** `user-experience-designer`
|
||||
- **Revisit criterion:** If a second component needs the diff-render or expand state, reopen the shared-hook extraction.
|
||||
- **Driven by rounds:** R1
|
||||
- **Dependent decisions:** —
|
||||
- **Referenced in plan:** Implementation Approach → Architecture and Integration Points, Decomposition and Sequencing, Testing Strategy
|
||||
@@ -0,0 +1,64 @@
|
||||
# Implementation iteration history — Git diff panel
|
||||
|
||||
Round-by-round record of the `plan-implementation` discussion for the git-diff-panel feature. The primary
|
||||
plan is [`../feature-implementation-plan.md`](../feature-implementation-plan.md); decisions are in
|
||||
[`implementation-decision-log.md`](implementation-decision-log.md). No `feature-technical-notes.md` exists
|
||||
(no T# notes), so the spec-maturity gate reduces to the spec-level threshold alone.
|
||||
|
||||
Team size: **medium** (round cap 2). Converged in **1 round** — every open question resolved from evidence;
|
||||
the spec-maturity gate did not trip.
|
||||
|
||||
---
|
||||
|
||||
## R1 — Parallel specialist review
|
||||
|
||||
**Specialists engaged:** software-architect, adversarial-security-analyst, on-call-engineer, test-engineer,
|
||||
junior-developer (all sonnet, parallel). project-manager synthesized (Step 8).
|
||||
|
||||
**New input provided:** the feature spec + decision-log (D1–D18) + team-findings (F1–F21), the discovery
|
||||
notes (`.discovery-notes.md`), and domain-scoped briefs. Mid-round the orchestrator verified the Docker
|
||||
mount and project_bootstrap git-write precedent, which refuted the architect's service-split premise.
|
||||
|
||||
### Claim ledger
|
||||
|
||||
| # | Claim | State | Spec-maturity | Supporting |
|
||||
|---|-------|-------|---------------|-----------|
|
||||
| C1 | Read + write both in apps/server; architect's write-in-coder premise refuted by `/opt` rw mount + `project_bootstrap.ts` git-write precedent | Evidenced | plan-level | junior (coupling flag) + evidence; security (safe runGit only in server) |
|
||||
| C2 | Read route + `git_diff.ts` pure helpers (parse, base-resolve, mode-select, classify, in-progress) — TDD-first | Evidenced | plan-level | architect, test-engineer |
|
||||
| C3 | Write ops via argv-safe `runGit`/`execFile` + `--` separators; never `hostExec(shell)` | Evidenced | plan-level | architect, security |
|
||||
| C4 | Path validation via `pathGuard` (reject `..`/abs/symlink-escape + repo-root discard) | Evidenced | plan-level | security |
|
||||
| C5 | Commit identity server-derived (`-c` from git config, bootstrap fallback); `.strict()` request, no author fields | Evidenced | plan-level | security, on-call |
|
||||
| C6 | Refresh = client `git_diff_refresh` sessionEvent + in-flight coalescence ref (no WS frame, no contracts rebuild) | Evidenced | plan-level | architect, on-call |
|
||||
| C7 | Read deadline 30s + `maxBuffer` 10MB (distinct from D5 display cap) | Evidenced | plan-level | on-call |
|
||||
| C8 | Index-lock → HTTP 409 "repository busy"; no server retry | Evidenced | plan-level | on-call |
|
||||
| C9 | In-progress detection via `.git` sentinel `stat`s folded into read response → disable writes | Evidenced | plan-level | on-call, test-engineer |
|
||||
| C10 | Write endpoints excluded from `ALL_TOOLS`; artifact sandbox `connect-src 'none'` blocks artifact→endpoint | Evidenced | plan-level | security |
|
||||
| C11 | RightRail Files/Git tab + `GitDiffView` (Shiki `lang:'diff'` lazy-on-expand) + dirty dot from `useProjectGit` | Evidenced | plan-level | architect, junior |
|
||||
| C12 | Two-phase build: read/display first, writes second (same deploy surface) | Evidenced | plan-level | architect, junior |
|
||||
| C13 | Test plan T1–T12: pure-helper units + temp-repo integration; skip Shiki/layout (no web harness) | Evidenced | plan-level | test-engineer |
|
||||
|
||||
### Open Questions and resolutions
|
||||
|
||||
| OQ | Question | Resolution source | Outcome |
|
||||
|----|----------|-------------------|---------|
|
||||
| OQ-1 | Which service owns the git routes? (raised by all five) | evidence | Read + write both in apps/server (D-1) — `/opt` rw mount + project_bootstrap precedent + D8 logic refute the coder-split premise |
|
||||
| OQ-2 | Refresh-wiring mechanism across CoderPane↔RightRail subtrees | evidence | Client `git_diff_refresh` sessionEvent; no WS frame, no contracts rebuild (D-8) |
|
||||
| OQ-6 | Commit identity source | evidence | Server-derived `-c` from git config, project_bootstrap constants fallback; request has no author fields (D-5) |
|
||||
| OQ-7 | Committed-mode base resolution command | evidence | `@{upstream}` → `origin/HEAD` → null→Uncommitted fallback (D-6) |
|
||||
| OQ-3/8/9 | Header condensation / dirty-indicator placement / D14 notification | evidence | Tab strip replaces "Files" label; FilePlus on Files tab only; dirty dot from `useProjectGit`; D14 = inline non-blocking line, not a toast (D-15) |
|
||||
| OQ-4 | Is the write half gated/sequenced? | evidence | Two-phase build, read/display then writes (D-14) |
|
||||
| OQ-5 | Shiki lazy vs eager | evidence | Lazy highlight on expand, per-file loading state (D-15) |
|
||||
|
||||
### Spec-maturity gate
|
||||
|
||||
**NOT tripped.** Zero spec-level findings — the spec committed every behavior (D1–D18); all Round-1 findings
|
||||
are plan-level HOW choices, each resolved from codebase evidence. No T#-contradictions (no T# notes exist).
|
||||
|
||||
### Next-step recommendation
|
||||
|
||||
**Go to synthesis.** All open questions resolved by evidence; no handoffs requested; no plan-level question
|
||||
left unresolved.
|
||||
|
||||
**Decisions produced:** D-1 through D-15.
|
||||
**Changed in plan:** Implementation Approach, Decomposition and Sequencing, Security Posture, On-Call
|
||||
Resilience Posture, Operational Readiness, Testing Strategy, Deferred (YAGNI), UX Notes.
|
||||
105
docs/features/git-diff-panel/artifacts/synthesis-input.md
Normal file
105
docs/features/git-diff-panel/artifacts/synthesis-input.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Synthesis input — Round 1 aggregation + resolutions (git-diff-panel impl)
|
||||
|
||||
Deterministic aggregation of Round-1 (software-architect, adversarial-security-analyst, on-call-engineer,
|
||||
test-engineer, junior-developer). Team size: medium (round cap 2; converged in 1 — all open questions
|
||||
resolved from evidence). Spec-maturity gate: NOT tripped (0 spec-level findings — the spec committed all
|
||||
behaviors; every finding is a plan-level HOW). Next step: go to synthesis.
|
||||
|
||||
## THE corrected key decision (D-1)
|
||||
|
||||
**Both the git READ and the git WRITE routes live in apps/server, NOT a read-server/write-coder split.**
|
||||
|
||||
The software-architect (A1) recommended read-in-server / write-in-coder, on the premise that apps/server is
|
||||
"read-only by posture" and adding writes there is a new surface. The junior-developer flagged the resulting
|
||||
coupling (a plain BooChat session reaching the coder write service). Evidence REFUTES the architect's premise:
|
||||
- `docker-compose.yml:16` mounts `/opt:/opt` **read-write** into the boocode container — apps/server can
|
||||
already write project paths.
|
||||
- `apps/server/src/services/project_bootstrap.ts` ALREADY runs git writes from apps/server: `git init` +
|
||||
commits via safe `execFile` with `-c user.name/-c user.email` flags (`GIT_USER_NAME='indifferentketchup'`,
|
||||
`GIT_USER_EMAIL='samkintop@gmail.com'` at :38-39, applied :122-123). Git-write from the server is an
|
||||
existing, proven pattern — not new.
|
||||
- D8's own logic: "read-only" governs the **assistant's tools**, not the container's filesystem or the
|
||||
user's own UI actions. A user-driven git-write route is not an assistant tool.
|
||||
- apps/server has the safe `runGit` (`git_meta.ts:30`, `execFile` discrete argv); apps/coder has only the
|
||||
UNSAFE `hostExec(shell string)` (`worktrees.ts`). Writing in coder would require building a NEW safe
|
||||
wrapper there; writing in server reuses the existing safe one and the project_bootstrap `-c` precedent.
|
||||
- Writing in server avoids: a cross-service `/api/coder/*` proxy hop per write, a second deploy surface
|
||||
(coder restart), and making coder a dependency for a BooChat-session commit.
|
||||
|
||||
**Decision:** read + write both in apps/server. New `apps/server/src/services/git_diff.ts` (read) +
|
||||
git-write helpers, new routes beside `GET /api/projects/:id/git` in `routes/projects.ts`. No apps/coder
|
||||
changes. Single deploy surface (`docker compose up --build -d boocode`). Rejected: A1's read-server/
|
||||
write-coder split (premise refuted); all-in-coder (needs a new safe wrapper, adds coupling).
|
||||
|
||||
## Resolved open questions (all plan-level, settled by evidence)
|
||||
|
||||
- **OQ-1 service split** → D-1 above (read+write in apps/server).
|
||||
- **OQ-2 refresh wiring (F20/D10)** → a client-side `sessionEvents` event `git_diff_refresh`, NOT a new WS
|
||||
frame. The five triggers are all client-side (tab open, post-mutation, message_complete, pending-apply,
|
||||
on-demand); no server push needed → no `@boocode/contracts` rebuild. Emit from the `message_complete`
|
||||
handler + CoderPane apply/discard callbacks; `useGitDiff` subscribes; add a no-op `case` in
|
||||
`useSidebar.ts:applyEvent` per the apps/web CLAUDE.md rule. (architect A4.)
|
||||
- **OQ-6 commit identity (D12/F3)** → server-derived `-c user.name=… -c user.email=…`, read from git config
|
||||
at commit time, fallback to the project_bootstrap constants. Request schema is `.strict()`,
|
||||
`{message, files?}` only — no author/email/date fields. (security #4, project_bootstrap precedent.)
|
||||
- **OQ-7 base resolution (D13)** → `git rev-parse --abbrev-ref --symbolic-full-name @{upstream}` (tracking
|
||||
branch; non-zero if none) → else `git rev-parse --abbrev-ref origin/HEAD` (default branch) → else null →
|
||||
fall back to Uncommitted, labeled. Pure helper `resolveCommittedBase`. (architect A2.)
|
||||
- **OQ-3/8/9 UX placement** → adopt architect A4: the Files/Git tab strip replaces the static "Files" label;
|
||||
the FilePlus button shows only on the Files tab (keeps header one line, D18); dirty dot on the Git tab
|
||||
button + the `Session.tsx` FolderTree toggle, fed by `useProjectGit`'s existing `is_dirty` (no new fetch,
|
||||
D17); D14's "briefly notes the change" is a non-blocking inline line inside the panel (NOT a toast —
|
||||
avoids mobile-drawer z-index collision).
|
||||
- **OQ-4 sequencing (F19)** → adopt architect A5 two-phase: Phase 1 = read + display (git_diff.ts pure
|
||||
helpers TDD-first → read route → client + RightRail tab + GitDiffView read-only + refresh wiring); Phase 2
|
||||
= write actions (write helpers + routes + stage/commit/discard affordances + in-progress disable). Both
|
||||
phases are now the SAME deploy surface (server+web) since writes are in apps/server.
|
||||
- **OQ-5 Shiki (D9)** → lazy: highlight a file's diff only when expanded, with a per-file loading state;
|
||||
"expand all" triggers lazy highlight per file as rendered. (junior OQ-5.)
|
||||
|
||||
## Claim ledger (consolidated)
|
||||
|
||||
| # | Claim | State | Spec-maturity | Supporting |
|
||||
|---|-------|-------|---------------|-----------|
|
||||
| C1 | Read+write both in apps/server; architect's write-in-coder premise refuted by /opt rw mount + project_bootstrap git-write precedent | Evidenced | plan-level | junior (flag) + corrected via evidence; security (safe-runGit-only-in-server) |
|
||||
| C2 | Read route + git_diff.ts pure helpers (parseNameStatus, splitDiffByFile, resolveCommittedBase, autoSelectMode, classify binary/large, detectInProgress) — TDD-first | Evidenced | plan-level | architect, test-engineer |
|
||||
| C3 | Write ops via argv-safe runGit/execFile with `--` separators; NEVER hostExec(shell) | Evidenced | plan-level | architect, security |
|
||||
| C4 | Path validation: pathGuard(repoRoot, file) rejects ../ + absolute + symlink-escape; also reject discard of repo-root (`.`) | Evidenced | plan-level | security |
|
||||
| C5 | Commit identity server-derived (-c from git config, bootstrap fallback); request `.strict()` no author fields | Evidenced | plan-level | security, on-call OQ4 |
|
||||
| C6 | Refresh = sessionEvent git_diff_refresh + client in-flight coalescence ref (no WS frame, no debounce) | Evidenced | plan-level | architect, on-call |
|
||||
| C7 | Read deadline 30s + maxBuffer 10MB (distinct from D5 size cap); timeout→error+Refresh | Evidenced | plan-level | on-call |
|
||||
| C8 | Index-lock → HTTP 409 "repository busy", NO server retry (user-driven retry) | Evidenced | plan-level | on-call |
|
||||
| C9 | In-progress detection via .git sentinel stats (MERGE_HEAD/rebase-merge/CHERRY_PICK_HEAD/BISECT_LOG) folded into read response → disable writes | Evidenced | plan-level | on-call, test-engineer |
|
||||
| C10 | Write endpoints NOT in the assistant tool registry (ALL_TOOLS); artifact sandbox connect-src 'none' already blocks artifact→endpoint | Evidenced | plan-level | security |
|
||||
| C11 | RightRail Files/Git tab + GitDiffView (Shiki lang:'diff' lazy-on-expand) + dirty dot from useProjectGit | Evidenced | plan-level | architect, junior |
|
||||
| C12 | Two-phase build: read/display first, writes second (same deploy surface) | Evidenced | plan-level | architect, junior |
|
||||
| C13 | Test plan T1-T12: pure-helper units + temp-repo integration (mkdtemp/git init, like path_guard.test.ts); skip Shiki/layout (no web harness) | Evidenced | plan-level | test-engineer |
|
||||
|
||||
## YAGNI ledger
|
||||
|
||||
- **Shared `packages/` runGit extraction** → DEFER. Rule of Three not met (now only apps/server consumes it
|
||||
for this feature; coder untouched). Reopen: a third consumer needs git ops. Source: architect.
|
||||
- **New WS frame for refresh** → REPLACE with the simpler client `sessionEvents` event (no server push, no
|
||||
contracts rebuild). Source: architect/junior OQ-2.
|
||||
- **Server-side retry on index-lock** → DEFER. "Try again" is user-driven; a server retry hides the busy
|
||||
state and adds timer state. Reopen: lock contention observed frequent in practice. Source: on-call F5.
|
||||
- **Streaming diff reader (cap mid-stream)** → DEFER. `execFile` maxBuffer 10MB covers any realistic
|
||||
single-user working-tree diff; streaming is ~40-50 LoC for a transient memory spike that doesn't matter at
|
||||
this scale. Reopen: diffs routinely exceed 10MB. Source: on-call §6.
|
||||
- **CSRF custom-header on the write routes** → DEFER. `connect-src 'none'` on artifacts + SameSite=Lax
|
||||
Authelia cookie + same-origin SPA covers it at single-user scale. Reopen: routes exposed to a less-
|
||||
controlled origin. Source: security #3.
|
||||
- **Separate per-file expand-state hook** → REPLACE with local state in GitDiffView (one consumer). Reopen:
|
||||
a second component needs the same expand state. Source: architect.
|
||||
- **`autoSelectMode` / `canCommit` as separate files** → keep as inline pure helpers in git_diff.ts (tested),
|
||||
not separate modules. Source: architect/test-engineer.
|
||||
|
||||
## Notes for synthesis
|
||||
|
||||
- No `feature-technical-notes.md` (no T# notes) — omit all T# handling.
|
||||
- Honor: deploy-by-surface (now single surface: docker rebuild), stage-commits-by-path, WS-frame/sessionEvent
|
||||
parity steps (only a sessionEvent here — `useSidebar.ts` case), pure-helper-then-wire TDD precedent,
|
||||
`globals:false` + `.js` import suffixes + `src/**/__tests__/**` glob for tests.
|
||||
- Security posture section: real content (argv-safety, path-traversal, identity, not-in-tool-registry,
|
||||
artifact-sandbox). On-call resilience section: real content (deadline, index-lock, coalescence,
|
||||
in-progress, partial-failure). Operational readiness: single deploy surface, no migration.
|
||||
168
docs/features/git-diff-panel/artifacts/team-findings.md
Normal file
168
docs/features/git-diff-panel/artifacts/team-findings.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Team findings — Git diff panel
|
||||
|
||||
Review-team findings on [`feature-specification.md`](../feature-specification.md) and resolutions. Team
|
||||
(medium): junior-developer, user-experience-designer, adversarial-security-analyst, on-call-engineer.
|
||||
Dispatched 2026-06-02. All findings resolved by evidence/convention/design-judgment; none required new
|
||||
user input beyond the three foundational answers already given. Shared F# counter. All resolutions
|
||||
applied to spec and decision log 2026-06-02.
|
||||
|
||||
## Major findings
|
||||
|
||||
### F1 — Assistant could drive the new git-write endpoints via a rendered artifact (security)
|
||||
- **Raised by:** adversarial-security-analyst (D8). Highest priority.
|
||||
- **Concern:** D8 says the read-only-assistant rule covers the AI's tools, not the user's UI. But the AI can
|
||||
emit HTML artifacts; if an artifact could POST to the new stage/commit/discard endpoints using the user's
|
||||
Authelia cookie, the assistant would gain a write path it is forbidden.
|
||||
- **Resolved by:** evidence. The HTML artifact iframe is sandboxed with `connect-src 'none'` (per
|
||||
`BOOCHAT.md` output-format section — fetch/WebSocket do not work in artifacts), so an artifact cannot reach
|
||||
the endpoints. Spec gains an explicit commitment: the git-write actions are user-initiated UI actions only,
|
||||
are never registered as an assistant tool, and the artifact sandbox prevents an artifact from invoking them.
|
||||
- **Affected decisions:** D8 (expanded), D12 (new).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Actors and triggers, Coordinations.
|
||||
|
||||
### F2 — D11 scoping needs explicit derivation + argument-safety commitments (security)
|
||||
- **Raised by:** adversarial-security-analyst (D11).
|
||||
- **Concern:** "confined to the project path" is a destination constraint that doesn't say the path is
|
||||
derived server-side, that per-file arguments are validated to resolve inside the repo, or that the commit
|
||||
message and file arguments are passed as discrete arguments (not shell-interpolated).
|
||||
- **Resolved by:** evidence (the existing project path-scoping guard derives roots from the project record,
|
||||
never from the request). Spec/D11 gain three commitments: repository root derived server-side from the
|
||||
session's project; per-file arguments validated as repo-relative and rejected if they escape; commit
|
||||
message and file arguments passed as discrete arguments, never built into a shell string.
|
||||
- **Affected decisions:** D11 (expanded), D12 (new).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Coordinations.
|
||||
|
||||
### F3 — Commit author identity must be server-derived, not request-supplied (security + clarity)
|
||||
- **Raised by:** adversarial-security-analyst, junior-developer (JD-004; Open items).
|
||||
- **Concern:** identity was deferred entirely to implementation; an unauthenticated local request could set an
|
||||
arbitrary author, and the codebase already hardcodes differing identities elsewhere.
|
||||
- **Resolved by:** evidence + commitment. Spec commits: a panel commit's author/committer is derived from a
|
||||
server-side source (host git config or a configured value); the request body cannot set or influence it.
|
||||
The exact source is left to plan-implementation.
|
||||
- **Affected decisions:** D6 (expanded), D12 (new).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Primary flow, Open items (closed).
|
||||
|
||||
### F4 — Discard semantics: own "irrecoverable", distinguish tracked vs untracked, separate the affordance
|
||||
- **Raised by:** adversarial-security-analyst (D7), on-call-engineer (OCE-002), user-experience-designer (UX-005).
|
||||
- **Concern:** the spec calls discard "irrecoverable" but defers the git mechanic, creating a tension; a
|
||||
tracked-file revert and an untracked-file permanent delete are different losses; and Discard sitting next to
|
||||
Stage at equal weight invites accidental taps on mobile.
|
||||
- **Resolved by:** design-judgment. Discard hard-deletes (own the word "irrecoverable"). The confirmation uses
|
||||
two variants — "Discard changes to X?" (tracked, reverts to committed content) vs "Delete X? It has never
|
||||
been committed and cannot be recovered" (untracked). The Discard affordance is separated from Stage/Unstage
|
||||
(an overflow/secondary placement), not an equal-weight sibling button.
|
||||
- **Affected decisions:** D7 (expanded), D15 (new).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Alternate flows and states, User interactions.
|
||||
|
||||
### F5 — Index-lock contention with concurrent agent turns is unnamed (resilience)
|
||||
- **Raised by:** on-call-engineer (OCE-001).
|
||||
- **Resolved by:** evidence. Spec names the case: when a write fails because the repository is busy (its index
|
||||
is locked by another process, e.g. a concurrent agent turn), the panel communicates "the repository is busy,
|
||||
try again" rather than a raw git error.
|
||||
- **Affected decisions:** —
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Edge cases and failure modes.
|
||||
|
||||
### F6 — "Leave state unchanged" is unenforceable for partial failures (resilience wording)
|
||||
- **Raised by:** on-call-engineer (OCE-002).
|
||||
- **Resolved by:** reword. "Leaves the repository state unchanged" → "leaves the repository as close to its
|
||||
pre-action state as the git layer allows, and the list refreshes to reflect the repository's true state";
|
||||
an untracked-directory discard that fails partway may leave a partially-removed tree, surfaced on refresh.
|
||||
- **Affected decisions:** —
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Edge cases and failure modes.
|
||||
|
||||
### F7 — No deadline on a hanging git read (resilience)
|
||||
- **Raised by:** on-call-engineer (OCE-003).
|
||||
- **Resolved by:** add commitment. If a git read does not complete within a deadline, the panel leaves the
|
||||
loading state, shows an error, and offers Refresh (distinct from the large-result cap in D5).
|
||||
- **Affected decisions:** D5 (expanded).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Edge cases and failure modes.
|
||||
|
||||
### F8 — Concurrent refresh triggers have no coalescence commitment (resilience)
|
||||
- **Raised by:** on-call-engineer (OCE-004).
|
||||
- **Resolved by:** add commitment. Concurrent refresh triggers are coalesced — a refresh already in flight
|
||||
absorbs later triggers instead of spawning a second concurrent read; the panel settles to a single final
|
||||
snapshot.
|
||||
- **Affected decisions:** D10 (expanded).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Alternate flows and states.
|
||||
|
||||
### F9 — In-progress git states (merge/rebase/cherry-pick/bisect) make writes fail opaquely (resilience)
|
||||
- **Raised by:** on-call-engineer (OCE-005).
|
||||
- **Resolved by:** add commitment. When the repository is mid-operation (merge, rebase, cherry-pick, or bisect),
|
||||
the panel disables its write affordances and shows the repository's state rather than letting stage/commit/
|
||||
discard fail with raw git errors.
|
||||
- **Affected decisions:** —
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Edge cases and failure modes.
|
||||
|
||||
### F10 — The Changes tab is undiscoverable and collides with "Pending Changes" naming (UX)
|
||||
- **Raised by:** user-experience-designer (UX-001, UX-008), junior-developer (JD-001).
|
||||
- **Resolved by:** design-judgment. (a) The new tab is named **Git** (Files / Git), distinct from the existing
|
||||
"Pending Changes" panel; the existing panel is NOT renamed (out of scope). (b) An ambient indicator on the
|
||||
file-panel toggle/header signals the repository is dirty (derived from the refresh data already gathered),
|
||||
so the tab is findable. (c) When the Git view is empty but the session has unapplied pending changes, the
|
||||
empty state hints that those live in the pending-changes panel.
|
||||
- **Affected decisions:** D16 (new tab name), D17 (new dirty indicator + empty-state hint).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Actors and triggers, Alternate flows and states, User interactions, Coordinations, Out of scope.
|
||||
|
||||
### F11 — Committed-mode base is undefined and unlabeled (UX + correctness)
|
||||
- **Raised by:** user-experience-designer (UX-002), junior-developer (JD-002).
|
||||
- **Resolved by:** decision. Committed mode compares the current branch against its **base** — the upstream
|
||||
tracking branch when set, otherwise the repository's default branch (main/master). The view labels the base
|
||||
it used ("Git — branch vs <base>"). When no base resolves, the panel shows uncommitted changes and labels
|
||||
the mode as falling back, rather than silently swapping.
|
||||
- **Affected decisions:** D2 (expanded), D13 (new).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Primary flow, Edge cases and failure modes, User interactions.
|
||||
|
||||
### F12 — Auto-mode selection silently dislocates the view on refresh (UX)
|
||||
- **Raised by:** user-experience-designer (UX-003).
|
||||
- **Resolved by:** design-judgment. Auto mode-selection applies on first open only; once the user picks a mode
|
||||
it is pinned for the session and refreshes do not override it. A refresh that would change the mode (e.g. the
|
||||
tree went clean) briefly notes the change rather than swapping silently.
|
||||
- **Affected decisions:** D3 (expanded), D14 (new).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Primary flow, Alternate flows and states.
|
||||
|
||||
### F13 — Staged vs unstaged distinction must not be color-only (UX/accessibility)
|
||||
- **Raised by:** user-experience-designer (UX-004).
|
||||
- **Resolved by:** add commitment. Staged and unstaged files are distinguished by more than color (a label/icon,
|
||||
and grouping into staged/unstaged sections); each stage/unstage control carries an accessible name that
|
||||
includes the file path.
|
||||
- **Affected decisions:** —
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** Primary flow, User interactions.
|
||||
|
||||
### F14 — Discard's availability across modes is unspecified (clarity)
|
||||
- **Raised by:** junior-developer (JD-003).
|
||||
- **Resolved by:** decision. Stage, unstage, commit, and discard are available only in Uncommitted mode;
|
||||
Committed mode is read-only review (no per-file revert of committed history in v1).
|
||||
- **Affected decisions:** D6 (scoped), D15 (new).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** User interactions, Alternate flows and states.
|
||||
|
||||
### F15 — Mobile fit + tap-target convention for the new tab and controls (UX)
|
||||
- **Raised by:** user-experience-designer (UX-009), junior-developer (JD-008).
|
||||
- **Resolved by:** convention. All interactive controls in the panel follow the app's existing mobile tap-target
|
||||
minimum; the Files / Git tab strip and header fit one line without horizontal scroll or wrapping (the project's
|
||||
toolbar-fit rule), condensing existing header elements if needed.
|
||||
- **Affected decisions:** D18 (new).
|
||||
- **Affected tech-notes:** —
|
||||
- **Changed in spec:** User interactions.
|
||||
|
||||
## Minor edits
|
||||
|
||||
- F16: Successful commit shows a brief, non-blocking success confirmation (not just files disappearing) — user-experience-designer (UX-006) — **Affected decisions:** — **Affected tech-notes:** — **Changed in spec:** Primary flow.
|
||||
- F17: Error placement — commit-area errors appear by the commit control, per-file action errors appear in the affected file row — user-experience-designer (UX-007) — **Affected decisions:** — **Affected tech-notes:** — **Changed in spec:** Edge cases and failure modes.
|
||||
- F18: Which service runs the git read vs write operations (read-only server vs write-capable host service) is an architecture/module-boundary decision routed to plan-implementation; the spec stays behavioral ("the system performs…") — junior-developer (JD-005) — **Affected decisions:** — **Affected tech-notes:** — **Changed in spec:** — (plan-implementation input).
|
||||
- F19: The git-write surface is the larger half of v1; implementation should sequence diff-display before the write actions even within v1 — junior-developer (JD-007) — **Affected decisions:** — **Affected tech-notes:** — **Changed in spec:** — (plan-implementation note).
|
||||
- F20: The refresh-on-pending-apply and refresh-on-turn-complete triggers require an event/frame wiring that must follow the project's WS-frame / sessionEvents parity steps — junior-developer (JD-009) — **Affected decisions:** — **Affected tech-notes:** — **Changed in spec:** — (plan-implementation note).
|
||||
- F21: D8's commit-button-in-a-read-only-session affordance needs no extra label; the "Git" tab name and dirty indicator make it clearly the user's own git surface, not assistant output — junior-developer (JD-006) — **Affected decisions:** D8 confirmed, no extra label needed — **Affected tech-notes:** — **Changed in spec:** — (D8 confirmed).
|
||||
197
docs/features/git-diff-panel/feature-implementation-plan.md
Normal file
197
docs/features/git-diff-panel/feature-implementation-plan.md
Normal file
@@ -0,0 +1,197 @@
|
||||
# Feature Implementation Plan: Git Diff Panel
|
||||
|
||||
A Files / Git tab in the right-side file panel that reads the project repository's diff (two modes) and stages, unstages, commits, and discards whole files — built entirely in `apps/server` ([D-1](artifacts/implementation-decision-log.md#d-1-both-git-read-and-git-write-live-in-appsserver)), shipped via a single docker rebuild, in two phases (read/display, then write actions) with no schema change, no migration, and no `@boocode/contracts` change.
|
||||
|
||||
## Source Specification
|
||||
|
||||
- **Feature specification:** [feature-specification.md](feature-specification.md)
|
||||
- **Specification decision log:** [artifacts/decision-log.md](artifacts/decision-log.md)
|
||||
- **Specification team findings:** [artifacts/team-findings.md](artifacts/team-findings.md)
|
||||
- **Specification decisions this plan inherits:** D1–D18
|
||||
- **Specification open items this plan must respect or resolve:** None — the spec's only open item (commit identity) was settled at spec time (F3, D12).
|
||||
|
||||
## Outcome
|
||||
|
||||
When this plan is executed, the right-side file panel gains a Files / Git tab. Selecting **Git** shows the project repository's changed files (Uncommitted or Committed mode), each expandable to a syntax-highlighted unified diff. The user can stage, unstage, commit (with a server-derived identity), and discard whole files without leaving the session. The work is delivered by a new `apps/server/src/services/git_diff.ts` (read logic + git-write helpers + pure helpers), new read and write routes in `apps/server/src/routes/projects.ts`, and new `apps/web` UI (`GitDiffView`, a tab in `RightRail.tsx`, a `useGitDiff` hook, and a `git_diff_refresh` sessionEvent). No apps/coder change, no Postgres schema change, no new env var.
|
||||
|
||||
## Context
|
||||
|
||||
- **Driving constraint:** A direct user request (2026-06-02) to add a Paseo-style git diff panel shown "instead of the file browser." Not deadline-bound; scoped to ship as a coherent v1.
|
||||
- **Stakeholders:** The single session user (reviews and commits their own repo changes in-session). The project's security posture (the write surface must not become an assistant-reachable path). On-call/operability (the panel must not stall on large or slow repos or fail opaquely under index contention).
|
||||
- **Future-state concern:** The git-write surface now lives in `apps/server`. Watch for a third consumer of git ops (would trigger a shared-`packages/` extraction, [D-1](artifacts/implementation-decision-log.md#d-1-both-git-read-and-git-write-live-in-appsserver)) and for working-tree diffs routinely exceeding the 10MB read buffer ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)).
|
||||
- **Out-of-scope boundary:** No remote operations (push/pull/PR/merge), no side-by-side layout, no per-hunk staging, no continuous file-watch streaming, no rename of the pending-changes panel, no per-line review/re-prompt surface — all deferred in the spec under YAGNI.
|
||||
|
||||
## Team Composition and Participation
|
||||
|
||||
| Specialist | Status | Key Input |
|
||||
|------------|--------|-----------|
|
||||
| `project-manager` | Coordinator | Facilitated R1, corrected the service-owner decision against evidence, synthesized the plan. |
|
||||
| `software-architect` | Active | Recommended a read-server/write-coder split (R1); the split's premise was refuted by evidence and recorded as the rejected alternative on [D-1](artifacts/implementation-decision-log.md#d-1-both-git-read-and-git-write-live-in-appsserver). Owns the read shape, base resolution, refresh wiring, two-phase sequencing. |
|
||||
| `adversarial-security-analyst` | Active | Argv-safety, path-traversal, server-derived identity, tool-registry exclusion, artifact-sandbox evidence ([D-3](artifacts/implementation-decision-log.md#d-3-write-ops-via-argv-safe-rungitexecfile-with----separators)–[D-5](artifacts/implementation-decision-log.md#d-5-commit-identity-server-derived-request-schema-strict-with-no-author-fields), [D-13](artifacts/implementation-decision-log.md#d-13-write-endpoints-excluded-from-the-assistant-tool-registry-artifact-sandbox-closes-the-indirect-path)). |
|
||||
| `on-call-engineer` | Active | Read deadline + maxBuffer, index-lock 409, refresh coalescence, in-progress detection, partial-failure honesty ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes), [D-8](artifacts/implementation-decision-log.md#d-8-refresh-via-client-git_diff_refresh-sessionevent-coalesced--not-a-ws-frame)). |
|
||||
| `test-engineer` | Active | T1–T12: pure-helper units + temp-repo integration; skip Shiki/layout (no web harness). |
|
||||
| `junior-developer` | Reframer | Flagged the cross-service coupling of the write-in-coder split, which seeded the evidence correction on [D-1](artifacts/implementation-decision-log.md#d-1-both-git-read-and-git-write-live-in-appsserver); confirmed the read-only-session Git tab needs no extra label (F21). |
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
Both the git read and the git write operations live in `apps/server` ([D-1](artifacts/implementation-decision-log.md#d-1-both-git-read-and-git-write-live-in-appsserver)) — reusing the existing safe argv `runGit` and the `project_bootstrap.ts` server-side git-write precedent, and avoiding a cross-service proxy hop, a second deploy surface, and coder coupling. The feature reuses Shiki (`lang:'diff'`) already in `apps/web`, the `useProjectGit` dirty signal, and the existing `RightRail.tsx` file-panel host; it introduces one new server service, new routes, one new web view + hook, and one new client `sessionEvents` event.
|
||||
|
||||
### Architecture and Integration Points
|
||||
|
||||
- **New `apps/server/src/services/git_diff.ts`** — read logic, git-write helpers, and TDD-first pure helpers (`parseNameStatus`, `splitDiffByFile`, `resolveCommittedBase`, `autoSelectMode`, `classify`, `detectInProgress`) ([D-2](artifacts/implementation-decision-log.md#d-2-read-route--git_diffts-pure-helpers-tdd-first)). `autoSelectMode` and `canCommit` are inline tested helpers, not separate modules ([D-12](artifacts/implementation-decision-log.md#trivial-decisions)).
|
||||
- **New routes in `apps/server/src/routes/projects.ts`**, beside `GET /api/projects/:id/git`: read `GET /api/projects/:id/git/diff?mode=<uncommitted|committed>` ([D-2](artifacts/implementation-decision-log.md#d-2-read-route--git_diffts-pure-helpers-tdd-first)); writes for stage / unstage / commit / discard ([D-3](artifacts/implementation-decision-log.md#d-3-write-ops-via-argv-safe-rungitexecfile-with----separators)). The repository root is derived server-side via `path_guard.ts` `resolveProjectRoot`, never from the request ([D-4](artifacts/implementation-decision-log.md#d-4-path-validation--repo-relative-pathguard-reject-escape-and-repo-root-discard)).
|
||||
- **Frontend** — a Files / Git tab in the existing `RightRail.tsx` header (replacing the static "Files" label, fitting one line); a new `GitDiffView` in the same slot as the file tree; a `useGitDiff` hook; the dirty dot fed by `useProjectGit`'s existing `is_dirty` ([D-15](artifacts/implementation-decision-log.md#d-15-frontend--rightrail-filesgit-tab-gitdiffview-with-lazy-shiki-dirty-dot-from-useprojectgit)). Per-file expand/collapse is local state in `GitDiffView`, not a shared hook ([D-11](artifacts/implementation-decision-log.md#trivial-decisions)).
|
||||
|
||||
### Data Model and Persistence
|
||||
|
||||
None. The panel reads git state at request time and writes the project repository directly via git; nothing is persisted in Postgres — no schema change, no migration, no new env var ([D-10](artifacts/implementation-decision-log.md#trivial-decisions)).
|
||||
|
||||
### Runtime Behavior
|
||||
|
||||
- **Read:** the read route runs argv `runGit` calls under a 30s deadline and a 10MB `maxBuffer` ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)); `parseNameStatus` builds the file list, `splitDiffByFile` segments the unified diff, `classify` marks binary/large bodies, `detectInProgress` reads `.git` sentinels, and the response carries the resolved base label and the in-progress flag.
|
||||
- **Mode/base:** `autoSelectMode` picks Uncommitted (dirty) or Committed (clean) on first open only ([D-12](artifacts/implementation-decision-log.md#trivial-decisions)); `resolveCommittedBase` resolves `@{upstream}` → `origin/HEAD` → null, falling back to a labeled Uncommitted on null ([D-6](artifacts/implementation-decision-log.md#d-6-committed-mode-base-resolution-via-upstream--originhead--null)).
|
||||
- **Refresh:** the client `git_diff_refresh` sessionEvent fires on the five triggers, coalesced behind an in-flight ref so a running refresh absorbs later triggers ([D-8](artifacts/implementation-decision-log.md#d-8-refresh-via-client-git_diff_refresh-sessionevent-coalesced--not-a-ws-frame)).
|
||||
- **Diff render:** `GitDiffView` highlights a file's diff via Shiki `lang:'diff'` lazily on expand, with a per-file loading state ([D-15](artifacts/implementation-decision-log.md#d-15-frontend--rightrail-filesgit-tab-gitdiffview-with-lazy-shiki-dirty-dot-from-useprojectgit)).
|
||||
|
||||
### External Interfaces
|
||||
|
||||
New HTTP routes only (read + four writes) under `/api/projects/:id/git/…`. Refresh is a client-side `sessionEvents` event (`git_diff_refresh`), **not** a WS frame, so `@boocode/contracts` is not touched or rebuilt; the only parity step is a no-op `case 'git_diff_refresh'` in `useSidebar.ts` `applyEvent` ([D-8](artifacts/implementation-decision-log.md#d-8-refresh-via-client-git_diff_refresh-sessionevent-coalesced--not-a-ws-frame), [D-9](artifacts/implementation-decision-log.md#trivial-decisions)).
|
||||
|
||||
## Decomposition and Sequencing
|
||||
|
||||
Two phases on a single deploy surface ([D-14](artifacts/implementation-decision-log.md#d-14-two-phase-build--readdisplay-phase-1-then-write-actions-phase-2)); both ship via `docker compose up --build -d boocode`.
|
||||
|
||||
| # | Work Unit | Delivers | Depends On | Verification |
|
||||
|---|-----------|----------|------------|--------------|
|
||||
| 1 | `git_diff.ts` pure helpers (TDD-first) | `parseNameStatus`, `splitDiffByFile`, `resolveCommittedBase`, `autoSelectMode`, `classify`, `detectInProgress` ([D-2](artifacts/implementation-decision-log.md#d-2-read-route--git_diffts-pure-helpers-tdd-first), [D-6](artifacts/implementation-decision-log.md#d-6-committed-mode-base-resolution-via-upstream--originhead--null)) | — | Unit tests T1–T7 |
|
||||
| 2 | Read route `GET /api/projects/:id/git/diff?mode=` | Diff payload (files, counts, base label, in-progress flag) under 30s/10MB bounds ([D-2](artifacts/implementation-decision-log.md#d-2-read-route--git_diffts-pure-helpers-tdd-first), [D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)) | 1 | Temp-repo integration T8 |
|
||||
| 3 | `useGitDiff` + RightRail Files/Git tab + dirty dot | Read-only panel, tab switch, dirty indicator from `useProjectGit` ([D-15](artifacts/implementation-decision-log.md#d-15-frontend--rightrail-filesgit-tab-gitdiffview-with-lazy-shiki-dirty-dot-from-useprojectgit)) | 2 | Manual (no web harness) |
|
||||
| 4 | `GitDiffView` read-only + lazy Shiki + refresh wiring | Per-file expand, lazy `lang:'diff'`, `git_diff_refresh` sessionEvent + `useSidebar.ts` no-op case, coalescence ref ([D-8](artifacts/implementation-decision-log.md#d-8-refresh-via-client-git_diff_refresh-sessionevent-coalesced--not-a-ws-frame), [D-15](artifacts/implementation-decision-log.md#d-15-frontend--rightrail-filesgit-tab-gitdiffview-with-lazy-shiki-dirty-dot-from-useprojectgit)) | 3 | Manual |
|
||||
| 5 | Write helpers + routes (stage/unstage/commit/discard) | Argv-safe writes with `--` separators, `pathGuard`, server-derived `-c` identity, `.strict()` commit schema, 409 on index-lock ([D-3](artifacts/implementation-decision-log.md#d-3-write-ops-via-argv-safe-rungitexecfile-with----separators), [D-4](artifacts/implementation-decision-log.md#d-4-path-validation--repo-relative-pathguard-reject-escape-and-repo-root-discard), [D-5](artifacts/implementation-decision-log.md#d-5-commit-identity-server-derived-request-schema-strict-with-no-author-fields), [D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)); endpoints kept out of `ALL_TOOLS` ([D-13](artifacts/implementation-decision-log.md#d-13-write-endpoints-excluded-from-the-assistant-tool-registry-artifact-sandbox-closes-the-indirect-path)) | 1, 2 | Temp-repo integration T9–T12 |
|
||||
| 6 | Write affordances + in-progress disable | Stage/unstage/commit/discard UI, tracked/untracked discard confirmation, in-progress disable ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes), [D-15](artifacts/implementation-decision-log.md#d-15-frontend--rightrail-filesgit-tab-gitdiffview-with-lazy-shiki-dirty-dot-from-useprojectgit)) | 4, 5 | Manual |
|
||||
|
||||
Phase 1 = units 1–4 (read + display); Phase 2 = units 5–6 (write actions).
|
||||
|
||||
## RAID Log
|
||||
|
||||
### Assumptions
|
||||
|
||||
| ID | Assumption | What Changes If Wrong | Verifier | Status |
|
||||
|----|------------|-----------------------|----------|--------|
|
||||
| A1 | The boocode container keeps the `/opt:/opt` read-write mount, so apps/server can write project paths. | [D-1](artifacts/implementation-decision-log.md#d-1-both-git-read-and-git-write-live-in-appsserver) reopens — writes would need a host service. | `docker-compose.yml:16` | Verified (R1) |
|
||||
| A2 | A realistic single-user working-tree diff stays under 10MB. | The read truncates/errors; the streaming reader reopens (Deferred YAGNI). | `on-call-engineer` | Unverified — bounded by [D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes) |
|
||||
|
||||
### Dependencies
|
||||
|
||||
| ID | Dependency | Owner | Status |
|
||||
|----|------------|-------|--------|
|
||||
| Dep1 | Shiki `^1.29.2` (`lang:'diff'`) already in `apps/web`. | `apps/web` | Present (no install) |
|
||||
| Dep2 | `useProjectGit` already returns `is_dirty` for the dirty dot. | `apps/web` | Present (no new fetch) |
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
Sourced from `test-engineer` (T1–T12). Server-side only — `apps/web` has no test harness, so Shiki rendering and layout/tab behavior are verified manually, not in the suite. Vitest conventions: `globals:false` (import `describe`/`it`/`expect`), `.js` import suffixes, include glob `src/**/__tests__/**/*.test.ts`.
|
||||
|
||||
- **Observable behaviors to test:** porcelain → file-list parse with change types (T1); unified-diff split per file (T2); base resolution `@{upstream}` → `origin/HEAD` → null (T3); auto-select mode by dirty/clean (T4); binary/large classify (T5); in-progress sentinel detection (T6); `canCommit` gating (T7); read route over a temp repo (T8); stage/unstage round-trip (T9); commit with server-derived identity (T10); discard tracked vs untracked (T11); path-guard rejection of `..`/absolute/symlink-escape and repo-root discard (T12).
|
||||
- **Test doubles posture:** pure helpers tested directly with fixture strings (no git spawn); route/write tests use a real temp repo via `mkdtemp` + `git init` (the `path_guard.test.ts` integration pattern).
|
||||
- **Edge cases requiring coverage:** binary file, oversized diff, no resolvable base, in-progress state, index-lock 409, untracked-discard partial failure, path-escape attempts ([D-4](artifacts/implementation-decision-log.md#d-4-path-validation--repo-relative-pathguard-reject-escape-and-repo-root-discard), [D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)).
|
||||
- **Test levels:** unit (T1–T7, pure helpers) + integration (T8–T12, temp-repo). No end-to-end / web-layer automation.
|
||||
|
||||
## Security Posture
|
||||
|
||||
`adversarial-security-analyst` contributed the full write-surface posture. Threat vectors and the mitigations this plan commits to:
|
||||
|
||||
- **Command/flag injection** — every git invocation uses discrete argv with explicit `--` separators between options and user-supplied paths and a flag-injection guard (reject path args starting with `-`); never `hostExec(shell)` ([D-3](artifacts/implementation-decision-log.md#d-3-write-ops-via-argv-safe-rungitexecfile-with----separators)). User text (commit message, file targets) is always discrete argv.
|
||||
- **Path traversal** — `pathGuard(repoRoot, file)` resolves each per-file argument and rejects absolute paths, `..` traversal, and symlink escape outside the server-derived root; discarding the repo root (`.`) is rejected ([D-4](artifacts/implementation-decision-log.md#d-4-path-validation--repo-relative-pathguard-reject-escape-and-repo-root-discard)). The root is derived from the project record via `path_guard.ts`, never the request.
|
||||
- **Identity spoofing** — commit identity is server-derived (`-c user.name`/`-c user.email` from git config, falling back to the `project_bootstrap.ts` constants); the commit request schema is Zod `.strict()` with `{message, files?}` only — no author/email/date field can be supplied ([D-5](artifacts/implementation-decision-log.md#d-5-commit-identity-server-derived-request-schema-strict-with-no-author-fields)).
|
||||
- **Assistant-driven invocation** — the write endpoints are never registered in `ALL_TOOLS`, so no assistant tool surface reaches them; the indirect artifact path is already closed by the artifact iframe's `connect-src 'none'` sandbox (F1), so no new CSRF mitigation is built ([D-13](artifacts/implementation-decision-log.md#d-13-write-endpoints-excluded-from-the-assistant-tool-registry-artifact-sandbox-closes-the-indirect-path)).
|
||||
|
||||
## Operational Readiness
|
||||
|
||||
- **Deploy surface:** single — `docker compose up --build -d boocode` (rebuilds web + server from the working tree). No `sudo systemctl restart boocoder`, because no apps/coder code changes ([D-1](artifacts/implementation-decision-log.md#d-1-both-git-read-and-git-write-live-in-appsserver)).
|
||||
- **Schema / migration / env:** none — no Postgres change, no migration, no new env var ([D-10](artifacts/implementation-decision-log.md#trivial-decisions)).
|
||||
- **Contracts package:** not touched — refresh is a client `sessionEvents` event, not a WS frame, so `packages/contracts/` is not rebuilt ([D-9](artifacts/implementation-decision-log.md#trivial-decisions)).
|
||||
- **Feature flag / rollout:** none — the panel is additive and inert until the Git tab is opened; rollback is reverting the build.
|
||||
|
||||
## On-Call Resilience Posture
|
||||
|
||||
`on-call-engineer` contributed; each commitment maps to a flagged failure mode.
|
||||
|
||||
- **Timeouts and deadlines:** the read path runs under a 30s execution deadline and a 10MB `maxBuffer`, both distinct from the D5 per-file display-size cap; a read that exceeds the deadline exits loading, shows an error, and offers Refresh ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)).
|
||||
- **Retry strategy:** none on the server. An index-lock write failure returns **HTTP 409 "repository busy"**; retry is user-driven, so no timer state or hidden busy state is introduced ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)).
|
||||
- **Concurrency / coalescence:** the client holds an in-flight coalescence ref so concurrent `git_diff_refresh` triggers absorb into the running refresh — one read at a time, settling to a single final snapshot ([D-8](artifacts/implementation-decision-log.md#d-8-refresh-via-client-git_diff_refresh-sessionevent-coalesced--not-a-ws-frame)).
|
||||
- **Graceful degradation:** `detectInProgress` (`.git` MERGE_HEAD / rebase / CHERRY_PICK_HEAD / BISECT_LOG sentinels) folds an in-progress flag into the read response; the client disables write affordances when set, so stage/commit/discard never fail with raw git errors mid-operation ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)).
|
||||
- **Data integrity:** an untracked-file discard that fails partway reports honest partial failure on the next refresh rather than claiming an unenforceable "state unchanged" ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes)).
|
||||
|
||||
## Definition of Done
|
||||
|
||||
- [ ] Opening the Git tab shows the project repo's changed files in the auto-selected mode; switching to Files restores the tree in the same slot ([D-15](artifacts/implementation-decision-log.md#d-15-frontend--rightrail-filesgit-tab-gitdiffview-with-lazy-shiki-dirty-dot-from-useprojectgit)).
|
||||
- [ ] Committed mode labels its resolved base (`@{upstream}` → `origin/HEAD` → labeled Uncommitted fallback) ([D-6](artifacts/implementation-decision-log.md#d-6-committed-mode-base-resolution-via-upstream--originhead--null)).
|
||||
- [ ] Stage / unstage / commit / discard operate at whole-file granularity in Uncommitted mode; discard prompts the tracked vs untracked confirmation; Committed mode is read-only ([D-3](artifacts/implementation-decision-log.md#d-3-write-ops-via-argv-safe-rungitexecfile-with----separators), [D-15](artifacts/implementation-decision-log.md#d-15-frontend--rightrail-filesgit-tab-gitdiffview-with-lazy-shiki-dirty-dot-from-useprojectgit)).
|
||||
- [ ] Commit identity is server-derived; the request cannot set it; the schema is `.strict()` ([D-5](artifacts/implementation-decision-log.md#d-5-commit-identity-server-derived-request-schema-strict-with-no-author-fields)).
|
||||
- [ ] Path-escape and repo-root discard are rejected; writes use argv + `--` separators ([D-3](artifacts/implementation-decision-log.md#d-3-write-ops-via-argv-safe-rungitexecfile-with----separators), [D-4](artifacts/implementation-decision-log.md#d-4-path-validation--repo-relative-pathguard-reject-escape-and-repo-root-discard)).
|
||||
- [ ] Write endpoints are absent from `ALL_TOOLS` ([D-13](artifacts/implementation-decision-log.md#d-13-write-endpoints-excluded-from-the-assistant-tool-registry-artifact-sandbox-closes-the-indirect-path)).
|
||||
- [ ] Read respects the 30s/10MB bounds; index-lock returns 409; in-progress disables writes; refresh coalesces ([D-7](artifacts/implementation-decision-log.md#d-7-read-deadline-30s--maxbuffer-10mb-index-lock--http-409-in-progress-detection-disables-writes), [D-8](artifacts/implementation-decision-log.md#d-8-refresh-via-client-git_diff_refresh-sessionevent-coalesced--not-a-ws-frame)).
|
||||
- [ ] Tests T1–T12 pass (`pnpm -C apps/server test`).
|
||||
- [ ] Phase 1 ships and is reviewable before Phase 2 lands ([D-14](artifacts/implementation-decision-log.md#d-14-two-phase-build--readdisplay-phase-1-then-write-actions-phase-2)).
|
||||
- [ ] Post-ship owner: the session user (single-user app); no separate on-call rotation.
|
||||
|
||||
## Specialist Handoffs for Implementation
|
||||
|
||||
- **`test-engineer`** — dispatch at the start of unit 1 to own T1–T12; needs the helper signatures from `git_diff.ts` and the temp-repo integration pattern (`path_guard.test.ts`).
|
||||
- **`adversarial-security-analyst`** — dispatch to review unit 5 before merge; needs the write-route handlers and the `.strict()` commit schema to confirm argv-safety, path-guard coverage, and identity derivation.
|
||||
- **`user-experience-designer`** — dispatch during units 3–4 and 6; needs the `RightRail.tsx` header layout to confirm one-line fit (D18), tap-target minimum, and the tracked/untracked discard wording.
|
||||
|
||||
## Deferred (YAGNI)
|
||||
|
||||
### Shared `packages/` runGit extraction
|
||||
- **Why deferred:** Rule of Three not met — only apps/server consumes the git ops for this feature (coder is untouched); a single-consumer abstraction is premature.
|
||||
- **Reopen when:** a third consumer needs the same git ops.
|
||||
- **Source:** R1, software-architect.
|
||||
|
||||
### New WS frame for refresh
|
||||
- **Why deferred:** Replaced by the simpler client `sessionEvents` event `git_diff_refresh` — no trigger needs a server push, so a WS frame and a `@boocode/contracts` rebuild are unnecessary (simpler-version test).
|
||||
- **Reopen when:** a refresh trigger genuinely originates server-side (an out-of-band repo mutation the client cannot observe).
|
||||
- **Source:** R1, software-architect / junior-developer.
|
||||
|
||||
### Server-side retry on index-lock
|
||||
- **Why deferred:** "Try again" is user-driven; a server retry hides the busy state and adds timer state (evidence test — no observed contention).
|
||||
- **Reopen when:** index-lock contention is observed frequent in practice.
|
||||
- **Source:** R1, on-call-engineer (F5).
|
||||
|
||||
### Streaming diff reader (cap mid-stream)
|
||||
- **Why deferred:** `execFile` maxBuffer 10MB covers any realistic single-user working-tree diff; ~40–50 LoC for a transient memory spike that does not matter at this scale (simpler-version test).
|
||||
- **Reopen when:** working-tree diffs routinely exceed 10MB.
|
||||
- **Source:** R1, on-call-engineer.
|
||||
|
||||
### CSRF custom-header on the write routes
|
||||
- **Why deferred:** `connect-src 'none'` on artifacts + the SameSite=Lax Authelia cookie + a same-origin SPA cover it at single-user scale (evidence test — no exposed cross-origin path).
|
||||
- **Reopen when:** the write routes are exposed to a less-controlled origin.
|
||||
- **Source:** R1, adversarial-security-analyst.
|
||||
|
||||
### Separate per-file expand-state hook
|
||||
- **Why deferred:** Replaced by local state in `GitDiffView` — one consumer (single-implementation abstraction).
|
||||
- **Reopen when:** a second component needs the same expand state.
|
||||
- **Source:** R1, software-architect.
|
||||
|
||||
### `autoSelectMode` / `canCommit` as separate modules
|
||||
- **Why deferred:** Kept as inline tested pure helpers in `git_diff.ts` — splitting into modules adds files without a second consumer (simpler-version test).
|
||||
- **Reopen when:** a second module needs to import them independently.
|
||||
- **Source:** R1, software-architect / test-engineer.
|
||||
|
||||
## Open Items
|
||||
|
||||
- None. Every Round-1 open question resolved from evidence; the spec's only open item (commit identity) was settled at spec time. The plan is shippable as written.
|
||||
|
||||
## Summary
|
||||
|
||||
- **Outcome delivered:** A Files / Git tab in the right-side file panel that reads and (whole-file) stages, unstages, commits, and discards the project repository's changes, built entirely in `apps/server`.
|
||||
- **Team size:** 6 specialists — see [artifacts/implementation-iteration-history.md](artifacts/implementation-iteration-history.md)
|
||||
- **Rounds of facilitation:** 1 — see [artifacts/implementation-iteration-history.md](artifacts/implementation-iteration-history.md)
|
||||
- **Decisions committed:** 15 — see [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md)
|
||||
- **Decisions settled by evidence:** 15 — see [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md)
|
||||
- **Decisions settled by junior-developer reframing:** 0 (the junior-developer's coupling flag seeded the evidence correction on D-1, but the resolution was evidence) — see [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md)
|
||||
- **Decisions settled by user input:** 0 — see [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md)
|
||||
- **Rejected alternatives recorded:** 22 — see [artifacts/implementation-decision-log.md](artifacts/implementation-decision-log.md)
|
||||
- **Open items remaining:** 0
|
||||
- **Recommendation:** Ship as planned — build Phase 1 (units 1–4) first, then Phase 2 (units 5–6).
|
||||
203
docs/features/git-diff-panel/feature-specification.md
Normal file
203
docs/features/git-diff-panel/feature-specification.md
Normal file
@@ -0,0 +1,203 @@
|
||||
# Feature specification — Git diff panel
|
||||
|
||||
## Source
|
||||
|
||||
No formal upstream PRD. Authored from a direct request (2026-06-02): "add a git diff panel that can be
|
||||
shown instead of the file browser, similar to Paseo," plus a read-only review of the Paseo reference at
|
||||
`/opt/forks/paseo`. Ground-truth conventions drawn from the project's own docs and existing surfaces.
|
||||
|
||||
## Outcome
|
||||
|
||||
A person working in a session can review the uncommitted (and on-branch) changes of the project's
|
||||
repository in a diff view that lives in the same right-side panel slot as the file browser, switching
|
||||
between the two with a tab. From that view they can read each changed file's diff, stage and unstage
|
||||
files, commit staged work with a message, and discard a file's changes — without leaving the session or
|
||||
opening a terminal. The view tells them, at a glance, which files changed and by how much, and stays
|
||||
current as agents and the user make edits.
|
||||
|
||||
## Actors and triggers
|
||||
|
||||
- **The session user** (single user) opens the right-side file panel and switches its tab from Files to
|
||||
Git. The Git tab is the trigger; switching back to Files restores the file tree in the same slot
|
||||
([D1](artifacts/decision-log.md#d1--placement-a-tab-inside-the-file-browser)).
|
||||
- The panel is available in any session that has the file panel, because the stage / commit / discard
|
||||
actions are the human user's own UI actions, not assistant tools — the AI has no access to these
|
||||
endpoints, and the artifact sandbox prevents a rendered artifact from reaching them
|
||||
([D8](artifacts/decision-log.md#d8--git-write-is-a-user-action-not-an-assistant-tool),
|
||||
[D12](artifacts/decision-log.md#d12--git-write-security-posture)).
|
||||
- An ambient indicator on the file-panel header signals the repository is dirty, making the Git tab
|
||||
findable without opening it ([D17](artifacts/decision-log.md#d17--ambient-dirty-indicator-and-empty-state-hint)).
|
||||
|
||||
## Primary flow
|
||||
|
||||
1. The user opens the right-side file panel and selects the **Git** tab.
|
||||
2. The panel shows the changes of the **project's repository**, defaulting its comparison mode by the
|
||||
repository state on first open: **Uncommitted** (working tree vs. the last commit) when the working
|
||||
tree is dirty, **Committed** (the current branch vs. its base) when it is clean. Once the user
|
||||
selects a mode explicitly, that choice is pinned for the session and subsequent refreshes do not
|
||||
override it; if a refresh would change the auto-selected mode (e.g. the tree went clean while
|
||||
Uncommitted was pinned), the panel briefly notes the change rather than swapping silently
|
||||
([D2](artifacts/decision-log.md#d2--scope-the-project-repository-with-two-comparison-modes),
|
||||
[D3](artifacts/decision-log.md#d3--mode-auto-selection-and-session-pinning),
|
||||
[D14](artifacts/decision-log.md#d14--mode-pinning-and-first-open-auto-selection)).
|
||||
3. The panel presents a list of changed files, each with its path, change type (added / modified /
|
||||
deleted / renamed / untracked), and an added/removed line count. In Committed mode the header labels
|
||||
the base used ("Git — branch vs <base>")
|
||||
([D13](artifacts/decision-log.md#d13--committed-mode-base-resolution-and-labeling)).
|
||||
4. The user expands a file to read its diff inline; collapsing hides it again. A control expands or
|
||||
collapses all files at once.
|
||||
5. The user **stages** or **unstages** individual files. Staged and unstaged files are grouped into
|
||||
separate sections and distinguished by both a label/icon and grouping — not color alone — and each
|
||||
stage/unstage control carries an accessible name that includes the file path.
|
||||
6. The user writes a commit message and **commits** the staged files. The commit's author and committer
|
||||
identity is derived from a server-side source; the request cannot set or influence it
|
||||
([D12](artifacts/decision-log.md#d12--git-write-security-posture)). On success the committed files
|
||||
leave the list, the panel refreshes to the new repository state, and a brief non-blocking confirmation
|
||||
is shown ([D6](artifacts/decision-log.md#d6--v1-actions-stage-unstage-commit-discard-no-push)).
|
||||
7. The user may switch the comparison mode explicitly (Uncommitted ↔ Committed) at any time; the file
|
||||
list and counts update to the selected mode, and the choice is pinned for the remainder of the session.
|
||||
|
||||
## Alternate flows and states
|
||||
|
||||
- **Loading:** while the first difference is being computed the panel shows a brief loading indicator in
|
||||
place of the file list.
|
||||
- **Empty (no changes):** when the selected mode has no changes the panel shows a mode-specific empty
|
||||
message ("No uncommitted changes" / "No changes vs. the base branch") instead of a file list. When
|
||||
the Git view is empty but the session has unapplied pending changes, the empty state hints that those
|
||||
live in the pending-changes panel
|
||||
([D17](artifacts/decision-log.md#d17--ambient-dirty-indicator-and-empty-state-hint)).
|
||||
- **Discard a file:** the user discards a single file's changes from an overflow or secondary affordance,
|
||||
separated from the Stage/Unstage controls (not an equal-weight sibling). Because discard is
|
||||
irrecoverable, the panel asks for a plain confirmation before acting, with wording that distinguishes
|
||||
the two cases: "Discard changes to X?" for a tracked file (which returns to its committed content) and
|
||||
"Delete X? It has never been committed and cannot be recovered" for an untracked file (which is
|
||||
permanently removed). On confirmation the file's changes are applied and the list refreshes
|
||||
([D7](artifacts/decision-log.md#d7--discard-requires-a-plain-confirmation),
|
||||
[D15](artifacts/decision-log.md#d15--discard-is-irrecoverable-tracked-vs-untracked-confirmation-separated-affordance)).
|
||||
- **Commit with an empty message or nothing staged:** the commit control is unavailable until at least one
|
||||
file is staged and a non-empty message is present.
|
||||
- **Refresh:** the panel re-reads the repository state when the Git tab is opened, after any stage /
|
||||
unstage / commit / discard it performs, after an agent turn completes, after the user applies or
|
||||
discards a queued change in the pending-changes panel, and on an explicit Refresh control. Concurrent
|
||||
refresh triggers are coalesced — a refresh already in flight absorbs later triggers rather than
|
||||
spawning a second concurrent read, so the panel settles to a single final snapshot
|
||||
([D10](artifacts/decision-log.md#d10--refresh-on-open-on-mutation-on-turn-completion-on-demand-with-coalescence)).
|
||||
|
||||
## Edge cases and failure modes
|
||||
|
||||
- **Not a git repository:** when the project's path is not a git repository the Git tab is not offered;
|
||||
the file panel stays on Files only.
|
||||
- **Binary files:** a changed binary file appears in the list with its path and change type but its body
|
||||
shows a "Binary file" placeholder instead of a diff.
|
||||
- **Very large diffs:** a file whose diff exceeds a display threshold appears in the list with its path and
|
||||
counts but its body shows a "Diff too large to display" placeholder; a git read that does not complete
|
||||
within a deadline exits the loading state, shows an error, and offers Refresh; the overall response is
|
||||
bounded so a huge change set cannot stall the panel
|
||||
([D5](artifacts/decision-log.md#d5--binary-and-large-file-handling)).
|
||||
- **A git action fails:** the panel surfaces the failure as an inline error — commit-area errors appear
|
||||
near the commit control; per-file action errors appear in the affected file row. The panel leaves the
|
||||
repository as close to its pre-action state as the git layer allows; the list refreshes to reflect the
|
||||
repository's true state.
|
||||
- **Repository busy (index locked):** when a write fails because the repository's index is locked by
|
||||
another process (e.g. a concurrent agent turn), the panel communicates "the repository is busy, try
|
||||
again" rather than a raw error.
|
||||
- **In-progress git operations:** when the repository is mid-operation (merge, rebase, cherry-pick, or
|
||||
bisect), the panel disables its write affordances and shows the repository's current state, rather than
|
||||
allowing stage / commit / discard to fail with raw errors.
|
||||
- **Concurrent edits during a read:** the displayed diff is a snapshot at read time; a later edit is picked
|
||||
up on the next refresh rather than mutating the view mid-read.
|
||||
- **The base branch cannot be resolved** (Committed mode, no discoverable base): the panel falls back to
|
||||
showing uncommitted changes and labels the mode as a fallback, rather than silently swapping or erroring
|
||||
([D13](artifacts/decision-log.md#d13--committed-mode-base-resolution-and-labeling)).
|
||||
|
||||
## User interactions
|
||||
|
||||
- A **Files / Git** tab switch in the file panel header. The diff view occupies the same slot as the
|
||||
file tree; only one is shown at a time. The tab strip and header fit on one line without horizontal
|
||||
scroll or wrapping; all interactive controls meet the app's existing mobile tap-target minimum. The
|
||||
tab affordance and the panel work the same on mobile (where the panel is a slide-in drawer) as on
|
||||
desktop ([D16](artifacts/decision-log.md#d16--tab-named-git),
|
||||
[D18](artifacts/decision-log.md#d18--mobile-tap-target-and-header-fit)).
|
||||
- An **ambient dirty indicator** on the file-panel toggle/header when the repository is dirty
|
||||
([D17](artifacts/decision-log.md#d17--ambient-dirty-indicator-and-empty-state-hint)).
|
||||
- A **comparison-mode** selector (Uncommitted / Committed) at the top of the Git view.
|
||||
- Per-file rows showing path, change type, and an added/removed count, each expandable to reveal a
|
||||
syntax-highlighted unified diff, with an expand-all / collapse-all control.
|
||||
- Files grouped into **staged** and **unstaged** sections; each section is labeled and the grouping
|
||||
itself is the primary distinction (supplemented by a per-file label/icon), not color alone; each
|
||||
stage/unstage control carries an accessible name including the file path.
|
||||
- Per-file **Stage / Unstage** affordances and a **Discard** affordance in an overflow or secondary
|
||||
position (not an equal-weight sibling of Stage/Unstage), and a **commit message** field with a
|
||||
**Commit** action. Stage / Unstage / Commit / Discard are available only in Uncommitted mode;
|
||||
Committed mode is read-only review
|
||||
([D6](artifacts/decision-log.md#d6--v1-actions-stage-unstage-commit-discard-no-push),
|
||||
[D15](artifacts/decision-log.md#d15--discard-is-irrecoverable-tracked-vs-untracked-confirmation-separated-affordance)).
|
||||
- A **Refresh** control.
|
||||
- Diffs render in a single (unified) layout; additions and removals are visually distinguished with line
|
||||
numbers ([D9](artifacts/decision-log.md)).
|
||||
|
||||
## Coordinations
|
||||
|
||||
- **The file panel** hosts the view and owns the Files ↔ Git tab state.
|
||||
- **The project repository** is the single source of truth for the diff and the target of stage / commit /
|
||||
discard. The repository root is derived server-side from the session's project record; per-file
|
||||
arguments are validated to resolve inside the repository and rejected if they escape it; user-supplied
|
||||
text (commit message, file targets) is passed as discrete arguments and never interpreted as commands.
|
||||
The git-write actions are never registered as assistant tools; the artifact sandbox prevents a rendered
|
||||
artifact from invoking them
|
||||
([D11](artifacts/decision-log.md#d11--all-git-operations-scoped-to-the-project-repository-path),
|
||||
[D12](artifacts/decision-log.md#d12--git-write-security-posture)).
|
||||
- **The pending-changes panel** remains the place where unapplied agent edits (held in a separate working
|
||||
copy) are reviewed and applied; the diff panel reflects the project repository's real state, so applying
|
||||
or discarding a pending change is one of the events that refreshes the diff panel. The two panels are
|
||||
complementary, not duplicates ([D2](artifacts/decision-log.md#d2--scope-the-project-repository-with-two-comparison-modes)).
|
||||
- **Agent turns and the user's own edits** change the repository; turn completion is a refresh trigger.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Pushing, pulling, creating pull requests, merging, or any operation that talks to a remote.
|
||||
- Per-line or per-hunk review comments and "send selected lines to an agent" — that is a separate feature
|
||||
(the diff-line re-prompt item), and this panel deliberately does not build a line-selection/commenting
|
||||
surface.
|
||||
- Side-by-side (split) diff layout.
|
||||
- Staging or discarding individual hunks/lines (stage and discard operate at whole-file granularity).
|
||||
- A live file-system watcher that streams diffs as files change on disk; refresh is event- and
|
||||
demand-driven, not continuous.
|
||||
- Showing the session agent's separate working-copy diff in this panel; that remains the pending-changes
|
||||
panel's job.
|
||||
- Renaming the existing pending-changes panel; naming and scope changes to that panel are out of scope
|
||||
for this feature.
|
||||
|
||||
## Deferred (YAGNI)
|
||||
|
||||
- **Push / pull / pull-request / merge actions.** Deferred — not requested (the request was "stage/commit"),
|
||||
and the assistant-level no-remote-write rule signals remotes are out of band for now. Reopening trigger: a
|
||||
stated need to publish commits from the panel. Evidence gate failed: no user-described need.
|
||||
- **Side-by-side diff layout.** Deferred — the primary surface is mobile-first and unified reads well there;
|
||||
a split layout is a desktop-only enhancement. Reopening trigger: a request to compare wide files
|
||||
side-by-side on desktop. Evidence gate: simpler unified version satisfies the stated need.
|
||||
- **Per-hunk staging / discarding.** Deferred — whole-file granularity covers the stated stage/commit need.
|
||||
Reopening trigger: a request to commit part of a file.
|
||||
- **Continuous file-watch streaming of the diff.** Deferred — event- and demand-driven refresh covers a
|
||||
single-user workflow without a watcher's cost. Reopening trigger: the diff is observed to feel stale
|
||||
between refresh events in practice.
|
||||
|
||||
## Open items
|
||||
|
||||
- None. Commit author/committer identity is settled: derived server-side from the host git configuration;
|
||||
the request cannot set or influence it (F3, [D12](artifacts/decision-log.md#d12--git-write-security-posture)).
|
||||
|
||||
## Summary
|
||||
|
||||
A Files / Git tab in the right-side file panel that shows the project repository's diff in two modes
|
||||
(uncommitted vs. HEAD, and the branch vs. its upstream/default base, auto-selected by repo state on
|
||||
first open and then pinned to the user's choice), lets the user stage, unstage, commit (with
|
||||
server-derived identity), and discard whole files (with irrecoverable-discard confirmation distinguishing
|
||||
tracked and untracked cases), and stays current via coalesced event- and demand-driven refresh. Single
|
||||
actor (the session user); the panel is complementary to the existing pending-changes panel. The tab is
|
||||
named "Git" (Files / Git), distinct from the pending-changes panel. An ambient dirty indicator makes it
|
||||
findable. Write affordances are disabled during in-progress git operations. 18 decisions recorded. Four
|
||||
items deferred under YAGNI (remote actions, split layout, per-hunk granularity, file-watch streaming).
|
||||
Review team: junior-developer, user-experience-designer, adversarial-security-analyst, on-call-engineer.
|
||||
No load-bearing mechanics required a technical-notes file (the git mechanics are discoverable from the
|
||||
codebase's existing git-metadata path).
|
||||
76
docs/plans/post-review-backlog/artifacts/.discovery-notes.md
Normal file
76
docs/plans/post-review-backlog/artifacts/.discovery-notes.md
Normal file
@@ -0,0 +1,76 @@
|
||||
# Discovery notes — post-review-backlog plan
|
||||
|
||||
Single source of truth for project context. Specialists: read this first, do NOT re-grep what is here.
|
||||
Search further only for what your domain needs that is not covered.
|
||||
|
||||
## Tech stack
|
||||
|
||||
- Monorepo, pnpm workspaces: `apps/server` (BooChat — Fastify + postgres, native inference, read-only tools),
|
||||
`apps/web` (React + Vite SPA), `apps/coder` (BooCoder — host systemd service, write tools + external-agent
|
||||
dispatch, port 9502), `apps/booterm` (PTY/tmux). TypeScript strict, NodeNext (`.js` import suffixes) on
|
||||
server + coder.
|
||||
- Tests: vitest (pinned ^3). server `pnpm -C apps/server test`; coder `pnpm -C apps/coder test`
|
||||
(`globals:false` — import describe/it/expect). Include glob `src/**/__tests__/**/*.test.ts`. No web test
|
||||
harness, no linters. DB-integration tests opt-in via `DATABASE_URL` + `describe.runIf`.
|
||||
- Deploy: apps/coder → `sudo systemctl restart boocoder`; apps/web|server → `docker compose up --build -d boocode`.
|
||||
- Postgres 16, DB `boochat`. Two schema files: `apps/server/src/schema.sql` (sessions/chats/messages/
|
||||
message_parts) + `apps/coder/src/schema.sql` (agent_sessions, worktrees, pending_changes, available_agents,
|
||||
checkpoints, claude_session_entries, tasks extension).
|
||||
|
||||
## ADRs / coding standards found
|
||||
|
||||
- No `docs/adr/` directory. Architectural decisions live in `boocode_roadmap.md` (Decisions log) +
|
||||
per-app `CLAUDE.md` files (auto-loaded when editing that subtree) + `openspec/changes/archived/`.
|
||||
- Coding standards: `docs/coding-standards/` (canonical), surfaced via `.claude/rules/coding-standards/`
|
||||
path-scoped index files. Not loaded automatically; open on demand.
|
||||
- Cross-cutting conventions in root `CLAUDE.md` "Conventions" section (WS-frame parity, sentinels, JSONB
|
||||
via `sql.json`, event dedup discipline, deploy-by-surface).
|
||||
|
||||
## Code touch points (per scope-brief item)
|
||||
|
||||
- **F1 task-cancel:** `apps/coder/src/services/dispatcher.ts` (private `ac=new AbortController()` at
|
||||
~316/655/991/1248; `inflight=Map<sessionId,Promise>` at :56 — no per-task AbortController registry);
|
||||
`apps/coder/src/routes/tasks.ts:110-148` (cancel route — inference.cancel + DB only); `routes/messages.ts:388`
|
||||
(session stop); backends honor `ctx.signal`: `pty-dispatch.ts:159` (child.kill), `backends/warm-acp.ts:318`
|
||||
(session/cancel), `backends/opencode-server.ts:775` (session.abort), `backends/claude-sdk.ts:209` (interrupt).
|
||||
Frontend `apps/web/src/components/panes/CoderPane.tsx:987` handleStop; `api/client.ts:395` cancelTask.
|
||||
- **F2 parser prune:** `apps/server/src/services/inference/tool-call-parser.ts` (exports extractToolCallBlocks,
|
||||
stripToolMarkup, parseXmlToolCall, parseInvokeToolCall, isPlaceholderArgValue, XML_/INVOKE_ consts).
|
||||
Live consumers: `stream-phase.ts:263-284` (extractToolCallBlocks, text-delta fallback path), `:285-294`
|
||||
(structured tool-call path — authoritative today), `tool-phase.ts:122` + `error-handler.ts:25,106`
|
||||
(stripToolMarkup). llama-swap native `--jinja` parsing confirmed ON (external host `:8401`).
|
||||
- **F3 xml logging:** `tool-call-parser.ts:65` console.debug; one call site in `stream-phase.ts` executeStreamPhase.
|
||||
- **F4 notify-hook:** agent config paths `~/.claude/settings.json`, `~/.qwen/settings.json`,
|
||||
`~/.config/goose/`; existing readers `claude-command-discovery.ts:84`, `qwen-settings.ts`,
|
||||
`provider-registry.ts`. Existing normalize helper `apps/coder/src/services/normalize-agent-status.ts`
|
||||
(`normalizeAgentEvent`). Existing status publish wired at dispatcher turn boundaries (v2.7.6);
|
||||
`index.ts:86` references #10. `permission-waiter.ts:47` has a `PermissionHooks` registry.
|
||||
- **F5 compaction surfacing:** `apps/coder/src/services/backends/opencode-server.ts` SSE arm handling
|
||||
(~215-311 region); WS frame parity → server `ws-frames.ts` + web `apps/web/src/api/types.ts` (`WsFrame`).
|
||||
- **F6 resilience:** `apps/server/src/services/inference/stream-phase.ts:261` (`for await ... result.fullStream`),
|
||||
abort check at :333, usage at :343. Frontend 60s `discard_stale` watchdog is the only stall guard today.
|
||||
- **F7 session-history MCP tool:** `apps/coder/src/services/mcp-server.ts` (existing BooCoder MCP tools);
|
||||
read path `messages_with_parts` view.
|
||||
- **F8 diff-line UX:** diff UI component NOT located by `git ls-files apps/web | grep -i diff` (returned
|
||||
empty) — UX specialist must locate the actual diff/changes panel component (BOOCODER.md calls it
|
||||
"DiffPanel"; may be named differently or nested). Routes through dispatcher + AgentComposerBar.
|
||||
- **F9 retire :9502:** `apps/coder/web/` package + static serve in `apps/coder/src/index.ts` + coder build
|
||||
scripts/Dockerfile. KEEP WS + REST routes.
|
||||
|
||||
## Recent activity / precedent
|
||||
|
||||
- HEAD `e5ce01a` (v2.7.11). v2.7.x line (relicense, write-edit-robustness, sampling-streamjson-tokens,
|
||||
mistake-tracker-ledger, claude-sdk-sessionstore, agent-status-normalize, UI batches) all shipped 2026-06-01.
|
||||
- Pure-helper + TDD precedent for extraction: `backends/turn-guard.ts`, `backends/lifecycle-decisions.ts`,
|
||||
`mistake-tracker.ts` (pure module + unit test, then wire). F1/F2/F6 should follow it.
|
||||
- Parallel-disjoint-file agent precedent: v2.7.0/v2.7.1/v2.7.3 each built by 3 parallel agents over disjoint
|
||||
files — relevant to decomposition/sequencing.
|
||||
|
||||
## Enumerated gaps (searched, not found)
|
||||
|
||||
- No `feature-specification.md` — `scope-brief.md` is the ground-truth stand-in.
|
||||
- No `docs/adr/`.
|
||||
- Diff UI component filename not found via naive grep (F8) — needs UX specialist location.
|
||||
- llama-swap config is NOT in-repo (external host `:8401`); native-jinja state confirmed by live probe only.
|
||||
- F8 (diff-line UX) and F4 (notify-hook) are the two items most likely to need their own plan-a-feature; they
|
||||
have no behavioral spec beyond the review-doc pattern description.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user