From 6f92b43cd332e0b217dea3c0a7471e34b9f33aef Mon Sep 17 00:00:00 2001 From: Zeus-Deus Date: Tue, 9 Jun 2026 23:31:09 +0200 Subject: [PATCH] perf: finish the Tauri/React performance & architecture epic (#81) Four remaining items from the epic, each implemented and tested e2e: - perf(agent-chat): stream chat events over per-thread Tauri Channels instead of the global event bus (#75). forward_event routes thread-scoped events (incl. the content_delta token stream) to channels registered via attach/detach_agent_chat_output in a new AgentChatChannelRegistry, mirroring the PTY output path; only threadless RuntimeWarnings keep the legacy agent_chat_event bus. Frontend hook attaches a Channel per pane/thread; the dev mock mirrors the channel contract. Tests cover routing, cross-thread isolation, detach, delta ordering, and the legacy fallback. - perf(files): move blocking files.rs commands off the GTK main thread (#79). list_directory, search_in_files, search_file_names, reveal_in_file_manager, read_file, write_file and both clipboard-image commands are now async + spawn_blocking, same fix class as commands/git.rs. - remote(worktree): headless daemon worktree creation reaches desktop parity (#78). worktree_create on codemux-remote now runs worktree includes (.env copy) + project setup scripts in the background with the same CODEMUX_* env and deterministic per-workspace port the desktop injects. scripts.rs gains a SetupEmitter enum (desktop emits Tauri events; the daemon logs to stderr/serve.log). The tool response carries setup { configured, port }. Fetch-before-branch was already shared from #76. Covered by a live-daemon integration test plus a containerized clean-host e2e harness (scripts/e2e/daemon-worktree-setup-e2e.sh). - feat(agent-chat): opt-in background rollback checkpoint at run start (#80). With git.agent_checkpoint_enabled on (Settings -> Git, default off), agent_chat_start_session fires a background scratch-index snapshot (captures modified AND untracked files, never touches the user's index/worktree, pinned under refs/codemux/checkpoints/, recorded on the agent_chat_sessions row) with zero latency added to the first token. The chat pane header gains a Restore checkpoint action: tree-only restore that never moves refs, preceded by a pinned pre-restore safety snapshot. Design note in docs/plans/agent-run-checkpoints.md. Verified: cargo check, cargo test (lib + agent_chat_commands + codemux_remote_serve_mcp), tsc, vitest (1826 passing), browser e2e of channel streaming/isolation + restore flow + settings toggle against the dev mock runtime, and the Docker clean-host daemon e2e. --- docs/INDEX.md | 1 + docs/core/STATUS.md | 4 + docs/features/agent-chat.md | 62 +++- docs/features/setup-teardown.md | 1 + docs/plans/agent-run-checkpoints.md | 113 ++++++ scripts/e2e/daemon-worktree-setup-e2e.sh | 114 ++++++ src-tauri/src/commands/agent_chat.rs | 341 ++++++++++++++++-- src-tauri/src/commands/files.rs | 283 +++++++++------ src-tauri/src/commands/workspace.rs | 6 +- src-tauri/src/control.rs | 3 +- src-tauri/src/database.rs | 61 ++++ src-tauri/src/git.rs | 265 ++++++++++++++ src-tauri/src/lib.rs | 8 + src-tauri/src/remote/tools/mod.rs | 152 +++++++- src-tauri/src/scripts.rs | 41 ++- src-tauri/src/settings_sync.rs | 9 + src-tauri/tests/agent_chat_commands.rs | 302 ++++++++++++++-- src-tauri/tests/codemux_remote_serve_mcp.rs | 105 ++++++ .../chat/AgentChatPaneHeader.test.tsx | 2 + src/components/chat/AgentChatPaneHeader.tsx | 123 ++++++- .../settings/SettingsPanel.test.tsx | 2 +- src/components/settings/settings-view.tsx | 15 + src/dev/tauri-mock.ts | 90 ++++- src/hooks/use-agent-chat-events.ts | 75 ++-- src/stores/settings-store.test.ts | 4 +- src/stores/settings-sync-integration.test.ts | 4 +- src/stores/synced-settings-store.test.ts | 4 +- src/stores/synced-settings-store.ts | 2 +- src/tauri/commands.ts | 43 +++ src/tauri/events.ts | 14 +- src/tauri/types.ts | 3 + 31 files changed, 2024 insertions(+), 228 deletions(-) create mode 100644 docs/plans/agent-run-checkpoints.md create mode 100755 scripts/e2e/daemon-worktree-setup-e2e.sh diff --git a/docs/INDEX.md b/docs/INDEX.md index 16e4e247..1981762b 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -40,6 +40,7 @@ If the docs themselves feel stale or scattered, also read `docs/reference/DOCS_R - MCP host runtime (Step 9 — Codemux as MCP host/client for chat): current behavior in `docs/features/agent-chat.md` (§ MCP host runtime) + `docs/features/mcp-server.md`; research at `docs/plans/step-9-mcp-servers.md`; Codex MCP gateway feasibility spike (future Step 11) at `docs/plans/step-9-codex-mcp-spike.md` - Beta Features toggle (Step 13): `docs/plans/step-13-beta-toggle-research.md`; operator UI smoke at `docs/plans/step-13-ui-smoke-checklist.md` - Setup/teardown scripts: `docs/features/setup-teardown.md` +- Agent-run rollback checkpoints (opt-in run-start snapshot + restore, issue #80): current behavior in `docs/features/agent-chat.md` (§ Run-start rollback checkpoints); plan at `docs/plans/agent-run-checkpoints.md` - Worktree bootstrapping: `docs/features/worktree-setup.md` - Browser work: `docs/features/browser.md`, `docs/plans/browser.md`, `docs/reference/BROWSER-AGENT-COMMANDS.md` (browser stream stability fix archived at `docs/archive/browser-stream-fix.md`) - OpenFlow work: `docs/features/openflow.md`, `docs/plans/openflow.md` diff --git a/docs/core/STATUS.md b/docs/core/STATUS.md index b924967d..82c4e63e 100644 --- a/docs/core/STATUS.md +++ b/docs/core/STATUS.md @@ -12,6 +12,8 @@ Codemux is past Linux MVP and shipping cross-platform binaries. The workspace sh Landed on `main` after the `v0.7.9` tag (unreleased) are three changes: **OpenCode conversation sync across cloud-push** (issue #16), a **project-favicon cache-bust fix**, and a **dev-only Tauri mock runtime**. (1) **OpenCode conversation sync** — the OpenCode counterpart to PR #15's Claude conversation sync. Pushing a workspace with an active OpenCode pane now continues that conversation on the remote (and pull-back continues it on the laptop), verified monotonic across 3+ cycles. Because OpenCode keeps every conversation in one SQLite DB (`~/.local/share/opencode/opencode.db`) that can't be rsynced without clobbering the host's other history, the sync uses OpenCode's **own `export`/`import` CLI** instead of a bespoke `rusqlite` row extractor (deliberately *not* the issue's original plan — the schema is large and fast-moving). `opencode export ` produces a portable bundle; `opencode import` on the receiving side preserves the session id, associates the session with the cwd it runs from, and is idempotent — so only the one synced session is touched and the host's unrelated OpenCode sessions are never clobbered (the explicit acceptance criterion). New module `src-tauri/src/ssh/opencode_db_sync.rs` (push `sync_opencode_session` + pull `pull_opencode_session`), wired into `commands/hosts.rs` alongside the Claude JSONL sync; `terminal/daemon_backed.rs` gained a generalized `build_agent_relaunch_command` that builds `opencode --session ` / `--continue` (the Claude-only relaunch synthesis is now per-agent), plus an `opencode` session adapter for local app-restart parity. Verified end-to-end against a Docker SSH host (`scripts/e2e/opencode-sync-e2e.sh` + env-gated `src-tauri/tests/opencode_sync_roundtrip.rs`): a session grew 4→644 messages across a host continuation, preserved over 3 push/pull cycles, with the unrelated host session held intact throughout. See `docs/features/opencode-conversation-sync.md`. (2) **Project-favicon cache-bust fix** (PR #71) — re-saving a project image (or re-opening the picker) now appends a changing `&v=` token to the derived favicon-service URL via `resolveImageUrl(input, cacheBust)`, so a site that changed its favicon visibly refreshes instead of the WebView serving the same stale cached bytes forever; direct/data URLs are passed through untouched. Touches `src/lib/project-image.ts`, `src/components/ui/project-avatar.tsx`, `src/components/layout/sidebar-project-group.tsx`, `src/components/overlays/project-image-dialog.tsx`. (3) **Dev-only Tauri mock runtime** (PR #82) — a `src/dev/` shim (`tauri-mock.ts` + `mock-fixtures.ts`, ~1k lines) installs a faithful `window.__TAURI_INTERNALS__` so the real React UI boots in a plain browser tab under `npm run dev` (Vite at `localhost:1420`) with seed data and no desktop window or Rust backend, enabling the Codemux browser-pane screenshot workflow for UI work. Dual-guarded in `main.tsx` (`import.meta.env.DEV` tree-shakes it out of production; `!("__TAURI_INTERNALS__" in window)` keeps it dormant under `npm run tauri:dev`). See `docs/features/dev-mock-runtime.md`. +Also landed on `main` after `v0.7.9` (unreleased) is the remainder of the **Tauri/React performance & architecture epic (issue #81)** — four changes: (1) **agent-chat events stream over per-thread Tauri Channels** (issue #75) — `forward_event` routes thread-scoped provider events (including the high-frequency `content_delta` token stream) to channels registered via `attach_agent_chat_output`/`detach_agent_chat_output` in a new `AgentChatChannelRegistry`, mirroring the PTY output path, instead of `app.emit`-broadcasting every token of every thread to every webview listener; only threadless global `RuntimeWarning`s keep the legacy `agent_chat_event` bus. The frontend hook (`use-agent-chat-events.ts`) attaches a `Channel` per pane/thread, the dev mock mirrors the channel contract, and integration tests cover routing, cross-thread isolation, detach, and delta ordering. (2) **`files.rs` commands moved off the GTK main thread** (issue #79) — `list_directory`, `search_in_files`, `search_file_names`, `reveal_in_file_manager`, `read_file`, `write_file`, and both clipboard-image commands are now `async` + `spawn_blocking`, same fix class as `commands/git.rs`. (3) **Headless daemon worktree creation reaches desktop parity** (issue #78) — `worktree_create` on `codemux-remote` now runs worktree includes (`.env` copy) + the project's setup scripts in the background with the same `CODEMUX_*` env + deterministic port the desktop injects (`scripts.rs` gained a `SetupEmitter` enum: desktop emits Tauri events, the daemon logs to stderr/serve.log); fetch-before-branch was already shared from issue #76. The tool response gains a `setup: { configured, port }` field; covered by a live-daemon e2e test (`http_worktree_create_runs_setup_scripts`). (4) **Opt-in background rollback checkpoint at agent-run start** (issue #80) — when `git.agent_checkpoint_enabled` is on (Settings → Git, default off), `agent_chat_start_session` fires a background scratch-index snapshot (captures modified AND untracked files, never touches the user's index/worktree, pinned under `refs/codemux/checkpoints/`, recorded on the `agent_chat_sessions` row) with zero added latency to the first token; the chat pane header gains a "Restore checkpoint" action (tree-only restore — refs never move; a pre-restore safety snapshot is pinned first). See `docs/plans/agent-run-checkpoints.md`. + Shipped in `v0.7.9` is **"Operate a remote workspace in place" (Open on host)** — the no-pull remote-operation capability (issue #64). The Workspaces overview's host-backed sibling row gains an **"Open on host"** action (enabled when the host is configured locally) that creates a local *attach-in-place* workspace: `WorkspaceSnapshot` gains `remote_cwd` (the workspace's real on-host directory) + `attach_only` (operated in place, no local files), `create_remote_attach_workspace` builds a ready single-terminal workspace with `host_id` set and **nothing copied under `~/.codemux/` locally**, and the daemon-backed terminal path (`remote_spawn_cwd`) spawns into `remote_cwd` over the existing SSH-tunneled pty-daemon so commands run on the host with live streaming. Persistence is real: `ssh::tunnel::build_remote_command` now **reuses a still-running daemon** (via a `.pid` liveness probe) or **spawns it detached** (`setsid`/`nohup`, stdio redirected) instead of `exec`-ing it in the SSH foreground, so closing the app leaves the host process running and reopening re-tunnels + `client.list()`-reattaches the live sessions (a strict improvement for the push flow too). The command (`workspace_open_on_host`) resolves the host-backed sync row → local host → `origin_path`, is idempotent, and is excluded from `reconcile_from_snapshot` so it never creates a duplicate cloud row; the overview dedupes the sibling card against the open in-place view and renders an "on host" badge with detach-only close. See `docs/features/remote-in-place.md`. Shipped in `v0.7.9` is a **multi-device robustness + remote-persistence pass** layered on top of repo-unit sync. (1) **SSH tunnel health is now surfaced in the UI**: the `TunnelStatus` the supervisor already computed (`connected`/`pending`/`reconnecting`/`circuit_open`) is bridged to the frontend via a new `tunnel-status-changed` event + `spawn_tunnel_status_forwarder` (self-terminating per supervisor), a zustand `tunnel-status-store` fed by an app-root `useTunnelStatusEvents` hook, and a sidebar pill — amber **"Reconnecting…"** on a sleep/wake or WiFi flap, red **"Connection lost — re-push"** once the circuit breaker trips — so a dropped tunnel no longer looks like a frozen workspace. (2) **Host persistence**: auto-upgrade no longer kills host-side agents — `hosts_upgrade` probes the daemon's `live_terminals` (via `codemux-remote serve status`) and **defers** the systemd-unit restart when sessions are live (`UpgradeOutcome::Skipped`); separately, the local pty-daemon now **idle-reaps** itself after 1h with zero sessions (hard re-check under lock so it can never reap a live session). (3) **Workspaces-sync robustness**: project-first remote pull with a real protected root (new local-only `default_branch` column + `resolve_default_branch`/`ensure_origin_head` + `workspaces_adopt_project` "Pull project" action), serialized adopts via a per-row creation lock, client-side `dedupe_sibling_rows` collapse of cross-device duplicate cards, daemon-side `collapse_main_for_uid`/`normalize_main_workspaces` (one repo root per project), uid-keyed collision-safe host paths (`-`), and a non-destructive `workspaces_reconcile_copy` action for legacy divergent copies. (4) **OpenFlow comm-log fix**: the daemon-backed agent spawn path (default since persistent agents) now tees cleaned PTY output to the communication log via the shared `comm_log_entry_for_chunk` helper, so daemon-spawned OpenFlow agents stop producing an empty log that blinded stuck-detection. See `docs/features/remote-hosts.md`, `docs/features/persistent-agents.md`, `docs/features/workspaces-sync.md`, `docs/features/workspaces-overview.md`, `docs/features/openflow.md`, `docs/plans/repo-unit-sync.md`. @@ -200,6 +202,8 @@ The repo structure is clean and domain-split: - Worktree-include listener no longer re-attaches every backend tick - `ensure-draft-when-empty` effect uses a primitive fingerprint - Chat transcript rows + file-tree nodes memoised to skip per-token re-renders +- Agent-chat streaming uses per-thread Tauri Channels (issue #75) — no global event-bus fan-out for `content_delta` tokens +- `files.rs` commands (directory listing, in-files/file-name search, file read/write, clipboard images, reveal) run on the blocking pool, not the GTK main thread (issue #79) ## Partial / Being Hardened diff --git a/docs/features/agent-chat.md b/docs/features/agent-chat.md index 42327a44..f1b4e343 100644 --- a/docs/features/agent-chat.md +++ b/docs/features/agent-chat.md @@ -516,22 +516,62 @@ string. ## Event bridge -Every registered provider's canonical event stream is forwarded to -the frontend on a single Tauri channel named `agent_chat_event`. -Payloads carry the originating `thread_id` alongside the raw -`ProviderRuntimeEvent` so subscribers can filter without re-parsing. -Global events (`RuntimeWarning` without a thread id) are still -forwarded, with an empty `ThreadId`. The frontend hook -`useAgentChatEvents(threadId, handler)` in -`src/hooks/use-agent-chat-events.ts` wires this up with a -thread-id filter. +Every registered provider's canonical event stream is routed to the +frontend over **per-thread Tauri Channels** (issue #75 — Tauri's +recommended mechanism for high-throughput streaming, mirroring the +PTY output path). When a pane binds to a thread, the +`useAgentChatEvents(threadId, handler)` hook +(`src/hooks/use-agent-chat-events.ts`) invokes +`attach_agent_chat_output(thread_id, channel)`; on unmount it calls +`detach_agent_chat_output(thread_id, subscription_id)`. The backend's +`AgentChatChannelRegistry` (`commands/agent_chat.rs`) maps each +thread id to its attached channels and `forward_event` sends each +thread-scoped event — including the high-frequency `content_delta` +token stream — only to that thread's channels, so a pane never +receives (or filters) another thread's traffic. Multiple panes may +attach to the same thread; dead channels (webview reload without +detach) fail on send and are pruned lazily. + +Only **threadless** events (global `RuntimeWarning`s with no owning +pane) still go out on the legacy `agent_chat_event` broadcast bus, +with an empty `ThreadId`. + +Replay semantics: transcript-mutating events are persisted to +`agent_chat_messages` (unchanged), so a late-attaching or resumed +pane hydrates history from the DB via `agent_chat_list_messages` +while the channel carries only live deltas. Partial deltas are never +persisted — they're superseded by their `item_completed`. The bridge is a thin loop: one background Tokio task per provider, -each consuming the provider's `event_stream()` and re-emitting each -event via `AppHandle::emit`. `broadcast::error::RecvError::Lagged` +each consuming the provider's `event_stream()` and routing each +event through `forward_event`. `broadcast::error::RecvError::Lagged` is already swallowed by each provider's event-stream helper, so slow subscribers never crash the loop — they just drop old events. +## Run-start rollback checkpoints (issue #80, opt-in) + +When `git.agent_checkpoint_enabled` is on (Settings → Git; default +**off**), `agent_chat_start_session` fires a **background** snapshot +of the workspace working tree after the session is live — nothing on +the first-token path awaits it. The snapshot uses a scratch index +(`GIT_INDEX_FILE`), so it captures modified **and untracked** files +without ever touching the user's real index or worktree, and is +pinned under `refs/codemux/checkpoints/`; the commit + HEAD +hashes are recorded on the `agent_chat_sessions` row +(`checkpoint_commit` / `checkpoint_head`). + +The pane header shows a "Restore checkpoint" action (History icon, +hover-reveal) when the thread has a recorded checkpoint. Restore is +**tree-only**: a safety snapshot of the current state is pinned under +`refs/codemux/pre-restore/` first, then +`git read-tree --reset -u` + `git clean -fd` make the tree match the +snapshot — files created after the checkpoint are removed, ignored +files and branch refs/commits are untouched. Commands: +`agent_chat_get_checkpoint`, `agent_chat_restore_checkpoint`; git +helpers in `src-tauri/src/git.rs` +(`git_create_workspace_checkpoint` / `git_restore_workspace_checkpoint`). +Design notes: `docs/plans/agent-run-checkpoints.md`. + ## Feature flag The new flag `enable_agent_chat` lives on the existing `FeatureFlags` diff --git a/docs/features/setup-teardown.md b/docs/features/setup-teardown.md index 26fef3fa..fa148dfe 100644 --- a/docs/features/setup-teardown.md +++ b/docs/features/setup-teardown.md @@ -134,6 +134,7 @@ The "Configure" button opens Settings > Projects. Dismiss persists per-project. - Re-run setup via context menu, Tauri command, socket API, and CLI - DB-stored project scripts via `get_project_scripts` / `set_project_scripts` Tauri commands - Unit tests for config reading, git root resolution, worktree fallback, and DB roundtrip +- **Headless daemon parity (issue #78)**: `worktree_create` on `codemux-remote` runs the same pipeline — worktree includes copy + setup scripts with `CODEMUX_*` env and the deterministic per-workspace port — in a background thread after registering the workspace. File-based config only (`.codemux/config.json`; the daemon has no settings DB). Progress/failure events that the desktop emits to the setup overlay are logged to stderr instead (captured in `serve.log`) via the `SetupEmitter::Log` variant in `scripts.rs`; the tool response carries `setup: { configured, port }`. Covered by a live-daemon e2e test (`http_worktree_create_runs_setup_scripts` in `src-tauri/tests/codemux_remote_serve_mcp.rs`). ## Current Constraints diff --git a/docs/plans/agent-run-checkpoints.md b/docs/plans/agent-run-checkpoints.md new file mode 100644 index 00000000..faf825c7 --- /dev/null +++ b/docs/plans/agent-run-checkpoints.md @@ -0,0 +1,113 @@ +# Agent-Run Rollback Checkpoints (issue #80) + +- Purpose: Track the design + implementation of the opt-in background rollback checkpoint taken at agent-chat run start. +- Audience: Anyone changing agent-chat run lifecycle or the checkpoint/restore git helpers. +- Authority: Active work plan only, not current truth. +- Update when: Scope, restore semantics, or touch points change. +- Read next: `docs/features/agent-chat.md`, `docs/core/STATUS.md` + +## Goal + +Give users a rollback point for an agent run without ever delaying the +agent's first token. When the (opt-in) setting is enabled, starting an +agent-chat session fires a **background** snapshot of the workspace +working tree; the chat pane exposes a "Restore checkpoint" action that +returns the tree to that state. + +## Design decisions (locked) + +1. **Trigger**: `agent_chat_start_session` — one checkpoint per run + (session start). Per-message checkpoint history is explicitly out of + scope for v1. +2. **Zero first-token latency**: the snapshot runs on + `tauri::async_runtime::spawn` + blocking pool *after* the command + has already returned the `ThreadId`. Nothing on the start path + awaits it. +3. **Non-destructive snapshot mechanism**: a *temporary index file* + (`GIT_INDEX_FILE` pointed at a scratch path) seeded from `HEAD`, + `git add -A` into that index (captures modified **and untracked** + files; respects `.gitignore`), `git write-tree`, then + `git commit-tree` with `HEAD` as parent. The user's real index and + working tree are never touched — unlike `git stash create`, this + also captures untracked files, which is the common agent-output + case. +4. **Pinning**: the snapshot commit is pinned under + `refs/codemux/checkpoints/` so GC can't reap it, and the + commit + `HEAD`-at-checkpoint hashes are persisted on the + `agent_chat_sessions` row (`checkpoint_commit`, `checkpoint_head`). +5. **Restore semantics (tree-only)**: restore makes the working tree + + index match the snapshot exactly: + - take a fresh *safety* snapshot of the current state first (so + restore is itself undoable from the ref, `refs/codemux/pre-restore/`), + - `git read-tree --reset -u ` (checks out the + snapshot tree, removing tracked files that didn't exist then), + - `git clean -fd` (removes files created after the snapshot that + are untracked; ignored files like `node_modules`/`.env` survive + because `git clean` without `-x` skips them, and files that were + untracked at snapshot time were captured in the snapshot tree and + are re-tracked by the `read-tree`, so they survive too). + - Branch refs / `HEAD` are **not** moved: commits the agent made + during the run stay in history; only the tree contents revert. +6. **Opt-in**: `UserSettings.git.agent_checkpoint_enabled` + (default **false**), toggle in Settings → Git. The empty-repo / + non-git-workspace case is a silent no-op. +7. **UI**: a "Restore checkpoint" item in the chat pane header menu, + visible only when the thread has a recorded checkpoint; destructive + confirm (AlertDialog, `variant="destructive"`); resolution surfaces + via toast. Per chat-UI rules: no accent color, no modal approval in + the transcript — this is pane chrome, not conversation content. + +## Active Priorities + +1. ~~git helpers + tests~~ → see Already Landed +2. ~~start-session hook + persistence~~ +3. ~~settings plumbing + UI toggle~~ +4. ~~restore command + pane-header action~~ + +## Open Questions + +- Should restore also offer "reset branch to checkpoint HEAD" + (un-commit agent commits)? Deferred — tree-only restore is the safe + v1; the `checkpoint_head` hash is already recorded for a future + follow-up. +- Checkpoint retention/pruning (refs accumulate one per thread). + Cheap; revisit if `refs/codemux/*` ever becomes noisy. + +## Likely Touch Points + +- `src-tauri/src/git.rs` — `git_create_workspace_checkpoint`, + `git_restore_workspace_checkpoint` +- `src-tauri/src/commands/agent_chat.rs` — start-session hook, + `agent_chat_get_checkpoint`, `agent_chat_restore_checkpoint` +- `src-tauri/src/database.rs` — `checkpoint_commit`/`checkpoint_head` + columns on `agent_chat_sessions` +- `src-tauri/src/settings*` / `src/tauri/types.ts` — new setting +- `src/components/chat/AgentChatPane.tsx` (+ header menu component) +- `src/components/settings/*` — toggle +- `src/dev/tauri-mock.ts` — mock checkpoint state for browser e2e + +## Already Landed + +- `git_create_workspace_checkpoint` / `git_restore_workspace_checkpoint` + + 4 unit tests against real repos (`src-tauri/src/git.rs`) +- `checkpoint_commit` / `checkpoint_head` columns on + `agent_chat_sessions` + `set/get_agent_chat_checkpoint`, + `get_agent_chat_session_cwd` (`src-tauri/src/database.rs`) +- Background run-start hook (`spawn_run_checkpoint` / + `perform_run_checkpoint`) in `agent_chat_start_session`, plus + `agent_chat_get_checkpoint` / `agent_chat_restore_checkpoint` + commands (`src-tauri/src/commands/agent_chat.rs`) +- `git.agent_checkpoint_enabled` setting (Rust `settings_sync.rs` + + TS types + Settings → Git toggle) +- "Restore checkpoint" header action with destructive confirm dialog + (`src/components/chat/AgentChatPaneHeader.tsx`), dev-mock support +- Integration test `run_checkpoint_records_and_restores_against_real_repo` + (`src-tauri/tests/agent_chat_commands.rs`) + +## Notes + +- The snapshot is best-effort: failures log to stderr and never + surface as run errors. +- A second session start on the same thread overwrites the thread's + checkpoint (latest run wins), keeping "revert to before this run" + semantics. diff --git a/scripts/e2e/daemon-worktree-setup-e2e.sh b/scripts/e2e/daemon-worktree-setup-e2e.sh new file mode 100755 index 00000000..33d4be61 --- /dev/null +++ b/scripts/e2e/daemon-worktree-setup-e2e.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# End-to-end harness for issue #78: headless daemon worktree creation +# runs setup scripts + worktree includes (desktop parity). +# +# Stands up a clean containerized host (no desktop, no dev environment), +# mounts the built `codemux-remote` binary, seeds a git repo with a +# committed `.codemux/config.json` setup script and a gitignored `.env`, +# starts `codemux-remote serve`, then drives the REAL authed HTTP tool +# surface (`tools/call worktree_create`) exactly like the MCP bridge +# does — and asserts on the container's filesystem that: +# 1. the worktree was created at the canonical path, +# 2. the setup script ran with CODEMUX_BRANCH/CODEMUX_PORT injected, +# 3. the gitignored .env was copied from the main checkout. +# +# Requires: docker, python3, and a debug build of codemux-remote +# (`cargo build --manifest-path src-tauri/Cargo.toml --bin codemux-remote`). +# Run from the repo root: bash scripts/e2e/daemon-worktree-setup-e2e.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +BIN="$REPO_ROOT/src-tauri/target/debug/codemux-remote" +IMAGE="${CMX_WT_E2E_IMAGE:-archlinux:latest}" +CONTAINER="codemux-worktree-setup-e2e" + +log() { printf '\n\033[1;36m[e2e]\033[0m %s\n' "$*"; } +err() { printf '\n\033[1;31m[e2e:ERROR]\033[0m %s\n' "$*" >&2; } + +cleanup() { + docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +command -v docker >/dev/null || { err "docker not found"; exit 1; } +[ -x "$BIN" ] || { err "codemux-remote not built at $BIN"; exit 1; } + +log "starting clean host container ($IMAGE)" +docker rm -f "$CONTAINER" >/dev/null 2>&1 || true +docker run -d --name "$CONTAINER" \ + -v "$BIN":/usr/local/bin/codemux-remote:ro \ + "$IMAGE" sleep infinity >/dev/null + +log "installing git + runtime libs in the container" +# The codemux-remote bin target shares the desktop crate, so the debug +# binary dynamically links gtk/webkit even though serve never uses them. +docker exec "$CONTAINER" pacman -Sy --noconfirm git gtk3 webkit2gtk-4.1 >/dev/null 2>&1 + +log "seeding repo with setup config + gitignored .env" +docker exec -i "$CONTAINER" bash -eu -o pipefail <<'SEED' +git config --global user.email t@e.com +git config --global user.name T +git config --global init.defaultBranch main +mkdir -p /root/repo/.codemux +cd /root/repo +git init -q +# Single-quoted heredoc below keeps $CODEMUX_* literal for the daemon. +cat > .codemux/config.json <<'CFG' +{ "setup": ["printf '%s\n%s\n' \"$CODEMUX_BRANCH\" \"$CODEMUX_PORT\" > setup-ran.txt"] } +CFG +printf '.env\n' > .gitignore +printf 'SECRET=docker-host\n' > .env +printf 'x\n' > f.txt +git add . +git commit -qm init +SEED + +log "starting codemux-remote serve" +docker exec -d "$CONTAINER" bash -c \ + 'mkdir -p /root/state && codemux-remote serve --state-dir /root/state > /root/state/serve.log 2>&1' + +log "waiting for manifest" +for i in $(seq 1 50); do + if docker exec "$CONTAINER" test -f /root/state/manifest.json; then break; fi + sleep 0.2 +done +docker exec "$CONTAINER" test -f /root/state/manifest.json || { + err "daemon never wrote manifest"; docker exec "$CONTAINER" cat /root/state/serve.log || true; exit 1; +} + +MANIFEST="$(docker exec "$CONTAINER" cat /root/state/manifest.json)" +ENDPOINT="$(printf '%s' "$MANIFEST" | python3 -c 'import sys,json;print(json.load(sys.stdin)["endpoint"])')" +SECRET="$(printf '%s' "$MANIFEST" | python3 -c 'import sys,json;print(json.load(sys.stdin)["secret"])')" +log "daemon live at $ENDPOINT" + +log "calling tools/call worktree_create over authed HTTP" +RESPONSE="$(docker exec "$CONTAINER" curl -sf -X POST "$ENDPOINT/tools/call" \ + -H "Authorization: Bearer $SECRET" \ + -H 'Content-Type: application/json' \ + -d '{"name":"worktree_create","arguments":{"repo_path":"/root/repo","branch":"feature/provision","base":"main"}}')" +printf '%s\n' "$RESPONSE" | python3 -m json.tool + +WS_PATH="$(printf '%s' "$RESPONSE" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["workspace"]["path"])')" +CONFIGURED="$(printf '%s' "$RESPONSE" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["setup"]["configured"])')" +PORT="$(printf '%s' "$RESPONSE" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["setup"]["port"])')" +[ "$CONFIGURED" = "True" ] || { err "setup.configured should be true"; exit 1; } + +log "worktree at $WS_PATH — polling for setup marker" +MARKER="$WS_PATH/setup-ran.txt" +for i in $(seq 1 100); do + if docker exec "$CONTAINER" test -f "$MARKER"; then break; fi + sleep 0.2 +done +docker exec "$CONTAINER" test -f "$MARKER" || { + err "setup script never ran"; docker exec "$CONTAINER" cat /root/state/serve.log || true; exit 1; +} + +GOT_BRANCH="$(docker exec "$CONTAINER" sed -n 1p "$MARKER")" +GOT_PORT="$(docker exec "$CONTAINER" sed -n 2p "$MARKER")" +GOT_ENV="$(docker exec "$CONTAINER" cat "$WS_PATH/.env")" + +[ "$GOT_BRANCH" = "feature/provision" ] || { err "CODEMUX_BRANCH wrong: $GOT_BRANCH"; exit 1; } +[ "$GOT_PORT" = "$PORT" ] || { err "CODEMUX_PORT mismatch: $GOT_PORT vs $PORT"; exit 1; } +[ "$GOT_ENV" = "SECRET=docker-host" ] || { err ".env not copied: $GOT_ENV"; exit 1; } + +log "PASS — daemon-created worktree was provisioned (setup script ran with branch=$GOT_BRANCH port=$GOT_PORT, .env copied)" diff --git a/src-tauri/src/commands/agent_chat.rs b/src-tauri/src/commands/agent_chat.rs index 9f2b7e28..bb47f335 100644 --- a/src-tauri/src/commands/agent_chat.rs +++ b/src-tauri/src/commands/agent_chat.rs @@ -13,10 +13,11 @@ //! any session is bound to it. Provider-session commands and the //! event bridge land in follow-up commits. +use std::collections::HashMap; use std::sync::Arc; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Emitter, Manager, Runtime, State}; +use tauri::{ipc::Channel, AppHandle, Emitter, Manager, Runtime, State}; use crate::agent_provider::{ AgentProvider, ApprovalDecision, ProviderChatCapabilities, ProviderError, ProviderKind, @@ -27,23 +28,123 @@ use crate::database::{AgentChatSessionRecord, DatabaseStore}; use crate::observability::ObservabilityStore; use crate::state::AppStateStore; -/// Event name emitted by the backend whenever a provider produces a -/// runtime event. The frontend's `useAgentChatEvents` hook subscribes -/// to this channel and filters by `thread_id`. +/// Event name used for the legacy global broadcast. Thread-scoped +/// events — including the high-frequency `content_delta` token stream — +/// now flow over per-thread [`Channel`]s registered via +/// `attach_agent_chat_output` (Tauri's recommended mechanism for +/// streaming data). Only events with NO owning thread (global +/// `RuntimeWarning`s) still go out on this bus, since there is no +/// per-thread subscriber to route them to. pub const AGENT_CHAT_EVENT: &str = "agent_chat_event"; -/// Payload emitted on the [`AGENT_CHAT_EVENT`] Tauri channel. +/// Payload streamed to per-thread chat output channels (and, for +/// threadless events only, emitted on the [`AGENT_CHAT_EVENT`] bus). /// /// Carries the originating thread id alongside the raw canonical -/// event so subscribers can filter without re-parsing the payload. +/// event so subscribers can route without re-parsing the payload. /// Events that are not scoped to a single thread (global -/// `RuntimeWarning`s) are emitted with an empty `ThreadId`. +/// `RuntimeWarning`s) carry an empty `ThreadId`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentChatEventPayload { pub thread_id: ThreadId, pub event: ProviderRuntimeEvent, } +/// Per-thread registry of live streaming channels. +/// +/// Mirrors the PTY pattern (`SessionRuntime.output_channel` in +/// `terminal/mod.rs`): when a chat pane binds to a thread it invokes +/// `attach_agent_chat_output` with a [`Channel`]; `forward_event` +/// routes every thread-scoped provider event to the channels attached +/// to that thread. This replaces the previous global `app.emit` +/// broadcast, which fanned every token of every thread to every +/// webview listener — Tauri's event system is explicitly not designed +/// for high-throughput streaming, and the frontend had to filter by +/// `thread_id` anyway. +/// +/// Multiple panes may attach to the same thread (each gets its own +/// channel); a pane detaches on unmount. Channels whose webview has +/// gone away fail on `send` and are pruned lazily. +#[derive(Default)] +pub struct AgentChatChannelRegistry { + /// thread_id → attached channels. The channel's IPC id doubles as + /// the subscription id handed back to the frontend for detach. + channels: std::sync::Mutex>>>, +} + +impl AgentChatChannelRegistry { + pub fn new() -> Self { + Self::default() + } + + /// Register a channel for a thread. Returns the subscription id + /// (the channel's IPC id) the frontend passes back on detach. + pub fn attach(&self, thread_id: &str, channel: Channel) -> u32 { + let id = channel.id(); + let mut map = self.channels.lock().unwrap(); + map.entry(thread_id.to_string()).or_default().push(channel); + id + } + + /// Remove a previously attached channel. Idempotent — detaching + /// an unknown subscription is a no-op. + pub fn detach(&self, thread_id: &str, subscription_id: u32) { + let mut map = self.channels.lock().unwrap(); + if let Some(list) = map.get_mut(thread_id) { + list.retain(|c| c.id() != subscription_id); + if list.is_empty() { + map.remove(thread_id); + } + } + } + + /// Number of channels currently attached to a thread. Test hook. + pub fn attached_count(&self, thread_id: &str) -> usize { + self.channels + .lock() + .unwrap() + .get(thread_id) + .map(|l| l.len()) + .unwrap_or(0) + } + + /// Send a payload to every channel attached to its thread. + /// + /// The subscriber list is cloned out of the lock before sending so + /// a slow webview eval can never block attach/detach. Channels + /// that error (webview reloaded/closed without detaching) are + /// pruned afterwards. + pub fn route(&self, payload: &AgentChatEventPayload) { + let subscribers: Vec> = { + let map = self.channels.lock().unwrap(); + match map.get(&payload.thread_id.0) { + Some(list) => list.clone(), + None => return, + } + }; + let mut dead: Vec = Vec::new(); + for channel in subscribers { + if let Err(error) = channel.send(payload.clone()) { + eprintln!( + "[codemux::agent_chat] dropping dead chat channel {} for thread {}: {error}", + channel.id(), + payload.thread_id.0 + ); + dead.push(channel.id()); + } + } + if !dead.is_empty() { + let mut map = self.channels.lock().unwrap(); + if let Some(list) = map.get_mut(&payload.thread_id.0) { + list.retain(|c| !dead.contains(&c.id())); + if list.is_empty() { + map.remove(&payload.thread_id.0); + } + } + } + } +} + /// Error string returned when a command runs with /// `enable_agent_chat` off. pub const FEATURE_DISABLED_ERROR: &str = "feature_disabled: enable_agent_chat is off"; @@ -348,10 +449,148 @@ pub async fn agent_chat_start_session( ); } } + // Opt-in rollback checkpoint (issue #80): snapshot the workspace + // tree so this run can be rolled back. Fire-and-forget on the + // blocking pool AFTER the session is live — nothing on the + // first-token path awaits it, so checkpointing adds zero latency + // to the agent's first response. + spawn_run_checkpoint(&app, &session.thread_id.0, cwd_for_persist.as_deref()); crate::state::emit_app_state(&app); Ok(session.thread_id) } +/// Sanitize a thread id into a single git ref path component. +/// Alphanumerics, `-` and `_` pass through; everything else becomes +/// `-` so the result is always a valid `refs/codemux/...` segment. +pub fn checkpoint_ref_component(thread_id: &str) -> String { + let out: String = thread_id + .chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '-' || c == '_' { + c + } else { + '-' + } + }) + .collect(); + if out.is_empty() { + "unknown".to_string() + } else { + out + } +} + +/// Fire the background run-start checkpoint when the opt-in +/// `git.agent_checkpoint_enabled` setting is on. +/// +/// Everything — including the settings-cache read — runs on the +/// blocking pool so the caller returns immediately. Failures (not a +/// git repo, empty repo, setting off) are logged and never surface +/// as run errors; the checkpoint is best-effort by design. +fn spawn_run_checkpoint(app: &AppHandle, thread_id: &str, cwd: Option<&str>) { + let Some(cwd) = cwd.map(str::to_string) else { + return; + }; + let thread_id = thread_id.to_string(); + let app = app.clone(); + tauri::async_runtime::spawn_blocking(move || { + let enabled = crate::settings_sync::load_cache() + .unwrap_or_default() + .git + .agent_checkpoint_enabled; + if !enabled { + return; + } + perform_run_checkpoint(&app, &thread_id, &cwd); + }); +} + +/// Synchronous body of the run-start checkpoint: snapshot the tree, +/// pin the ref, persist the hashes on the session row. Extracted from +/// [`spawn_run_checkpoint`] (which adds the opt-in gate + background +/// spawn) so integration tests can drive it directly against a real +/// temp repository. +pub fn perform_run_checkpoint(app: &AppHandle, thread_id: &str, cwd: &str) { + let ref_name = format!( + "refs/codemux/checkpoints/{}", + checkpoint_ref_component(thread_id) + ); + match crate::git::git_create_workspace_checkpoint( + std::path::Path::new(cwd), + &ref_name, + "codemux: pre-run checkpoint", + ) { + Ok(checkpoint) => { + let db: State<'_, DatabaseStore> = app.state(); + if let Err(error) = + db.set_agent_chat_checkpoint(thread_id, &checkpoint.commit, &checkpoint.head) + { + eprintln!("[codemux::agent_chat] failed to persist run checkpoint: {error}"); + } else { + eprintln!( + "[codemux::agent_chat] run checkpoint {} recorded for thread {thread_id}", + &checkpoint.commit[..12.min(checkpoint.commit.len())] + ); + } + } + Err(error) => { + // Expected for non-git workspaces / empty repos. + eprintln!("[codemux::agent_chat] run checkpoint skipped: {error}"); + } + } +} + +/// The recorded run-start rollback checkpoint for a thread, if any. +/// Drives the visibility of the chat pane's "Restore checkpoint" +/// action. Not feature-gated for the same reason as +/// `agent_chat_list_sessions` — absence should render as "nothing to +/// show", not an error string. +#[tauri::command] +pub async fn agent_chat_get_checkpoint( + db: State<'_, DatabaseStore>, + thread_id: String, +) -> Result, String> { + Ok(db + .get_agent_chat_checkpoint(&thread_id) + .map(|(commit, head)| crate::git::WorkspaceCheckpoint { commit, head })) +} + +/// Roll the workspace tree back to the thread's run-start checkpoint. +/// +/// Tree-only restore — branch refs never move (see +/// `git::git_restore_workspace_checkpoint`). A safety snapshot of the +/// pre-restore state is pinned under `refs/codemux/pre-restore/` +/// so the action is itself recoverable. +#[tauri::command] +pub async fn agent_chat_restore_checkpoint( + app: AppHandle, + thread_id: String, +) -> Result<(), String> { + let observability: State<'_, ObservabilityStore> = app.state(); + feature_flag_on(&observability)?; + let db: State<'_, DatabaseStore> = app.state(); + let (commit, _head) = db.get_agent_chat_checkpoint(&thread_id).ok_or_else(|| { + "no_checkpoint: this thread has no recorded run checkpoint".to_string() + })?; + let cwd = db.get_agent_chat_session_cwd(&thread_id).ok_or_else(|| { + "no_cwd: this session has no recorded working directory".to_string() + })?; + let safety_ref = format!( + "refs/codemux/pre-restore/{}", + checkpoint_ref_component(&thread_id) + ); + tokio::task::spawn_blocking(move || { + crate::git::git_restore_workspace_checkpoint( + std::path::Path::new(&cwd), + &commit, + &safety_ref, + ) + .map(|_| ()) + }) + .await + .map_err(|e| format!("restore task join failed: {e}"))? +} + /// Queue a user turn on an existing session. #[tauri::command] pub async fn agent_chat_send_turn( @@ -692,17 +931,57 @@ pub async fn agent_chat_list_messages( Ok(db.list_agent_chat_messages(&thread_id)) } +// ── Streaming channel attach/detach ─────────────────────────────────── + +/// Attach a per-thread streaming [`Channel`] for live provider events. +/// +/// Called by the frontend when a chat pane binds to a thread. Live +/// events (especially the `content_delta` token stream) arrive over +/// this channel only; the pane hydrates the persisted transcript from +/// the DB via `agent_chat_list_messages` on mount, so a late attach +/// never misses completed items — the channel carries live deltas, the +/// DB carries history. +/// +/// Returns the subscription id to pass to `detach_agent_chat_output` +/// on unmount. +#[tauri::command] +pub fn attach_agent_chat_output( + observability: State<'_, ObservabilityStore>, + channels: State<'_, AgentChatChannelRegistry>, + thread_id: String, + channel: Channel, +) -> Result { + feature_flag_on(&observability)?; + Ok(channels.attach(&thread_id, channel)) +} + +/// Detach a previously attached chat output channel. +/// +/// Idempotent, and deliberately NOT feature-gated: unmount cleanup +/// must always succeed, even if the beta flag was switched off while +/// the pane was open. +#[tauri::command] +pub fn detach_agent_chat_output( + channels: State<'_, AgentChatChannelRegistry>, + thread_id: String, + subscription_id: u32, +) -> Result<(), String> { + channels.detach(&thread_id, subscription_id); + Ok(()) +} + // ── Event bridge ────────────────────────────────────────────────────── /// Start the provider-event forwarding tasks. /// /// Spawns one Tokio task per registered provider. Each task consumes -/// the provider's canonical event stream and re-emits each event on -/// the [`AGENT_CHAT_EVENT`] Tauri channel wrapped in an -/// [`AgentChatEventPayload`]. When a provider's stream ends (on -/// shutdown) the task exits cleanly; the `event_stream()` helper on -/// each provider already swallows `broadcast::error::RecvError::Lagged` -/// and continues, so slow subscribers never crash this loop. +/// the provider's canonical event stream and routes each event to the +/// per-thread channels registered in [`AgentChatChannelRegistry`] +/// (threadless events fall back to the [`AGENT_CHAT_EVENT`] bus). +/// When a provider's stream ends (on shutdown) the task exits cleanly; +/// the `event_stream()` helper on each provider already swallows +/// `broadcast::error::RecvError::Lagged` and continues, so slow +/// subscribers never crash this loop. /// /// Intended to be called once after the registry has been fully /// populated at startup. Idempotency is not required — call sites @@ -728,7 +1007,12 @@ pub async fn spawn_event_bridge(app: AppHandle) { } } -/// Emit a single provider event to the frontend. +/// Forward a single provider event to the frontend. +/// +/// Thread-scoped events are routed to the per-thread streaming +/// channels in [`AgentChatChannelRegistry`]; only threadless events +/// (global `RuntimeWarning`s) fall back to the legacy +/// [`AGENT_CHAT_EVENT`] broadcast, since they have no owning pane. /// /// Extracted so tests can exercise the translation without spinning a /// Tokio task or a real provider. Also used as the inner loop of @@ -786,14 +1070,27 @@ pub fn forward_event(app: &AppHandle, event: ProviderRuntimeEvent } } } - let thread_id = thread_id_for_event(&event) - // Events without a thread_id (e.g. global RuntimeWarning) are - // forwarded with an empty ThreadId so the frontend at least - // sees them; a richer global-warning channel is a follow-up. - .unwrap_or_else(|| ThreadId(String::new())); - let payload = AgentChatEventPayload { thread_id, event }; - if let Err(error) = app.emit(AGENT_CHAT_EVENT, &payload) { - eprintln!("[codemux::agent_chat] Failed to emit {AGENT_CHAT_EVENT}: {error}"); + match thread_id_for_event(&event) { + Some(thread_id) if !thread_id.0.is_empty() => { + // Thread-scoped: stream over the per-thread channels. If + // no pane is attached the event is dropped live — the DB + // persistence above already covers transcript replay, and + // partial deltas are superseded by their item_completed. + let channels: State<'_, AgentChatChannelRegistry> = app.state(); + channels.route(&AgentChatEventPayload { thread_id, event }); + } + _ => { + // Threadless (global RuntimeWarning): no per-thread + // subscriber exists, so keep the legacy low-frequency + // broadcast with an empty ThreadId. + let payload = AgentChatEventPayload { + thread_id: ThreadId(String::new()), + event, + }; + if let Err(error) = app.emit(AGENT_CHAT_EVENT, &payload) { + eprintln!("[codemux::agent_chat] Failed to emit {AGENT_CHAT_EVENT}: {error}"); + } + } } } diff --git a/src-tauri/src/commands/files.rs b/src-tauri/src/commands/files.rs index 73a2c3e3..594ee9f5 100644 --- a/src-tauri/src/commands/files.rs +++ b/src-tauri/src/commands/files.rs @@ -2,6 +2,15 @@ use serde::Serialize; use std::path::Path; use std::process::Command; +// Several commands here spawn subprocesses (`git check-ignore`, `rg`/`grep`, +// `fd`/`find`, `xdg-open`) or do blocking file I/O. They MUST run on Tokio's +// blocking pool: a sync `#[tauri::command]` runs on the GTK main thread, and +// any wedged subprocess or slow/stuck filesystem freezes the whole UI hard +// enough that even window-close requests can't be processed. The fix is +// uniform — async command + `spawn_blocking`, same as `commands/git.rs`. +// Frontend-side `invoke()` already returns a Promise either way, so no +// caller changes are needed. + #[derive(Debug, Clone, Serialize)] pub struct FileEntry { pub name: String, @@ -21,7 +30,19 @@ pub struct SearchResult { } #[tauri::command] -pub fn list_directory(path: String, show_hidden: Option) -> Result, String> { +pub async fn list_directory( + path: String, + show_hidden: Option, +) -> Result, String> { + tokio::task::spawn_blocking(move || list_directory_blocking(path, show_hidden)) + .await + .map_err(|e| format!("list_directory task join failed: {e}"))? +} + +fn list_directory_blocking( + path: String, + show_hidden: Option, +) -> Result, String> { let dir = Path::new(&path); let show_hidden = show_hidden.unwrap_or(false); if !dir.is_dir() { @@ -145,7 +166,7 @@ fn git_ignored_set( } #[tauri::command] -pub fn search_in_files( +pub async fn search_in_files( path: String, query: String, max_results: Option, @@ -156,13 +177,17 @@ pub fn search_in_files( let limit = max_results.unwrap_or(100); - // Try ripgrep first - if let Ok(results) = search_with_rg(&path, &query, limit) { - return Ok(results); - } + tokio::task::spawn_blocking(move || { + // Try ripgrep first + if let Ok(results) = search_with_rg(&path, &query, limit) { + return Ok(results); + } - // Fall back to grep - search_with_grep(&path, &query, limit) + // Fall back to grep + search_with_grep(&path, &query, limit) + }) + .await + .map_err(|e| format!("search_in_files task join failed: {e}"))? } fn search_with_rg(path: &str, query: &str, limit: u32) -> Result, String> { @@ -281,7 +306,7 @@ fn search_with_grep(path: &str, query: &str, limit: u32) -> Result, @@ -291,24 +316,29 @@ pub fn search_file_names( } let limit = max_results.unwrap_or(50); - let base = Path::new(&path); - - // Try fd first - if let Ok(results) = search_with_fd(&path, &query, limit) { - // Convert to relative paths - return Ok(results - .into_iter() - .map(|p| { - Path::new(&p) - .strip_prefix(base) - .map(|r| r.to_string_lossy().to_string()) - .unwrap_or(p) - }) - .collect()); - } - // Fall back to find - search_with_find(&path, &query, limit, base) + tokio::task::spawn_blocking(move || { + let base = Path::new(&path); + + // Try fd first + if let Ok(results) = search_with_fd(&path, &query, limit) { + // Convert to relative paths + return Ok(results + .into_iter() + .map(|p| { + Path::new(&p) + .strip_prefix(base) + .map(|r| r.to_string_lossy().to_string()) + .unwrap_or(p) + }) + .collect()); + } + + // Fall back to find + search_with_find(&path, &query, limit, base) + }) + .await + .map_err(|e| format!("search_file_names task join failed: {e}"))? } fn search_with_fd(path: &str, query: &str, limit: u32) -> Result, String> { @@ -339,23 +369,29 @@ fn search_with_fd(path: &str, query: &str, limit: u32) -> Result, St } #[tauri::command] -pub fn reveal_in_file_manager(path: String) -> Result<(), String> { - if Command::new("which") - .arg("xdg-open") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .map(|s| s.success()) - .unwrap_or(false) - == false - { - return Err("xdg-open not found — cannot open file manager. Install xdg-utils.".to_string()); - } - Command::new("xdg-open") - .arg(&path) - .spawn() - .map_err(|e| format!("Failed to open file manager: {e}"))?; - Ok(()) +pub async fn reveal_in_file_manager(path: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + if Command::new("which") + .arg("xdg-open") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) + == false + { + return Err( + "xdg-open not found — cannot open file manager. Install xdg-utils.".to_string(), + ); + } + Command::new("xdg-open") + .arg(&path) + .spawn() + .map_err(|e| format!("Failed to open file manager: {e}"))?; + Ok(()) + }) + .await + .map_err(|e| format!("reveal_in_file_manager task join failed: {e}"))? } fn search_with_find( @@ -409,34 +445,42 @@ fn search_with_find( const MAX_FILE_SIZE: u64 = 2 * 1024 * 1024; // 2 MB #[tauri::command] -pub fn read_file(path: String) -> Result { - let p = Path::new(&path); - if !p.is_file() { - return Err(format!("Not a file: {path}")); - } +pub async fn read_file(path: String) -> Result { + tokio::task::spawn_blocking(move || { + let p = Path::new(&path); + if !p.is_file() { + return Err(format!("Not a file: {path}")); + } - let metadata = std::fs::metadata(p).map_err(|e| format!("Cannot read metadata: {e}"))?; - if metadata.len() > MAX_FILE_SIZE { - return Err(format!( - "File too large ({:.1} MB, limit is 2 MB)", - metadata.len() as f64 / (1024.0 * 1024.0) - )); - } + let metadata = std::fs::metadata(p).map_err(|e| format!("Cannot read metadata: {e}"))?; + if metadata.len() > MAX_FILE_SIZE { + return Err(format!( + "File too large ({:.1} MB, limit is 2 MB)", + metadata.len() as f64 / (1024.0 * 1024.0) + )); + } - let bytes = std::fs::read(p).map_err(|e| format!("Failed to read file: {e}"))?; + let bytes = std::fs::read(p).map_err(|e| format!("Failed to read file: {e}"))?; - // Detect binary: check for null bytes in first 8 KB - let check_len = bytes.len().min(8192); - if bytes[..check_len].contains(&0) { - return Err("Binary file".into()); - } + // Detect binary: check for null bytes in first 8 KB + let check_len = bytes.len().min(8192); + if bytes[..check_len].contains(&0) { + return Err("Binary file".into()); + } - String::from_utf8(bytes).map_err(|_| "Binary file (not valid UTF-8)".into()) + String::from_utf8(bytes).map_err(|_| "Binary file (not valid UTF-8)".into()) + }) + .await + .map_err(|e| format!("read_file task join failed: {e}"))? } #[tauri::command] -pub fn write_file(path: String, content: String) -> Result<(), String> { - std::fs::write(&path, &content).map_err(|e| format!("Failed to write file: {e}")) +pub async fn write_file(path: String, content: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + std::fs::write(&path, &content).map_err(|e| format!("Failed to write file: {e}")) + }) + .await + .map_err(|e| format!("write_file task join failed: {e}"))? } /// Hard ceiling for clipboard image payloads. Mirrors the soft 5 MB @@ -511,8 +555,10 @@ fn write_clipboard_image_to_disk(bytes: &[u8], mime: &str) -> Result, mime: String) -> Result { - write_clipboard_image_to_disk(&bytes, &mime) +pub async fn save_clipboard_image_bytes(bytes: Vec, mime: String) -> Result { + tokio::task::spawn_blocking(move || write_clipboard_image_to_disk(&bytes, &mime)) + .await + .map_err(|e| format!("save_clipboard_image_bytes task join failed: {e}"))? } /// Encode a raw RGBA pixel buffer (8 bits per channel) as PNG. @@ -567,30 +613,34 @@ fn encode_rgba_to_png(rgba: &[u8], width: u32, height: u32) -> Result, S /// image viewers and agents cannot decode. Encoding PNG here /// means the resulting attachment is a valid PNG. #[tauri::command] -pub fn paste_clipboard_image_to_file( +pub async fn paste_clipboard_image_to_file( app: tauri::AppHandle, ) -> Result { - use tauri_plugin_clipboard_manager::ClipboardExt; - - // `read_image` resolves with an error when the clipboard does - // not hold an image (e.g. text-only). Surface a stable error - // string so the frontend can distinguish "no image" from a - // genuine failure and let the default paste behaviour run. - let img = app - .clipboard() - .read_image() - .map_err(|e| format!("clipboard read_image failed: {e}"))?; - - let width = img.width(); - let height = img.height(); - let rgba = img.rgba(); - - if width == 0 || height == 0 || rgba.is_empty() { - return Err("Clipboard image is empty".into()); - } + tokio::task::spawn_blocking(move || { + use tauri_plugin_clipboard_manager::ClipboardExt; + + // `read_image` resolves with an error when the clipboard does + // not hold an image (e.g. text-only). Surface a stable error + // string so the frontend can distinguish "no image" from a + // genuine failure and let the default paste behaviour run. + let img = app + .clipboard() + .read_image() + .map_err(|e| format!("clipboard read_image failed: {e}"))?; + + let width = img.width(); + let height = img.height(); + let rgba = img.rgba(); + + if width == 0 || height == 0 || rgba.is_empty() { + return Err("Clipboard image is empty".into()); + } - let png_bytes = encode_rgba_to_png(rgba, width, height)?; - write_clipboard_image_to_disk(&png_bytes, "image/png") + let png_bytes = encode_rgba_to_png(rgba, width, height)?; + write_clipboard_image_to_disk(&png_bytes, "image/png") + }) + .await + .map_err(|e| format!("paste_clipboard_image_to_file task join failed: {e}"))? } /// Count occurrences of `pattern` across `cwd` using ripgrep. @@ -802,11 +852,12 @@ mod tests { assert_eq!(clipboard_image_extension("image/x-icon"), "bin"); } - #[test] - fn save_clipboard_image_bytes_writes_png_and_returns_path() { + #[tokio::test] + async fn save_clipboard_image_bytes_writes_png_and_returns_path() { // Minimal PNG signature — enough to verify the bytes round-trip. let payload: Vec = vec![0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; let path = save_clipboard_image_bytes(payload.clone(), "image/png".into()) + .await .expect("png write should succeed"); assert!(path.ends_with(".png"), "expected .png extension, got {path}"); @@ -821,8 +872,8 @@ mod tests { cleanup_clipboard_temp(&path); } - #[test] - fn save_clipboard_image_bytes_uses_correct_extension_per_mime() { + #[tokio::test] + async fn save_clipboard_image_bytes_uses_correct_extension_per_mime() { // Spot-check that the filename extension follows the MIME. // Doesn't re-test every mapping (clipboard_image_extension // covers that) — just verifies the command actually wires @@ -834,6 +885,7 @@ mod tests { ]; for (mime, expected_ext) in cases { let path = save_clipboard_image_bytes(vec![0xff, 0xd8, 0xff], mime.into()) + .await .expect("write should succeed"); assert!( path.ends_with(expected_ext), @@ -843,72 +895,85 @@ mod tests { } } - #[test] - fn save_clipboard_image_bytes_generates_unique_filenames() { + #[tokio::test] + async fn save_clipboard_image_bytes_generates_unique_filenames() { // Two paste-cycles in a row must not clobber each other — // the UUID in the filename guarantees this. Regression // guard: if someone "simplifies" the naming and a user // pastes twice quickly, the second image would silently // overwrite the first attachment. let bytes = vec![0x89, 0x50, 0x4e, 0x47]; - let a = save_clipboard_image_bytes(bytes.clone(), "image/png".into()).unwrap(); - let b = save_clipboard_image_bytes(bytes, "image/png".into()).unwrap(); + let a = save_clipboard_image_bytes(bytes.clone(), "image/png".into()) + .await + .unwrap(); + let b = save_clipboard_image_bytes(bytes, "image/png".into()) + .await + .unwrap(); assert_ne!(a, b, "consecutive saves must yield distinct paths"); cleanup_clipboard_temp(&a); cleanup_clipboard_temp(&b); } - #[test] - fn save_clipboard_image_bytes_rejects_empty_payload() { - let err = save_clipboard_image_bytes(vec![], "image/png".into()).unwrap_err(); + #[tokio::test] + async fn save_clipboard_image_bytes_rejects_empty_payload() { + let err = save_clipboard_image_bytes(vec![], "image/png".into()) + .await + .unwrap_err(); assert!( err.to_lowercase().contains("empty"), "expected empty-payload error, got: {err}", ); } - #[test] - fn save_clipboard_image_bytes_rejects_non_image_mime() { + #[tokio::test] + async fn save_clipboard_image_bytes_rejects_non_image_mime() { // The frontend already filters for image/* before invoking // this command, but the IPC boundary must not trust the // caller — a misbehaving (or compromised) frontend mustn't // be able to write arbitrary blobs to the temp directory // via this command. - let err = save_clipboard_image_bytes(vec![1, 2, 3], "text/plain".into()).unwrap_err(); + let err = save_clipboard_image_bytes(vec![1, 2, 3], "text/plain".into()) + .await + .unwrap_err(); assert!( err.contains("Unsupported"), "expected unsupported-mime error, got: {err}", ); - let err = save_clipboard_image_bytes(vec![1, 2, 3], "application/pdf".into()).unwrap_err(); + let err = save_clipboard_image_bytes(vec![1, 2, 3], "application/pdf".into()) + .await + .unwrap_err(); assert!( err.contains("Unsupported"), "expected unsupported-mime error, got: {err}", ); } - #[test] - fn save_clipboard_image_bytes_rejects_oversize_payload() { + #[tokio::test] + async fn save_clipboard_image_bytes_rejects_oversize_payload() { // Use a payload exactly one byte over the cap. Allocating // 25 MB + 1 in a test is cheap (Vec::with_capacity is a // single allocation) and exercises the exact boundary // condition. let oversize = vec![0u8; MAX_CLIPBOARD_IMAGE_BYTES + 1]; - let err = save_clipboard_image_bytes(oversize, "image/png".into()).unwrap_err(); + let err = save_clipboard_image_bytes(oversize, "image/png".into()) + .await + .unwrap_err(); assert!( err.contains("too large"), "expected oversize error, got: {err}", ); } - #[test] - fn save_clipboard_image_bytes_writes_under_codemux_clipboard_dir() { + #[tokio::test] + async fn save_clipboard_image_bytes_writes_under_codemux_clipboard_dir() { // The artifacts must land under a clearly-named subdir so // operators can wipe the cache without grepping through // every UUID in /tmp. Lock the directory name as part of // the contract. - let path = - save_clipboard_image_bytes(vec![0x89, 0x50, 0x4e, 0x47], "image/png".into()).unwrap(); + let path = save_clipboard_image_bytes(vec![0x89, 0x50, 0x4e, 0x47], "image/png".into()) + .await + .unwrap(); assert!( path.contains("codemux-clipboard-images"), "expected path under codemux-clipboard-images/, got {path}", diff --git a/src-tauri/src/commands/workspace.rs b/src-tauri/src/commands/workspace.rs index b4f60a31..8042a036 100644 --- a/src-tauri/src/commands/workspace.rs +++ b/src-tauri/src/commands/workspace.rs @@ -1765,7 +1765,8 @@ fn spawn_setup_scripts( if let Some(config) = config { if !config.setup.is_empty() { if let Err(e) = crate::scripts::run_setup_scripts_with_config( - &ws_path, &ws_title, &ws_id, &app2, &config, &root_path, + &ws_path, &ws_title, &ws_id, + &crate::scripts::SetupEmitter::App(&app2), &config, &root_path, ws_branch.as_deref(), Some(port), ) { eprintln!("[codemux::scripts] Setup failed for workspace {ws_id}: {e}"); @@ -1804,7 +1805,8 @@ pub fn run_workspace_setup( }; let port = crate::scripts::allocate_workspace_port(&workspace_id); crate::scripts::run_setup_scripts( - Path::new(&cwd), &title, &workspace_id, &app, Some(&db), + Path::new(&cwd), &title, &workspace_id, + &crate::scripts::SetupEmitter::App(&app), Some(&db), branch.as_deref(), Some(port), ) } diff --git a/src-tauri/src/control.rs b/src-tauri/src/control.rs index 026015f7..78b1c3c6 100644 --- a/src-tauri/src/control.rs +++ b/src-tauri/src/control.rs @@ -1232,7 +1232,8 @@ async fn dispatch_request(app: &AppHandle, request: ControlRequest) -> ControlRe }?; let port = crate::scripts::allocate_workspace_port(&ws_id); crate::scripts::run_setup_scripts( - std::path::Path::new(&cwd), &title, &ws_id, app, Some(&db), + std::path::Path::new(&cwd), &title, &ws_id, + &crate::scripts::SetupEmitter::App(app), Some(&db), branch.as_deref(), Some(port), )?; Ok(serde_json::json!({ "workspace_id": ws_id, "status": "complete" })) diff --git a/src-tauri/src/database.rs b/src-tauri/src/database.rs index 37ef343c..5719dbea 100644 --- a/src-tauri/src/database.rs +++ b/src-tauri/src/database.rs @@ -380,6 +380,12 @@ fn create_schema(conn: &Connection) -> Result<(), String> { // server-schema change. Lets a pull land a repo ROOT on the right // branch and protect it even when `git_branch` is null. "ALTER TABLE workspaces_sync ADD COLUMN default_branch TEXT", + // Agent-run rollback checkpoints (issue #80): the snapshot + // commit pinned under refs/codemux/checkpoints/ at run + // start, plus where HEAD pointed at that moment. Both null for + // sessions started with the opt-in setting off. + "ALTER TABLE agent_chat_sessions ADD COLUMN checkpoint_commit TEXT", + "ALTER TABLE agent_chat_sessions ADD COLUMN checkpoint_head TEXT", ] { if let Err(e) = conn.execute(stmt, []) { let msg = e.to_string(); @@ -2310,6 +2316,61 @@ impl DatabaseStore { Ok(()) } + /// Record (or replace) the run-start rollback checkpoint for a + /// session (issue #80). Latest run wins — a resumed session's new + /// run overwrites the previous checkpoint, preserving "revert to + /// before this run" semantics. + pub fn set_agent_chat_checkpoint( + &self, + thread_id: &str, + checkpoint_commit: &str, + checkpoint_head: &str, + ) -> Result<(), String> { + let conn = self.conn.lock().unwrap(); + conn.execute( + "UPDATE agent_chat_sessions + SET checkpoint_commit = ?2, checkpoint_head = ?3 + WHERE thread_id = ?1", + params![thread_id, checkpoint_commit, checkpoint_head], + ) + .map_err(|e| format!("Failed to set checkpoint: {e}"))?; + Ok(()) + } + + /// The working directory a session was started from. `None` when + /// the session is absent or recorded no cwd. Used by the + /// checkpoint-restore path to locate the repository. + pub fn get_agent_chat_session_cwd(&self, thread_id: &str) -> Option { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT cwd FROM agent_chat_sessions WHERE thread_id = ?1", + params![thread_id], + |row| row.get::<_, Option>(0), + ) + .ok() + .flatten() + } + + /// The recorded run-start checkpoint for a session, as + /// `(checkpoint_commit, checkpoint_head)`. `None` when the session + /// is absent or was started with checkpoints disabled. + pub fn get_agent_chat_checkpoint(&self, thread_id: &str) -> Option<(String, String)> { + let conn = self.conn.lock().unwrap(); + conn.query_row( + "SELECT checkpoint_commit, checkpoint_head + FROM agent_chat_sessions WHERE thread_id = ?1", + params![thread_id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + )) + }, + ) + .ok() + .and_then(|(commit, head)| Some((commit?, head?))) + } + /// Set (or replace) the dropdown title for a session. Called /// once from the first-turn auto-title path and again any time /// the user renames the session from the dropdown. diff --git a/src-tauri/src/git.rs b/src-tauri/src/git.rs index adf3fcc5..f33770f4 100644 --- a/src-tauri/src/git.rs +++ b/src-tauri/src/git.rs @@ -427,6 +427,135 @@ pub fn git_stash_pop(repo_path: &Path) -> Result<(), String> { Ok(()) } +// ── Agent-run checkpoints (issue #80) ──────────────────────────────── +// +// Non-destructive snapshot of the working tree taken in the background +// when an agent-chat run starts (opt-in), so the user can roll the +// tree back to its pre-run state. Design notes in +// docs/plans/agent-run-checkpoints.md. + +/// A recorded rollback point for a workspace. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WorkspaceCheckpoint { + /// Snapshot commit capturing the full working tree at checkpoint + /// time — tracked changes AND untracked files (`git add -A` into a + /// scratch index respects `.gitignore`, so ignored artifacts are + /// excluded). + pub commit: String, + /// Where `HEAD` pointed when the checkpoint was taken. Recorded + /// for a potential future "reset branch too" affordance; the v1 + /// restore is tree-only and never moves refs. + pub head: String, +} + +/// Run git with `GIT_INDEX_FILE` pointing at a scratch index so the +/// user's real index is never touched. +fn run_git_with_index( + repo_path: &Path, + index_file: &Path, + args: &[&str], +) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(repo_path) + .env("GIT_INDEX_FILE", index_file) + .output() + .map_err(|e| format!("Failed to run git: {e}"))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(format!( + "git {} failed: {}", + args.first().unwrap_or(&""), + stderr.trim() + )); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim_end().to_string()) +} + +/// Snapshot the working tree WITHOUT disturbing the user's index or +/// working tree. +/// +/// Mechanism: a scratch index (`GIT_INDEX_FILE`) is seeded from +/// `HEAD`, `git add -A` stages the live tree into it (capturing +/// modified and untracked files — the common agent-output case that +/// `git stash create` would miss), `write-tree` + `commit-tree` +/// produce a commit parented on `HEAD`, and the commit is pinned +/// under `ref_name` (e.g. `refs/codemux/checkpoints/`) so GC +/// can't reap it. The user's `.git/index` and working tree are never +/// modified. +/// +/// Errors are expected for non-repos and empty repos (no `HEAD`); +/// callers treat the checkpoint as best-effort. +pub fn git_create_workspace_checkpoint( + repo_path: &Path, + ref_name: &str, + message: &str, +) -> Result { + let head = run_git(repo_path, &["rev-parse", "HEAD"])?; + + let index_file = std::env::temp_dir().join(format!( + "codemux-checkpoint-index-{}", + uuid::Uuid::new_v4() + )); + let result = (|| { + run_git_with_index(repo_path, &index_file, &["read-tree", "HEAD"])?; + run_git_with_index(repo_path, &index_file, &["add", "-A"])?; + let tree = run_git_with_index(repo_path, &index_file, &["write-tree"])?; + let commit = run_git_with_index( + repo_path, + &index_file, + &["commit-tree", &tree, "-p", &head, "-m", message], + )?; + run_git(repo_path, &["update-ref", ref_name, &commit])?; + Ok(WorkspaceCheckpoint { + commit, + head: head.clone(), + }) + })(); + let _ = std::fs::remove_file(&index_file); + result +} + +/// Restore the working tree + index to a checkpoint snapshot. +/// +/// Tree-only rollback — branch refs and `HEAD` are never moved, so +/// commits made during the run stay in history; only file contents +/// revert. Sequence: +/// +/// 1. Verify the checkpoint commit still exists. +/// 2. Take a fresh *safety* snapshot of the current state under +/// `safety_ref` so the restore is itself recoverable. A failed +/// safety snapshot aborts the restore. +/// 3. `git read-tree --reset -u ` — index + worktree now match +/// the snapshot, including files that were untracked at checkpoint +/// time (they were captured in the snapshot tree). +/// 4. `git clean -fd` — removes files created after the checkpoint +/// (now untracked). Ignored files (`node_modules`, `.env`, …) +/// survive because `clean` without `-x` skips them. +pub fn git_restore_workspace_checkpoint( + repo_path: &Path, + checkpoint_commit: &str, + safety_ref: &str, +) -> Result { + run_git( + repo_path, + &["cat-file", "-e", &format!("{checkpoint_commit}^{{commit}}")], + ) + .map_err(|_| "Checkpoint commit no longer exists in this repository".to_string())?; + + let safety = git_create_workspace_checkpoint( + repo_path, + safety_ref, + "codemux pre-restore safety checkpoint", + )?; + + run_git(repo_path, &["read-tree", "--reset", "-u", checkpoint_commit])?; + run_git(repo_path, &["clean", "-fd"])?; + Ok(safety) +} + pub fn git_discard_file(repo_path: &Path, file: &str) -> Result<(), String> { // Try git restore first (works for tracked files) let restore = run_git(repo_path, &["restore", "--", file]); @@ -2719,6 +2848,142 @@ C source.txt -> copy.txt"; assert!(!branches_after.contains(&"feature-test".to_string()), "branch should be deleted"); } + // ── Agent-run checkpoints (issue #80) ── + + fn commit_all(repo: &Path, msg: &str) { + run_git(repo, &["add", "."]).expect("git add"); + run_git( + repo, + &[ + "-c", + "user.name=Test", + "-c", + "user.email=test@test.com", + "commit", + "-m", + msg, + ], + ) + .expect("git commit"); + } + + #[test] + fn test_checkpoint_captures_tree_without_disturbing_state() { + let (_dir, repo) = setup_test_repo(); + std::fs::write(repo.join("tracked.txt"), "v1\n").unwrap(); + std::fs::write(repo.join(".gitignore"), "ignored.txt\n").unwrap(); + commit_all(&repo, "base"); + + // Dirty state: modified tracked file, untracked file, ignored file. + std::fs::write(repo.join("tracked.txt"), "v2\n").unwrap(); + std::fs::write(repo.join("untracked.txt"), "new\n").unwrap(); + std::fs::write(repo.join("ignored.txt"), "secret\n").unwrap(); + + let status_before = run_git(&repo, &["status", "--porcelain"]).unwrap(); + let cp = git_create_workspace_checkpoint( + &repo, + "refs/codemux/checkpoints/test-thread", + "codemux run checkpoint", + ) + .expect("checkpoint"); + let status_after = run_git(&repo, &["status", "--porcelain"]).unwrap(); + assert_eq!( + status_before, status_after, + "checkpoint must not disturb the user's index or worktree" + ); + + // Snapshot tree contains the modified + untracked files, not ignored. + let files = run_git(&repo, &["ls-tree", "-r", "--name-only", &cp.commit]).unwrap(); + assert!(files.contains("tracked.txt")); + assert!(files.contains("untracked.txt"), "untracked captured: {files}"); + assert!(!files.contains("ignored.txt"), "ignored excluded: {files}"); + assert_eq!( + run_git(&repo, &["show", &format!("{}:tracked.txt", cp.commit)]).unwrap(), + "v2", + "snapshot carries the modified contents" + ); + + // Pinned ref + recorded HEAD. + assert_eq!( + run_git(&repo, &["rev-parse", "refs/codemux/checkpoints/test-thread"]).unwrap(), + cp.commit + ); + assert_eq!(cp.head, run_git(&repo, &["rev-parse", "HEAD"]).unwrap()); + } + + #[test] + fn test_restore_checkpoint_rolls_tree_back() { + let (_dir, repo) = setup_test_repo(); + std::fs::write(repo.join("code.txt"), "original\n").unwrap(); + std::fs::write(repo.join(".gitignore"), "node_modules/\n").unwrap(); + commit_all(&repo, "base"); + std::fs::create_dir_all(repo.join("node_modules")).unwrap(); + std::fs::write(repo.join("node_modules/dep.js"), "dep\n").unwrap(); + // User work-in-progress, untracked at checkpoint time. + std::fs::write(repo.join("wip.txt"), "user wip\n").unwrap(); + + let cp = git_create_workspace_checkpoint( + &repo, + "refs/codemux/checkpoints/t", + "codemux run checkpoint", + ) + .expect("checkpoint"); + + // Simulate the agent run: clobber a file, add junk, delete the WIP. + std::fs::write(repo.join("code.txt"), "agent broke this\n").unwrap(); + std::fs::write(repo.join("agent-junk.txt"), "junk\n").unwrap(); + std::fs::remove_file(repo.join("wip.txt")).unwrap(); + + git_restore_workspace_checkpoint(&repo, &cp.commit, "refs/codemux/pre-restore/t") + .expect("restore"); + + assert_eq!( + std::fs::read_to_string(repo.join("code.txt")).unwrap(), + "original\n", + "modified file reverts" + ); + assert_eq!( + std::fs::read_to_string(repo.join("wip.txt")).unwrap(), + "user wip\n", + "file untracked at checkpoint time comes back" + ); + assert!( + !repo.join("agent-junk.txt").exists(), + "files created after the checkpoint are removed" + ); + assert!( + repo.join("node_modules/dep.js").exists(), + "ignored files survive the restore" + ); + // The restore is itself recoverable from the safety ref… + run_git(&repo, &["rev-parse", "refs/codemux/pre-restore/t"]).expect("safety ref"); + // …and no refs were moved. + assert_eq!(cp.head, run_git(&repo, &["rev-parse", "HEAD"]).unwrap()); + } + + #[test] + fn test_restore_rejects_missing_checkpoint_commit() { + let (_dir, repo) = setup_test_repo(); + let err = git_restore_workspace_checkpoint( + &repo, + "0123456789abcdef0123456789abcdef01234567", + "refs/codemux/pre-restore/x", + ) + .unwrap_err(); + assert!(err.contains("no longer exists"), "got: {err}"); + } + + #[test] + fn test_checkpoint_errors_outside_a_repo() { + let dir = TempDir::new().unwrap(); + assert!(git_create_workspace_checkpoint( + dir.path(), + "refs/codemux/checkpoints/x", + "m" + ) + .is_err()); + } + #[test] fn test_recreate_worktree_for_adopted_repo_prunes_stale_host_entry() { // Simulate a repo rsynced from a codemux-remote host during diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c6a4b590..2023a3da 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -182,6 +182,10 @@ pub fn run() { .manage(encryption::EncryptionManager::default()) .manage(skills_sync::SyncEngine::new()) .manage(commands::agent_chat::ProviderRegistry::new()) + // Per-thread streaming channels for agent-chat events: panes + // attach a Channel per thread instead of filtering a global + // event-bus broadcast (issue #75). + .manage(commands::agent_chat::AgentChatChannelRegistry::new()) // Step 12 Stage 2 — singleton supervisor for the lazily // spawned `opencode serve` child. `ensure_running()` is the // entry point used by `opencode_list_models`; the server is @@ -1487,6 +1491,10 @@ pub fn run() { commands::agent_chat_rename_session, commands::agent_chat_delete_session, commands::agent_chat_list_messages, + commands::attach_agent_chat_output, + commands::detach_agent_chat_output, + commands::agent_chat_get_checkpoint, + commands::agent_chat_restore_checkpoint, commands::opencode_check_availability, commands::opencode_ping, commands::opencode_list_models, diff --git a/src-tauri/src/remote/tools/mod.rs b/src-tauri/src/remote/tools/mod.rs index 72358f30..9ff1b7aa 100644 --- a/src-tauri/src/remote/tools/mod.rs +++ b/src-tauri/src/remote/tools/mod.rs @@ -277,7 +277,49 @@ fn worktree_create(params: &Value, store: &WorkspaceStore) -> ToolResult { Some(created.repo_root.to_string_lossy().to_string()), ) .map_err(workspace_err)?; - Ok(json!({ "workspace": ws })) + + // Desktop parity (#78): provision the new worktree in the + // background — copy gitignored includes (.env etc.) and run the + // project's setup scripts with the same CODEMUX_* env and stable + // per-workspace port the desktop injects. File-based config only + // (`.codemux/config.json` in the worktree or repo root): the + // daemon has no settings DB. Failures never fail the tool call — + // they're logged to stderr, which serve-mode captures in + // `serve.log` — matching the desktop's fire-and-forget background + // thread. With no config present the pipeline is a clean no-op + // (includes still copy the default `.env*` patterns). + let setup_configured = crate::config::workspace_config::read_workspace_config( + std::path::Path::new(&ws.path), + ) + .map(|c| !c.setup.is_empty()) + .unwrap_or(false); + let port = crate::scripts::allocate_workspace_port(&ws.id); + { + let ws_path = std::path::PathBuf::from(&ws.path); + let ws_name = ws.name.clone(); + let ws_id = ws.id.clone(); + let ws_branch = ws.branch.clone(); + std::thread::spawn(move || { + if let Err(e) = crate::scripts::run_setup_scripts( + &ws_path, + &ws_name, + &ws_id, + &crate::scripts::SetupEmitter::Log, + None, + ws_branch.as_deref(), + Some(port), + ) { + eprintln!( + "[codemux-remote] setup scripts failed for workspace {ws_id}: {e}" + ); + } + }); + } + + Ok(json!({ + "workspace": ws, + "setup": { "configured": setup_configured, "port": port }, + })) } fn workspace_list(store: &WorkspaceStore) -> ToolResult { @@ -503,6 +545,12 @@ mod tests { // main + worktree of the same local repo share a project_uid. assert!(ws["project_uid"].is_string()); + // No `.codemux/config.json` in this repo → setup is reported + // as unconfigured (the background pipeline is a clean no-op), + // but a deterministic port is still allocated. + assert_eq!(out["setup"]["configured"], false); + assert!(out["setup"]["port"].as_u64().is_some()); + // restore HOME match prev_home { Some(v) => std::env::set_var("HOME", v), @@ -510,6 +558,108 @@ mod tests { } } + #[test] + #[serial] + fn worktree_create_runs_setup_scripts_with_env_and_includes() { + // Desktop-parity coverage for issue #78: a daemon-created + // worktree must (1) copy gitignored worktree includes (.env) + // from the main checkout, and (2) run the project's setup + // scripts with the CODEMUX_* env + deterministic port. + let home = TempDir::new().unwrap(); + let prev_home = std::env::var_os("HOME"); + std::env::set_var("HOME", home.path()); + + let repo = TempDir::new().unwrap(); + init_repo(repo.path()); + + // Project setup config: the command captures the injected env + // into a marker file inside the new worktree (cwd = worktree). + std::fs::create_dir_all(repo.path().join(".codemux")).unwrap(); + let config = json!({ + "setup": [ + "printf '%s\n%s\n%s\n%s\n' \"$CODEMUX_BRANCH\" \"$CODEMUX_PORT\" \"$CODEMUX_WORKSPACE_PATH\" \"$CODEMUX_ROOT_PATH\" > setup-ran.txt" + ] + }); + std::fs::write( + repo.path().join(".codemux/config.json"), + config.to_string(), + ) + .unwrap(); + // Gitignored .env in the repo root — the includes step must + // copy it into the worktree (default `.env*` patterns). + std::fs::write(repo.path().join(".gitignore"), ".env\n").unwrap(); + std::fs::write(repo.path().join(".env"), "SECRET=1\n").unwrap(); + let run = |args: &[&str]| { + assert!( + Command::new("git") + .arg("-C") + .arg(repo.path()) + .args(args) + .output() + .unwrap() + .status + .success(), + "git {args:?}" + ); + }; + run(&["add", "."]); + run(&["commit", "-m", "add setup config"]); + + let store_dir = TempDir::new().unwrap(); + let store = open_store(&store_dir); + + let params = json!({ + "repo_path": repo.path().to_string_lossy(), + "branch": "feature/setup", + "base": "main" + }); + let out = worktree_create(¶ms, &store).expect("worktree_create"); + + assert_eq!(out["setup"]["configured"], true); + let port = out["setup"]["port"].as_u64().expect("port allocated"); + let ws_path = + std::path::PathBuf::from(out["workspace"]["path"].as_str().unwrap()); + + // Setup runs on a background thread (parity with the desktop's + // fire-and-forget spawn) — poll for the marker. + let marker = ws_path.join("setup-ran.txt"); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(15); + while !marker.exists() && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(50)); + } + assert!(marker.exists(), "setup script should have run in the worktree"); + + let contents = std::fs::read_to_string(&marker).unwrap(); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines[0], "feature/setup", "CODEMUX_BRANCH injected"); + assert_eq!(lines[1], port.to_string(), "CODEMUX_PORT matches response"); + assert_eq!( + lines[2], + ws_path.to_string_lossy(), + "CODEMUX_WORKSPACE_PATH points at the worktree" + ); + // Canonicalize both sides — the root recorded in the worktree's + // `.git` file may differ from the TempDir path by symlinks. + assert_eq!( + std::fs::canonicalize(lines[3]).unwrap(), + std::fs::canonicalize(repo.path()).unwrap(), + "CODEMUX_ROOT_PATH resolves to the main checkout" + ); + + // Includes run before setup commands, so the marker existing + // implies the .env copy already happened. + assert_eq!( + std::fs::read_to_string(ws_path.join(".env")).unwrap(), + "SECRET=1\n", + "gitignored .env must be copied from the main checkout" + ); + + match prev_home { + Some(v) => std::env::set_var("HOME", v), + None => std::env::remove_var("HOME"), + } + } + #[test] #[serial] fn worktree_create_dispatch_rejects_non_repo() { diff --git a/src-tauri/src/scripts.rs b/src-tauri/src/scripts.rs index 51252a26..30930f17 100644 --- a/src-tauri/src/scripts.rs +++ b/src-tauri/src/scripts.rs @@ -34,6 +34,33 @@ struct SetupComplete { workspace_id: String, } +/// Destination for setup progress/failure events. +/// +/// The desktop passes its Tauri [`AppHandle`] so the workspace-setup +/// overlay receives `workspace-setup-progress` / `workspace-setup-failed` +/// / `workspace-setup-complete` events. The headless daemon +/// (`codemux-remote serve`) has no webview to emit to, so it logs the +/// same payloads to stderr instead — which serve-mode redirects into +/// `serve.log`, keeping setup failures inspectable on the host. +pub enum SetupEmitter<'a> { + App(&'a AppHandle), + Log, +} + +impl SetupEmitter<'_> { + fn emit(&self, event: &str, payload: S) { + match self { + SetupEmitter::App(app) => { + let _ = app.emit(event, payload); + } + SetupEmitter::Log => { + let json = serde_json::to_string(&payload).unwrap_or_default(); + eprintln!("[codemux::scripts] {event}: {json}"); + } + } + } +} + /// Build the common environment variables for setup/teardown/run scripts /// and terminal PTY sessions. pub fn script_env( @@ -266,7 +293,7 @@ pub fn run_setup_scripts( workspace_path: &Path, workspace_name: &str, workspace_id: &str, - app_handle: &AppHandle, + emitter: &SetupEmitter<'_>, db: Option<&DatabaseStore>, branch: Option<&str>, port: Option, @@ -293,7 +320,7 @@ pub fn run_setup_scripts( // Step 1: Process worktree includes (copy gitignored files from main worktree) match process_worktree_includes(&root_path, workspace_path, &setting_patterns) { Ok(result) => { - let _ = app_handle.emit( + emitter.emit( "worktree-includes-applied", WorktreeIncludesApplied { workspace_id: workspace_id.to_string(), @@ -321,7 +348,7 @@ pub fn run_setup_scripts( workspace_path, workspace_name, workspace_id, - app_handle, + emitter, &config, &root_path, branch, @@ -336,7 +363,7 @@ pub fn run_setup_scripts_with_config( workspace_path: &Path, workspace_name: &str, workspace_id: &str, - app_handle: &AppHandle, + emitter: &SetupEmitter<'_>, config: &WorkspaceConfig, root_path: &Path, branch: Option<&str>, @@ -356,7 +383,7 @@ pub fn run_setup_scripts_with_config( let total = config.setup.len(); for (index, command) in config.setup.iter().enumerate() { - let _ = app_handle.emit( + emitter.emit( "workspace-setup-progress", SetupProgress { workspace_id: workspace_id.to_string(), @@ -385,7 +412,7 @@ pub fn run_setup_scripts_with_config( let stderr = String::from_utf8_lossy(&output.stderr).to_string(); let exit_code = output.status.code(); - let _ = app_handle.emit( + emitter.emit( "workspace-setup-failed", SetupFailed { workspace_id: workspace_id.to_string(), @@ -406,7 +433,7 @@ pub fn run_setup_scripts_with_config( } } - let _ = app_handle.emit( + emitter.emit( "workspace-setup-complete", SetupComplete { workspace_id: workspace_id.to_string(), diff --git a/src-tauri/src/settings_sync.rs b/src-tauri/src/settings_sync.rs index cdb99ef7..9f1acf35 100644 --- a/src-tauri/src/settings_sync.rs +++ b/src-tauri/src/settings_sync.rs @@ -77,12 +77,20 @@ impl Default for TerminalSettings { pub struct GitSettings { #[serde(default = "default_base_branch")] pub default_base_branch: String, + /// Opt-in (issue #80): when an agent-chat run starts, snapshot the + /// workspace working tree in the background so the run can be + /// rolled back from the chat pane. Off by default — checkpoint + /// refs accumulate under `refs/codemux/` and not every user wants + /// that. + #[serde(default)] + pub agent_checkpoint_enabled: bool, } impl Default for GitSettings { fn default() -> Self { Self { default_base_branch: default_base_branch(), + agent_checkpoint_enabled: false, } } } @@ -483,6 +491,7 @@ mod tests { }, git: GitSettings { default_base_branch: "develop".into(), + agent_checkpoint_enabled: true, }, keyboard: KeyboardSettings { shortcuts: { diff --git a/src-tauri/tests/agent_chat_commands.rs b/src-tauri/tests/agent_chat_commands.rs index cc91220e..01c7ca1a 100644 --- a/src-tauri/tests/agent_chat_commands.rs +++ b/src-tauri/tests/agent_chat_commands.rs @@ -33,13 +33,14 @@ use codemux_lib::agent_provider::{ SendTurnInput, StartSessionInput, ThreadId, TurnId, }; use codemux_lib::commands::agent_chat::{ - feature_flag_on, forward_event, thread_id_for_event, AgentChatEventPayload, - ProviderRegistry, AGENT_CHAT_EVENT, FEATURE_DISABLED_ERROR, + checkpoint_ref_component, feature_flag_on, forward_event, perform_run_checkpoint, + thread_id_for_event, AgentChatChannelRegistry, AgentChatEventPayload, ProviderRegistry, + AGENT_CHAT_EVENT, FEATURE_DISABLED_ERROR, }; use codemux_lib::database::DatabaseStore; use codemux_lib::observability::{FeatureFlags, ObservabilityStore}; use codemux_lib::state::AppStateStore; -use tauri::Manager; +use tauri::{Manager, State}; use crate::mock_agent_provider::{MockAgentProvider, MockCall}; @@ -290,6 +291,115 @@ async fn registry_returns_none_when_provider_missing() { assert!(registry.get(ProviderKind::Codex).await.is_none()); } +// ── Run-start rollback checkpoints (issue #80) ── + +#[test] +fn checkpoint_round_trips_through_session_row() { + let db = DatabaseStore::new_in_memory(); + db.upsert_agent_chat_session("th-cp", "ws-1", Some("/tmp/repo"), "claude") + .expect("upsert session"); + + // No checkpoint recorded yet (setting off / snapshot pending). + assert!(db.get_agent_chat_checkpoint("th-cp").is_none()); + + db.set_agent_chat_checkpoint("th-cp", "abc123", "def456") + .expect("set checkpoint"); + assert_eq!( + db.get_agent_chat_checkpoint("th-cp"), + Some(("abc123".to_string(), "def456".to_string())) + ); + // The restore path resolves the repo via the session's cwd. + assert_eq!( + db.get_agent_chat_session_cwd("th-cp"), + Some("/tmp/repo".to_string()) + ); + // Unknown threads stay None. + assert!(db.get_agent_chat_checkpoint("th-other").is_none()); +} + +/// Full backend loop against a real repository: run-start checkpoint +/// records hashes on the session row; the recorded commit restores +/// the tree after an "agent run" mangles it. +#[tokio::test] +async fn run_checkpoint_records_and_restores_against_real_repo() { + let app = tauri::test::mock_app(); + app.manage(DatabaseStore::new_in_memory()); + let handle = app.handle().clone(); + + // Real repo with one committed file + one untracked WIP file. + let dir = tempfile::TempDir::new().unwrap(); + let repo = dir.path(); + let git = |args: &[&str]| { + let out = std::process::Command::new("git") + .arg("-C") + .arg(repo) + .args(args) + .output() + .expect("git runs"); + assert!(out.status.success(), "git {args:?}: {out:?}"); + }; + git(&["init", "--initial-branch=main"]); + git(&["config", "user.email", "t@e.com"]); + git(&["config", "user.name", "T"]); + std::fs::write(repo.join("code.txt"), "original\n").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "base"]); + std::fs::write(repo.join("wip.txt"), "user wip\n").unwrap(); + + let cwd = repo.to_string_lossy().to_string(); + let db: State<'_, DatabaseStore> = handle.state(); + db.upsert_agent_chat_session("th-real", "ws-1", Some(&cwd), "claude") + .expect("upsert"); + + // Run-start hook body (the spawn wrapper only adds the opt-in + // gate + background spawn around exactly this call). + perform_run_checkpoint(&handle, "th-real", &cwd); + + let (commit, head) = db + .get_agent_chat_checkpoint("th-real") + .expect("checkpoint recorded on the session row"); + assert!(!commit.is_empty() && !head.is_empty()); + + // "Agent run": clobber + junk + delete. + std::fs::write(repo.join("code.txt"), "agent broke this\n").unwrap(); + std::fs::write(repo.join("junk.txt"), "junk\n").unwrap(); + std::fs::remove_file(repo.join("wip.txt")).unwrap(); + + // Restore using exactly what the restore command reads from the DB. + codemux_lib::git::git_restore_workspace_checkpoint( + repo, + &commit, + &format!( + "refs/codemux/pre-restore/{}", + checkpoint_ref_component("th-real") + ), + ) + .expect("restore"); + + assert_eq!( + std::fs::read_to_string(repo.join("code.txt")).unwrap(), + "original\n" + ); + assert_eq!( + std::fs::read_to_string(repo.join("wip.txt")).unwrap(), + "user wip\n" + ); + assert!(!repo.join("junk.txt").exists()); +} + +#[test] +fn checkpoint_ref_component_sanitizes_thread_ids() { + // Typical local thread id passes through untouched. + assert_eq!( + checkpoint_ref_component("chat-pane-1-1700000000"), + "chat-pane-1-1700000000" + ); + // Ref-hostile characters are flattened to dashes. + assert_eq!(checkpoint_ref_component("a b/c~d:e?f"), "a-b-c-d-e-f"); + // Never produces an empty ref segment. + assert_eq!(checkpoint_ref_component(""), "unknown"); +} + // ── Event bridge ── #[test] @@ -324,19 +434,159 @@ fn thread_id_for_event_extracts_bound_threads() { ); } -#[tokio::test] -async fn event_bridge_forwards_runtime_events_to_tauri() { - // Build a MockRuntime app, subscribe to the agent_chat_event - // channel, and verify that forwarding a runtime event through - // forward_event produces a matching AgentChatEventPayload. - // - // forward_event persists ItemCompleted via DatabaseStore::append_*, - // so the mock app must manage a DatabaseStore or `app.state::<…>` - // panics with "state() called before manage()". An in-memory store - // is sufficient — we're only asserting on the emitted event, not - // on what gets written to disk. +/// Build a mock app managing everything `forward_event` touches: +/// DatabaseStore (transcript persistence) and the per-thread channel +/// registry (live routing). Without either, `app.state::<…>` panics +/// with "state() called before manage()". +fn channel_test_app() -> tauri::App { let app = tauri::test::mock_app(); app.manage(DatabaseStore::new_in_memory()); + app.manage(AgentChatChannelRegistry::new()); + app +} + +/// Construct a test Channel whose deliveries land in the returned +/// shared Vec. `Channel::send` invokes the handler synchronously, so +/// tests can assert immediately after `forward_event` returns. +fn collecting_channel() -> ( + tauri::ipc::Channel, + Arc>>, +) { + let received: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = received.clone(); + let channel = tauri::ipc::Channel::new(move |body: tauri::ipc::InvokeResponseBody| { + let payload: AgentChatEventPayload = body + .deserialize() + .expect("channel payload should deserialize"); + sink.lock().unwrap().push(payload); + Ok(()) + }); + (channel, received) +} + +fn item_completed(thread: &str, text: &str) -> ProviderRuntimeEvent { + ProviderRuntimeEvent::ItemCompleted { + thread_id: ThreadId(thread.into()), + turn_id: TurnId("turn-1".into()), + item: CompletedItem::AssistantText { text: text.into() }, + } +} + +#[tokio::test] +async fn event_bridge_routes_thread_events_to_attached_channel() { + let app = channel_test_app(); + let handle = app.handle().clone(); + + let (channel, received) = collecting_channel(); + let registry: State<'_, AgentChatChannelRegistry> = handle.state(); + registry.attach("thread-bridge", channel); + + forward_event(&handle, item_completed("thread-bridge", "hello")); + + let received = received.lock().unwrap(); + assert_eq!(received.len(), 1, "exactly one event should be received"); + assert_eq!(received[0].thread_id.0, "thread-bridge"); + match &received[0].event { + ProviderRuntimeEvent::ItemCompleted { thread_id, .. } => { + assert_eq!(thread_id.0, "thread-bridge"); + } + other => panic!("unexpected event variant: {other:?}"), + } +} + +#[tokio::test] +async fn event_bridge_does_not_leak_across_threads() { + // A pane attached to thread A must never see thread B's events — + // the core no-cross-thread-leakage acceptance criterion of the + // channel migration. + let app = channel_test_app(); + let handle = app.handle().clone(); + + let (channel_a, received_a) = collecting_channel(); + let (channel_b, received_b) = collecting_channel(); + let registry: State<'_, AgentChatChannelRegistry> = handle.state(); + registry.attach("thread-a", channel_a); + registry.attach("thread-b", channel_b); + + forward_event(&handle, item_completed("thread-b", "for b only")); + + assert!( + received_a.lock().unwrap().is_empty(), + "thread-a channel must not receive thread-b events" + ); + let received_b = received_b.lock().unwrap(); + assert_eq!(received_b.len(), 1); + assert_eq!(received_b[0].thread_id.0, "thread-b"); +} + +#[tokio::test] +async fn event_bridge_stops_delivery_after_detach() { + let app = channel_test_app(); + let handle = app.handle().clone(); + + let (channel, received) = collecting_channel(); + let registry: State<'_, AgentChatChannelRegistry> = handle.state(); + let subscription_id = registry.attach("thread-detach", channel); + assert_eq!(registry.attached_count("thread-detach"), 1); + + forward_event(&handle, item_completed("thread-detach", "first")); + registry.detach("thread-detach", subscription_id); + assert_eq!(registry.attached_count("thread-detach"), 0); + forward_event(&handle, item_completed("thread-detach", "second")); + + let received = received.lock().unwrap(); + assert_eq!( + received.len(), + 1, + "only the pre-detach event should be delivered" + ); +} + +#[tokio::test] +async fn event_bridge_preserves_delta_ordering() { + // Channel sends are synchronous and per-channel ordered; a burst + // of content deltas must arrive in emission order. + let app = channel_test_app(); + let handle = app.handle().clone(); + + let (channel, received) = collecting_channel(); + let registry: State<'_, AgentChatChannelRegistry> = handle.state(); + registry.attach("thread-order", channel); + + for i in 0..50 { + forward_event( + &handle, + ProviderRuntimeEvent::ContentDelta { + thread_id: ThreadId("thread-order".into()), + turn_id: TurnId("turn-1".into()), + delta: codemux_lib::agent_provider::ContentDelta::Text { + text: format!("tok-{i}"), + }, + }, + ); + } + + let received = received.lock().unwrap(); + assert_eq!(received.len(), 50); + for (i, payload) in received.iter().enumerate() { + match &payload.event { + ProviderRuntimeEvent::ContentDelta { delta, .. } => { + let codemux_lib::agent_provider::ContentDelta::Text { text } = delta else { + panic!("unexpected delta kind"); + }; + assert_eq!(text, &format!("tok-{i}"), "delta order must be preserved"); + } + other => panic!("unexpected event variant: {other:?}"), + } + } +} + +#[tokio::test] +async fn event_bridge_emits_threadless_warnings_on_legacy_bus() { + // Global RuntimeWarnings have no owning thread/pane, so they keep + // the legacy broadcast path with an empty ThreadId. + let app = channel_test_app(); let handle = app.handle().clone(); let received: Arc>> = @@ -359,14 +609,14 @@ async fn event_bridge_forwards_runtime_events_to_tauri() { }); }); - let event = ProviderRuntimeEvent::ItemCompleted { - thread_id: ThreadId("thread-bridge".into()), - turn_id: TurnId("turn-1".into()), - item: CompletedItem::AssistantText { - text: "hello".into(), + forward_event( + &handle, + ProviderRuntimeEvent::RuntimeWarning { + thread_id: None, + message: "global warning".into(), + original_payload: None, }, - }; - forward_event(&handle, event); + ); timeout(Duration::from_secs(2), rx) .await @@ -374,12 +624,6 @@ async fn event_bridge_forwards_runtime_events_to_tauri() { .expect("one-shot sender should send before dropping"); let received = received.lock().await; - assert_eq!(received.len(), 1, "exactly one event should be received"); - assert_eq!(received[0].thread_id.0, "thread-bridge"); - match &received[0].event { - ProviderRuntimeEvent::ItemCompleted { thread_id, .. } => { - assert_eq!(thread_id.0, "thread-bridge"); - } - other => panic!("unexpected event variant: {other:?}"), - } + assert_eq!(received.len(), 1); + assert_eq!(received[0].thread_id.0, "", "threadless payload uses empty id"); } diff --git a/src-tauri/tests/codemux_remote_serve_mcp.rs b/src-tauri/tests/codemux_remote_serve_mcp.rs index c82f8406..f7bddc26 100644 --- a/src-tauri/tests/codemux_remote_serve_mcp.rs +++ b/src-tauri/tests/codemux_remote_serve_mcp.rs @@ -47,6 +47,13 @@ struct ServeFixture { impl ServeFixture { fn start() -> Self { + Self::start_with(|_| {}) + } + + /// Same as [`start`](Self::start) but lets the caller adjust the + /// daemon's `Command` before spawn — e.g. overriding `HOME` so + /// worktrees land in a disposable directory. + fn start_with(configure: impl FnOnce(&mut Command)) -> Self { let bin = binary_path(); assert!( bin.exists(), @@ -64,6 +71,7 @@ impl ServeFixture { .arg(state_dir.path()) .stdout(Stdio::null()) .stderr(Stdio::inherit()); + configure(&mut cmd); let child = cmd.spawn().expect("spawn codemux-remote serve"); // Wait for the manifest to appear (the daemon writes it @@ -218,6 +226,103 @@ fn http_workspace_create_then_list() { fx.stop(); } +/// End-to-end coverage for issue #78: a worktree created through the +/// running daemon must be provisioned like a desktop-created one — +/// the project's setup script runs (with `CODEMUX_*` env + the +/// deterministic per-workspace port) and gitignored worktree includes +/// (`.env`) are copied from the main checkout. +#[test] +fn http_worktree_create_runs_setup_scripts() { + // Isolated HOME so the daemon's `~/.codemux/worktrees/...` output + // lands in a disposable directory. + let home = TempDir::new().expect("home tempdir"); + let fx = ServeFixture::start_with(|cmd| { + cmd.env("HOME", home.path()); + }); + let client = reqwest::blocking::Client::new(); + + // Seed repo: committed `.codemux/config.json` with a setup command + // that captures the injected env, plus a gitignored `.env`. + let repo = TempDir::new().expect("repo tempdir"); + let git = |args: &[&str]| { + let out = Command::new("git") + .arg("-C") + .arg(repo.path()) + .args(args) + .output() + .expect("git runs"); + assert!(out.status.success(), "git {args:?}: {out:?}"); + }; + git(&["init", "--initial-branch=main"]); + git(&["config", "user.email", "t@e.com"]); + git(&["config", "user.name", "T"]); + std::fs::create_dir_all(repo.path().join(".codemux")).unwrap(); + std::fs::write( + repo.path().join(".codemux/config.json"), + json!({ + "setup": [ + "printf '%s\n%s\n' \"$CODEMUX_BRANCH\" \"$CODEMUX_PORT\" > setup-ran.txt" + ] + }) + .to_string(), + ) + .unwrap(); + std::fs::write(repo.path().join(".gitignore"), ".env\n").unwrap(); + std::fs::write(repo.path().join(".env"), "SECRET=e2e\n").unwrap(); + std::fs::write(repo.path().join("f.txt"), "x").unwrap(); + git(&["add", "."]); + git(&["commit", "-m", "init"]); + + // worktree_create through the daemon's authed HTTP surface. + let resp = client + .post(format!("{}/tools/call", fx.endpoint)) + .bearer_auth(&fx.secret) + .json(&json!({ + "name": "worktree_create", + "arguments": { + "repo_path": repo.path().to_string_lossy(), + "branch": "feature/provision", + "base": "main" + } + })) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + let body: Value = resp.json().unwrap(); + assert_eq!(body["ok"], json!(true), "body: {body}"); + assert_eq!(body["data"]["setup"]["configured"], json!(true)); + let port = body["data"]["setup"]["port"].as_u64().expect("port"); + let ws_path = PathBuf::from( + body["data"]["workspace"]["path"].as_str().expect("path"), + ); + + // Setup runs on a daemon background thread — poll for the marker. + let marker = ws_path.join("setup-ran.txt"); + let deadline = Instant::now() + Duration::from_secs(20); + while !marker.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(100)); + } + assert!( + marker.exists(), + "setup script never ran in daemon-created worktree at {}", + ws_path.display() + ); + let contents = std::fs::read_to_string(&marker).unwrap(); + let lines: Vec<&str> = contents.lines().collect(); + assert_eq!(lines[0], "feature/provision", "CODEMUX_BRANCH injected"); + assert_eq!(lines[1], port.to_string(), "CODEMUX_PORT matches response"); + + // Includes run before setup commands, so the marker existing + // implies the gitignored .env was already copied. + assert_eq!( + std::fs::read_to_string(ws_path.join(".env")).unwrap(), + "SECRET=e2e\n", + ".env must be copied from the main checkout" + ); + + fx.stop(); +} + /// Drive `codemux-remote mcp` over stdio: initialize → tools/list → /// tools/call workspace_create → tools/call workspace_list. This is /// the actual code path a CLI agent (Claude Code, Codex) takes. diff --git a/src/components/chat/AgentChatPaneHeader.test.tsx b/src/components/chat/AgentChatPaneHeader.test.tsx index 1576d22e..b85fc6bd 100644 --- a/src/components/chat/AgentChatPaneHeader.test.tsx +++ b/src/components/chat/AgentChatPaneHeader.test.tsx @@ -14,7 +14,9 @@ import type { // the tests via `vi.mocked(...)`. vi.mock("@/tauri/commands", () => ({ + agentChatGetCheckpoint: vi.fn().mockResolvedValue(null), agentChatListMessages: vi.fn().mockResolvedValue([]), + agentChatRestoreCheckpoint: vi.fn().mockResolvedValue(undefined), agentChatStartSession: vi.fn().mockResolvedValue("thread-new"), agentChatStopSession: vi.fn().mockResolvedValue(undefined), closePane: vi.fn().mockResolvedValue(undefined), diff --git a/src/components/chat/AgentChatPaneHeader.tsx b/src/components/chat/AgentChatPaneHeader.tsx index c7b4f1c9..5acb8a67 100644 --- a/src/components/chat/AgentChatPaneHeader.tsx +++ b/src/components/chat/AgentChatPaneHeader.tsx @@ -1,18 +1,37 @@ -import { SplitSquareHorizontal, SplitSquareVertical, X } from "lucide-react"; +import { useEffect, useState } from "react"; +import { + History, + SplitSquareHorizontal, + SplitSquareVertical, + X, +} from "lucide-react"; import { SessionSelector } from "@/components/chat/SessionSelector"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; import { Button } from "@/components/ui/button"; import { sessionDisplayTitle } from "@/lib/agent-chat/session-history"; import { toast } from "@/lib/toast"; import { useAgentChatStore } from "@/stores/agent-chat-store"; import { findWorkspaceIdForPane, useAppStore } from "@/stores/app-store"; import { + agentChatGetCheckpoint, agentChatListMessages, + agentChatRestoreCheckpoint, agentChatStartSession, agentChatStopSession, closePane, splitPane, type AgentChatSessionRecord, + type WorkspaceCheckpoint, } from "@/tauri/commands"; import type { AgentChatProviderKind, @@ -157,6 +176,61 @@ export function AgentChatPaneHeader({ pane, isActive, onPointerDown }: Props) { closePane(pane.pane_id).catch(console.error); }; + // Run-start rollback checkpoint (issue #80). The snapshot is taken + // in the background shortly after session start, so fetch once on + // thread bind and retry once after a short delay if it wasn't + // there yet. The affordance only renders when a checkpoint exists. + const [checkpoint, setCheckpoint] = useState( + null, + ); + const [confirmRestoreOpen, setConfirmRestoreOpen] = useState(false); + const [restoring, setRestoring] = useState(false); + useEffect(() => { + setCheckpoint(null); + if (!pane.thread_id) return; + const threadId = pane.thread_id; + let cancelled = false; + let retryTimer: number | null = null; + const fetchCheckpoint = (retry: boolean) => { + agentChatGetCheckpoint(threadId) + .then((cp) => { + if (cancelled) return; + if (cp) { + setCheckpoint(cp); + } else if (retry) { + // The background snapshot may still be running right + // after session start — check again once. + retryTimer = window.setTimeout( + () => fetchCheckpoint(false), + 4000, + ); + } + }) + .catch(() => {}); + }; + fetchCheckpoint(true); + return () => { + cancelled = true; + if (retryTimer != null) window.clearTimeout(retryTimer); + }; + }, [pane.thread_id]); + + const handleRestoreCheckpoint = async () => { + if (!pane.thread_id) return; + setRestoring(true); + try { + await agentChatRestoreCheckpoint(pane.thread_id); + toast.success( + "Workspace restored to the checkpoint taken when this run started.", + ); + } catch (error) { + toast.error(`Restore failed: ${error}`); + } finally { + setRestoring(false); + setConfirmRestoreOpen(false); + } + }; + return (
+ {checkpoint && ( + + )} + { + if (!restoring) setConfirmRestoreOpen(open); + }} + > + + + Restore checkpoint? + + The working tree will be rolled back to the snapshot taken + when this run started + {checkpoint + ? ` (${checkpoint.commit.slice(0, 12)})` + : ""} + . Files created since then are removed; commits and ignored + files are kept. A safety snapshot of the current state is + recorded first. + + + + Cancel + { + e.preventDefault(); + void handleRestoreCheckpoint(); + }} + > + {restoring ? "Restoring…" : "Restore"} + + + +
); } diff --git a/src/components/settings/SettingsPanel.test.tsx b/src/components/settings/SettingsPanel.test.tsx index 4f5ec754..e196abe1 100644 --- a/src/components/settings/SettingsPanel.test.tsx +++ b/src/components/settings/SettingsPanel.test.tsx @@ -79,7 +79,7 @@ vi.mock("@/stores/synced-settings-store", () => ({ appearance: { theme: "system", shell_font: null, terminal_font_size: 13 }, editor: { default_ide: null }, terminal: { scrollback_limit: 10000, cursor_style: "bar" }, - git: { default_base_branch: "main" }, + git: { default_base_branch: "main", agent_checkpoint_enabled: false }, keyboard: { shortcuts: {} }, notifications: { sound_enabled: true, desktop_enabled: true }, }, diff --git a/src/components/settings/settings-view.tsx b/src/components/settings/settings-view.tsx index f824adfb..a5877860 100644 --- a/src/components/settings/settings-view.tsx +++ b/src/components/settings/settings-view.tsx @@ -1223,6 +1223,21 @@ export function SettingsView() { className="w-36 h-9" /> + + { + updateSyncedSetting( + "git", + "agent_checkpoint_enabled", + checked, + ).catch(console.error); + }} + /> + diff --git a/src/dev/tauri-mock.ts b/src/dev/tauri-mock.ts index da38f93c..b4292be3 100644 --- a/src/dev/tauri-mock.ts +++ b/src/dev/tauri-mock.ts @@ -178,7 +178,7 @@ const SYNCED_SETTINGS: UserSettings = { }, editor: { default_ide: null }, terminal: { scrollback_limit: 10_000, cursor_style: "bar" }, - git: { default_base_branch: "main" }, + git: { default_base_branch: "main", agent_checkpoint_enabled: true }, keyboard: { shortcuts: {} }, notifications: { sound_enabled: true, desktop_enabled: true }, file_tree: { show_hidden_files: false }, @@ -205,9 +205,10 @@ const EMPTY_CAPABILITIES: ProviderChatCapabilities = { // `agent_chat_list_messages`, which returns a long generated // transcript replayed through the real reducer — so the virtualized // MessageList renders real ChatViewItems. `agent_chat_send_turn` -// simulates a streaming reply through the real `agent_chat_event` -// channel; `window.__codemuxChatMock.streamReply()` triggers one on -// demand for scroll/perf testing. +// simulates a streaming reply over the pane's attached per-thread +// `Channel` (same path as the real backend — issue #75); +// `window.__codemuxChatMock.streamReply()` triggers one on demand for +// scroll/perf testing. const MOCK_CHAT_MODEL: ChatModelInfo = { id: "mock-sonnet", @@ -330,9 +331,61 @@ function mockChatTranscript(): string[] { let mockChatTurnSeq = 0; -/** Simulate a streaming assistant reply on the real event channel. - * Returns the turn id immediately; deltas tick on an interval so - * stick-to-bottom / scroll-up-freedom can be observed live. */ +// ── Per-thread chat event channels (mirrors the real backend) ────── +// +// The real backend streams thread-scoped events over per-thread Tauri +// `Channel`s registered via `attach_agent_chat_output` (issue #75). +// A `Channel` registers its ordered dispatcher through +// `transformCallback` and expects `{ index, message }` payloads with a +// per-channel sequential index starting at 0. We track attached +// channel callback-ids per thread plus each channel's next index. + +// thread_id -> Set of channel callback ids +const mockChatChannels = new Map>(); +// channel callback id -> next message index +const mockChatChannelIndexes = new Map(); + +function attachMockChatChannel(threadId: string, channel: unknown): number { + const id = (channel as { id?: number } | undefined)?.id; + if (typeof id !== "number") return 0; + let set = mockChatChannels.get(threadId); + if (!set) { + set = new Set(); + mockChatChannels.set(threadId, set); + } + set.add(id); + mockChatChannelIndexes.set(id, 0); + return id; +} + +function detachMockChatChannel(threadId: string, subscriptionId: number): void { + mockChatChannels.get(threadId)?.delete(subscriptionId); + mockChatChannelIndexes.delete(subscriptionId); +} + +/** Deliver one AgentChatEventPayload to every channel attached to the + * thread, preserving the Channel dispatcher's ordered-index contract. */ +function sendMockChatEvent(threadId: string, event: unknown): void { + const subs = mockChatChannels.get(threadId); + if (!subs || subs.size === 0) return; + const payload = { thread_id: threadId, event }; + for (const id of subs) { + const cb = callbacks.get(id); + if (!cb) continue; + const index = mockChatChannelIndexes.get(id) ?? 0; + mockChatChannelIndexes.set(id, index + 1); + try { + cb.fn({ index, message: payload } as unknown as never); + } catch (err) { + console.error("[dev mock] chat channel dispatch threw:", err); + } + } +} + +/** Simulate a streaming assistant reply over the thread's attached + * channels. Returns the turn id immediately; deltas tick on an + * interval so stick-to-bottom / scroll-up-freedom can be observed + * live. */ function streamMockChatReply( threadId: string, opts: { tokens?: number; intervalMs?: number } = {}, @@ -340,8 +393,7 @@ function streamMockChatReply( const turnId = `live-turn-${++mockChatTurnSeq}`; const tokens = Math.max(1, opts.tokens ?? 120); const intervalMs = Math.max(5, opts.intervalMs ?? 40); - const send = (event: unknown) => - emitEvent("agent_chat_event", { thread_id: threadId, event }); + const send = (event: unknown) => sendMockChatEvent(threadId, event); send({ type: "session_state_changed", @@ -536,6 +588,26 @@ const handlers: Record = { agent_chat_stop_session: () => undefined, agent_chat_rename_session: () => undefined, agent_chat_delete_session: () => undefined, + attach_agent_chat_output: (a) => + attachMockChatChannel(a.threadId as string, a.channel), + detach_agent_chat_output: (a) => { + detachMockChatChannel(a.threadId as string, a.subscriptionId as number); + return undefined; + }, + // Run-start rollback checkpoint (issue #80): the seeded thread has + // one so the header's restore affordance + confirm dialog can be + // exercised in plain-browser dev. + agent_chat_get_checkpoint: (a) => + a.threadId === MOCK_CHAT_THREAD_ID + ? { + commit: "c0ffee1234567890c0ffee1234567890c0ffee12", + head: "feedface1234567890feedface1234567890feed", + } + : null, + agent_chat_restore_checkpoint: (a) => { + console.log("[dev mock] agent_chat_restore_checkpoint", a.threadId); + return undefined; + }, grep_count_pattern: () => 0, // ── Presets ── diff --git a/src/hooks/use-agent-chat-events.ts b/src/hooks/use-agent-chat-events.ts index 032ff602..b06101c8 100644 --- a/src/hooks/use-agent-chat-events.ts +++ b/src/hooks/use-agent-chat-events.ts @@ -1,38 +1,69 @@ -import { useCallback } from "react"; +import { useEffect, useRef } from "react"; -import { useTauriEvent } from "@/hooks/use-tauri-event"; import { - onAgentChatEvent, - type AgentChatEventPayload, - type EventCallback, -} from "@/tauri/events"; + attachAgentChatOutput, + Channel, + detachAgentChatOutput, +} from "@/tauri/commands"; +import type { AgentChatEventPayload } from "@/tauri/events"; /** - * Subscribe to provider runtime events, filtered by thread id. + * Subscribe to a thread's live provider runtime events. * - * The Rust side fans every provider's canonical event stream into a - * single `agent_chat_event` Tauri channel. Each pane is interested in - * exactly one thread, so the hook takes a thread id and invokes the - * handler only when the payload's `thread_id` matches. + * The Rust side routes each thread's canonical event stream to the + * per-thread `Channel`s registered via `attach_agent_chat_output` — + * the same streaming mechanism PTY output uses — instead of fanning + * every token of every thread through the global event bus. The hook + * attaches a channel for its thread on mount (and whenever the thread + * id changes) and detaches it on unmount, so a pane only ever receives + * its own thread's events. * * Passing a `null` thread id disables the subscription — useful for * panes that have not yet started a session. + * + * The handler is kept in a ref so a new handler identity never forces + * a detach/re-attach round-trip. */ export function useAgentChatEvents( threadId: string | null, handler: (payload: AgentChatEventPayload) => void, ) { - const filtered = useCallback>( - (payload) => { - if (threadId == null) return; + const handlerRef = useRef(handler); + handlerRef.current = handler; + + useEffect(() => { + if (threadId == null) return; + + let cancelled = false; + let subscriptionId: number | null = null; + + const channel = new Channel((payload) => { + if (cancelled) return; + // The backend routes per thread already; this guard is defense + // in depth against a stale channel delivering after a re-bind. if (payload.thread_id !== threadId) return; - handler(payload); - }, - [threadId, handler], - ); + handlerRef.current(payload); + }); + + void attachAgentChatOutput(threadId, channel) + .then((id) => { + subscriptionId = id; + if (cancelled) { + // Unmounted while the attach round-trip was in flight — + // detach immediately so the backend doesn't hold a channel + // for a dead pane. + void detachAgentChatOutput(threadId, id).catch(() => {}); + } + }) + .catch((error) => { + console.error("[agent-chat] failed to attach event channel:", error); + }); - useTauriEvent(onAgentChatEvent, filtered, [ - threadId, - handler, - ]); + return () => { + cancelled = true; + if (subscriptionId != null) { + void detachAgentChatOutput(threadId, subscriptionId).catch(() => {}); + } + }; + }, [threadId]); } diff --git a/src/stores/settings-store.test.ts b/src/stores/settings-store.test.ts index 15f718cb..fd00d571 100644 --- a/src/stores/settings-store.test.ts +++ b/src/stores/settings-store.test.ts @@ -17,7 +17,7 @@ const mockSyncedState = { appearance: { theme: "system", shell_font: null as string | null, terminal_font_size: 13 }, editor: { default_ide: null as string | null }, terminal: { scrollback_limit: 10_000, cursor_style: "bar" }, - git: { default_base_branch: "main" }, + git: { default_base_branch: "main", agent_checkpoint_enabled: false }, keyboard: { shortcuts: {} as Record }, notifications: { sound_enabled: true, desktop_enabled: true }, }, @@ -139,7 +139,7 @@ describe("settings-store", () => { mockSyncedGetState.mockReturnValue({ settings: { ...defaults, - git: { default_base_branch: "develop" }, + git: { default_base_branch: "develop", agent_checkpoint_enabled: false }, }, }); expect(getDefaultBaseBranch()).toBe("develop"); diff --git a/src/stores/settings-sync-integration.test.ts b/src/stores/settings-sync-integration.test.ts index 887571a8..72350fa4 100644 --- a/src/stores/settings-sync-integration.test.ts +++ b/src/stores/settings-sync-integration.test.ts @@ -35,7 +35,7 @@ const FULL_CUSTOM: UserSettings = { appearance: { theme: "dark", shell_font: "Fira Code", terminal_font_size: 20, show_resource_monitor: true }, editor: { default_ide: "cursor" }, terminal: { scrollback_limit: 2000, cursor_style: "underline" }, - git: { default_base_branch: "develop" }, + git: { default_base_branch: "develop", agent_checkpoint_enabled: false }, keyboard: { shortcuts: { "ctrl+s": "save", "ctrl+p": "palette" } }, notifications: { sound_enabled: false, desktop_enabled: false }, file_tree: { show_hidden_files: true }, @@ -117,7 +117,7 @@ describe("settings single-store integration", () => { const userBSettings: UserSettings = { ...DEFAULT_SETTINGS, appearance: { theme: "light", shell_font: null, terminal_font_size: 14, show_resource_monitor: true }, - git: { default_base_branch: "release" }, + git: { default_base_branch: "release", agent_checkpoint_enabled: false }, }; mockGetSyncedSettings.mockResolvedValue(userBSettings); await useSyncedSettingsStore.getState().loadSettings(); diff --git a/src/stores/synced-settings-store.test.ts b/src/stores/synced-settings-store.test.ts index 57b65010..df845bc0 100644 --- a/src/stores/synced-settings-store.test.ts +++ b/src/stores/synced-settings-store.test.ts @@ -30,7 +30,7 @@ const DARK_SETTINGS: UserSettings = { appearance: { theme: "dark", shell_font: "Fira Code", terminal_font_size: 16, show_resource_monitor: true }, editor: { default_ide: "cursor" }, terminal: { scrollback_limit: 5000, cursor_style: "block" }, - git: { default_base_branch: "develop" }, + git: { default_base_branch: "develop", agent_checkpoint_enabled: false }, keyboard: { shortcuts: { "ctrl+s": "save" } }, notifications: { sound_enabled: false, desktop_enabled: true }, file_tree: { show_hidden_files: true }, @@ -194,7 +194,7 @@ describe("synced-settings-store", () => { const userBSettings: UserSettings = { ...DEFAULT_SETTINGS, appearance: { theme: "light", shell_font: null, terminal_font_size: 14, show_resource_monitor: true }, - git: { default_base_branch: "develop" }, + git: { default_base_branch: "develop", agent_checkpoint_enabled: false }, }; mockGetSyncedSettings.mockResolvedValue(userBSettings); diff --git a/src/stores/synced-settings-store.ts b/src/stores/synced-settings-store.ts index 8af99558..8a10d795 100644 --- a/src/stores/synced-settings-store.ts +++ b/src/stores/synced-settings-store.ts @@ -16,7 +16,7 @@ const DEFAULT_SETTINGS: UserSettings = { }, editor: { default_ide: null }, terminal: { scrollback_limit: 10_000, cursor_style: "bar" }, - git: { default_base_branch: "main" }, + git: { default_base_branch: "main", agent_checkpoint_enabled: false }, keyboard: { shortcuts: {} }, notifications: { sound_enabled: true, desktop_enabled: true }, file_tree: { show_hidden_files: false }, diff --git a/src/tauri/commands.ts b/src/tauri/commands.ts index 4b9a4f95..e1a93513 100644 --- a/src/tauri/commands.ts +++ b/src/tauri/commands.ts @@ -1298,6 +1298,49 @@ export const agentChatDeleteSession = (threadId: string) => export const agentChatListMessages = (threadId: string) => invoke("agent_chat_list_messages", { threadId }); +// ── Run-start rollback checkpoints (issue #80) ── + +/** A recorded rollback point for a thread's workspace tree. */ +export interface WorkspaceCheckpoint { + /** Snapshot commit pinned under refs/codemux/checkpoints/. */ + commit: string; + /** Where HEAD pointed when the checkpoint was taken. */ + head: string; +} + +/** The recorded run-start checkpoint for a thread, if any. */ +export const agentChatGetCheckpoint = (threadId: string) => + invoke("agent_chat_get_checkpoint", { + threadId, + }); + +/** + * Roll the workspace tree back to the thread's run-start checkpoint. + * Tree-only: branch refs never move; a safety snapshot of the + * pre-restore state is pinned under refs/codemux/pre-restore/. + */ +export const agentChatRestoreCheckpoint = (threadId: string) => + invoke("agent_chat_restore_checkpoint", { threadId }); + +// ── Live event streaming (per-thread Channel) ── + +/** + * Attach a streaming `Channel` for a thread's live provider events. + * Mirrors `attachPtyOutput`: high-frequency streams use Tauri Channels + * instead of the global event bus. Resolves with the subscription id + * to pass to `detachAgentChatOutput` on unmount. + */ +export const attachAgentChatOutput = ( + threadId: string, + channel: Channel, +) => invoke("attach_agent_chat_output", { threadId, channel }); + +/** Detach a previously attached chat event channel. Idempotent. */ +export const detachAgentChatOutput = ( + threadId: string, + subscriptionId: number, +) => invoke("detach_agent_chat_output", { threadId, subscriptionId }); + // ── OpenCode (Step 12 Stage 1) ── // // Wrappers for the discovery + ping commands that land alongside the diff --git a/src/tauri/events.ts b/src/tauri/events.ts index a896ed3b..393ce1bb 100644 --- a/src/tauri/events.ts +++ b/src/tauri/events.ts @@ -228,17 +228,19 @@ export type ProviderRuntimeEvent = resume_cursor: unknown; }; -/** Canonical provider event payload as emitted to the frontend. */ +/** + * Canonical provider event payload as streamed to the frontend. + * + * Thread-scoped events arrive over the per-thread `Channel` attached + * via `attachAgentChatOutput` (see `use-agent-chat-events.ts`), not + * the global event bus; only threadless runtime warnings still use + * the legacy `agent_chat_event` broadcast. + */ export interface AgentChatEventPayload { thread_id: string; event: ProviderRuntimeEvent; } -export const onAgentChatEvent = ( - cb: EventCallback, -): Promise => - listen("agent_chat_event", (e) => cb(e.payload)); - // ── Tunnel health ── // // Mirror of src-tauri/src/ssh/tunnel_supervisor.rs:TunnelStatus diff --git a/src/tauri/types.ts b/src/tauri/types.ts index f42baa4a..c01b30bc 100644 --- a/src/tauri/types.ts +++ b/src/tauri/types.ts @@ -28,6 +28,9 @@ export interface TerminalSyncSettings { export interface GitSyncSettings { default_base_branch: string; + /** Opt-in (issue #80): snapshot the workspace tree in the background + * when an agent-chat run starts so the run can be rolled back. */ + agent_checkpoint_enabled: boolean; } export interface KeyboardSettings {