From 3af4d5d39897855bdcc74f23e690024a5dc05573 Mon Sep 17 00:00:00 2001 From: "grokkybara[bot]" <304785771+grokkybara[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 18:10:23 +0000 Subject: [PATCH] Synced from monorepo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Synced from monorepo Changes: - Shell: accept target response id on rewind execute - Shell: stamp response id on chat user message chunks - Worktree: optional rebuild and stale git registration cleanup in auto-GC - Worktree: kind-aware auto-GC TTLs and config knobs - Worktree: macOS process CWD scan and Unix PID liveness for GC guards - Worktree: automatic throttled GC on startup (Linux age-based; non-Linux dead-only) - Pager: add `[ui].combine_queued_prompts` to batch queued follow-ups - Shell: stop overwriting user skills - Tools: read markdown in `skills/` directories untruncated - `/usage` shows per-session token and dollar usage in the TUI - Security: prompt on environment-dumping `ps` variants - Security: always-safe `kubectl` no longer runs arbitrary kubeconfig credential plugins without permission - Tools: make scheduler deletion durable - Shell: add relocation storage primitives - Shell: give side model calls their own conversation ids - Fix five workflow-runtime bugs (budget, pause, cancel, reconnect) - Security: peel `env -S` / `--split-string` operands in the Bash permission gate (managed deny/ask) - Pager: expose doctor in the TUI - Security: block unauthorized RCE via abused safe commands - Pager idle watcher cue: "1 subagent still running" instead of "watching · 1 subagent" - Security: block `rg --pre` arbitrary code execution in auto-mode - Voice: diagnose silent-mic failures (macOS permission) and add doctor/terminal-setup Voice section - App builder deployer: `allow_forking` and `show_built_with_grok` - Pager: stop stacking duplicate "Worked for" markers on parked turns - Shell: support `max` as a distinct reasoning effort tier - Tools: serialize background `/loop` fires on the whole work unit - Shell: add working-directory relocation state primitives - Proto: `ClientToolResult` and `ChatConfig` client-side tools - Shell: model providers - Chat: select App Builder product on the Build path - Shell: attach author identity to feedback when the deployment opts in - Doctor: fix for SSH wrap setup - Workflow authoring skills: create-workflow and import-claude-workflow docs - Add read-only grok doctor - Sandbox: apply Landlock without a controlling TTY - Pager: recover image paste over grok wrap on headless remotes - Pager: make actions screen-mode aware - Shell: resume sessions when the working directory moves - Pager: centralize terminal diagnostics - Workspace: gate inline shell file access - Pager: centralize terminal probes - Pager: edit minimal prompts in an external editor - Pager: standardize backgrounding on Ctrl+B - Shell: recap rides the parent turn's prompt cache - Tools: add scheduler lifecycle version clock Source-Revision: 0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899 --- Cargo.lock | 118 +- Cargo.toml | 5 + SOURCE_REV | 2 +- .../codegen/xai-chat-state/src/actor/mod.rs | 42 +- .../xai-chat-state/src/actor/mutations.rs | 31 + .../src/actor/request_builder.rs | 15 +- .../codegen/xai-chat-state/src/actor/tests.rs | 260 +- crates/codegen/xai-chat-state/src/commands.rs | 31 + .../xai-chat-state/src/compaction_utils.rs | 119 +- crates/codegen/xai-chat-state/src/handle.rs | 25 +- crates/codegen/xai-chat-state/src/lib.rs | 2 +- .../codegen/xai-chat-state/src/persistence.rs | 123 +- crates/codegen/xai-fast-worktree/Cargo.toml | 5 +- crates/codegen/xai-fast-worktree/src/api.rs | 1355 +++++- .../codegen/xai-fast-worktree/src/auto_gc.rs | 2379 +++++++++ .../codegen/xai-fast-worktree/src/db/mod.rs | 64 +- .../xai-fast-worktree/src/discovery.rs | 71 +- .../xai-fast-worktree/src/git/checkout.rs | 6 +- crates/codegen/xai-fast-worktree/src/lib.rs | 15 + crates/codegen/xai-grok-agent/src/builder.rs | 22 + crates/codegen/xai-grok-agent/src/config.rs | 13 +- .../codegen/xai-grok-config-types/src/lib.rs | 312 ++ .../xai-grok-config/src/config_override.rs | 23 +- crates/codegen/xai-grok-config/src/lib.rs | 1 + .../src/managed_text/format.rs | 505 ++ .../xai-grok-config/src/managed_text/mod.rs | 258 + .../src/managed_text/source.rs | 421 ++ .../xai-grok-config/src/managed_text/tests.rs | 632 +++ .../src/managed_text/transaction.rs | 454 ++ .../src/managed_text/validator.rs | 244 + .../codegen/xai-grok-hooks/src/dispatcher.rs | 3 +- .../codegen/xai-grok-markdown/src/mermaid.rs | 9 +- crates/codegen/xai-grok-mcp/src/servers.rs | 6 +- crates/codegen/xai-grok-memory/src/dream.rs | 3 +- crates/codegen/xai-grok-pager-bin/Cargo.toml | 2 +- crates/codegen/xai-grok-pager-bin/src/main.rs | 108 +- .../xai-grok-pager-minimal/src/live.rs | 7 +- .../xai-grok-pager-pty-harness/src/content.rs | 325 +- .../xai-grok-pager-pty-harness/src/lib.rs | 4 +- .../src/scenarios/empty_enter_send_now.rs | 20 +- .../src/scenarios/plan_approval_resume.rs | 17 +- .../src/scripted.rs | 58 +- .../src/scroll_matrix/runner.rs | 32 +- .../src/scroll_matrix/session.rs | 48 +- .../tests/scroll_correctness_ptyctl.rs | 8 +- .../tests/scroll_matrix_curated.rs | 13 +- .../src/appearance/cache.rs | 52 + .../src/clipboard/mod.rs | 6 +- .../xai-grok-pager-render/src/glyphs.rs | 2 +- .../src/terminal/keyboard.rs | 13 +- .../xai-grok-pager-render/src/terminal/mod.rs | 103 +- .../src/terminal/test.rs | 9 + .../src/terminal/tmux_probe.rs | 274 ++ .../src/theme/color_support.rs | 158 +- .../codegen/xai-grok-pager-render/src/util.rs | 21 + crates/codegen/xai-grok-pager/Cargo.toml | 14 +- crates/codegen/xai-grok-pager/README.md | 5 +- .../docs/user-guide/03-keyboard-shortcuts.md | 9 +- .../docs/user-guide/04-slash-commands.md | 387 +- .../docs/user-guide/05-configuration.md | 338 +- .../docs/user-guide/06-theming.md | 2 +- .../docs/user-guide/08-skills.md | 2 +- .../docs/user-guide/14-headless-mode.md | 6 +- .../docs/user-guide/16-subagents.md | 4 +- .../docs/user-guide/20-background-tasks.md | 10 +- .../docs/user-guide/21-terminal-support.md | 36 +- .../user-guide/22-permissions-and-safety.md | 7 +- .../docs/user-guide/23-dashboard.md | 2 +- .../xai-grok-pager/docs/user-guide/README.md | 4 +- crates/codegen/xai-grok-pager/src/acp/meta.rs | 2 + .../xai-grok-pager/src/acp/model_state.rs | 3 - .../codegen/xai-grok-pager/src/acp/spawn.rs | 48 +- .../codegen/xai-grok-pager/src/acp/tracker.rs | 150 +- .../xai-grok-pager/src/actions/defaults.rs | 89 +- .../codegen/xai-grok-pager/src/actions/mod.rs | 226 +- .../xai-grok-pager/src/app/acp_handler/mcp.rs | 8 +- .../xai-grok-pager/src/app/acp_handler/mod.rs | 99 +- .../src/app/acp_handler/prompt_origin.rs | 1 + .../src/app/acp_handler/queue.rs | 33 +- .../app/acp_handler/session_notification.rs | 56 +- .../src/app/acp_handler/settings.rs | 3 +- .../src/app/acp_handler/tests/goals.rs | 246 + .../src/app/acp_handler/tests/mod.rs | 64 + .../src/app/acp_handler/tests/plan_mode.rs | 5 +- .../acp_handler/tests/queue_and_adoption.rs | 61 + .../app/acp_handler/tests/session_events.rs | 8 + .../src/app/acp_handler/workflow_ingest.rs | 196 + .../codegen/xai-grok-pager/src/app/actions.rs | 64 +- .../codegen/xai-grok-pager/src/app/agent.rs | 333 +- .../src/app/agent_view/input.rs | 371 +- .../src/app/agent_view/links.rs | 18 + .../xai-grok-pager/src/app/agent_view/mod.rs | 162 +- .../src/app/agent_view/notices.rs | 1 + .../src/app/agent_view/panes.rs | 104 +- .../src/app/agent_view/prompt.rs | 54 +- .../src/app/agent_view/queue.rs | 113 +- .../src/app/agent_view/render.rs | 59 +- .../src/app/agent_view/selection.rs | 59 + .../src/app/agent_view/session.rs | 252 +- .../src/app/agent_view/workflows_overlay.rs | 694 +++ .../xai-grok-pager/src/app/app_view.rs | 384 +- crates/codegen/xai-grok-pager/src/app/cli.rs | 170 +- .../src/app/dispatch/dashboard.rs | 19 +- .../src/app/dispatch/external_editor.rs | 43 + .../xai-grok-pager/src/app/dispatch/mod.rs | 1 + .../xai-grok-pager/src/app/dispatch/prompt.rs | 10 +- .../xai-grok-pager/src/app/dispatch/queue.rs | 368 +- .../xai-grok-pager/src/app/dispatch/router.rs | 84 +- .../src/app/dispatch/session/foreign.rs | 25 + .../src/app/dispatch/session/lifecycle.rs | 14 +- .../src/app/dispatch/settings/setters.rs | 25 + .../src/app/dispatch/settings/ui.rs | 26 +- .../xai-grok-pager/src/app/dispatch/status.rs | 78 +- .../src/app/dispatch/task_result.rs | 67 +- .../src/app/dispatch/tests/auth.rs | 3 + .../src/app/dispatch/tests/billing.rs | 204 +- .../src/app/dispatch/tests/cta_e2e.rs | 8 +- .../src/app/dispatch/tests/dashboard.rs | 835 +--- .../src/app/dispatch/tests/mod.rs | 5 +- .../src/app/dispatch/tests/modes.rs | 22 +- .../src/app/dispatch/tests/prompt.rs | 163 +- .../src/app/dispatch/tests/rewind.rs | 24 +- .../src/app/dispatch/tests/router.rs | 301 +- .../src/app/dispatch/tests/session/foreign.rs | 5 + .../src/app/dispatch/tests/session/fork.rs | 11 +- .../app/dispatch/tests/session/lifecycle.rs | 7 +- .../src/app/dispatch/tests/session/load.rs | 23 +- .../dispatch/tests/session/take_deferred.rs | 12 +- .../src/app/dispatch/tests/settings.rs | 129 +- .../src/app/dispatch/tests/status.rs | 64 +- .../src/app/dispatch/tests/task_result.rs | 185 +- .../src/app/dispatch/tests/turn.rs | 69 + .../src/app/dispatch/tests/voice.rs | 84 + .../src/app/dispatch/transcript.rs | 4 + .../xai-grok-pager/src/app/dispatch/turn.rs | 10 +- .../xai-grok-pager/src/app/effects/helpers.rs | 21 + .../xai-grok-pager/src/app/effects/mod.rs | 141 +- .../xai-grok-pager/src/app/effects/tests.rs | 101 +- .../xai-grok-pager/src/app/event_loop.rs | 178 +- .../xai-grok-pager/src/app/external_editor.rs | 590 +++ .../src/app/leader_cluster/scenarios.rs | 9 +- crates/codegen/xai-grok-pager/src/app/mod.rs | 1 + .../codegen/xai-grok-pager/src/app/modals.rs | 156 +- .../codegen/xai-grok-pager/src/app/mouse.rs | 26 +- .../xai-grok-pager/src/app/queue_edit.rs | 26 +- .../xai-grok-pager/src/app/session_startup.rs | 26 +- ...ests__session_usage_block_absent_cost.snap | 10 + ...ocks__tests__session_usage_block_full.snap | 10 + .../xai-grok-pager/src/app/status_blocks.rs | 228 +- .../xai-grok-pager/src/app/subagent.rs | 4 +- .../src/app/turn_completion/tests.rs | 251 +- .../src/diagnostics/doctor_format.rs | 202 + .../src/diagnostics/doctor_format_tests.rs | 508 ++ .../xai-grok-pager/src/diagnostics/fix.rs | 448 ++ .../src/diagnostics/fix_tests.rs | 561 +++ .../{diagnostics.rs => diagnostics/mod.rs} | 631 +-- .../xai-grok-pager/src/diagnostics/model.rs | 171 + .../src/diagnostics/probes/mod.rs | 448 ++ .../src/diagnostics/probes/tmux.rs | 27 + .../xai-grok-pager/src/diagnostics/view.rs | 424 ++ .../src/diagnostics/view_tests.rs | 449 ++ crates/codegen/xai-grok-pager/src/docs.rs | 2 +- .../xai-grok-pager/src/doctor_cmd/human.rs | 260 + .../xai-grok-pager/src/doctor_cmd/json.rs | 466 ++ .../xai-grok-pager/src/doctor_cmd/mod.rs | 225 + .../xai-grok-pager/src/doctor_cmd/tests.rs | 969 ++++ crates/codegen/xai-grok-pager/src/headless.rs | 87 +- .../src/input/keyboard_normalizer.rs | 20 +- .../codegen/xai-grok-pager/src/input/mouse.rs | 6 + .../xai-grok-pager/src/input/mouse/tests.rs | 19 + crates/codegen/xai-grok-pager/src/lib.rs | 1 + .../codegen/xai-grok-pager/src/minimal/api.rs | 4 +- .../codegen/xai-grok-pager/src/plugin_cmd.rs | 7 +- .../xai-grok-pager/src/scrollback/block.rs | 8 +- .../src/scrollback/blocks/mod.rs | 2 + .../src/scrollback/blocks/session_event.rs | 7 +- .../src/scrollback/blocks/tool/edit.rs | 96 +- .../src/scrollback/blocks/workflow.rs | 309 ++ .../src/scrollback/state/mod.rs | 82 +- .../src/scrollback/wrappers/entry_renderer.rs | 15 +- .../xai-grok-pager/src/settings/defs.rs | 16 + .../xai-grok-pager/src/settings/registry.rs | 11 + .../xai-grok-pager/src/slash/acp_command.rs | 38 +- .../xai-grok-pager/src/slash/command.rs | 15 + .../src/slash/commands/always_approve.rs | 1 + .../src/slash/commands/announcements.rs | 7 + .../xai-grok-pager/src/slash/commands/auto.rs | 1 + .../xai-grok-pager/src/slash/commands/cd.rs | 1 + .../xai-grok-pager/src/slash/commands/copy.rs | 1 + .../src/slash/commands/dashboard.rs | 3 + .../src/slash/commands/debug.rs | 2 + .../xai-grok-pager/src/slash/commands/docs.rs | 3 + .../src/slash/commands/doctor.rs | 50 + .../src/slash/commands/edit_prompt.rs | 129 + .../src/slash/commands/effort.rs | 7 + .../src/slash/commands/effort_levels.rs | 3 +- .../src/slash/commands/expand.rs | 1 + .../src/slash/commands/export.rs | 3 + .../xai-grok-pager/src/slash/commands/find.rs | 1 + .../xai-grok-pager/src/slash/commands/fork.rs | 1 + .../xai-grok-pager/src/slash/commands/help.rs | 1 + .../src/slash/commands/history.rs | 1 + .../xai-grok-pager/src/slash/commands/jump.rs | 1 + .../src/slash/commands/loop_cmd.rs | 1 + .../xai-grok-pager/src/slash/commands/mod.rs | 242 +- .../src/slash/commands/model.rs | 9 + .../src/slash/commands/multiline.rs | 1 + .../xai-grok-pager/src/slash/commands/plan.rs | 2 + .../src/slash/commands/privacy.rs | 1 + .../src/slash/commands/queue.rs | 1 + .../src/slash/commands/screen_mode_switch.rs | 3 + .../src/slash/commands/settings_cmd.rs | 1 + .../src/slash/commands/tasks.rs | 1 + .../src/slash/commands/terminal_setup.rs | 199 - .../src/slash/commands/theme.rs | 16 + .../slash/commands/toggle_mouse_reporting.rs | 3 + .../src/slash/commands/transcript.rs | 2 + .../src/slash/commands/usage.rs | 50 +- .../src/slash/commands/workflows.rs | 78 + .../codegen/xai-grok-pager/src/slash/mod.rs | 105 +- .../xai-grok-pager/src/slash/registry.rs | 51 +- .../codegen/xai-grok-pager/src/views/agent.rs | 41 +- .../xai-grok-pager/src/views/agent_status.rs | 24 +- .../src/views/dashboard/layout.rs | 1 + .../src/views/dashboard/peek.rs | 2 + .../src/views/dashboard/render.rs | 19 +- .../xai-grok-pager/src/views/dashboard/row.rs | 94 +- .../src/views/dashboard/state.rs | 1 + .../src/views/extensions_modal.rs | 155 + .../xai-grok-pager/src/views/goal_detail.rs | 78 +- .../xai-grok-pager/src/views/memory_modal.rs | 3 +- .../codegen/xai-grok-pager/src/views/mod.rs | 1 + .../codegen/xai-grok-pager/src/views/modal.rs | 71 +- .../xai-grok-pager/src/views/queue_pane.rs | 1 + .../src/views/session_picker.rs | 1 + .../src/views/settings_modal/state.rs | 18 +- .../src/views/settings_modal/tests.rs | 65 +- .../src/views/shortcuts_help.rs | 198 +- .../xai-grok-pager/src/views/tasks_pane.rs | 429 +- .../xai-grok-pager/src/views/turn_status.rs | 219 +- .../xai-grok-pager/src/views/welcome/mod.rs | 20 +- .../xai-grok-pager/src/views/workflows.rs | 1536 ++++++ .../xai-grok-pager/src/voice/handle.rs | 17 +- .../tests/doctor_early_dispatch.rs | 273 ++ ...ake_cancel_preserves_queued_user_prompt.rs | 58 +- .../pty_e2e/background_task_reaped_on_quit.rs | 16 +- .../tests/pty_e2e/basename_path_demo_pty.rs | 13 +- .../bash_queued_mid_turn_drains_as_bash.rs | 2 +- .../cancel_discards_buffered_interjection.rs | 13 +- .../cancel_then_resend_prompt_appears_once.rs | 8 +- .../xai-grok-pager/tests/pty_e2e/common.rs | 158 +- ...lc_after_activity_no_rewind_prompt_once.rs | 5 +- .../ctrlc_with_queued_prompt_no_dup.rs | 16 +- ...lect_autoscroll_full_scrollout_copy_pty.rs | 8 +- .../pty_e2e/edit_collapsed_oneliner_pty.rs | 2 +- .../pty_e2e/edit_hl_inplace_refresh_pty.rs | 2 +- ...terject_lone_queued_row_keeps_tui_alive.rs | 14 +- .../pty_e2e/edit_merge_sequential_pty.rs | 50 +- .../empty_enter_force_sends_top_queued.rs | 14 +- .../empty_enter_sends_top_not_last_of_two.rs | 21 +- .../endline_park_two_static_markers.rs | 84 +- .../pty_e2e/endline_wakeups_are_markerless.rs | 100 +- ...n_reaches_model_ctrl_l_in_vscode_family.rs | 16 +- ...interjection_reaches_model_in_same_turn.rs | 14 +- ...mal_commits_thinking_body_to_scrollback.rs | 23 +- .../minimal_continue_reprints_transcript.rs | 2 +- ...l_ctrl_o_send_now_queued_apple_terminal.rs | 21 +- ...c_committed_queued_prompt_single_render.rs | 18 +- .../minimal_external_editor_round_trip.rs | 86 + .../minimal/minimal_flush_left_no_hpad.rs | 16 +- ...minimal_lookup_commits_one_line_summary.rs | 2 +- ...mal_queue_indicator_shows_while_running.rs | 10 +- .../tests/pty_e2e/minimal/mod.rs | 1 + .../tests/pty_e2e/page_flip_on_send_pty.rs | 27 +- .../queue_and_interjection_lifecycle.rs | 21 +- ...ueued_bash_promotion_renders_output_pty.rs | 3 +- .../queued_message_renders_once_not_twice.rs | 58 +- ...l_header_selection_copies_path_only_pty.rs | 2 +- ...g_efforts_fallback_menu_matches_builtin.rs | 4 +- ...reasoning_efforts_from_config_toml_menu.rs | 2 +- .../removed_queued_prompt_never_sent.rs | 21 +- .../reparked_wait_repushes_buried_marker.rs | 117 +- .../xai-grok-pager/tests/pty_e2e/scroll.rs | 35 +- .../send_now_tip_after_mid_turn_queue.rs | 5 +- ...inking_blocks_toggle_hides_existing_pty.rs | 7 +- .../spinner_reappears_after_wait_resumes.rs | 62 +- .../pty_e2e/stuck_drag_recovers_on_esc_pty.rs | 2 +- .../verb_group_fold_expand_collapse_pty.rs | 58 +- .../verb_group_header_drag_copy_pty.rs | 20 +- .../pty_e2e/verb_group_settings_toggle_pty.rs | 20 +- .../pty_e2e/verb_group_streaming_fold_pty.rs | 20 +- .../pty_e2e/verb_group_thinking_fold_pty.rs | 20 +- .../verify_bashq_claim2_force_interject.rs | 10 +- .../verify_bashq_claim3_edit_keeps_bash.rs | 33 +- ...l_at_bottom_reengages_follow_mid_stream.rs | 9 +- ..._scrolls_viewport_during_streaming_turn.rs | 9 +- .../xai-grok-pager/tests/pty_e2e_clipboard.rs | 6 +- .../xai-grok-pager/tests/pty_xtversion.rs | 40 +- .../xai-grok-pager/tests/settings_e2e.rs | 72 +- .../xai-grok-plugin-marketplace/src/config.rs | 20 +- .../src/actor/request_task.rs | 192 +- crates/codegen/xai-grok-sampler/src/config.rs | 3 + crates/codegen/xai-grok-sampler/src/retry.rs | 36 +- .../xai-grok-sampler/src/stream/responses.rs | 110 +- .../src/conversation.rs | 118 +- .../xai-grok-sampling-types/src/error.rs | 32 +- .../xai-grok-sampling-types/src/types.rs | 27 +- crates/codegen/xai-grok-sandbox/src/paths.rs | 6 +- .../codegen/xai-grok-sandbox/src/profiles.rs | 119 +- .../codegen/xai-grok-shared/src/clipboard.rs | 228 +- .../codegen/xai-grok-shared/src/ui_config.rs | 4 + .../xai-grok-shell-base/src/cpu_profile.rs | 30 +- crates/codegen/xai-grok-shell/CHANGELOG.md | 58 + crates/codegen/xai-grok-shell/Cargo.toml | 3 +- .../xai-grok-shell/benches/session_list.rs | 4 + .../xai-grok-shell/changelogs/0.2.107.json | 72 + .../xai-grok-shell/changelogs/0.2.107.md | 25 + .../xai-grok-shell/changelogs/0.2.108.json | 22 + .../xai-grok-shell/changelogs/0.2.108.md | 11 + .../xai-grok-shell/changelogs/0.2.109.json | 57 + .../xai-grok-shell/changelogs/0.2.109.md | 19 + .../xai-grok-shell/skills/best-of-n/SKILL.md | 93 - .../xai-grok-shell/skills/check-work/SKILL.md | 287 -- .../skills/code-review/SKILL.md | 192 - .../skills/create-skill/SKILL.md | 81 - .../xai-grok-shell/skills/help/SKILL.md | 51 - .../xai-grok-shell/skills/imagine/SKILL.md | 131 - .../codegen/xai-grok-shell/src/agent/app.rs | 35 +- .../xai-grok-shell/src/agent/config.rs | 357 +- .../src/agent/config_model_override_parse.rs | 47 +- .../xai-grok-shell/src/agent/ext_parsers.rs | 8 + .../codegen/xai-grok-shell/src/agent/init.rs | 6 +- .../codegen/xai-grok-shell/src/agent/mod.rs | 1 + .../src/agent/model_providers.rs | 917 ++++ .../src/agent/mvp_agent/acp_agent.rs | 44 +- .../src/agent/mvp_agent/agent_ops.rs | 49 +- .../xai-grok-shell/src/agent/mvp_agent/mod.rs | 57 +- .../agent/mvp_agent/subagent_coordinator.rs | 127 +- .../src/agent/mvp_agent/tests.rs | 32 + .../codegen/xai-grok-shell/src/agent/relay.rs | 38 +- .../src/agent/session_config.rs | 1 + .../src/agent/session_registry_client.rs | 2 +- .../agent/subagent/coordinator_lifecycle.rs | 45 +- .../src/agent/subagent/coordinator_query.rs | 48 +- .../src/agent/subagent/handle_request.rs | 217 +- .../xai-grok-shell/src/agent/subagent/mod.rs | 26 +- .../src/agent/subagent/tests/mod.rs | 58 +- .../src/agent/subagent/tests/rest.rs | 38 + .../xai-grok-shell/src/auth/auth_provider.rs | 38 +- .../xai-grok-shell/src/auth/external_auth.rs | 134 +- .../xai-grok-shell/src/auth/manager.rs | 11 +- .../src/auth/refresh/external_refresher.rs | 101 +- .../xai-grok-shell/src/auth/refresh/mod.rs | 8 +- crates/codegen/xai-grok-shell/src/builtin.rs | 409 +- .../xai-grok-shell/src/claude_import.rs | 5 +- .../xai-grok-shell/src/config/reloader.rs | 6 +- .../xai-grok-shell/src/config/tests.rs | 96 + .../xai-grok-shell/src/config/watcher.rs | 306 +- .../xai-grok-shell/src/extensions/feedback.rs | 37 +- .../src/extensions/marketplace.rs | 8 +- .../xai-grok-shell/src/extensions/mod.rs | 1 + .../src/extensions/notification.rs | 65 + .../xai-grok-shell/src/extensions/rewind.rs | 31 +- .../src/extensions/session_admin.rs | 33 +- .../xai-grok-shell/src/extensions/skills.rs | 26 + .../xai-grok-shell/src/extensions/usage.rs | 104 + .../codegen/xai-grok-shell/src/inspect/mod.rs | 110 +- .../xai-grok-shell/src/leader/client.rs | 72 +- .../xai-grok-shell/src/leader/protocol.rs | 90 +- .../codegen/xai-grok-shell/src/remote/pull.rs | 4 + .../src/session/acp_conversion.rs | 1 + .../xai-grok-shell/src/session/acp_session.rs | 116 +- .../src/session/acp_session_impl/goal.rs | 3281 ++++++------- .../session/acp_session_impl/goal_support.rs | 253 +- .../session/acp_session_impl/interjection.rs | 3 +- .../src/session/acp_session_impl/laziness.rs | 2 +- .../session/acp_session_impl/memory_dream.rs | 4 +- .../session/acp_session_impl/model_switch.rs | 13 +- .../acp_session_impl/notification_drain.rs | 137 +- .../session/acp_session_impl/prompt_queue.rs | 243 +- .../src/session/acp_session_impl/recap.rs | 6 +- .../src/session/acp_session_impl/reminders.rs | 387 +- .../src/session/acp_session_impl/run_loop.rs | 1429 +++--- .../session/acp_session_impl/sampler_turn.rs | 45 +- .../session/acp_session_impl/session_setup.rs | 13 +- .../session/acp_session_impl/slash_exec.rs | 248 +- .../src/session/acp_session_impl/spawn.rs | 285 +- .../src/session/acp_session_impl/stop_gate.rs | 3 +- .../session/acp_session_impl/tasks_cancel.rs | 19 +- .../session/acp_session_impl/tool_calls.rs | 65 +- .../src/session/acp_session_impl/turn.rs | 124 +- .../src/session/acp_session_impl/turn_end.rs | 5 +- .../src/session/acp_session_impl/updates.rs | 8 +- .../src/session/acp_session_impl/workflow.rs | 371 ++ .../auto_wake_suppression_tests.rs | 1 + .../cancel_running_task_tests.rs | 80 +- .../goal/goal_backoff_tests.rs | 4282 ----------------- .../goal/goal_classifier_e2e_tests.rs | 3440 ------------- .../goal_reminder_subagent_rules_tests.rs | 2088 -------- .../goal/goal_strategist_e2e_tests.rs | 813 ---- .../goal/goal_summarizer_e2e_tests.rs | 641 --- .../acp_session_tests/idle_resume_tests.rs | 14 +- .../inline_auto_compact_flow_tests.rs | 32 +- .../acp_session_tests/memory_config_tests.rs | 9 +- .../prompt_queue_actor_tests.rs | 151 +- .../recap_display_only_tests.rs | 70 + .../reminder_policy_tests.rs | 21 +- .../replay_buffer_send_update_tests.rs | 9 +- .../rewrite_zero_turn_prefix_tests.rs | 300 -- .../src/session/acp_session_tests/support.rs | 22 +- .../src/session/agent_rebuild.rs | 4 + .../src/session/chat_persistence.rs | 55 +- .../xai-grok-shell/src/session/commands.rs | 19 + .../xai-grok-shell/src/session/compaction.rs | 8 +- .../src/session/feedback_manager.rs | 277 +- .../src/session/goal_classifier.rs | 14 +- .../src/session/goal_classifier/evidence.rs | 29 +- .../src/session/goal_evaluator.rs | 246 + .../src/session/goal_orchestrator.rs | 18 +- .../src/session/goal_planner.rs | 6 + .../src/session/goal_role_tools.rs | 2 - .../src/session/goal_strategist.rs | 4 + .../src/session/goal_summarizer.rs | 4 + .../src/session/goal_tracker.rs | 93 +- .../xai-grok-shell/src/session/handle.rs | 22 + .../src/session/helpers/compaction_context.rs | 8 + .../src/session/helpers/memory_flush.rs | 2 +- .../src/session/helpers/replay.rs | 63 + .../src/session/helpers/session_compact.rs | 6 + .../src/session/helpers/session_recap.rs | 4 +- .../xai-grok-shell/src/session/merge.rs | 60 +- .../codegen/xai-grok-shell/src/session/mod.rs | 18 +- .../xai-grok-shell/src/session/persistence.rs | 190 + .../src/session/prompt_queue.rs | 15 +- .../src/session/slash_commands.rs | 1013 ++-- .../session/storage/jsonl/durable_tests.rs | 212 + .../src/session/storage/jsonl/mod.rs | 430 +- .../src/session/storage/jsonl/tests.rs | 300 +- .../xai-grok-shell/src/session/storage/mod.rs | 124 +- .../src/session/storage/relocation/fs.rs | 321 ++ .../src/session/storage/relocation/journal.rs | 225 + .../src/session/storage/relocation/mod.rs | 91 + .../src/session/storage/relocation/tests.rs | 167 + .../src/session/storage/search.rs | 14 + .../src/session/storage/summary_write.rs | 12 + .../templates/goal_continuation_directive.md | 12 +- .../goal_continuation_directive_legacy.md | 28 + .../src/session/templates/goal_rules.md | 14 +- .../session/templates/goal_rules_legacy.md | 43 + .../src/session/unified_list/mod.rs | 250 +- .../src/session/workflow/host_service.rs | 993 ++++ .../src/session/workflow/manager.rs | 1426 ++++++ .../src/session/workflow/mod.rs | 45 + .../src/session/workflow/notify.rs | 272 ++ .../src/session/workflow/registry.rs | 873 ++++ .../src/session/workflow/schema_contract.rs | 177 + .../src/session/workflow/store.rs | 492 ++ .../src/session/workflow/tracker.rs | 1202 +++++ .../src/session/workflows/deep_research.rhai | 583 +++ .../src/test_support/lsp_runtime.rs | 12 +- .../src/tools/notification_bridge.rs | 266 +- .../xai-grok-shell/src/tools/tool_context.rs | 193 +- .../src/trace_classifier/mod.rs | 13 +- .../xai-grok-shell/src/upload/config_files.rs | 12 - .../codegen/xai-grok-shell/src/upload/gcs.rs | 22 - .../xai-grok-shell/src/upload/manifest.rs | 4 +- .../codegen/xai-grok-shell/src/upload/mod.rs | 1 - .../xai-grok-shell/src/upload/trace.rs | 79 +- .../codegen/xai-grok-shell/src/upload/turn.rs | 3 +- .../xai-grok-shell/src/util/config/mcp.rs | 68 + .../xai-grok-shell/src/util/config/mod.rs | 2 +- .../src/util/config/settings_writes.rs | 5 + .../src/util/config/worktree.rs | 425 +- crates/codegen/xai-grok-shell/src/util/mod.rs | 2 + .../xai-grok-shell/src/util/subprocess.rs | 367 ++ .../xai-grok-shell/src/util/user_identity.rs | 904 ++++ .../tests/test_doom_loop_recovery.rs | 1 + .../tests/test_leader_stdio_integration.rs | 52 +- .../xai-grok-subagent-resolution/src/lib.rs | 2 +- .../src/overrides.rs | 55 +- .../codegen/xai-grok-test-support/README.md | 2 +- .../xai-grok-test-support/src/acp_client.rs | 41 +- .../xai-grok-test-support/src/headless.rs | 30 +- .../codegen/xai-grok-test-support/src/lib.rs | 11 + .../codegen/xai-grok-test-support/src/sse.rs | 198 +- .../src/config_validation.rs | 16 +- .../xai-grok-tools-api/src/slash_commands.rs | 2 + .../schema/tool_meta.schema.json | 2 +- crates/codegen/xai-grok-tools/src/bridge.rs | 17 +- .../src/implementations/grok_build/mod.rs | 2 + .../grok_build/read_file/mod.rs | 114 +- .../grok_build/scheduler/actor.rs | 1204 ++++- .../grok_build/scheduler/create.rs | 9 +- .../grok_build/scheduler/delete.rs | 14 +- .../grok_build/scheduler/list.rs | 5 +- .../grok_build/scheduler/types.rs | 255 +- .../grok_build/task/backend.rs | 86 +- .../implementations/grok_build/task/mod.rs | 6 + .../implementations/grok_build/task/types.rs | 52 +- .../grok_build/workflow/mod.rs | 414 ++ .../src/implementations/lsp/types.rs | 3 +- .../xai-grok-tools/src/normalization.rs | 1 + .../xai-grok-tools/src/notification/handle.rs | 23 +- .../src/notification/handle_tests.rs | 17 +- .../xai-grok-tools/src/notification/types.rs | 29 + .../src/registry/proto_convert.rs | 16 +- .../xai-grok-tools/src/registry/types.rs | 4 + .../src/reminders/task_completion.rs | 1 + .../xai-grok-tools/src/tool_taxonomy.rs | 2 + .../xai-grok-tools/src/types/output.rs | 2 + .../codegen/xai-grok-tools/src/types/tool.rs | 1 + .../xai-grok-tools/src/types/tool_io.rs | 1 + crates/codegen/xai-grok-version/Cargo.toml | 2 +- .../xai-grok-voice/src/audio/capture.rs | 99 +- .../xai-grok-voice/src/audio/capture_linux.rs | 72 +- .../codegen/xai-grok-voice/src/audio/mod.rs | 6 +- crates/codegen/xai-grok-voice/src/event.rs | 9 +- crates/codegen/xai-grok-voice/src/lib.rs | 6 +- crates/codegen/xai-grok-voice/src/pcm.rs | 63 + crates/codegen/xai-grok-voice/src/pipeline.rs | 72 +- crates/codegen/xai-grok-voice/src/probe.rs | 43 +- .../xai-grok-workspace/src/capability.rs | 4 +- .../xai-grok-workspace/src/folder_trust.rs | 23 +- .../codegen/xai-grok-workspace/src/handle.rs | 3 + .../xai-grok-workspace/src/hub_server.rs | 22 +- .../src/permission/auto_mode.rs | 89 + .../src/permission/bash_command_splitting.rs | 752 ++- .../src/permission/exec_risk.rs | 826 ++++ .../src/permission/manager.rs | 859 +++- .../xai-grok-workspace/src/permission/mod.rs | 3 +- .../src/permission/policy.rs | 444 +- .../src/permission/resolution.rs | 3 +- .../src/permission/shell_access.rs | 846 +++- .../src/preview_supervisor.rs | 12 + .../xai-grok-workspace/src/worktree/mod.rs | 80 +- .../xai-hunk-tracker/src/actor/file_utils.rs | 23 +- crates/codegen/xai-prompt-queue/Cargo.toml | 2 - .../codegen/xai-prompt-queue/src/combine.rs | 247 + crates/codegen/xai-prompt-queue/src/lib.rs | 9 +- crates/codegen/xai-prompt-queue/src/types.rs | 47 + .../examples/textarea_demo.rs | 3 +- .../xai-ratatui-textarea/src/textarea.rs | 3 +- crates/codegen/xai-workflow/Cargo.toml | 26 + .../codegen/xai-workflow/examples/validate.rs | 23 + crates/codegen/xai-workflow/src/engine.rs | 1779 +++++++ crates/codegen/xai-workflow/src/host.rs | 128 + crates/codegen/xai-workflow/src/journal.rs | 498 ++ crates/codegen/xai-workflow/src/lib.rs | 44 + crates/codegen/xai-workflow/src/meta.rs | 331 ++ crates/codegen/xai-workflow/src/run.rs | 48 + crates/codegen/xai-workflow/src/validate.rs | 297 ++ .../src/bridge.rs | 10 +- .../xai-computer-hub-sdk/src/notification.rs | 9 +- .../tests/identifier_validation.rs | 9 +- crates/common/xai-tool-runtime/src/render.rs | 9 +- .../tests/error_conversion.rs | 11 +- 556 files changed, 56930 insertions(+), 22213 deletions(-) create mode 100644 crates/codegen/xai-fast-worktree/src/auto_gc.rs create mode 100644 crates/codegen/xai-grok-config/src/managed_text/format.rs create mode 100644 crates/codegen/xai-grok-config/src/managed_text/mod.rs create mode 100644 crates/codegen/xai-grok-config/src/managed_text/source.rs create mode 100644 crates/codegen/xai-grok-config/src/managed_text/tests.rs create mode 100644 crates/codegen/xai-grok-config/src/managed_text/transaction.rs create mode 100644 crates/codegen/xai-grok-config/src/managed_text/validator.rs create mode 100644 crates/codegen/xai-grok-pager-render/src/terminal/tmux_probe.rs create mode 100644 crates/codegen/xai-grok-pager/src/app/acp_handler/workflow_ingest.rs create mode 100644 crates/codegen/xai-grok-pager/src/app/agent_view/workflows_overlay.rs create mode 100644 crates/codegen/xai-grok-pager/src/app/dispatch/external_editor.rs create mode 100644 crates/codegen/xai-grok-pager/src/app/external_editor.rs create mode 100644 crates/codegen/xai-grok-pager/src/app/snapshots/xai_grok_pager__app__status_blocks__tests__session_usage_block_absent_cost.snap create mode 100644 crates/codegen/xai-grok-pager/src/app/snapshots/xai_grok_pager__app__status_blocks__tests__session_usage_block_full.snap create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/doctor_format.rs create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/doctor_format_tests.rs create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/fix.rs create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/fix_tests.rs rename crates/codegen/xai-grok-pager/src/{diagnostics.rs => diagnostics/mod.rs} (86%) create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/model.rs create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/probes/mod.rs create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/probes/tmux.rs create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/view.rs create mode 100644 crates/codegen/xai-grok-pager/src/diagnostics/view_tests.rs create mode 100644 crates/codegen/xai-grok-pager/src/doctor_cmd/human.rs create mode 100644 crates/codegen/xai-grok-pager/src/doctor_cmd/json.rs create mode 100644 crates/codegen/xai-grok-pager/src/doctor_cmd/mod.rs create mode 100644 crates/codegen/xai-grok-pager/src/doctor_cmd/tests.rs create mode 100644 crates/codegen/xai-grok-pager/src/scrollback/blocks/workflow.rs create mode 100644 crates/codegen/xai-grok-pager/src/slash/commands/doctor.rs create mode 100644 crates/codegen/xai-grok-pager/src/slash/commands/edit_prompt.rs delete mode 100644 crates/codegen/xai-grok-pager/src/slash/commands/terminal_setup.rs create mode 100644 crates/codegen/xai-grok-pager/src/slash/commands/workflows.rs create mode 100644 crates/codegen/xai-grok-pager/src/views/workflows.rs create mode 100644 crates/codegen/xai-grok-pager/tests/doctor_early_dispatch.rs create mode 100644 crates/codegen/xai-grok-pager/tests/pty_e2e/minimal/minimal_external_editor_round_trip.rs create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.107.json create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.107.md create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.108.json create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.108.md create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.109.json create mode 100644 crates/codegen/xai-grok-shell/changelogs/0.2.109.md delete mode 100644 crates/codegen/xai-grok-shell/skills/best-of-n/SKILL.md delete mode 100644 crates/codegen/xai-grok-shell/skills/check-work/SKILL.md delete mode 100644 crates/codegen/xai-grok-shell/skills/code-review/SKILL.md delete mode 100644 crates/codegen/xai-grok-shell/skills/create-skill/SKILL.md delete mode 100644 crates/codegen/xai-grok-shell/skills/help/SKILL.md delete mode 100644 crates/codegen/xai-grok-shell/skills/imagine/SKILL.md create mode 100644 crates/codegen/xai-grok-shell/src/agent/model_providers.rs create mode 100644 crates/codegen/xai-grok-shell/src/extensions/usage.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/acp_session_impl/workflow.rs delete mode 100644 crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_backoff_tests.rs delete mode 100644 crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_classifier_e2e_tests.rs delete mode 100644 crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_reminder_subagent_rules_tests.rs delete mode 100644 crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_strategist_e2e_tests.rs delete mode 100644 crates/codegen/xai-grok-shell/src/session/acp_session_tests/goal/goal_summarizer_e2e_tests.rs delete mode 100644 crates/codegen/xai-grok-shell/src/session/acp_session_tests/rewrite_zero_turn_prefix_tests.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/goal_evaluator.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/storage/relocation/fs.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/storage/relocation/journal.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/storage/relocation/mod.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/storage/relocation/tests.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/templates/goal_continuation_directive_legacy.md create mode 100644 crates/codegen/xai-grok-shell/src/session/templates/goal_rules_legacy.md create mode 100644 crates/codegen/xai-grok-shell/src/session/workflow/host_service.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/workflow/manager.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/workflow/mod.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/workflow/notify.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/workflow/registry.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/workflow/schema_contract.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/workflow/store.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/workflow/tracker.rs create mode 100644 crates/codegen/xai-grok-shell/src/session/workflows/deep_research.rhai delete mode 100644 crates/codegen/xai-grok-shell/src/upload/config_files.rs create mode 100644 crates/codegen/xai-grok-shell/src/util/subprocess.rs create mode 100644 crates/codegen/xai-grok-shell/src/util/user_identity.rs create mode 100644 crates/codegen/xai-grok-tools/src/implementations/grok_build/workflow/mod.rs create mode 100644 crates/codegen/xai-grok-voice/src/pcm.rs create mode 100644 crates/codegen/xai-grok-workspace/src/permission/exec_risk.rs create mode 100644 crates/codegen/xai-prompt-queue/src/combine.rs create mode 100644 crates/codegen/xai-workflow/Cargo.toml create mode 100644 crates/codegen/xai-workflow/examples/validate.rs create mode 100644 crates/codegen/xai-workflow/src/engine.rs create mode 100644 crates/codegen/xai-workflow/src/host.rs create mode 100644 crates/codegen/xai-workflow/src/journal.rs create mode 100644 crates/codegen/xai-workflow/src/lib.rs create mode 100644 crates/codegen/xai-workflow/src/meta.rs create mode 100644 crates/codegen/xai-workflow/src/run.rs create mode 100644 crates/codegen/xai-workflow/src/validate.rs diff --git a/Cargo.lock b/Cargo.lock index 8a8a5c4..f0c913b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -66,6 +66,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", + "const-random", "getrandom 0.3.4", "once_cell", "serde", @@ -514,8 +515,7 @@ dependencies = [ [[package]] name = "async-openai" version = "0.33.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc48c3deb4ad9a2ee8c8e364c79eb0f74e69e17ed7e883d55988b90ea44fe986" +source = "git+https://github.com/our-forks/async-openai.git?rev=95b52ebdedf42143083cf3d6f0e0be7c84e9c808#95b52ebdedf42143083cf3d6f0e0be7c84e9c808" dependencies = [ "async-openai-macros", "backoff", @@ -543,8 +543,7 @@ dependencies = [ [[package]] name = "async-openai-macros" version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81872a8e595e8ceceab71c6ba1f9078e313b452a1e31934e6763ef5d308705e4" +source = "git+https://github.com/our-forks/async-openai.git?rev=95b52ebdedf42143083cf3d6f0e0be7c84e9c808#95b52ebdedf42143083cf3d6f0e0be7c84e9c808" dependencies = [ "proc-macro2", "quote", @@ -1952,6 +1951,26 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.16", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -6918,6 +6937,9 @@ name = "once_cell" version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +dependencies = [ + "portable-atomic", +] [[package]] name = "once_cell_polyfill" @@ -8576,6 +8598,35 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "rhai" +version = "1.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd4dd0f8c36625202a4ba553c416c19b719947cd2a31d1bda06126e4a5727daf" +dependencies = [ + "ahash", + "bitflags 2.13.0", + "num-traits", + "once_cell", + "rhai_codegen", + "serde", + "smallvec", + "smartstring", + "thin-vec", + "web-time", +] + +[[package]] +name = "rhai_codegen" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cd3a7535e50bf36857e7be7bec276d334e8c2dfa469c2201226fd01638ea5ca" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "ring" version = "0.17.14" @@ -9756,6 +9807,21 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "serde", + "static_assertions", + "version_check", +] [[package]] name = "smawk" @@ -10281,6 +10347,15 @@ dependencies = [ "unicode-width 0.2.0", ] +[[package]] +name = "thin-vec" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0f7e269b48f0a7dd0146680fa24b50cc67fc0373f086a5b2f99bd084639b482" +dependencies = [ + "serde", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -10412,6 +10487,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tiny-skia" version = "0.12.0" @@ -13305,7 +13389,7 @@ dependencies = [ [[package]] name = "xai-grok-pager" -version = "0.2.106" +version = "0.2.109" dependencies = [ "agent-client-protocol", "ansi-to-tui", @@ -13343,6 +13427,7 @@ dependencies = [ "serde_json", "serial_test", "shellexpand", + "shlex", "signal-hook 0.3.18", "similar", "strip-ansi-escapes", @@ -13394,7 +13479,7 @@ dependencies = [ [[package]] name = "xai-grok-pager-bin" -version = "0.2.106" +version = "0.2.109" dependencies = [ "anyhow", "clap", @@ -13656,7 +13741,7 @@ dependencies = [ [[package]] name = "xai-grok-shell" -version = "0.2.106" +version = "0.2.109" dependencies = [ "agent-client-protocol", "anyhow", @@ -13793,6 +13878,7 @@ dependencies = [ "xai-tool-types", "xai-tracing-macros", "xai-tty-utils", + "xai-workflow", "zstd", ] @@ -14048,7 +14134,7 @@ dependencies = [ [[package]] name = "xai-grok-version" -version = "0.2.106" +version = "0.2.109" dependencies = [ "semver", ] @@ -14411,6 +14497,22 @@ dependencies = [ "windows 0.61.3", ] +[[package]] +name = "xai-workflow" +version = "0.1.0" +dependencies = [ + "libc", + "rhai", + "serde", + "serde_json", + "sha2 0.10.9", + "tempfile", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "xattr" version = "1.6.1" diff --git a/Cargo.toml b/Cargo.toml index d7c9052..b65aab6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,8 @@ # Auto-generated workspace root. Prefer editing per-crate Cargo.toml files. +[patch.crates-io] +async-openai = { git = "https://github.com/our-forks/async-openai.git", rev = "95b52ebdedf42143083cf3d6f0e0be7c84e9c808" } + [workspace] resolver = "2" members = [ @@ -66,6 +69,7 @@ members = [ "crates/codegen/xai-token-estimation", "crates/codegen/xai-tracing-macros", "crates/codegen/xai-tty-utils", + "crates/codegen/xai-workflow", "crates/common/xai-circuit-breaker", "crates/common/xai-computer-hub-core", "crates/common/xai-computer-hub-mcp-adapter", @@ -206,6 +210,7 @@ regex = "1" reqwest = { version = "0.12", features = ["rustls-tls", "stream", "json", "multipart", "http2", "blocking", "socks"], default-features = false } reqwest-middleware = { version = "0.4.1", features = ["json", "multipart"] } resvg = { version = "0.47", default-features = false, features = ["text"] } +rhai = { version = "1.25", features = ["serde"] } ring = "0.17" rsa = "0.9" runfiles = "0.1" diff --git a/SOURCE_REV b/SOURCE_REV index e31e81e..b10496c 100644 --- a/SOURCE_REV +++ b/SOURCE_REV @@ -1 +1 @@ -c5c4ce03436b4bb2cec43d3feaa27dee0109bf37 +0f4d7c91b8b2b408333f6de1e8a76cb8eaa71899 diff --git a/crates/codegen/xai-chat-state/src/actor/mod.rs b/crates/codegen/xai-chat-state/src/actor/mod.rs index 1789860..1c6cf26 100644 --- a/crates/codegen/xai-chat-state/src/actor/mod.rs +++ b/crates/codegen/xai-chat-state/src/actor/mod.rs @@ -16,7 +16,7 @@ mod tests; use tokio::sync::mpsc; use tracing::debug; -use crate::commands::ChatStateCommand; +use crate::commands::{ChatStateCommand, StrictAppendAck}; use crate::events::ChatStateEvent; use crate::handle::ChatStateHandle; use crate::persistence::ChatPersistence; @@ -107,14 +107,14 @@ impl ChatStateActor { debug!("ChatStateActor shutting down: all handles dropped"); break; }; - self.handle_command(cmd); + self.handle_command(cmd).await; } } } } /// Dispatch a command to the appropriate mutation or query handler. - fn handle_command(&mut self, cmd: ChatStateCommand) { + async fn handle_command(&mut self, cmd: ChatStateCommand) { match cmd { // ═══ Mutations ═══ ChatStateCommand::PushUserMessage { item } => { @@ -124,6 +124,42 @@ impl ChatStateActor { self.push_user_message(item); let _ = reply.send(()); } + ChatStateCommand::AppendWorkingDirectorySwitchAndAck { + content, + cwd_generation, + reply, + } => { + let generation = cwd_generation.get(); + let candidate = ConversationItem::working_directory_switch(content, generation); + let persist_rx = self + .persistence + .persist_working_directory_switch_and_ack(&candidate); + let result = persist_rx.await.unwrap_or_else(|_| { + Err(crate::commands::StrictAppendError::Indeterminate( + std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "working-directory switch acknowledgement dropped; retry by generation", + ), + )) + }); + let authoritative = match &result { + Ok(StrictAppendAck::Appended) + | Err(crate::commands::StrictAppendError::Committed { + acknowledgement: StrictAppendAck::Appended, + .. + }) => Some(&candidate), + Ok(StrictAppendAck::AlreadyPresent(authoritative)) + | Err(crate::commands::StrictAppendError::Committed { + acknowledgement: StrictAppendAck::AlreadyPresent(authoritative), + .. + }) => Some(authoritative), + Err(_) => None, + }; + if let Some(authoritative) = authoritative { + self.converge_working_directory_switch(generation, authoritative.clone()); + } + let _ = reply.send(result); + } ChatStateCommand::PushUserMessageWithRepairReason { item, reason } => { self.push_user_message_with_repair_reason(item, reason); } diff --git a/crates/codegen/xai-chat-state/src/actor/mutations.rs b/crates/codegen/xai-chat-state/src/actor/mutations.rs index 808c1ce..030ede8 100644 --- a/crates/codegen/xai-chat-state/src/actor/mutations.rs +++ b/crates/codegen/xai-chat-state/src/actor/mutations.rs @@ -109,6 +109,37 @@ impl ChatStateActor { report } + /// Make memory match the disk-authoritative switch for one generation. + pub(super) fn converge_working_directory_switch( + &mut self, + generation: u64, + authoritative: ConversationItem, + ) { + let existing = self + .state + .conversation + .iter_mut() + .find(|item| item.working_directory_switch_generation() == Some(generation)); + if let Some(existing) = existing { + let old_tokens = super::state::estimate_item_tokens(existing); + let new_tokens = super::state::estimate_item_tokens(&authoritative); + self.state.estimated_tokens_since_model = if new_tokens >= old_tokens { + self.state + .estimated_tokens_since_model + .saturating_add(new_tokens - old_tokens) + } else { + self.state + .estimated_tokens_since_model + .saturating_sub(old_tokens - new_tokens) + }; + *existing = authoritative; + } else { + self.state.estimated_tokens_since_model += + super::state::estimate_item_tokens(&authoritative); + self.state.conversation.push(authoritative); + } + } + /// Push any conversation item (user, assistant, or tool result) and persist it. pub(super) fn push_message(&mut self, item: ConversationItem) { let count_in_delta = !matches!(item, ConversationItem::Assistant(_)); diff --git a/crates/codegen/xai-chat-state/src/actor/request_builder.rs b/crates/codegen/xai-chat-state/src/actor/request_builder.rs index 3c0dc02..63ef460 100644 --- a/crates/codegen/xai-chat-state/src/actor/request_builder.rs +++ b/crates/codegen/xai-chat-state/src/actor/request_builder.rs @@ -142,6 +142,7 @@ impl ChatStateActor { x_grok_deployment_id: None, x_grok_user_id: None, trace, + prompt_cache_key: None, reasoning_effort: self.state.sampling_config.reasoning_effort, json_schema: None, } @@ -598,13 +599,13 @@ mod tests { fn has_placeholder(item: &ConversationItem) -> bool { matches!( - item, - ConversationItem::User(u) if u.content.iter().any(|p| matches!( - p, - ContentPart::Text { text } - if text.as_ref() == IMAGE_COMPACT_PLACEHOLDER - )) - ) + item, + ConversationItem::User(u) if u.content.iter().any(|p| matches!( + p, + ContentPart::Text { text } +if text.as_ref() == IMAGE_COMPACT_PLACEHOLDER + )) + ) } // Images are sized ~100 KB so the ~235 B placeholder that replaces an diff --git a/crates/codegen/xai-chat-state/src/actor/tests.rs b/crates/codegen/xai-chat-state/src/actor/tests.rs index 156a27e..a494789 100644 --- a/crates/codegen/xai-chat-state/src/actor/tests.rs +++ b/crates/codegen/xai-chat-state/src/actor/tests.rs @@ -6,6 +6,7 @@ use std::time::Duration; use tokio::sync::mpsc; use xai_grok_sampling_types::{ConversationItem, SamplingConfig}; +use crate::StrictAppendAck; use crate::actor::ChatStateActor; use crate::events::ChatStateEvent; use crate::persistence::{MockChatPersistence, MockPersistenceReceiver, PersistenceRecord}; @@ -54,11 +55,23 @@ impl TestHarness { fn with_config(items: Vec, config: SamplingConfig) -> Self { let (mock, persistence_rx) = MockChatPersistence::new(); + Self::with_persistence(items, config, mock, persistence_rx) + } + + fn with_manual_persistence_ack(items: Vec) -> Self { + let (mock, persistence_rx) = MockChatPersistence::new_with_manual_persistence_ack(); + Self::with_persistence(items, test_config(), mock, persistence_rx) + } + + fn with_persistence( + items: Vec, + config: SamplingConfig, + mock: MockChatPersistence, + persistence_rx: MockPersistenceReceiver, + ) -> Self { let (event_tx, event_rx) = mpsc::unbounded_channel(); let token = tokio_util::sync::CancellationToken::new(); - let handle = ChatStateActor::spawn(items, config, Box::new(mock), event_tx, token.clone()); - Self { handle, event_rx, @@ -155,6 +168,249 @@ async fn push_user_message_and_ack_waits_for_actor_acceptance() { assert!(matches!(&records[0], PersistenceRecord::Message(_))); } +#[tokio::test] +async fn strict_switch_append_preserves_prefix_and_deduplicates_generation() { + let prefix = vec![ + ConversationItem::system("sys"), + ConversationItem::assistant("assistant"), + ConversationItem::tool_result("dangling", "must remain"), + ]; + let prefix_json: Vec> = prefix + .iter() + .map(|item| serde_json::to_vec(item).unwrap()) + .collect(); + let mut h = TestHarness::with_conversation(prefix); + let reminder = ConversationItem::working_directory_switch("moved", 3); + + assert!(matches!( + h.handle + .append_working_directory_switch_and_ack( + "moved".into(), + std::num::NonZeroU64::new(3).unwrap(), + ) + .await + .unwrap(), + StrictAppendAck::Appended + )); + let conversation = h.handle.get_conversation().await; + assert_eq!(conversation.len(), 4); + for (actual, expected) in conversation.iter().zip(&prefix_json) { + assert_eq!(serde_json::to_vec(actual).unwrap(), *expected); + } + assert_eq!( + serde_json::to_vec(&conversation[3]).unwrap(), + serde_json::to_vec(&reminder).unwrap() + ); + assert!(matches!( + h.drain_persistence().as_slice(), + [PersistenceRecord::AcknowledgedMessage(_)] + )); + + assert!(matches!( + h.handle + .append_working_directory_switch_and_ack( + "different text".into(), + std::num::NonZeroU64::new(3).unwrap(), + ) + .await + .unwrap(), + StrictAppendAck::AlreadyPresent(_) + )); + assert_eq!(h.handle.get_conversation().await.len(), 4); + assert!(matches!( + h.drain_persistence().as_slice(), + [PersistenceRecord::AcknowledgedMessage(_)] + )); + + assert!(matches!( + h.handle + .append_working_directory_switch_and_ack( + "next move".into(), + std::num::NonZeroU64::new(4).unwrap(), + ) + .await + .unwrap(), + StrictAppendAck::Appended + )); + assert_eq!(h.handle.get_conversation().await.len(), 5); +} + +#[tokio::test] +async fn strict_switch_append_ack_waits_for_persistence() { + let mut h = TestHarness::with_manual_persistence_ack(vec![]); + let handle = h.handle.clone(); + let task = tokio::spawn(async move { + handle + .append_working_directory_switch_and_ack( + "moved".into(), + std::num::NonZeroU64::new(1).unwrap(), + ) + .await + }); + let persistence_ack = h + .persistence_rx + .next_persistence_ack() + .await + .expect("acknowledged append requested"); + assert!(matches!( + h.drain_persistence().as_slice(), + [PersistenceRecord::AcknowledgedMessage(_)] + )); + assert!(!task.is_finished(), "actor ack must wait for persistence"); + persistence_ack.send(Ok(StrictAppendAck::Appended)).unwrap(); + assert!(matches!( + task.await.unwrap().unwrap(), + StrictAppendAck::Appended + )); +} + +#[tokio::test] +async fn committed_storage_result_converges_actor_memory() { + let mut h = TestHarness::with_manual_persistence_ack(vec![]); + let handle = h.handle.clone(); + let task = tokio::spawn(async move { + handle + .append_working_directory_switch_and_ack( + "moved".into(), + std::num::NonZeroU64::new(2).unwrap(), + ) + .await + }); + let persistence_ack = h.persistence_rx.next_persistence_ack().await.unwrap(); + persistence_ack + .send(Err(crate::StrictAppendError::Committed { + acknowledgement: StrictAppendAck::Appended, + source: std::io::Error::other("summary failed"), + })) + .unwrap(); + let result = task.await.unwrap(); + assert!(matches!( + result, + Err(crate::StrictAppendError::Committed { + acknowledgement: StrictAppendAck::Appended, + .. + }) + )); + let conversation = h.handle.get_conversation().await; + assert_eq!(conversation.len(), 1); + assert_eq!( + conversation[0].working_directory_switch_generation(), + Some(2) + ); +} + +#[tokio::test] +async fn already_present_replaces_stale_switch_in_actor_memory() { + let generation = NonZeroU64::new(3).unwrap(); + let mut h = + TestHarness::with_manual_persistence_ack(vec![ConversationItem::working_directory_switch( + "stale", + generation.get(), + )]); + let handle = h.handle.clone(); + let task = tokio::spawn(async move { + handle + .append_working_directory_switch_and_ack("candidate".into(), generation) + .await + }); + h.persistence_rx + .next_persistence_ack() + .await + .unwrap() + .send(Ok(StrictAppendAck::AlreadyPresent( + ConversationItem::working_directory_switch("authoritative", generation.get()), + ))) + .unwrap(); + + assert!(matches!( + task.await.unwrap().unwrap(), + StrictAppendAck::AlreadyPresent(item) if item.text_content() == "authoritative" + )); + let conversation = h.handle.get_conversation().await; + assert_eq!(conversation.len(), 1); + assert_eq!(conversation[0].text_content(), "authoritative"); +} + +#[tokio::test] +async fn committed_already_present_replaces_retry_candidate_in_actor_memory() { + let generation = NonZeroU64::new(4).unwrap(); + let mut h = + TestHarness::with_manual_persistence_ack(vec![ConversationItem::working_directory_switch( + "retry candidate", + generation.get(), + )]); + let handle = h.handle.clone(); + let task = tokio::spawn(async move { + handle + .append_working_directory_switch_and_ack("another retry".into(), generation) + .await + }); + h.persistence_rx + .next_persistence_ack() + .await + .unwrap() + .send(Err(crate::StrictAppendError::Committed { + acknowledgement: StrictAppendAck::AlreadyPresent( + ConversationItem::working_directory_switch("authoritative", generation.get()), + ), + source: std::io::Error::other("summary failed"), + })) + .unwrap(); + + assert!(matches!( + task.await.unwrap(), + Err(crate::StrictAppendError::Committed { + acknowledgement: StrictAppendAck::AlreadyPresent(item), + .. + }) if item.text_content() == "authoritative" + )); + let conversation = h.handle.get_conversation().await; + assert_eq!(conversation.len(), 1); + assert_eq!(conversation[0].text_content(), "authoritative"); +} + +#[tokio::test] +async fn dropped_storage_reply_is_indeterminate_and_leaves_memory_unchanged() { + let mut h = TestHarness::with_manual_persistence_ack(vec![]); + let handle = h.handle.clone(); + let task = tokio::spawn(async move { + handle + .append_working_directory_switch_and_ack( + "moved".into(), + std::num::NonZeroU64::new(2).unwrap(), + ) + .await + }); + drop(h.persistence_rx.next_persistence_ack().await.unwrap()); + assert!(matches!( + task.await.unwrap(), + Err(crate::StrictAppendError::Indeterminate(_)) + )); + assert!(h.handle.get_conversation().await.is_empty()); +} + +#[tokio::test] +async fn uncommitted_storage_error_leaves_actor_memory_unchanged() { + let mut h = TestHarness::with_manual_persistence_ack(vec![]); + let handle = h.handle.clone(); + let task = tokio::spawn(async move { + handle + .append_working_directory_switch_and_ack( + "moved".into(), + std::num::NonZeroU64::new(2).unwrap(), + ) + .await + }); + let persistence_ack = h.persistence_rx.next_persistence_ack().await.unwrap(); + persistence_ack + .send(Err(crate::StrictAppendError::NotCommitted( + std::io::Error::other("append failed"), + ))) + .unwrap(); + assert!(task.await.unwrap().is_err()); + assert!(h.handle.get_conversation().await.is_empty()); +} + #[tokio::test] async fn push_assistant_response_appends_and_persists() { let mut h = TestHarness::new(); diff --git a/crates/codegen/xai-chat-state/src/commands.rs b/crates/codegen/xai-chat-state/src/commands.rs index 367d9b7..aaa1d82 100644 --- a/crates/codegen/xai-chat-state/src/commands.rs +++ b/crates/codegen/xai-chat-state/src/commands.rs @@ -35,6 +35,23 @@ impl std::fmt::Display for RepairHistoryBlocked { impl std::error::Error for RepairHistoryBlocked {} +/// Result of a strict persistence-acknowledged working-directory switch append. +#[derive(Debug, Clone)] +pub enum StrictAppendAck { + Appended, + AlreadyPresent(ConversationItem), +} + +#[derive(Debug)] +pub enum StrictAppendError { + NotCommitted(std::io::Error), + Committed { + acknowledgement: StrictAppendAck, + source: std::io::Error, + }, + Indeterminate(std::io::Error), +} + /// Commands sent to the ChatStateActor via mpsc channel. pub enum ChatStateCommand { // ═══ Mutations (fire-and-forget) ═══ @@ -48,6 +65,14 @@ pub enum ChatStateCommand { reply: oneshot::Sender<()>, }, + /// Append one working-directory switch without repair or pruning, then + /// acknowledge only after persistence processes the generation-aware append. + AppendWorkingDirectorySwitchAndAck { + content: String, + cwd_generation: std::num::NonZeroU64, + reply: oneshot::Sender>, + }, + /// Push a user message with an explicit dangling-repair reason. PushUserMessageWithRepairReason { item: ConversationItem, @@ -361,6 +386,12 @@ mod tests { item: ConversationItem::user("hello"), reply: tx, }; + let (tx, _rx) = oneshot::channel(); + let _ = ChatStateCommand::AppendWorkingDirectorySwitchAndAck { + content: "moved".into(), + cwd_generation: std::num::NonZeroU64::new(1).unwrap(), + reply: tx, + }; let _ = ChatStateCommand::PushAssistantResponse { item: ConversationItem::assistant("hi"), }; diff --git a/crates/codegen/xai-chat-state/src/compaction_utils.rs b/crates/codegen/xai-chat-state/src/compaction_utils.rs index ca75479..03ece24 100644 --- a/crates/codegen/xai-chat-state/src/compaction_utils.rs +++ b/crates/codegen/xai-chat-state/src/compaction_utils.rs @@ -532,6 +532,10 @@ pub struct TodoSummary { /// handled by the consumer (e.g. `xai-grok-shell`), which has access to /// memory backends and other shell-specific dependencies. pub struct CompactionStateContext { + /// Monotonic cwd generation; zero preserves the legacy compaction shape. + pub cwd_generation: u64, + /// Project instructions resolved for the latest destination cwd. + pub destination_project_instructions: Option, /// Messages since the last **real** user turn (assistant + omitted tool /// results). Synthetic user injections (system reminders) do not reset /// the boundary, preventing orphaned ToolResults in the compacted output. @@ -555,6 +559,8 @@ pub struct CompactionStateContext { /// [`CompactionStateContext::build`]. #[derive(Default)] pub struct CompactionInputs { + pub cwd_generation: u64, + pub destination_project_instructions: Option, pub running_tasks: Vec, pub running_subagents: Vec, pub agent_edited_paths: BTreeSet, @@ -569,6 +575,8 @@ impl CompactionStateContext { /// compaction boundary. pub async fn build(conversation: &[ConversationItem], inputs: CompactionInputs) -> Self { Self { + cwd_generation: inputs.cwd_generation, + destination_project_instructions: inputs.destination_project_instructions, recent_messages: extract_messages_since_last_real_user(conversation), last_user_query: extract_last_real_user_query(conversation), agent_edited_paths: inputs.agent_edited_paths.into_iter().collect(), @@ -602,6 +610,8 @@ impl CompactionStateContext { /// `recent_messages` so the model keeps verbatim tool context. pub fn for_compaction(&self) -> Self { Self { + cwd_generation: self.cwd_generation, + destination_project_instructions: self.destination_project_instructions.clone(), recent_messages: Vec::new(), last_user_query: self.last_user_query.clone(), agent_edited_paths: self.agent_edited_paths.clone(), @@ -839,7 +849,15 @@ pub fn build_compacted_history(input: CompactedHistoryInput<'_>) -> Vec Result { + self.query("AppendWorkingDirectorySwitchAndAck", |reply| { + ChatStateCommand::AppendWorkingDirectorySwitchAndAck { + content, + cwd_generation, + reply, + } + }) + .await + .unwrap_or_else(|| { + Err(StrictAppendError::Indeterminate(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "chat-state actor unavailable; retry by generation", + ))) + }) + } + /// Push a user message with an explicit dangling-repair reason. pub fn push_user_message_with_repair_reason( &self, diff --git a/crates/codegen/xai-chat-state/src/lib.rs b/crates/codegen/xai-chat-state/src/lib.rs index 94c10b2..3fdd1dd 100644 --- a/crates/codegen/xai-chat-state/src/lib.rs +++ b/crates/codegen/xai-chat-state/src/lib.rs @@ -42,7 +42,7 @@ pub use actor::state::{ estimate_system_message_tokens, estimate_tool_definition_tokens, estimate_tool_definitions_tokens, }; -pub use commands::ModelMetadata; +pub use commands::{ModelMetadata, StrictAppendAck, StrictAppendError}; pub use compaction_mode::CompactionMode; pub use compaction_transcript::CompactionDetail; pub use events::ChatStateEvent; diff --git a/crates/codegen/xai-chat-state/src/persistence.rs b/crates/codegen/xai-chat-state/src/persistence.rs index aa27c6f..8c3ea9a 100644 --- a/crates/codegen/xai-chat-state/src/persistence.rs +++ b/crates/codegen/xai-chat-state/src/persistence.rs @@ -5,9 +5,13 @@ //! The mock uses a channel to report records to the test, keeping everything //! in the actor / message-passing paradigm. -use tokio::sync::mpsc; +use std::io; + +use tokio::sync::{mpsc, oneshot}; use xai_grok_sampling_types::ConversationItem; +use crate::commands::{StrictAppendAck, StrictAppendError}; + /// Abstraction over chat-specific persistence operations. /// /// The actor owns this exclusively via `Box`, so all @@ -20,6 +24,12 @@ pub trait ChatPersistence: Send + 'static { /// Persist a single conversation item (append to chat_history.jsonl). fn persist_message(&mut self, item: &ConversationItem); + /// Persist one working-directory switch generation and report commit status. + fn persist_working_directory_switch_and_ack( + &mut self, + item: &ConversationItem, + ) -> oneshot::Receiver>; + /// Replace the entire chat history (compaction / rewind). fn replace_history(&mut self, items: &[ConversationItem]); @@ -36,6 +46,8 @@ pub trait ChatPersistence: Send + 'static { pub enum PersistenceRecord { /// A single message was persisted. Message(ConversationItem), + /// A persistence-acknowledged switch append was requested. + AcknowledgedMessage(ConversationItem), /// The full history was replaced. ReplaceHistory(Vec), /// A flush was requested. @@ -47,11 +59,17 @@ pub enum PersistenceRecord { /// the actor did. No locks, no atomics — just message passing. pub struct MockChatPersistence { tx: mpsc::UnboundedSender, + persistence_ack_tx: + Option>>>, + persisted_working_directory_switches: Vec, } /// Receiver side of the mock. Held by the test to drain and inspect records. pub struct MockPersistenceReceiver { rx: mpsc::UnboundedReceiver, + persistence_ack_rx: Option< + mpsc::UnboundedReceiver>>, + >, } impl MockChatPersistence { @@ -59,7 +77,34 @@ impl MockChatPersistence { /// receiver in the test. pub fn new() -> (Self, MockPersistenceReceiver) { let (tx, rx) = mpsc::unbounded_channel(); - (Self { tx }, MockPersistenceReceiver { rx }) + ( + Self { + tx, + persistence_ack_tx: None, + persisted_working_directory_switches: Vec::new(), + }, + MockPersistenceReceiver { + rx, + persistence_ack_rx: None, + }, + ) + } + + /// Create a mock whose persistence acknowledgement is test-controlled. + pub fn new_with_manual_persistence_ack() -> (Self, MockPersistenceReceiver) { + let (tx, rx) = mpsc::unbounded_channel(); + let (persistence_ack_tx, persistence_ack_rx) = mpsc::unbounded_channel(); + ( + Self { + tx, + persistence_ack_tx: Some(persistence_ack_tx), + persisted_working_directory_switches: Vec::new(), + }, + MockPersistenceReceiver { + rx, + persistence_ack_rx: Some(persistence_ack_rx), + }, + ) } } @@ -73,6 +118,16 @@ impl MockPersistenceReceiver { records } + /// Receive the next manual persistence acknowledgement sender. + pub async fn next_persistence_ack( + &mut self, + ) -> Option>> { + match &mut self.persistence_ack_rx { + Some(rx) => rx.recv().await, + None => None, + } + } + /// Collect all `Message` items received so far (drains the channel). pub fn messages(&mut self) -> Vec { self.drain() @@ -90,6 +145,40 @@ impl ChatPersistence for MockChatPersistence { let _ = self.tx.send(PersistenceRecord::Message(item.clone())); } + fn persist_working_directory_switch_and_ack( + &mut self, + item: &ConversationItem, + ) -> oneshot::Receiver> { + let (reply, receiver) = oneshot::channel(); + let sent = self + .tx + .send(PersistenceRecord::AcknowledgedMessage(item.clone())) + .map_err(|_| { + StrictAppendError::NotCommitted(io::Error::new( + io::ErrorKind::BrokenPipe, + "mock persistence closed", + )) + }); + if let Err(error) = sent { + let _ = reply.send(Err(error)); + } else if let Some(ack_tx) = &self.persistence_ack_tx { + let _ = ack_tx.send(reply); + } else { + let generation = item.working_directory_switch_generation(); + let acknowledgement = self + .persisted_working_directory_switches + .iter() + .find(|persisted| persisted.working_directory_switch_generation() == generation) + .cloned() + .map_or(StrictAppendAck::Appended, StrictAppendAck::AlreadyPresent); + if matches!(&acknowledgement, StrictAppendAck::Appended) { + self.persisted_working_directory_switches.push(item.clone()); + } + let _ = reply.send(Ok(acknowledgement)); + } + receiver + } + fn replace_history(&mut self, items: &[ConversationItem]) { let _ = self .tx @@ -110,6 +199,14 @@ pub struct NullChatPersistence; impl ChatPersistence for NullChatPersistence { fn persist_message(&mut self, _item: &ConversationItem) {} + fn persist_working_directory_switch_and_ack( + &mut self, + _item: &ConversationItem, + ) -> oneshot::Receiver> { + let (reply, receiver) = oneshot::channel(); + let _ = reply.send(Ok(StrictAppendAck::Appended)); + receiver + } fn replace_history(&mut self, _items: &[ConversationItem]) {} fn flush(&mut self) {} } @@ -163,6 +260,28 @@ mod tests { ); } + #[tokio::test] + async fn mock_persistence_deduplicates_working_directory_generation() { + let (mut mock, _rx) = MockChatPersistence::new(); + let first = ConversationItem::working_directory_switch("authoritative", 4); + assert!(matches!( + mock.persist_working_directory_switch_and_ack(&first) + .await + .unwrap() + .unwrap(), + StrictAppendAck::Appended + )); + assert!(matches!( + mock.persist_working_directory_switch_and_ack( + &ConversationItem::working_directory_switch("retry", 4), + ) + .await + .unwrap() + .unwrap(), + StrictAppendAck::AlreadyPresent(item) if item.text_content() == "authoritative" + )); + } + #[test] fn null_persistence_does_not_panic() { let mut null = NullChatPersistence; diff --git a/crates/codegen/xai-fast-worktree/Cargo.toml b/crates/codegen/xai-fast-worktree/Cargo.toml index b667992..c6057a9 100644 --- a/crates/codegen/xai-fast-worktree/Cargo.toml +++ b/crates/codegen/xai-fast-worktree/Cargo.toml @@ -56,12 +56,13 @@ xai-test-utils = { workspace = true } # Only used by tests behind #[cfg(feature = "metadata")] in db/tests.rs. rusqlite = { version = "0.37", features = ["bundled"] } -[target.'cfg(target_os = "linux")'.dev-dependencies] +[target.'cfg(unix)'.dependencies] libc = { workspace = true } + +[target.'cfg(target_os = "linux")'.dev-dependencies] nix = { version = "0.30", features = ["fs"] } [target.'cfg(target_os = "linux")'.dependencies] -libc = { workspace = true } nix = { version = "0.30", features = ["fs"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } diff --git a/crates/codegen/xai-fast-worktree/src/api.rs b/crates/codegen/xai-fast-worktree/src/api.rs index 70a593c..3510d00 100644 --- a/crates/codegen/xai-fast-worktree/src/api.rs +++ b/crates/codegen/xai-fast-worktree/src/api.rs @@ -7,6 +7,28 @@ use std::path::{Path, PathBuf}; use std::sync::Arc; +/// Serializes tests that chdir or assert process-CWD scan results (process-global cwd). +/// Gated on `metadata` because every caller lives under that feature's test modules +/// (`gc` / `auto_gc`); without the feature these would be dead under `-D warnings`. +#[cfg(all(test, feature = "metadata"))] +pub(crate) static CWD_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +#[cfg(all(test, feature = "metadata"))] +pub(crate) fn cwd_test_guard() -> std::sync::MutexGuard<'static, ()> { + CWD_TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()) +} + +/// Restores process cwd on drop (pair with [`cwd_test_guard`]). +#[cfg(all(test, feature = "metadata"))] +pub(crate) struct CwdGuard(pub PathBuf); + +#[cfg(all(test, feature = "metadata"))] +impl Drop for CwdGuard { + fn drop(&mut self) { + let _ = std::env::set_current_dir(&self.0); + } +} + use anyhow::Result; use tokio_util::sync::CancellationToken; @@ -1478,20 +1500,49 @@ impl BtrfsDelegate for RecordingDelegate { #[cfg(feature = "metadata")] pub mod gc { + use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::Result; use crate::BtrfsDelegate; - use crate::db::{ListFilter, WorktreeDb, WorktreeStatus}; + use crate::db::{ListFilter, WorktreeDb, WorktreeKind, WorktreeStatus}; use serde::{Deserialize, Serialize}; #[derive(Clone, Debug, Default, Serialize, Deserialize)] pub struct GcOptions { + /// Default max age for kinds not in [`Self::max_age_by_kind`]. + /// `None` + empty map disables the age path. pub max_age_secs: Option, pub force: bool, pub dry_run: bool, + /// Paths that must not be age-expired (auto path: process cwd). + #[serde(default)] + pub protect_paths: Vec, + /// Age-path only; equivalent to `max_age_by_kind[kind] = None`. + /// Honored even when `force=true`. Dead-path unregister still applies. + #[serde(default)] + pub skip_kinds: Vec, + /// Per-kind override of [`Self::max_age_secs`]. `None` = never age-expire. + /// `force` does not override never-expire. + #[serde(default)] + pub max_age_by_kind: BTreeMap>, + } + + /// `Some(secs)` to age-expire; `None` = never. Order: skip_kinds → map → max_age_secs. + pub fn effective_max_age(opts: &GcOptions, kind: WorktreeKind) -> Option { + if opts.skip_kinds.contains(&kind) { + return None; + } + opts.max_age_by_kind + .get(&kind) + .copied() + .unwrap_or(opts.max_age_secs) + } + + pub(crate) fn age_path_enabled(opts: &GcOptions) -> bool { + opts.max_age_secs.is_some() || !opts.max_age_by_kind.is_empty() } #[derive(Clone, Debug, Default, Serialize, Deserialize)] @@ -1504,104 +1555,229 @@ pub mod gc { /// from agents predating this field still deserialize. #[serde(default)] pub remove_failed: u64, - // TODO(v2): untracked_found, untracked_registered (via rebuild_worktree_db), - // stale_registrations_cleaned (stale .git/worktrees/ cleanup) + // Rebuild / stale-registration hygiene live on `AutoGcReport` (optional + // auto path), not on every `gc_worktrees` call. } - /// Decode a `kill(pid, 0)` outcome into liveness. `ret == 0` ⇒ the process - /// exists. Otherwise: `ESRCH` ⇒ no such process (dead); anything else - /// (notably `EPERM`/`EACCES` — exists but owned by another user) ⇒ alive. - /// `kill -0`'s exit status can't distinguish `EPERM` from `ESRCH` and wrongly - /// reports `EPERM` as dead; split out so this is unit-testable. - #[cfg(target_os = "linux")] + /// `ret == 0` or non-`ESRCH` errno ⇒ alive (`EPERM`/`EACCES` included). + #[cfg(unix)] fn pid_alive_from_kill(ret: i32, errno: i32) -> bool { ret == 0 || errno != libc::ESRCH } fn is_pid_alive(pid: u32) -> bool { - #[cfg(target_os = "linux")] + #[cfg(unix)] { - // pid 0 targets the caller's process group and pid > i32::MAX wraps to - // a negative pid_t (also a process group); neither is a real tracked - // pid (creator_pid is always our own process id), so treat as dead. + // pid 0 / >i32::MAX select process groups, not a tracked creator_pid. if pid == 0 || pid > i32::MAX as u32 { return false; } - // A null signal (sig 0) runs the kernel's existence/permission check - // without delivering a signal. let ret = unsafe { libc::kill(pid as libc::pid_t, 0) }; let errno = std::io::Error::last_os_error().raw_os_error().unwrap_or(0); pid_alive_from_kill(ret, errno) } - #[cfg(not(target_os = "linux"))] + #[cfg(not(unix))] { - // No libc dependency off Linux; fall back to `kill -0` exit status. - let mut cmd = std::process::Command::new("kill"); - xai_tty_utils::detach_std_command(&mut cmd); - cmd.stdin(std::process::Stdio::null()); - cmd.args(["-0", &pid.to_string()]) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .status() - .is_ok_and(|s| s.success()) + let _ = pid; + false } } - /// Physical CWDs of every readable running process, via the Linux `/proc` - /// scan. Empty on non-Linux — the creator-PID guard still applies. - /// Dep-free on purpose (avoids re-adding a process-listing crate just for - /// this guard). + /// Foreign process CWDs for GC age guards. + #[derive(Debug, Clone, PartialEq, Eq)] + pub(crate) enum LiveCwdScan { + Ok(Vec), + /// No enumerator (Windows/FreeBSD/…); PID guards only. + #[allow(dead_code)] + Unsupported, + /// Enumerator failed or unusable — age path fail-closes. + Failed, + } + + /// `force` bypasses CWD requirements; `Failed` blocks age deletes. + pub(crate) fn age_path_cwd_usable(scan: &LiveCwdScan, force: bool) -> bool { + force || matches!(scan, LiveCwdScan::Ok(_) | LiveCwdScan::Unsupported) + } + + fn scan_contains_cwd(cwds: &[PathBuf], path: &Path) -> bool { + let path_canon = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); + cwds.iter().any(|c| { + c.as_path() == path + || c == &path_canon + || dunce::canonicalize(c).is_ok_and(|cc| cc == path_canon) + }) + } + + /// Fail closed when our own CWD is not visible in the scan. + /// + /// `current_dir()` errors also fail closed — we cannot confirm this + /// process is outside every candidate path without knowing CWD. + fn validate_cwd_scan(cwds: Vec) -> LiveCwdScan { + match std::env::current_dir() { + Ok(cwd) if scan_contains_cwd(&cwds, &cwd) => LiveCwdScan::Ok(cwds), + Ok(_) => LiveCwdScan::Failed, + Err(_) => LiveCwdScan::Failed, + } + } + + /// Linux `/proc//cwd`, macOS libproc; else [`LiveCwdScan::Unsupported`]. #[cfg(target_os = "linux")] - fn live_process_cwds() -> Vec { + pub(crate) fn live_process_cwds() -> LiveCwdScan { let Ok(entries) = std::fs::read_dir("/proc") else { - return Vec::new(); + return LiveCwdScan::Failed; }; - entries + let cwds: Vec = entries .filter_map(Result::ok) - // Only numeric `/proc/` entries expose a `cwd` symlink. .filter(|e| { e.file_name() .to_str() .is_some_and(|n| n.parse::().is_ok()) }) - // An unreadable link (process exited / not permitted) means nothing - // is parked there, so drop it. .filter_map(|e| std::fs::read_link(e.path().join("cwd")).ok()) - .collect() + .collect(); + validate_cwd_scan(cwds) } - #[cfg(not(target_os = "linux"))] - fn live_process_cwds() -> Vec { - Vec::new() + #[cfg(target_os = "macos")] + pub(crate) fn live_process_cwds() -> LiveCwdScan { + macos_live_process_cwds() } - /// True if any `live_cwds` entry sits inside `wt_path`. Kernel CWD links are - /// physical paths, so also match the canonicalized worktree path — a - /// symlinked `$GROK_HOME` or custom worktree path would otherwise never match. + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + pub(crate) fn live_process_cwds() -> LiveCwdScan { + LiveCwdScan::Unsupported + } + + #[cfg(target_os = "macos")] + const VIP_PATH_LEN: usize = 1024; + + /// NUL-bounded path from fixed `vip_path` (never scan past `VIP_PATH_LEN`). + #[cfg(target_os = "macos")] + fn vip_path_to_pathbuf(path: &[[libc::c_char; 32]; 32]) -> Option { + use std::ffi::OsStr; + use std::os::unix::ffi::OsStrExt; + + // SAFETY: fixed-size array layout matches MAXPATHLEN vip_path. + let bytes = unsafe { std::slice::from_raw_parts(path.as_ptr().cast::(), VIP_PATH_LEN) }; + let nul = bytes.iter().position(|&b| b == 0)?; + let s = &bytes[..nul]; + if s.is_empty() { + return None; + } + Some(PathBuf::from(OsStr::from_bytes(s))) + } + + #[cfg(target_os = "macos")] + fn macos_live_process_cwds() -> LiveCwdScan { + let Some(pids) = macos_list_all_pids() else { + return LiveCwdScan::Failed; + }; + let expected = std::mem::size_of::() as i32; + let mut out = Vec::with_capacity(pids.len()); + for pid in pids { + if pid <= 0 { + continue; + } + // SAFETY: zeroed buffer sized for PROC_PIDVNODEPATHINFO. + let mut info = unsafe { std::mem::zeroed::() }; + let ret = unsafe { + libc::proc_pidinfo( + pid, + libc::PROC_PIDVNODEPATHINFO, + 0, + (&raw mut info).cast(), + expected, + ) + }; + if ret != expected || info.pvi_cdir.vip_vi.vi_stat.vst_dev == 0 { + continue; + } + if let Some(p) = vip_path_to_pathbuf(&info.pvi_cdir.vip_path) { + out.push(p); + } + } + validate_cwd_scan(out) + } + + /// `proc_listallpids` returns a **byte** count (probe and fill), not a PID count. + /// Convert via `size_of::()` / `i32` before allocating or truncating. + #[cfg(target_os = "macos")] + fn macos_list_all_pids() -> Option> { + const PID_SIZE: usize = std::mem::size_of::(); + // SAFETY: null + size 0 is the documented size probe (returns bytes needed). + let bytes_needed = unsafe { libc::proc_listallpids(std::ptr::null_mut(), 0) }; + if bytes_needed < 1 { + return None; + } + let mut capacity_pids = (bytes_needed as usize) / PID_SIZE; + if capacity_pids < 1 { + return None; + } + for _ in 0..4 { + // Headroom for pids that appear between probe and fill. + capacity_pids = capacity_pids + .saturating_add(capacity_pids / 4) + .max(capacity_pids + 32); + let mut pids = vec![0i32; capacity_pids]; + let buf_bytes = (pids.len() * PID_SIZE) as i32; + // SAFETY: kernel writes at most `buf_bytes` into `pids`; return is byte count. + let n_bytes = unsafe { libc::proc_listallpids(pids.as_mut_ptr().cast(), buf_bytes) }; + if n_bytes < 1 { + return None; + } + let n_pids = (n_bytes as usize) / PID_SIZE; + if n_pids < pids.len() { + pids.truncate(n_pids); + return Some(pids); + } + // Buffer was full — grow and retry. + capacity_pids = n_pids; + } + None + } + + /// True if any `live_cwds` entry sits inside `wt_path` (raw + canonical). fn cwd_within(wt_path: &Path, live_cwds: &[PathBuf]) -> bool { let wt_canon = dunce::canonicalize(wt_path).unwrap_or_else(|_| wt_path.to_path_buf()); - live_cwds - .iter() - .any(|cwd| cwd.starts_with(wt_path) || cwd.starts_with(&wt_canon)) + live_cwds.iter().any(|cwd| { + if cwd.starts_with(wt_path) || cwd.starts_with(&wt_canon) { + return true; + } + match dunce::canonicalize(cwd) { + Ok(cwd_canon) => cwd_canon.starts_with(wt_path) || cwd_canon.starts_with(&wt_canon), + Err(_) => false, + } + }) } - /// Effective freshness timestamp: the more recent of creation and last - /// access (last_accessed_at is never read as older than created_at). fn last_active(rec: &crate::db::WorktreeRecord) -> i64 { rec.last_accessed_at .unwrap_or(rec.created_at) .max(rec.created_at) } - /// Guarded — must not be reclaimed — when the creator process is still - /// running or any live process has its CWD inside the tree. fn is_guarded(rec: &crate::db::WorktreeRecord, live_cwds: &[PathBuf]) -> bool { rec.creator_pid.is_some_and(is_pid_alive) || cwd_within(Path::new(&rec.path), live_cwds) } - /// Reclaimable only when expired (older than `cutoff` by [`last_active`]) and - /// unguarded. Used for the per-candidate re-check against a freshly-read row. - fn is_reclaimable(rec: &crate::db::WorktreeRecord, cutoff: i64, live_cwds: &[PathBuf]) -> bool { + fn is_path_protected(wt_path: &Path, protect_paths: &[PathBuf]) -> bool { + !protect_paths.is_empty() && cwd_within(wt_path, protect_paths) + } + + /// Expired + unguarded + not path-protected + not never-expire (pre-remove re-check). + fn is_reclaimable( + rec: &crate::db::WorktreeRecord, + now: i64, + live_cwds: &[PathBuf], + opts: &GcOptions, + ) -> bool { + let Some(max_age) = effective_max_age(opts, rec.kind) else { + return false; + }; + let cutoff = now.saturating_sub(max_age.max(0)); + if is_path_protected(Path::new(&rec.path), &opts.protect_paths) { + return false; + } last_active(rec) < cutoff && !is_guarded(rec, live_cwds) } @@ -1650,64 +1826,90 @@ pub mod gc { } // Expired alive-worktree reclamation (liveness-guarded). - if let Some(max_age) = opts.max_age_secs { - // Clamp: a negative max_age must not push the cutoff into the future - // (which would expire everything), and an extreme value must not - // overflow the subtraction. - let cutoff = now.saturating_sub(max_age.max(0)); - // One process-table scan, reused by the in-tree liveness guard below; - // skipped under --force, where the guard never fires. - let live_cwds = if opts.force { - Vec::new() + // First-pass CWD scan (cheap filter). Pre-remove re-check rescans so a + // process that chdir'd into the tree after this snapshot is still guarded. + // Failed scan → fail closed. + if age_path_enabled(opts) { + let cwd_scan = if opts.force { + LiveCwdScan::Ok(Vec::new()) } else { live_process_cwds() }; + if !age_path_cwd_usable(&cwd_scan, opts.force) { + tracing::warn!( + "process CWD scan failed or unusable; skipping age-expiry (fail closed)" + ); + return Ok(report); + } + let live_cwds: &[PathBuf] = match &cwd_scan { + LiveCwdScan::Ok(v) => v.as_slice(), + LiveCwdScan::Unsupported => &[], + LiveCwdScan::Failed => unreachable!("gated by age_path_cwd_usable"), + }; let alive = db.list(&ListFilter::default())?; for rec in alive { - // A worktree touched within the age window must not expire; - // callers bump last_accessed_at on use. + let Some(max_age) = effective_max_age(opts, rec.kind) else { + // Metrics-only: never-expire kinds are retained on purpose. + // Prefer global `max_age_secs` as the "would have expired" + // reference when set; when only per-kind ages apply + // (`max_age_secs = None`), still count so dry-run/dogfood + // skip totals are not under-counted. + if let Some(ref_age) = opts.max_age_secs { + let ref_cutoff = now.saturating_sub(ref_age.max(0)); + if last_active(&rec) < ref_cutoff { + report.skipped_alive += 1; + } + } else { + report.skipped_alive += 1; + } + continue; + }; + let cutoff = now.saturating_sub(max_age.max(0)); if last_active(&rec) >= cutoff { continue; } let path = Path::new(&rec.path); - // Cheap first-pass guard against the upfront snapshot; the - // per-candidate re-check below re-confirms against a fresh row. - if !opts.force && is_guarded(&rec, &live_cwds) { + if !opts.force + && (is_guarded(&rec, live_cwds) || is_path_protected(path, &opts.protect_paths)) + { report.skipped_alive += 1; continue; } - // Dry run: count the candidate without touching disk or DB. Skip - // a missing path — a real run sweeps it to dead first, so it's - // already counted in dead_removed (don't double-count here). if opts.dry_run { if path.exists() { report.expired_removed += 1; } continue; } - // Re-evaluate against a freshly-read row + live process scan right - // before the destructive step: the list snapshot is stale (earlier - // removals take time), so a concurrent touch_worktree_for_cwd - // (bumps last_accessed_at), a revived creator, or a process that - // chdir'd into the tree must still protect it. + // Fresh DB row + fresh CWD scan immediately before remove. if !opts.force { match db.get_by_id(&rec.id) { Ok(Some(fresh)) => { - if !is_reclaimable(&fresh, cutoff, &live_process_cwds()) { + let recheck_scan = live_process_cwds(); + if !age_path_cwd_usable(&recheck_scan, false) { + tracing::warn!( + "process CWD scan failed on pre-remove re-check; skipping remaining age-expiry (fail closed)" + ); + return Ok(report); + } + let recheck_cwds: &[PathBuf] = match &recheck_scan { + LiveCwdScan::Ok(v) => v.as_slice(), + LiveCwdScan::Unsupported => &[], + LiveCwdScan::Failed => { + unreachable!("gated by age_path_cwd_usable") + } + }; + if !is_reclaimable(&fresh, now, recheck_cwds, opts) { report.skipped_alive += 1; continue; } } - // Fail closed: a vanished row (concurrently unregistered) - // or an unreadable DB must not green-light a remove. Ok(None) | Err(_) => continue, } } if path.exists() { match super::remove_worktree_with_delegate(path, delegate.clone()) { Ok(_) => report.expired_removed += 1, - // A failed remove (e.g. EPERM) leaves the record tracked - // for a later retry; surface it instead of reporting zero. Err(e) => { tracing::warn!( path = %path.display(), @@ -1718,7 +1920,6 @@ pub mod gc { } } } else if db.unregister(&rec.id).unwrap_or(false) { - // Path already gone: drop the stale record. report.expired_removed += 1; } } @@ -1731,7 +1932,7 @@ pub mod gc { mod tests { use super::*; - #[cfg(target_os = "linux")] + #[cfg(unix)] #[test] fn pid_alive_from_kill_decodes_errno() { // Testable without needing a process in each errno state. @@ -1744,15 +1945,24 @@ pub mod gc { assert!(pid_alive_from_kill(-1, libc::EACCES), "EACCES ⇒ alive"); } + #[cfg(unix)] #[test] fn is_pid_alive_true_for_running_processes() { assert!(is_pid_alive(std::process::id())); - // PID 1 (init) always exists. - #[cfg(target_os = "linux")] - assert!(is_pid_alive(1), "init must be detected as alive"); + // PID 1 (init/launchd) always exists on Unix. + assert!(is_pid_alive(1), "pid 1 must be detected as alive"); } - #[cfg(target_os = "linux")] + #[cfg(not(unix))] + #[test] + fn is_pid_alive_never_false_alive_on_non_unix() { + // Safe fallback: never report a pid as alive without a real probe. + assert!(!is_pid_alive(std::process::id())); + assert!(!is_pid_alive(0)); + assert!(!is_pid_alive(u32::MAX)); + } + + #[cfg(unix)] #[test] fn is_pid_alive_false_for_guarded_pids() { // pid 0 and pid > i32::MAX are process-group selectors to kill(2), not @@ -1761,6 +1971,7 @@ pub mod gc { assert!(!is_pid_alive(u32::MAX)); } + #[cfg(unix)] #[test] fn is_pid_alive_false_for_reaped_child() { // A fully reaped child's pid is gone (ESRCH) and must read as dead. @@ -1772,6 +1983,96 @@ pub mod gc { assert!(!is_pid_alive(pid)); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn live_process_cwds_includes_own_cwd_after_chdir() { + let _cwd_lock = crate::api::cwd_test_guard(); + let tmp = tempfile::TempDir::new().unwrap(); + let dir = dunce::canonicalize(tmp.path()).unwrap(); + let _cwd = crate::api::CwdGuard(std::env::current_dir().unwrap()); + std::env::set_current_dir(&dir).expect("chdir into temp"); + let LiveCwdScan::Ok(cwds) = live_process_cwds() else { + panic!("CWD scan must succeed on this OS after chdir"); + }; + assert!( + scan_contains_cwd(&cwds, &dir), + "own CWD {dir:?} must appear in live_process_cwds (got {} entries)", + cwds.len() + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn live_process_cwds_ok_and_nonempty_on_supported_os() { + let _cwd_lock = crate::api::cwd_test_guard(); + match live_process_cwds() { + LiveCwdScan::Ok(cwds) => assert!( + !cwds.is_empty(), + "process CWD scan must observe at least one CWD on this OS" + ), + other => panic!("expected LiveCwdScan::Ok on supported OS, got {other:?}"), + } + } + + #[test] + fn validate_cwd_scan_fails_closed_when_self_missing() { + let _cwd_lock = crate::api::cwd_test_guard(); + assert!( + matches!(validate_cwd_scan(Vec::new()), LiveCwdScan::Failed), + "empty scan cannot observe self CWD" + ); + assert!( + matches!( + validate_cwd_scan(vec![PathBuf::from("/no/such/unrelated/cwd")]), + LiveCwdScan::Failed + ), + "unrelated paths only ⇒ unusable scan" + ); + let cwd = std::env::current_dir().unwrap(); + match validate_cwd_scan(vec![cwd.clone()]) { + LiveCwdScan::Ok(v) => { + assert_eq!(v.len(), 1); + assert_eq!(v[0], cwd); + } + other => panic!("self path must validate: {other:?}"), + } + } + + #[test] + fn age_path_cwd_usable_fail_closed_on_failed_scan() { + assert!( + !age_path_cwd_usable(&LiveCwdScan::Failed, false), + "Failed scan must block age path" + ); + assert!( + age_path_cwd_usable(&LiveCwdScan::Failed, true), + "force bypasses CWD scan requirement" + ); + assert!(age_path_cwd_usable( + &LiveCwdScan::Ok(vec![PathBuf::from("/")]), + false + )); + assert!(age_path_cwd_usable(&LiveCwdScan::Unsupported, false)); + } + + #[cfg(target_os = "macos")] + #[test] + fn vip_path_to_pathbuf_respects_nul_bound() { + let mut raw = [[0 as libc::c_char; 32]; 32]; + // "ab" then NUL — rest garbage must not be read. + raw[0][0] = b'a' as libc::c_char; + raw[0][1] = b'b' as libc::c_char; + raw[0][2] = 0; + raw[0][3] = b'x' as libc::c_char; + let p = vip_path_to_pathbuf(&raw).expect("path"); + assert_eq!(p, PathBuf::from("ab")); + // All zeros → None + assert!(vip_path_to_pathbuf(&[[0 as libc::c_char; 32]; 32]).is_none()); + // No NUL within 1024 → None (not UB) + let no_nul = [[b'z' as libc::c_char; 32]; 32]; + assert!(vip_path_to_pathbuf(&no_nul).is_none()); + } + #[test] fn cwd_within_matches_nested_and_canonical_paths() { let tmp = tempfile::TempDir::new().unwrap(); @@ -1781,6 +2082,12 @@ pub mod gc { assert!(cwd_within(&wt, &[wt.join("a").join("b")])); assert!(!cwd_within(&wt, &[tmp.path().join("other")])); assert!(!cwd_within(&wt, &[])); + // protect/cwd entry matching after canonicalize (raw path may differ). + let nested = wt.join("a").join("b"); + let nested_canon = dunce::canonicalize(&nested).unwrap(); + let wt_canon = dunce::canonicalize(&wt).unwrap(); + assert!(cwd_within(&wt_canon, &[nested])); + assert!(cwd_within(&wt, &[nested_canon])); } fn rec_at(path: &str, created_at: i64) -> crate::db::WorktreeRecord { @@ -1804,24 +2111,106 @@ pub mod gc { #[test] fn is_reclaimable_requires_expired_and_unguarded() { - let cutoff = 1_000; - // Expired (old created_at, never accessed) and unguarded → reclaimable. - assert!(is_reclaimable(&rec_at("/no/such/wt", 1), cutoff, &[])); + let now = 1_000; + // max_age=0 → cutoff=now; created_at=1 is expired. + let base = GcOptions { + max_age_secs: Some(0), + ..Default::default() + }; + assert!(is_reclaimable(&rec_at("/no/such/wt", 1), now, &[], &base)); // A recent last_accessed_at within the window protects it. let mut fresh = rec_at("/no/such/wt", 1); - fresh.last_accessed_at = Some(cutoff + 10); - assert!(!is_reclaimable(&fresh, cutoff, &[])); - // A live creator pid protects it. + fresh.last_accessed_at = Some(now + 10); + assert!(!is_reclaimable(&fresh, now, &[], &base)); + // A live creator pid protects it (Unix probe only; non-Unix is never alive). let mut live_creator = rec_at("/no/such/wt", 1); live_creator.creator_pid = Some(std::process::id()); - assert!(!is_reclaimable(&live_creator, cutoff, &[])); + #[cfg(unix)] + assert!(!is_reclaimable(&live_creator, now, &[], &base)); + #[cfg(not(unix))] + assert!( + is_reclaimable(&live_creator, now, &[], &base), + "non-Unix PID probe is fail-closed (never alive)" + ); // A live process CWD inside the tree protects it. let inside = std::path::PathBuf::from("/no/such/wt/sub"); assert!(!is_reclaimable( &rec_at("/no/such/wt", 1), - cutoff, - &[inside] + now, + &[inside], + &base )); + // protect_paths hit (same semantics as CWD) blocks reclaim. + let protect = std::path::PathBuf::from("/no/such/wt/nested"); + let with_protect = GcOptions { + max_age_secs: Some(0), + protect_paths: vec![protect], + ..Default::default() + }; + assert!(!is_reclaimable( + &rec_at("/no/such/wt", 1), + now, + &[], + &with_protect + )); + // skip_kinds / never-expire blocks reclaim even when otherwise expired. + let skip = GcOptions { + max_age_secs: Some(0), + skip_kinds: vec![WorktreeKind::Session], + ..Default::default() + }; + assert!(!is_reclaimable(&rec_at("/no/such/wt", 1), now, &[], &skip)); + let never_map = GcOptions { + max_age_secs: Some(0), + max_age_by_kind: [(WorktreeKind::Session, None)].into_iter().collect(), + ..Default::default() + }; + assert!(!is_reclaimable( + &rec_at("/no/such/wt", 1), + now, + &[], + &never_map + )); + // Finite per-kind cutoff: age=100 with max=50 → reclaimable; max=200 → not. + let mut aged = rec_at("/no/such/wt", 1); + aged.last_accessed_at = Some(now - 100); + let short = GcOptions { + max_age_secs: Some(10_000), + max_age_by_kind: [(WorktreeKind::Session, Some(50))].into_iter().collect(), + ..Default::default() + }; + assert!(is_reclaimable(&aged, now, &[], &short)); + let long = GcOptions { + max_age_secs: Some(10), + max_age_by_kind: [(WorktreeKind::Session, Some(200))].into_iter().collect(), + ..Default::default() + }; + assert!(!is_reclaimable(&aged, now, &[], &long)); + } + + #[test] + fn effective_max_age_precedence() { + let opts = GcOptions { + max_age_secs: Some(100), + skip_kinds: vec![WorktreeKind::Manual], + max_age_by_kind: [ + (WorktreeKind::Subagent, Some(10)), + (WorktreeKind::Pool, None), + // Conflict: map says expire Manual, skip_kinds wins. + (WorktreeKind::Manual, Some(1)), + ] + .into_iter() + .collect(), + ..Default::default() + }; + assert_eq!(effective_max_age(&opts, WorktreeKind::Session), Some(100)); + assert_eq!(effective_max_age(&opts, WorktreeKind::Subagent), Some(10)); + assert_eq!(effective_max_age(&opts, WorktreeKind::Pool), None); + assert_eq!( + effective_max_age(&opts, WorktreeKind::Manual), + None, + "skip_kinds beats max_age_by_kind for the same kind" + ); } } } @@ -2849,6 +3238,8 @@ mod tests { // (GROK_HOME is process-global) can't INSERT-OR-REPLACE our row. let wt_path = fx.home.join("register-fields-wt"); std::fs::create_dir(&wt_path).unwrap(); + // register_worktree stores the canonical path (/var → /private/var on macOS). + let wt_canon = dunce::canonicalize(&wt_path).unwrap_or_else(|_| wt_path.clone()); super::super::register_worktree( &wt_path, @@ -2869,7 +3260,7 @@ mod tests { .list(&ListFilter::default()) .unwrap() .into_iter() - .filter(|r| r.path == wt_path) + .filter(|r| r.path == wt_canon) .collect(); assert_eq!(mine.len(), 1); assert_eq!(mine[0].kind, WorktreeKind::Session); @@ -2989,9 +3380,10 @@ mod tests { let report = gc::gc_worktrees( &db, &gc::GcOptions { - max_age_secs: Some(0), // everything is expired + max_age_secs: Some(0), force: false, dry_run: false, + ..Default::default() }, ) .unwrap(); @@ -3079,6 +3471,7 @@ mod tests { max_age_secs: Some(0), force: true, dry_run: false, + ..Default::default() }, ) .unwrap(); @@ -3119,6 +3512,7 @@ mod tests { max_age_secs: Some(i64::MIN), force: false, dry_run: false, + ..Default::default() }, ) .unwrap(); @@ -3171,6 +3565,7 @@ mod tests { max_age_secs: Some(0), force: false, dry_run: false, + ..Default::default() }, ) .unwrap(); @@ -3186,10 +3581,33 @@ mod tests { assert_eq!(report.expired_removed, 1); } - #[cfg(target_os = "linux")] + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn scan_has_cwd_under(prefix: &std::path::Path) -> bool { + match gc::live_process_cwds() { + gc::LiveCwdScan::Ok(cwds) => cwds.iter().any(|p| { + p.starts_with(prefix) + || dunce::canonicalize(p).is_ok_and(|c| c.starts_with(prefix)) + }), + _ => false, + } + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + fn wait_until(pred: impl Fn() -> bool) -> bool { + use std::time::Duration; + for _ in 0..200 { + if pred() { + return true; + } + std::thread::sleep(Duration::from_millis(10)); + } + false + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] #[test] fn gc_cwd_guard_skips_then_reclaims_expired_worktree() { - use std::time::Duration; + let _cwd_lock = crate::api::cwd_test_guard(); let tmp = tempfile::TempDir::new().unwrap(); let db = db_at(&tmp); let dir = tmp.path().join("cwd-wt"); @@ -3205,34 +3623,30 @@ mod tests { git_ref: None, head_commit: None, session_id: None, - creator_pid: None, // creator gone: only the CWD guard can protect it - created_at: 1, // very old → expired + creator_pid: None, + created_at: 1, last_accessed_at: None, status: crate::db::WorktreeStatus::Alive, metadata: None, }; db.register(&record).unwrap(); - // A live process parked inside the expired worktree subtree. let mut child = std::process::Command::new("sleep") .arg("30") .current_dir(&nested) .spawn() .expect("spawn sleep"); - // Wait for the kernel to reflect the child's CWD before GC scans. let want = dunce::canonicalize(&nested).unwrap(); - let link = format!("/proc/{}/cwd", child.id()); - for _ in 0..200 { - if std::fs::read_link(&link).is_ok_and(|p| p.starts_with(&want)) { - break; - } - std::thread::sleep(Duration::from_millis(10)); - } + assert!( + wait_until(|| scan_has_cwd_under(&want)), + "live_process_cwds must observe the parked child before GC" + ); let opts = gc::GcOptions { max_age_secs: Some(0), force: false, dry_run: false, + ..Default::default() }; let guarded = gc::gc_worktrees(&db, &opts).unwrap(); assert_eq!( @@ -3245,6 +3659,10 @@ mod tests { // Once the process exits, the same expired worktree is reclaimed. child.kill().ok(); child.wait().ok(); + assert!( + wait_until(|| !scan_has_cwd_under(&want)), + "child CWD must leave the scan after exit before reclaim" + ); let reclaimed = gc::gc_worktrees(&db, &opts).unwrap(); assert_eq!( reclaimed.expired_removed, 1, @@ -3253,6 +3671,14 @@ mod tests { assert!(!dir.exists()); } + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn gc_age_path_fail_closed_when_scan_unusable() { + // Pure gate: Failed scan blocks age; force still allows. + assert!(!gc::age_path_cwd_usable(&gc::LiveCwdScan::Failed, false)); + assert!(gc::age_path_cwd_usable(&gc::LiveCwdScan::Failed, true)); + } + #[test] fn gc_dry_run_with_max_age_does_not_remove_expired() { // An expired worktree whose dir exists must be previewed (counted) @@ -3287,6 +3713,7 @@ mod tests { max_age_secs: Some(0), force: true, dry_run: true, + ..Default::default() }, ) .unwrap(); @@ -3334,6 +3761,7 @@ mod tests { max_age_secs: Some(0), force: true, dry_run: true, + ..Default::default() }, ) .unwrap(); @@ -3385,6 +3813,7 @@ mod tests { max_age_secs: Some(0), force: true, dry_run: false, + ..Default::default() }, ) .unwrap(); @@ -3404,12 +3833,14 @@ mod tests { /// True if a record with `path` exists in the DB (assert on our own /// record rather than total count: other tests may write to the same - /// open_default DB concurrently). + /// open_default DB concurrently). Matches `register_worktree`'s + /// canonical path storage (/var vs /private/var on macOS). fn record_present(db: &WorktreeDb, path: &std::path::Path) -> bool { + let canon = dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf()); db.list(&ListFilter::default()) .unwrap() .iter() - .any(|r| r.path == path) + .any(|r| r.path == path || r.path == canon) } #[test] @@ -3546,6 +3977,7 @@ mod tests { max_age_secs: Some(0), force: true, dry_run: false, + ..Default::default() }, Some(delegate), ) @@ -3586,12 +4018,713 @@ mod tests { max_age_secs: Some(86400), force: true, dry_run: false, + protect_paths: vec![std::path::PathBuf::from("/tmp/p")], + skip_kinds: vec![WorktreeKind::Manual], + max_age_by_kind: [(WorktreeKind::Subagent, Some(3600))].into_iter().collect(), }; let json = serde_json::to_string(&opts).unwrap(); let deser: gc::GcOptions = serde_json::from_str(&json).unwrap(); assert_eq!(deser.max_age_secs, Some(86400)); assert!(deser.force); assert!(!deser.dry_run); + assert_eq!(deser.protect_paths, opts.protect_paths); + assert_eq!(deser.skip_kinds, vec![WorktreeKind::Manual]); + assert_eq!( + deser.max_age_by_kind.get(&WorktreeKind::Subagent), + Some(&Some(3600)) + ); + // Absent new fields deserialize as empty (old agents). + let legacy = r#"{"max_age_secs":1,"force":false,"dry_run":true}"#; + let legacy_opts: gc::GcOptions = serde_json::from_str(legacy).unwrap(); + assert!(legacy_opts.protect_paths.is_empty()); + assert!(legacy_opts.skip_kinds.is_empty()); + assert!(legacy_opts.max_age_by_kind.is_empty()); + + // JSON null value in map → never-expire (None). + let with_null = r#"{ + "max_age_secs": 100, + "force": false, + "dry_run": false, + "max_age_by_kind": {"manual": null, "subagent": 3600} + }"#; + let null_opts: gc::GcOptions = serde_json::from_str(with_null).unwrap(); + assert_eq!( + null_opts.max_age_by_kind.get(&WorktreeKind::Manual), + Some(&None) + ); + assert_eq!( + null_opts.max_age_by_kind.get(&WorktreeKind::Subagent), + Some(&Some(3600)) + ); + let round = serde_json::to_string(&null_opts).unwrap(); + let back: gc::GcOptions = serde_json::from_str(&round).unwrap(); + assert_eq!(back.max_age_by_kind, null_opts.max_age_by_kind); + } + + #[test] + fn gc_protect_paths_skips_age_expiry_including_dry_run() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let dir = tmp.path().join("protected-wt"); + let nested = dir.join("nested"); + std::fs::create_dir_all(&nested).unwrap(); + let record = crate::db::WorktreeRecord { + id: "prot-1".to_string(), + path: dir.clone(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Session, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }; + db.register(&record).unwrap(); + + // Protect via a nested path (same canonicalize rules as cwd_within). + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: false, + dry_run: true, + protect_paths: vec![nested], + skip_kinds: vec![], + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + report.expired_removed, 0, + "dry_run must not count protect_paths hits as would-expire" + ); + assert_eq!(report.skipped_alive, 1); + assert!(dir.exists()); + + // Without protect, dry_run would-count it. + let unguarded = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: false, + dry_run: true, + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(unguarded.expired_removed, 1); + } + + #[test] + fn gc_protect_paths_pre_remove_recheck() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let dir = tmp.path().join("prot-real"); + std::fs::create_dir(&dir).unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "prot-real".to_string(), + path: dir.clone(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Session, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }) + .unwrap(); + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: false, + dry_run: false, + protect_paths: vec![dir.clone()], + skip_kinds: vec![], + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(report.expired_removed, 0); + assert_eq!(report.skipped_alive, 1); + assert!(dir.exists(), "protect_paths must block real remove"); + } + + #[test] + fn force_does_not_override_skip_kinds() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let dir = tmp.path().join("manual-force"); + std::fs::create_dir(&dir).unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "manual-force".into(), + path: dir.clone(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Manual, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }) + .unwrap(); + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: true, + dry_run: false, + protect_paths: vec![], + skip_kinds: vec![WorktreeKind::Manual], + ..Default::default() + }, + ) + .unwrap(); + assert!( + dir.exists(), + "force must not age-expire kinds listed in skip_kinds" + ); + assert_eq!(report.expired_removed, 0); + } + + #[test] + fn gc_skip_kinds_manual_age_only_not_dead() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let manual_dir = tmp.path().join("manual-alive"); + let session_dir = tmp.path().join("session-alive"); + std::fs::create_dir(&manual_dir).unwrap(); + std::fs::create_dir(&session_dir).unwrap(); + let base = crate::db::WorktreeRecord { + id: String::new(), + path: std::path::PathBuf::new(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Session, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }; + db.register(&crate::db::WorktreeRecord { + id: "manual".into(), + path: manual_dir.clone(), + kind: WorktreeKind::Manual, + ..base.clone() + }) + .unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "session".into(), + path: session_dir.clone(), + kind: WorktreeKind::Session, + ..base.clone() + }) + .unwrap(); + // Dead manual (missing path) still reclaimed on dead path. + db.register(&crate::db::WorktreeRecord { + id: "manual-dead".into(), + path: "/nonexistent/manual-dead".into(), + kind: WorktreeKind::Manual, + ..base + }) + .unwrap(); + + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: false, + dry_run: false, + protect_paths: vec![], + skip_kinds: vec![WorktreeKind::Manual], + ..Default::default() + }, + ) + .unwrap(); + assert!( + manual_dir.exists(), + "Manual must not age-expire when in skip_kinds" + ); + assert!( + !session_dir.exists(), + "Session must still age-expire under skip_kinds=[Manual]" + ); + assert_eq!(report.expired_removed, 1); + assert!( + report.skipped_alive >= 1, + "expired skip_kinds must surface in skipped_alive" + ); + assert_eq!(report.dead_removed, 1, "dead Manual still unregisters"); + + // dry_run must not count skipped kinds as would-expire. + let dir2 = tmp.path().join("manual-dry"); + std::fs::create_dir(&dir2).unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "manual-dry".into(), + path: dir2.clone(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Manual, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }) + .unwrap(); + let dry = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: false, + dry_run: true, + protect_paths: vec![], + skip_kinds: vec![WorktreeKind::Manual], + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + dry.expired_removed, 0, + "dry_run must not count skip_kinds as expired" + ); + assert!( + dry.skipped_alive >= 1, + "dry_run still counts expired skip_kinds as skipped_alive" + ); + assert!(dir2.exists()); + } + + #[test] + fn per_kind_max_age_expires_session_not_manual() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let session_dir = tmp.path().join("session-alive"); + let manual_dir = tmp.path().join("manual-alive"); + std::fs::create_dir(&session_dir).unwrap(); + std::fs::create_dir(&manual_dir).unwrap(); + let base = crate::db::WorktreeRecord { + id: String::new(), + path: std::path::PathBuf::new(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Session, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }; + db.register(&crate::db::WorktreeRecord { + id: "session".into(), + path: session_dir.clone(), + kind: WorktreeKind::Session, + ..base.clone() + }) + .unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "manual".into(), + path: manual_dir.clone(), + kind: WorktreeKind::Manual, + ..base + }) + .unwrap(); + + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: false, + dry_run: false, + max_age_by_kind: [(WorktreeKind::Manual, None)].into_iter().collect(), + ..Default::default() + }, + ) + .unwrap(); + assert!( + !session_dir.exists(), + "session must age-expire under default max_age" + ); + assert!( + manual_dir.exists(), + "manual never-expire via max_age_by_kind" + ); + assert_eq!(report.expired_removed, 1); + assert!(report.skipped_alive >= 1); + } + + #[test] + fn per_kind_shorter_ttl_expires_subagent_keeps_session() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let now = crate::db::now_epoch_secs(); + // Subagent last active 2h ago; session last active 2h ago. + // subagent TTL=1h → expire; session default=7d → keep. + let sub_dir = tmp.path().join("sub"); + let sess_dir = tmp.path().join("sess"); + std::fs::create_dir(&sub_dir).unwrap(); + std::fs::create_dir(&sess_dir).unwrap(); + let age = 2 * 3600; + let base = crate::db::WorktreeRecord { + id: String::new(), + path: std::path::PathBuf::new(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Session, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: now - age, + last_accessed_at: Some(now - age), + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }; + db.register(&crate::db::WorktreeRecord { + id: "sub".into(), + path: sub_dir.clone(), + kind: WorktreeKind::Subagent, + ..base.clone() + }) + .unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "sess".into(), + path: sess_dir.clone(), + kind: WorktreeKind::Session, + ..base + }) + .unwrap(); + + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(7 * 86400), + force: false, + dry_run: false, + max_age_by_kind: [(WorktreeKind::Subagent, Some(3600))].into_iter().collect(), + ..Default::default() + }, + ) + .unwrap(); + assert!(!sub_dir.exists(), "subagent past 1h TTL must expire"); + assert!(sess_dir.exists(), "session within 7d must stay"); + assert_eq!(report.expired_removed, 1); + } + + #[test] + fn force_does_not_override_max_age_by_kind_never() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let dir = tmp.path().join("manual-never"); + std::fs::create_dir(&dir).unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "manual-never".into(), + path: dir.clone(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Manual, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }) + .unwrap(); + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: true, + dry_run: false, + max_age_by_kind: [(WorktreeKind::Manual, None)].into_iter().collect(), + ..Default::default() + }, + ) + .unwrap(); + assert!( + dir.exists(), + "force must not age-expire never-kinds in max_age_by_kind" + ); + assert_eq!(report.expired_removed, 0); + } + + #[test] + fn dry_run_counts_per_kind_cutoffs() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let now = crate::db::now_epoch_secs(); + let age = 2 * 3600; + let sub_dir = tmp.path().join("sub-dry"); + let sess_dir = tmp.path().join("sess-dry"); + let man_dir = tmp.path().join("man-dry"); + std::fs::create_dir(&sub_dir).unwrap(); + std::fs::create_dir(&sess_dir).unwrap(); + std::fs::create_dir(&man_dir).unwrap(); + let base = crate::db::WorktreeRecord { + id: String::new(), + path: std::path::PathBuf::new(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Session, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: now - age, + last_accessed_at: Some(now - age), + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }; + db.register(&crate::db::WorktreeRecord { + id: "sub-dry".into(), + path: sub_dir.clone(), + kind: WorktreeKind::Subagent, + ..base.clone() + }) + .unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "sess-dry".into(), + path: sess_dir.clone(), + kind: WorktreeKind::Session, + ..base.clone() + }) + .unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "man-dry".into(), + path: man_dir.clone(), + kind: WorktreeKind::Manual, + ..base + }) + .unwrap(); + + let dry = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(7 * 86400), + force: false, + dry_run: true, + max_age_by_kind: [ + (WorktreeKind::Subagent, Some(3600)), + (WorktreeKind::Manual, None), + ] + .into_iter() + .collect(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + dry.expired_removed, 1, + "only subagent past its kind TTL is would-expire" + ); + assert!(sub_dir.exists() && sess_dir.exists() && man_dir.exists()); + + // max_age_secs=0: session+subagent would-expire; manual never → skipped. + let dry0 = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(0), + force: false, + dry_run: true, + max_age_by_kind: [ + (WorktreeKind::Subagent, Some(3600)), + (WorktreeKind::Manual, None), + ] + .into_iter() + .collect(), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(dry0.expired_removed, 2); + assert!(dry0.skipped_alive >= 1); + } + + #[test] + fn max_age_by_kind_only_without_default_enables_age_path() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let pool_dir = tmp.path().join("pool-only"); + let sess_dir = tmp.path().join("sess-unlisted"); + std::fs::create_dir(&pool_dir).unwrap(); + std::fs::create_dir(&sess_dir).unwrap(); + let base = crate::db::WorktreeRecord { + id: String::new(), + path: std::path::PathBuf::new(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Session, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }; + db.register(&crate::db::WorktreeRecord { + id: "pool-only".into(), + path: pool_dir.clone(), + kind: WorktreeKind::Pool, + ..base.clone() + }) + .unwrap(); + // Session has no map entry and max_age_secs=None → must not expire. + db.register(&crate::db::WorktreeRecord { + id: "sess-unlisted".into(), + path: sess_dir.clone(), + kind: WorktreeKind::Session, + ..base + }) + .unwrap(); + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: None, + force: false, + dry_run: false, + max_age_by_kind: [(WorktreeKind::Pool, Some(0))].into_iter().collect(), + ..Default::default() + }, + ) + .unwrap(); + assert!(!pool_dir.exists(), "listed kind expires"); + assert!( + sess_dir.exists(), + "unlisted kind must not expire when max_age_secs=None" + ); + assert_eq!(report.expired_removed, 1); + } + + #[test] + fn skip_kinds_beats_max_age_by_kind_on_same_kind() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let dir = tmp.path().join("manual-conflict"); + std::fs::create_dir(&dir).unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "manual-conflict".into(), + path: dir.clone(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Manual, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }) + .unwrap(); + let opts = gc::GcOptions { + max_age_secs: Some(0), + force: true, + dry_run: false, + skip_kinds: vec![WorktreeKind::Manual], + max_age_by_kind: [(WorktreeKind::Manual, Some(0))].into_iter().collect(), + ..Default::default() + }; + let report = gc::gc_worktrees(&db, &opts).unwrap(); + assert!( + dir.exists(), + "skip_kinds must win over max_age_by_kind Some(secs)" + ); + assert_eq!(report.expired_removed, 0); + + let dry = gc::gc_worktrees( + &db, + &gc::GcOptions { + dry_run: true, + force: false, + ..opts + }, + ) + .unwrap(); + assert_eq!( + dry.expired_removed, 0, + "dry_run must not count skip-winning kinds as would-expire" + ); + assert!(dry.skipped_alive >= 1); + } + + #[test] + fn configurable_manual_can_expire() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = db_at(&tmp); + let dir = tmp.path().join("manual-expire"); + std::fs::create_dir(&dir).unwrap(); + db.register(&crate::db::WorktreeRecord { + id: "manual-exp".into(), + path: dir.clone(), + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind: WorktreeKind::Manual, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at: 1, + last_accessed_at: None, + status: crate::db::WorktreeStatus::Alive, + metadata: None, + }) + .unwrap(); + let report = gc::gc_worktrees( + &db, + &gc::GcOptions { + max_age_secs: Some(7 * 86400), + force: false, + dry_run: false, + max_age_by_kind: [(WorktreeKind::Manual, Some(0))].into_iter().collect(), + ..Default::default() + }, + ) + .unwrap(); + assert!( + !dir.exists(), + "manual with explicit max_age_by_kind must be configurable to expire" + ); + assert_eq!(report.expired_removed, 1); } #[test] diff --git a/crates/codegen/xai-fast-worktree/src/auto_gc.rs b/crates/codegen/xai-fast-worktree/src/auto_gc.rs new file mode 100644 index 0000000..37157c5 --- /dev/null +++ b/crates/codegen/xai-fast-worktree/src/auto_gc.rs @@ -0,0 +1,2379 @@ +//! Throttled automatic worktree GC (feature `metadata`). + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::{Path, PathBuf}; + +use anyhow::Result; + +use crate::CleanupReport; +use crate::api::gc::{GcOptions, GcReport, age_path_enabled, gc_worktrees}; +use crate::db::{ListFilter, WorktreeDb, WorktreeKind, now_epoch_secs, resolve_grok_home}; +use crate::discovery::{RebuildReport, rebuild_worktree_db}; +use crate::git::checkout::git_command; + +pub const META_LAST_AUTO_GC_AT: &str = "last_auto_gc_at"; +/// Independent throttle stamp for optional DB rebuild (not shared with GC). +pub const META_LAST_AUTO_REBUILD_AT: &str = "last_auto_rebuild_at"; + +/// `0` / `false` / `off` / empty disables auto-GC. +pub const ENV_AUTO_GC: &str = "GROK_WORKTREE_AUTO_GC"; +/// `1` / `true` / `on` forces age-count without delete. +pub const ENV_AUTO_GC_DRY_RUN: &str = "GROK_WORKTREE_AUTO_GC_DRY_RUN"; +/// Default max age in seconds (overrides TOML/remote when set and parseable). +pub const ENV_AUTO_GC_MAX_AGE: &str = "GROK_WORKTREE_AUTO_GC_MAX_AGE"; +/// `1` / `true` / `on` enables optional discovery rebuild + stale git prune. +pub const ENV_AUTO_GC_REBUILD: &str = "GROK_WORKTREE_AUTO_GC_REBUILD"; + +pub const DEFAULT_MAX_AGE_SECS: i64 = 7 * 86400; +pub const DEFAULT_MIN_INTERVAL_SECS: i64 = 6 * 3600; +/// Rebuild is costlier than GC; default 24h until cost is measured in dogfood. +pub const DEFAULT_REBUILD_MIN_INTERVAL_SECS: i64 = 24 * 3600; + +pub const MAX_AGE_SECS_MIN: i64 = 3600; +pub const MAX_AGE_SECS_MAX: i64 = 90 * 86400; +pub const MIN_INTERVAL_SECS_MIN: i64 = 60; +pub const MIN_INTERVAL_SECS_MAX: i64 = 7 * 86400; + +/// Product default: Manual never age-expires unless config overrides. +pub fn default_max_age_by_kind() -> BTreeMap> { + BTreeMap::from([(WorktreeKind::Manual, None)]) +} + +/// Compile-time CWD-scan platforms (Linux/macOS). Runtime failure fail-closes in `gc_worktrees`. +pub fn process_cwd_scan_available() -> bool { + cfg!(any(target_os = "linux", target_os = "macos")) +} + +/// Real age-expiry when a CWD-scan platform, or dry-run metrics without deletes. +pub fn age_expiry_allowed(scan_platform: bool, dry_run: bool) -> bool { + scan_platform || dry_run +} + +/// One local/remote config layer (`max_age_by_kind`: `Some(secs)` or `None`=never). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct WorktreeAutoGcLayer { + pub enabled: Option, + pub max_age_secs: Option, + pub min_interval_secs: Option, + pub dry_run: Option, + pub include_orphan_snapshots: Option, + pub max_age_by_kind: BTreeMap>, + /// Optional discovery rebuild + stale `.git/worktrees/` prune (default off). + pub include_rebuild: Option, + /// Independent rebuild throttle; absent ⇒ 24h. + pub rebuild_min_interval_secs: Option, +} + +/// Policy after env / TOML / remote merge. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResolvedWorktreeAutoGc { + pub enabled: bool, + pub max_age_secs: i64, + pub min_interval_secs: i64, + pub dry_run: bool, + pub include_orphan_snapshots: bool, + pub max_age_by_kind: BTreeMap>, + /// Off by default until rebuild cost is measured. + pub include_rebuild: bool, + pub rebuild_min_interval_secs: i64, +} + +impl Default for ResolvedWorktreeAutoGc { + fn default() -> Self { + Self { + enabled: true, + max_age_secs: DEFAULT_MAX_AGE_SECS, + min_interval_secs: DEFAULT_MIN_INTERVAL_SECS, + dry_run: false, + include_orphan_snapshots: cfg!(target_os = "linux"), + max_age_by_kind: default_max_age_by_kind(), + include_rebuild: false, + rebuild_min_interval_secs: DEFAULT_REBUILD_MIN_INTERVAL_SECS, + } + } +} + +impl ResolvedWorktreeAutoGc { + /// Defaults plus env kill / dry-run / max-age only. + pub fn from_env_only() -> Self { + resolve_worktree_auto_gc_from_layers(None, None) + } +} + +fn clamp_kind_age(secs: Option) -> Option { + secs.map(clamp_max_age_secs) +} + +/// Merge kind maps: product default Manual=never, then remote, then local. +fn merge_max_age_by_kind( + local: Option<&BTreeMap>>, + remote: Option<&BTreeMap>>, +) -> BTreeMap> { + let mut map = default_max_age_by_kind(); + if let Some(r) = remote { + for (&k, &v) in r { + map.insert(k, clamp_kind_age(v)); + } + } + if let Some(l) = local { + for (&k, &v) in l { + map.insert(k, clamp_kind_age(v)); + } + } + map +} + +/// Precedence: env > local > remote > defaults (with numeric clamps). +pub fn resolve_worktree_auto_gc_from_layers( + local: Option<&WorktreeAutoGcLayer>, + remote: Option<&WorktreeAutoGcLayer>, +) -> ResolvedWorktreeAutoGc { + let enabled = if env_auto_gc_disabled() { + false + } else { + local + .and_then(|s| s.enabled) + .or(remote.and_then(|s| s.enabled)) + .unwrap_or(true) + }; + + let max_age_secs = env_auto_gc_max_age() + .or(local.and_then(|s| s.max_age_secs)) + .or(remote.and_then(|s| s.max_age_secs)) + .map(clamp_max_age_secs) + .unwrap_or(DEFAULT_MAX_AGE_SECS); + + let min_interval_secs = local + .and_then(|s| s.min_interval_secs) + .or(remote.and_then(|s| s.min_interval_secs)) + .map(clamp_min_interval_secs) + .unwrap_or(DEFAULT_MIN_INTERVAL_SECS); + + let dry_run = if env_auto_gc_dry_run() { + true + } else { + local + .and_then(|s| s.dry_run) + .or(remote.and_then(|s| s.dry_run)) + .unwrap_or(false) + }; + + let include_orphan_snapshots = local + .and_then(|s| s.include_orphan_snapshots) + .or(remote.and_then(|s| s.include_orphan_snapshots)) + .unwrap_or(cfg!(target_os = "linux")); + + // Env REBUILD=1 forces on; config cannot disable over env. + let include_rebuild = if env_auto_gc_rebuild() { + true + } else { + local + .and_then(|s| s.include_rebuild) + .or(remote.and_then(|s| s.include_rebuild)) + .unwrap_or(false) + }; + + let rebuild_min_interval_secs = local + .and_then(|s| s.rebuild_min_interval_secs) + .or(remote.and_then(|s| s.rebuild_min_interval_secs)) + .map(clamp_min_interval_secs) + .unwrap_or(DEFAULT_REBUILD_MIN_INTERVAL_SECS); + + ResolvedWorktreeAutoGc { + enabled, + max_age_secs, + min_interval_secs, + dry_run, + include_orphan_snapshots, + max_age_by_kind: merge_max_age_by_kind( + local.map(|s| &s.max_age_by_kind), + remote.map(|s| &s.max_age_by_kind), + ), + include_rebuild, + rebuild_min_interval_secs, + } +} + +/// Runtime options for [`maybe_auto_gc`]. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AutoGcOptions { + /// Env kill still wins inside [`maybe_auto_gc`]. + pub enabled: bool, + pub max_age_secs: i64, + pub min_interval_secs: i64, + pub include_orphan_snapshots: bool, + /// Env dry-run is re-applied inside [`maybe_auto_gc`]. + pub dry_run: bool, + pub max_age_by_kind: BTreeMap>, + /// When true, optionally rebuild DB from disk + prune stale git registrations. + pub include_rebuild: bool, + pub rebuild_min_interval_secs: i64, +} + +impl Default for AutoGcOptions { + fn default() -> Self { + Self::from_resolved(ResolvedWorktreeAutoGc::default()) + } +} + +impl AutoGcOptions { + pub fn from_resolved(policy: ResolvedWorktreeAutoGc) -> Self { + Self { + enabled: policy.enabled, + max_age_secs: policy.max_age_secs, + min_interval_secs: policy.min_interval_secs, + include_orphan_snapshots: policy.include_orphan_snapshots, + dry_run: policy.dry_run, + max_age_by_kind: policy.max_age_by_kind, + include_rebuild: policy.include_rebuild, + rebuild_min_interval_secs: policy.rebuild_min_interval_secs, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AutoGcOutcome { + Disabled, + Throttled, + Ran, +} + +#[derive(Debug)] +pub struct AutoGcReport { + pub outcome: AutoGcOutcome, + pub gc: Option, + pub overlay: Option, + pub btrfs: Option, + pub age_expiry_enabled: bool, + pub stamped: bool, + /// Present only when a rebuild pass ran in this invocation. + pub rebuild: Option, + /// True when `last_auto_rebuild_at` was written this pass. + pub rebuild_stamped: bool, + /// Entries removed from known source repos' `.git/worktrees/` via prune. + pub stale_registrations_cleaned: u64, +} + +impl AutoGcReport { + fn empty(outcome: AutoGcOutcome) -> Self { + Self { + outcome, + gc: None, + overlay: None, + btrfs: None, + age_expiry_enabled: false, + stamped: false, + rebuild: None, + rebuild_stamped: false, + stale_registrations_cleaned: 0, + } + } + + fn disabled() -> Self { + Self::empty(AutoGcOutcome::Disabled) + } + + fn throttled() -> Self { + Self::empty(AutoGcOutcome::Throttled) + } +} + +fn env_var_truthy(name: &str) -> bool { + match std::env::var(name) { + Ok(v) => matches!( + v.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" | "enabled" + ), + Err(_) => false, + } +} + +fn env_var_disabled(name: &str) -> bool { + match std::env::var(name) { + Ok(v) => matches!( + v.trim().to_ascii_lowercase().as_str(), + "0" | "false" | "no" | "off" | "disabled" | "" + ), + Err(_) => false, + } +} + +pub fn env_auto_gc_disabled() -> bool { + env_var_disabled(ENV_AUTO_GC) +} + +pub fn env_auto_gc_dry_run() -> bool { + env_var_truthy(ENV_AUTO_GC_DRY_RUN) +} + +pub fn env_auto_gc_rebuild() -> bool { + env_var_truthy(ENV_AUTO_GC_REBUILD) +} + +/// Parse `GROK_WORKTREE_AUTO_GC_MAX_AGE` as seconds; invalid/absent → None. +pub fn env_auto_gc_max_age() -> Option { + match std::env::var(ENV_AUTO_GC_MAX_AGE) { + Ok(v) => { + let s = v.trim(); + if s.is_empty() { + return None; + } + s.parse::().ok() + } + Err(_) => None, + } +} + +pub fn clamp_max_age_secs(v: u64) -> i64 { + // Cap in u64 so values > i64::MAX do not wrap negative. + let capped = v.min(MAX_AGE_SECS_MAX as u64) as i64; + capped.max(MAX_AGE_SECS_MIN) +} + +pub fn clamp_min_interval_secs(v: u64) -> i64 { + let capped = v.min(MIN_INTERVAL_SECS_MAX as u64) as i64; + capped.max(MIN_INTERVAL_SECS_MIN) +} + +/// Auto-path `GcOptions` (`force=false`, kind policy map, protect paths canon once). +pub fn build_auto_gc_options(auto_opts: &AutoGcOptions, protect_paths: Vec) -> GcOptions { + build_auto_gc_options_with_dry_run(auto_opts, protect_paths, auto_opts.dry_run) +} + +fn build_auto_gc_options_with_dry_run( + auto_opts: &AutoGcOptions, + protect_paths: Vec, + dry_run: bool, +) -> GcOptions { + let age_allowed = age_expiry_allowed(process_cwd_scan_available(), dry_run); + let protect_paths = protect_paths + .into_iter() + .map(|p| dunce::canonicalize(&p).unwrap_or(p)) + .collect(); + // Clone kind map only when the age path is live; platform-off drops it. + let max_age_by_kind = if age_allowed { + auto_opts.max_age_by_kind.clone() + } else { + BTreeMap::new() + }; + GcOptions { + max_age_secs: age_allowed.then_some(auto_opts.max_age_secs), + force: false, + dry_run, + protect_paths, + skip_kinds: vec![], + max_age_by_kind, + } +} + +/// Future stamps (clock skew) are treated as due so throttle cannot black out forever. +pub(crate) fn is_throttled(now: i64, last: i64, min_interval_secs: i64) -> bool { + if last > now { + return false; + } + now.saturating_sub(last) < min_interval_secs.max(0) +} + +/// Throttled auto-GC. `Ok` always carries a report; `Err` means infrastructure +/// failure before/during GC (not stamped). Env kill/dry-run/rebuild override +/// raw options. GC throttle short-circuits the whole pass (including rebuild). +pub fn maybe_auto_gc(db: &WorktreeDb, auto_opts: &AutoGcOptions) -> Result { + if env_auto_gc_disabled() || !auto_opts.enabled { + tracing::debug!("auto worktree gc disabled"); + return Ok(AutoGcReport::disabled()); + } + + let dry_run = auto_opts.dry_run || env_auto_gc_dry_run(); + let include_rebuild = auto_opts.include_rebuild || env_auto_gc_rebuild(); + + let now = now_epoch_secs(); + // GC meta: fail closed on read Err; unparseable fails open. Throttle skips rebuild too. + if let Some(ts) = db.get_meta(META_LAST_AUTO_GC_AT)? { + match ts.parse::() { + Ok(last) if is_throttled(now, last, auto_opts.min_interval_secs) => { + tracing::debug!( + last_auto_gc_at = last, + min_interval_secs = auto_opts.min_interval_secs, + "auto worktree gc throttled" + ); + return Ok(AutoGcReport::throttled()); + } + Ok(_) => {} + Err(_) => { + tracing::warn!( + value = %ts, + "auto worktree gc ignoring unparseable last_auto_gc_at; running reclaim" + ); + } + } + } + + // Rebuild before the prune-repo snapshot so newly registered worktrees' + // source repos are included. Snapshot still happens before dead-GC so + // sole-dead source repos remain in the set after unregister. + // + // Rebuild meta is **not** stamped here: if GC fails after a successful + // rebuild, we must leave rebuild unthrottled so the next pass can pick up + // worktrees created between this rebuild and the failed GC. + let (rebuild, rebuild_due_to_stamp) = maybe_run_rebuild( + db, + include_rebuild, + dry_run, + auto_opts.rebuild_min_interval_secs, + now, + ); + + let prune_repos = if include_rebuild && !dry_run { + collect_source_repos_for_prune(db) + } else { + BTreeSet::new() + }; + + let mut protect_paths = Vec::new(); + if let Ok(cwd) = std::env::current_dir() { + protect_paths.push(cwd); + } + + let gc_opts = build_auto_gc_options_with_dry_run(auto_opts, protect_paths, dry_run); + let age_expiry_enabled = age_path_enabled(&gc_opts); + debug_assert!(!gc_opts.force); + + let gc_report = match gc_worktrees(db, &gc_opts) { + Ok(r) => r, + Err(e) => { + tracing::warn!( + error = %e, + rebuild_ran = rebuild.is_some(), + "auto worktree gc failed; rebuild meta left unstamped so next pass can re-discover" + ); + return Err(e); + } + }; + + if gc_report.remove_failed > 0 { + tracing::warn!( + remove_failed = gc_report.remove_failed, + "auto worktree gc had remove failures" + ); + } + + let (overlay, btrfs) = run_orphan_cleaners(dry_run, auto_opts.include_orphan_snapshots); + + // Prune each full pass when opted in (cheap vs discovery; not rebuild-throttled). + let stale_registrations_cleaned = if include_rebuild && !dry_run { + prune_stale_git_worktree_registrations(&prune_repos) + } else { + 0 + }; + + let stamp_now = now_epoch_secs(); + // Stamp rebuild only after GC succeeds (see maybe_run_rebuild). + let rebuild_stamped = if rebuild_due_to_stamp { + match db.set_meta(META_LAST_AUTO_REBUILD_AT, &stamp_now.to_string()) { + Ok(()) => true, + Err(e) => { + tracing::warn!(error = %e, "auto worktree rebuild failed to stamp meta"); + false + } + } + } else { + false + }; + let stamped = match db.set_meta(META_LAST_AUTO_GC_AT, &stamp_now.to_string()) { + Ok(()) => true, + Err(e) => { + tracing::warn!(error = %e, "auto worktree gc failed to stamp meta"); + false + } + }; + + let overlay_errors = overlay.as_ref().map(|r| r.errors).unwrap_or(0); + let btrfs_errors = btrfs.as_ref().map(|r| r.errors).unwrap_or(0); + let rebuild_discovered = rebuild.as_ref().map(|r| r.discovered).unwrap_or(0); + let rebuild_registered = rebuild.as_ref().map(|r| r.registered).unwrap_or(0); + tracing::info!( + age_expiry_enabled, + dead_removed = gc_report.dead_removed, + expired_removed = gc_report.expired_removed, + skipped_alive = gc_report.skipped_alive, + remove_failed = gc_report.remove_failed, + overlay_errors, + btrfs_errors, + rebuild_discovered, + rebuild_registered, + stale_registrations_cleaned, + dry_run, + stamped, + rebuild_stamped, + "auto worktree gc complete" + ); + + Ok(AutoGcReport { + outcome: AutoGcOutcome::Ran, + gc: Some(gc_report), + overlay, + btrfs, + age_expiry_enabled, + stamped, + rebuild, + rebuild_stamped, + stale_registrations_cleaned, + }) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum RebuildMetaClass { + Due, + Throttled, + /// Meta read failed — skip rebuild, do not abort GC. + SkipFailed, +} + +/// `Err` ⇒ skip rebuild only (GC continues). +fn classify_rebuild_meta( + meta: Result>, + now: i64, + rebuild_min_interval_secs: i64, +) -> RebuildMetaClass { + match meta { + Err(e) => { + tracing::warn!( + error = %e, + "auto worktree rebuild skipped: meta read failed; continuing GC" + ); + RebuildMetaClass::SkipFailed + } + Ok(None) => RebuildMetaClass::Due, + Ok(Some(ts)) => match ts.parse::() { + Ok(last) if is_throttled(now, last, rebuild_min_interval_secs) => { + tracing::debug!( + last_auto_rebuild_at = last, + rebuild_min_interval_secs, + "auto worktree rebuild throttled" + ); + RebuildMetaClass::Throttled + } + Ok(_) => RebuildMetaClass::Due, + Err(_) => { + tracing::warn!( + value = %ts, + "auto worktree rebuild ignoring unparseable last_auto_rebuild_at" + ); + RebuildMetaClass::Due + } + }, + } +} + +/// Optional rebuild; never fails the GC pass. +/// +/// Returns `(report, due_to_stamp)`. Stamp is applied by the caller **only +/// after** GC succeeds — stamping here would throttle rebuild while GC can +/// still `Err` and leave `last_auto_gc_at` unstamped. +fn maybe_run_rebuild( + db: &WorktreeDb, + include_rebuild: bool, + dry_run: bool, + rebuild_min_interval_secs: i64, + now: i64, +) -> (Option, bool) { + if !include_rebuild || dry_run { + return (None, false); + } + + match classify_rebuild_meta( + db.get_meta(META_LAST_AUTO_REBUILD_AT), + now, + rebuild_min_interval_secs, + ) { + RebuildMetaClass::Due => {} + RebuildMetaClass::Throttled | RebuildMetaClass::SkipFailed => return (None, false), + } + + let home = match resolve_grok_home() { + Ok(h) => h, + Err(e) => { + tracing::warn!(error = %e, "auto worktree rebuild skipped: grok home unresolved"); + return (None, false); + } + }; + + match rebuild_worktree_db(db, &home) { + Ok(report) => { + tracing::info!( + discovered = report.discovered, + registered = report.registered, + already_tracked = report.already_tracked, + "auto worktree db rebuild complete" + ); + // Defer META_LAST_AUTO_REBUILD_AT until after GC succeeds. + (Some(report), true) + } + Err(e) => { + tracing::warn!(error = %e, "auto worktree rebuild failed; continuing GC"); + (None, false) + } + } +} + +/// Distinct non-unknown `source_repo` values (alive + dead) for prune. +fn collect_source_repos_for_prune(db: &WorktreeDb) -> BTreeSet { + let filter = ListFilter { + include_dead: true, + ..Default::default() + }; + let Ok(records) = db.list(&filter) else { + tracing::warn!("auto worktree prune skipped: list failed"); + return BTreeSet::new(); + }; + records + .into_iter() + .filter(|r| r.source_repo.as_os_str() != "unknown") + .map(|r| r.source_repo) + .collect() +} + +fn prune_stale_git_worktree_registrations(repos: &BTreeSet) -> u64 { + let cleaned: u64 = repos + .iter() + .filter(|repo| repo.is_dir()) + .map(|repo| prune_stale_registrations_in_repo(repo)) + .fold(0u64, u64::saturating_add); + if cleaned > 0 { + tracing::info!( + stale_registrations_cleaned = cleaned, + "auto worktree stale git registrations pruned" + ); + } + cleaned +} + +fn count_git_worktree_registrations(git_worktrees_dir: &Path) -> u64 { + let Ok(entries) = std::fs::read_dir(git_worktrees_dir) else { + return 0; + }; + entries + .filter_map(Result::ok) + .filter(|e| e.file_type().map(|t| t.is_dir()).unwrap_or(false)) + .count() as u64 +} + +fn prune_stale_registrations_in_repo(source_repo: &Path) -> u64 { + let git_worktrees = source_repo.join(".git").join("worktrees"); + let before = count_git_worktree_registrations(&git_worktrees); + + let output = git_command() + .args(["worktree", "prune"]) + .current_dir(source_repo) + .output(); + + match output { + Ok(o) if o.status.success() => { + let after = count_git_worktree_registrations(&git_worktrees); + before.saturating_sub(after) + } + Ok(o) => { + tracing::warn!( + source_repo = %source_repo.display(), + status = %o.status, + stderr = %String::from_utf8_lossy(&o.stderr), + "git worktree prune failed" + ); + 0 + } + Err(e) => { + tracing::warn!( + source_repo = %source_repo.display(), + error = %e, + "git worktree prune failed to spawn" + ); + 0 + } + } +} + +fn run_orphan_cleaners( + dry_run: bool, + include_orphan_snapshots: bool, +) -> (Option, Option) { + #[cfg(target_os = "linux")] + { + if dry_run || !include_orphan_snapshots { + return (None, None); + } + let overlay = crate::cleanup_orphaned_overlay_snapshots(); + let btrfs = crate::cleanup_orphaned_btrfs_snapshots(); + if overlay.errors > 0 { + tracing::warn!( + errors = overlay.errors, + "auto worktree gc overlay orphan cleanup had errors" + ); + } + if btrfs.errors > 0 { + tracing::warn!( + errors = btrfs.errors, + "auto worktree gc btrfs orphan cleanup had errors" + ); + } + (Some(overlay), Some(btrfs)) + } + #[cfg(not(target_os = "linux"))] + { + let _ = (dry_run, include_orphan_snapshots); + (None, None) + } +} + +/// Env + built-in defaults only (no TOML/remote). +pub fn maybe_auto_gc_default() -> Result { + let db = WorktreeDb::open_default()?; + let opts = AutoGcOptions::from_resolved(ResolvedWorktreeAutoGc::from_env_only()); + maybe_auto_gc(&db, &opts) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{WorktreeRecord, WorktreeStatus}; + use std::sync::{Mutex, MutexGuard}; + + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn env_guard() -> MutexGuard<'static, ()> { + ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn clear_auto_gc_env() { + unsafe { + std::env::remove_var(ENV_AUTO_GC); + std::env::remove_var(ENV_AUTO_GC_DRY_RUN); + std::env::remove_var(ENV_AUTO_GC_MAX_AGE); + std::env::remove_var(ENV_AUTO_GC_REBUILD); + } + } + + fn make_rec(id: &str, path: PathBuf, kind: WorktreeKind, created_at: i64) -> WorktreeRecord { + WorktreeRecord { + id: id.to_string(), + path, + source_repo: "/repo".into(), + repo_name: "repo".to_string(), + kind, + creation_mode: "linked".to_string(), + git_ref: None, + head_commit: None, + session_id: None, + creator_pid: None, + created_at, + last_accessed_at: None, + status: WorktreeStatus::Alive, + metadata: None, + } + } + + fn opts_enabled_no_orphans(dry_run: bool) -> AutoGcOptions { + AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + dry_run, + ..AutoGcOptions::default() + } + } + + /// Git repo + stale linked-worktree registration (working tree deleted). + fn plant_stale_git_worktree(repo: &Path, wt: &Path) { + std::fs::create_dir_all(repo).unwrap(); + assert!( + std::process::Command::new("git") + .args(["init"]) + .current_dir(repo) + .output() + .unwrap() + .status + .success() + ); + for (k, v) in [("user.email", "t@test"), ("user.name", "t")] { + assert!( + std::process::Command::new("git") + .args(["config", k, v]) + .current_dir(repo) + .output() + .unwrap() + .status + .success() + ); + } + std::fs::write(repo.join("f.txt"), b"x").unwrap(); + assert!( + std::process::Command::new("git") + .args(["add", "f.txt"]) + .current_dir(repo) + .output() + .unwrap() + .status + .success() + ); + assert!( + std::process::Command::new("git") + .args(["commit", "-m", "i"]) + .current_dir(repo) + .output() + .unwrap() + .status + .success() + ); + let add_wt = std::process::Command::new("git") + .args(["worktree", "add", "--detach", wt.to_str().unwrap(), "HEAD"]) + .current_dir(repo) + .output() + .unwrap(); + assert!( + add_wt.status.success(), + "worktree add failed: {}", + String::from_utf8_lossy(&add_wt.stderr) + ); + std::fs::remove_dir_all(wt).unwrap(); + } + + fn count_regs(repo: &Path) -> usize { + std::fs::read_dir(repo.join(".git/worktrees")) + .map(|rd| { + rd.filter_map(Result::ok) + .filter(|e| e.path().is_dir()) + .count() + }) + .unwrap_or(0) + } + + #[test] + fn build_auto_gc_options_never_sets_force() { + let opts = AutoGcOptions { + max_age_secs: 1, + min_interval_secs: 1, + include_orphan_snapshots: false, + ..AutoGcOptions::default() + }; + let gc = build_auto_gc_options(&opts, Vec::new()); + assert!(!gc.force, "auto path must never set force=true"); + assert!(gc.skip_kinds.is_empty()); + assert_eq!(gc.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); + } + + #[test] + fn age_expiry_allowed_table() { + assert!(!age_expiry_allowed(false, false)); + assert!( + age_expiry_allowed(false, true), + "dry_run enables age metrics" + ); + assert!( + age_expiry_allowed(true, false), + "scan platform enables real age" + ); + assert!(age_expiry_allowed(true, true)); + } + + #[test] + fn process_cwd_scan_available_implies_usable_scan() { + if !process_cwd_scan_available() { + assert!( + !age_expiry_allowed(false, false), + "no scan platform ⇒ non-dry-run age off" + ); + return; + } + // Serialize with chdir tests (process-global cwd / scan validation). + let _cwd_lock = crate::api::cwd_test_guard(); + match crate::api::gc::live_process_cwds() { + crate::api::gc::LiveCwdScan::Ok(cwds) => { + let cwd = std::env::current_dir().unwrap(); + assert!( + cwds.iter().any(|c| { + c == &cwd + || dunce::canonicalize(c) + .ok() + .and_then(|cc| dunce::canonicalize(&cwd).ok().map(|w| cc == w)) + .unwrap_or(false) + }), + "available scan must observe own CWD" + ); + } + other => panic!("process_cwd_scan_available but scan unusable: {other:?}"), + } + } + + #[test] + fn build_auto_gc_options_age_expiry_requires_scan_or_dry_run() { + let _g = env_guard(); + clear_auto_gc_env(); + let opts = AutoGcOptions { + max_age_secs: 999, + min_interval_secs: 1, + include_orphan_snapshots: true, + dry_run: false, + ..AutoGcOptions::default() + }; + let gc = build_auto_gc_options(&opts, Vec::new()); + assert_eq!( + gc.max_age_secs.is_some(), + age_expiry_allowed(process_cwd_scan_available(), false) + ); + if process_cwd_scan_available() { + assert_eq!(gc.max_age_secs, Some(999)); + assert_eq!(gc.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); + } else { + assert_eq!(gc.max_age_secs, None); + assert!(gc.max_age_by_kind.is_empty()); + } + } + + #[test] + fn build_auto_gc_options_dry_run_may_set_max_age_without_cwd_scan() { + let _g = env_guard(); + clear_auto_gc_env(); + let opts = AutoGcOptions { + max_age_secs: 123, + min_interval_secs: 1, + include_orphan_snapshots: false, + dry_run: true, + ..AutoGcOptions::default() + }; + let gc = build_auto_gc_options(&opts, Vec::new()); + assert_eq!(gc.max_age_secs, Some(123)); + assert!(gc.dry_run); + assert!(!gc.force); + assert_eq!(gc.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); + assert!(age_expiry_allowed(process_cwd_scan_available(), true)); + } + + #[test] + fn maybe_auto_gc_age_expiry_tracks_scan_for_real_and_dry_run() { + // Complementary: dry_run always enables age metrics; real run only when scan works. + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + + let dry = maybe_auto_gc(&db, &opts_enabled_no_orphans(true)).unwrap(); + assert_eq!(dry.outcome, AutoGcOutcome::Ran); + assert!( + dry.age_expiry_enabled, + "dry_run must enable age metrics even without CWD scan" + ); + + db.set_meta(META_LAST_AUTO_GC_AT, "0").unwrap(); // reset throttle + let real = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); + assert_eq!(real.outcome, AutoGcOutcome::Ran); + assert_eq!( + real.age_expiry_enabled, + age_expiry_allowed(process_cwd_scan_available(), false), + "non-dry-run age_expiry must match age_expiry_allowed(scan, false)" + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn maybe_auto_gc_real_expires_unguarded_protects_live_creator() { + // Age path needs a successful CWD scan — serialize with chdir tests. + let _g = env_guard(); + let _cwd_lock = crate::api::cwd_test_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + + let doomed = tmp.path().join("doomed-session"); + std::fs::create_dir(&doomed).unwrap(); + db.register(&make_rec( + "doomed", + doomed.clone(), + WorktreeKind::Session, + 1, + )) + .unwrap(); + + let kept = tmp.path().join("kept-session"); + std::fs::create_dir(&kept).unwrap(); + let mut live = make_rec("kept", kept.clone(), WorktreeKind::Session, 1); + live.creator_pid = Some(std::process::id()); + db.register(&live).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + max_age_secs: 0, + min_interval_secs: 0, + include_orphan_snapshots: false, + dry_run: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!(report.age_expiry_enabled); + assert!( + !doomed.exists(), + "unguarded expired session must be age-deleted on scan platforms" + ); + assert!( + kept.exists(), + "live creator_pid must protect the other tree" + ); + let gc = report.gc.as_ref().unwrap(); + assert!(gc.expired_removed >= 1); + assert!(gc.skipped_alive >= 1); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn maybe_auto_gc_default_never_expires_manual() { + let _g = env_guard(); + let _cwd_lock = crate::api::cwd_test_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + + let session = tmp.path().join("sess"); + let manual = tmp.path().join("man"); + std::fs::create_dir(&session).unwrap(); + std::fs::create_dir(&manual).unwrap(); + db.register(&make_rec("s", session.clone(), WorktreeKind::Session, 1)) + .unwrap(); + db.register(&make_rec("m", manual.clone(), WorktreeKind::Manual, 1)) + .unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + max_age_secs: 0, + min_interval_secs: 0, + include_orphan_snapshots: false, + dry_run: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!(!session.exists(), "session expires under default policy"); + assert!(manual.exists(), "manual never age-expires by default"); + } + + #[test] + fn env_dry_run_forces_dry_run_on_raw_opts() { + // Raw AutoGcOptions dry_run=false must still dry-run when env is set. + let _g = env_guard(); + clear_auto_gc_env(); + unsafe { std::env::set_var(ENV_AUTO_GC_DRY_RUN, "1") }; + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let dir = tmp.path().join("would-expire"); + std::fs::create_dir(&dir).unwrap(); + db.register(&make_rec("exp", dir.clone(), WorktreeKind::Session, 1)) + .unwrap(); + let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!( + dir.exists(), + "env dry-run must not delete even when opts.dry_run=false" + ); + assert!(report.age_expiry_enabled, "dry-run enables age metrics"); + clear_auto_gc_env(); + } + + #[test] + fn dry_run_skips_orphan_cleaners() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: true, + dry_run: true, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!( + report.overlay.is_none() && report.btrfs.is_none(), + "dry_run must not invoke orphan cleaners" + ); + } + + #[cfg(target_os = "linux")] + #[test] + fn non_dry_run_runs_orphan_cleaners_on_linux() { + // Complementary to dry_run_skips_orphan_cleaners (Linux-only symbols). + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: true, + dry_run: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!( + report.overlay.is_some() && report.btrfs.is_some(), + "non-dry-run + include_orphan_snapshots must invoke cleaners on Linux" + ); + } + + #[cfg(not(target_os = "linux"))] + #[test] + fn non_linux_orphan_cleaners_always_absent() { + // Orphan cleaners are compile-gated; non-Linux always returns None. + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: true, + dry_run: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert!(report.overlay.is_none() && report.btrfs.is_none()); + } + + #[test] + fn force_never_applied_by_auto_path_on_live_pid() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let dir = tmp.path().join("live-wt"); + std::fs::create_dir(&dir).unwrap(); + let mut rec = make_rec("live", dir.clone(), WorktreeKind::Session, 1); + rec.creator_pid = Some(std::process::id()); + db.register(&rec).unwrap(); + + let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(true)).unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!(dir.exists()); + let gc = report.gc.unwrap(); + assert_eq!(gc.expired_removed, 0); + assert_eq!(gc.skipped_alive, 1); + } + + #[test] + fn maybe_auto_gc_protects_process_cwd() { + // Lock order: ENV_LOCK then CWD_TEST_LOCK. + let _g = env_guard(); + let _cwd_lock = crate::api::cwd_test_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let dir = tmp.path().join("cwd-wt"); + std::fs::create_dir(&dir).unwrap(); + db.register(&make_rec("cwd", dir.clone(), WorktreeKind::Session, 1)) + .unwrap(); + + let _cwd = crate::api::CwdGuard(std::env::current_dir().unwrap()); + std::env::set_current_dir(&dir).unwrap(); + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + max_age_secs: 0, + min_interval_secs: 0, + include_orphan_snapshots: false, + dry_run: true, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + let gc = report.gc.unwrap(); + assert_eq!( + gc.expired_removed, 0, + "process cwd inside wt must not count as would-expire" + ); + assert!( + gc.skipped_alive >= 1, + "protect_paths must skip the cwd worktree" + ); + assert!(dir.exists()); + } + + #[test] + fn throttle_gc_ok_stamps_and_within_interval_throttles() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let opts = AutoGcOptions { + min_interval_secs: 3600, + include_orphan_snapshots: false, + dry_run: false, + ..AutoGcOptions::default() + }; + assert!(db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none()); + let first = maybe_auto_gc(&db, &opts).unwrap(); + assert_eq!(first.outcome, AutoGcOutcome::Ran); + assert!(first.stamped); + assert_eq!( + first.age_expiry_enabled, + process_cwd_scan_available(), + "non-dry-run age_expiry tracks process_cwd_scan_available" + ); + let stamp = db.get_meta(META_LAST_AUTO_GC_AT).unwrap(); + assert!(stamp.is_some(), "GC Ok must stamp last_auto_gc_at"); + + let second = maybe_auto_gc(&db, &opts).unwrap(); + assert_eq!(second.outcome, AutoGcOutcome::Throttled); + assert_eq!( + db.get_meta(META_LAST_AUTO_GC_AT).unwrap(), + stamp, + "throttled pass must not rewrite stamp" + ); + } + + #[test] + fn future_stamp_is_treated_as_due() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + // Far-future stamp must not black out auto-GC forever. + db.set_meta( + META_LAST_AUTO_GC_AT, + &(now_epoch_secs() + 86_400).to_string(), + ) + .unwrap(); + let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + } + + #[test] + fn is_throttled_logic() { + assert!(!is_throttled(1000, 2000, 3600), "future stamp is due"); + assert!(is_throttled(1000, 900, 3600), "within interval"); + assert!(!is_throttled(5000, 1000, 3600), "past interval"); + assert!( + !is_throttled(1000, 1000, 0), + "zero interval never throttles" + ); + } + + #[test] + fn throttle_gc_err_does_not_stamp() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + db.execute_batch_for_test("DROP TABLE worktrees;").unwrap(); + let err = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)); + assert!(err.is_err(), "GC Err must surface as Err"); + assert!( + db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none(), + "GC Err must not stamp last_auto_gc_at" + ); + } + + #[test] + fn throttle_stamps_even_when_remove_failed() { + // remove_failed > 0 still stamps (partial progress). + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let path = tmp.path().join("doomed"); + std::fs::write(&path, b"not a dir").unwrap(); + db.register(&make_rec("doomed", path, WorktreeKind::Session, 1)) + .unwrap(); + + let opts = AutoGcOptions { + max_age_secs: 0, + min_interval_secs: 0, + include_orphan_snapshots: false, + dry_run: false, + ..AutoGcOptions::default() + }; + let report = maybe_auto_gc(&db, &opts).unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!( + db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_some(), + "Ran must stamp even with remove_failed / dead-only" + ); + assert_eq!( + report.age_expiry_enabled, + process_cwd_scan_available(), + "non-dry-run age_expiry tracks platform CWD scan" + ); + if process_cwd_scan_available() { + let gc = report.gc.as_ref().unwrap(); + assert!( + gc.remove_failed >= 1, + "age path must record remove_failed on non-dir" + ); + } + } + + #[test] + fn disabled_env_wins_even_if_opts_enabled() { + let _g = env_guard(); + clear_auto_gc_env(); + unsafe { std::env::set_var(ENV_AUTO_GC, "0") }; + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Disabled); + assert!(db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none()); + clear_auto_gc_env(); + } + + #[test] + fn env_kill_truthy_falsy_table() { + let _g = env_guard(); + for (val, disabled) in [ + ("0", true), + ("false", true), + ("FALSE", true), + ("off", true), + ("no", true), + ("disabled", true), + ("", true), + ("1", false), + ("true", false), + ("on", false), + ("yes", false), + ("enabled", false), + ] { + clear_auto_gc_env(); + unsafe { std::env::set_var(ENV_AUTO_GC, val) }; + assert_eq!( + env_auto_gc_disabled(), + disabled, + "ENV_AUTO_GC={val:?} disabled={disabled}" + ); + } + clear_auto_gc_env(); + assert!(!env_auto_gc_disabled(), "unset is not disabled"); + } + + #[test] + fn env_dry_run_truthy_table() { + let _g = env_guard(); + for (val, on) in [ + ("1", true), + ("true", true), + ("yes", true), + ("on", true), + ("enabled", true), + ("0", false), + ("false", false), + ("", false), + ("nope", false), + ] { + clear_auto_gc_env(); + unsafe { std::env::set_var(ENV_AUTO_GC_DRY_RUN, val) }; + assert_eq!( + env_auto_gc_dry_run(), + on, + "ENV_AUTO_GC_DRY_RUN={val:?} on={on}" + ); + } + clear_auto_gc_env(); + assert!(!env_auto_gc_dry_run()); + } + + #[test] + fn disabled_opts_returns_disabled() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + enabled: false, + min_interval_secs: 0, + include_orphan_snapshots: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Disabled); + assert!(db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none()); + } + + #[test] + fn complementary_enabled_runs_when_env_unset() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!(db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_some()); + } + + #[test] + fn fail_closed_meta_read_returns_err_without_stamp() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + db.execute_batch_for_test("DROP TABLE meta;").unwrap(); + let err = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)); + assert!(err.is_err(), "meta read failure must fail closed"); + } + + #[test] + fn auto_path_dead_reclaim_includes_manual_kind() { + // never-expire is age-only; dead Manual still unregisters. + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + db.register(&make_rec( + "manual-dead", + PathBuf::from("/nonexistent/manual-wt"), + WorktreeKind::Manual, + 100, + )) + .unwrap(); + let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert_eq!(report.gc.as_ref().unwrap().dead_removed, 1); + let all = db + .list(&ListFilter { + include_dead: true, + ..Default::default() + }) + .unwrap(); + assert!(all.is_empty()); + } + + #[test] + fn resolve_layers_env_kill_and_local_and_clamps() { + let _g = env_guard(); + clear_auto_gc_env(); + + let local_off = WorktreeAutoGcLayer { + enabled: Some(false), + ..Default::default() + }; + let remote_on = WorktreeAutoGcLayer { + enabled: Some(true), + max_age_secs: Some(1), + min_interval_secs: Some(1), + dry_run: Some(false), + ..Default::default() + }; + let p = resolve_worktree_auto_gc_from_layers(Some(&local_off), Some(&remote_on)); + assert!(!p.enabled, "local enabled=false beats remote true"); + assert_eq!(p.max_age_secs, MAX_AGE_SECS_MIN, "remote TTL still clamped"); + assert_eq!(p.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); + + unsafe { std::env::set_var(ENV_AUTO_GC, "0") }; + let p = resolve_worktree_auto_gc_from_layers( + Some(&WorktreeAutoGcLayer { + enabled: Some(true), + ..Default::default() + }), + None, + ); + assert!(!p.enabled, "env kill wins"); + clear_auto_gc_env(); + + unsafe { std::env::set_var(ENV_AUTO_GC_DRY_RUN, "1") }; + let p = resolve_worktree_auto_gc_from_layers( + Some(&WorktreeAutoGcLayer { + dry_run: Some(false), + ..Default::default() + }), + Some(&WorktreeAutoGcLayer { + dry_run: Some(false), + ..Default::default() + }), + ); + assert!(p.dry_run, "env dry-run wins over local/remote false"); + clear_auto_gc_env(); + } + + #[test] + fn resolve_kind_map_local_wins_over_remote_and_clamps() { + let _g = env_guard(); + clear_auto_gc_env(); + let remote = WorktreeAutoGcLayer { + max_age_by_kind: BTreeMap::from([ + (WorktreeKind::Subagent, Some(1)), // clamps to MIN + (WorktreeKind::Manual, Some(86400)), + (WorktreeKind::Pool, Some(172800)), + ]), + ..Default::default() + }; + let local = WorktreeAutoGcLayer { + max_age_by_kind: BTreeMap::from([ + (WorktreeKind::Subagent, Some(7200)), + // local omits manual → remote's manual expire stays + ]), + ..Default::default() + }; + let p = resolve_worktree_auto_gc_from_layers(Some(&local), Some(&remote)); + assert_eq!( + p.max_age_by_kind.get(&WorktreeKind::Subagent), + Some(&Some(7200)), + "local kind TTL wins" + ); + assert_eq!( + p.max_age_by_kind.get(&WorktreeKind::Manual), + Some(&Some(86400)), + "remote can make manual expire when local omits" + ); + assert_eq!( + p.max_age_by_kind.get(&WorktreeKind::Pool), + Some(&Some(172800)) + ); + + // Local can restore manual never. + let local_never = WorktreeAutoGcLayer { + max_age_by_kind: BTreeMap::from([(WorktreeKind::Manual, None)]), + ..Default::default() + }; + let p = resolve_worktree_auto_gc_from_layers(Some(&local_never), Some(&remote)); + assert_eq!(p.max_age_by_kind.get(&WorktreeKind::Manual), Some(&None)); + } + + #[test] + fn resolve_env_max_age_wins_over_local_and_remote() { + let _g = env_guard(); + clear_auto_gc_env(); + unsafe { std::env::set_var(ENV_AUTO_GC_MAX_AGE, "7200") }; + let local = WorktreeAutoGcLayer { + max_age_secs: Some(86400), + ..Default::default() + }; + let remote = WorktreeAutoGcLayer { + max_age_secs: Some(3600), + ..Default::default() + }; + let p = resolve_worktree_auto_gc_from_layers(Some(&local), Some(&remote)); + assert_eq!(p.max_age_secs, 7200, "env MAX_AGE wins"); + clear_auto_gc_env(); + + unsafe { std::env::set_var(ENV_AUTO_GC_MAX_AGE, "not-a-number") }; + let p = resolve_worktree_auto_gc_from_layers(Some(&local), None); + assert_eq!( + p.max_age_secs, 86400, + "invalid env max age falls through to local" + ); + clear_auto_gc_env(); + } + + #[test] + fn resolve_defaults_include_manual_never() { + let _g = env_guard(); + clear_auto_gc_env(); + let p = ResolvedWorktreeAutoGc::from_env_only(); + assert_eq!(p.max_age_by_kind, default_max_age_by_kind()); + assert_eq!(p.max_age_secs, DEFAULT_MAX_AGE_SECS); + assert!( + !p.include_rebuild, + "rebuild off by default until cost measured" + ); + assert_eq!( + p.rebuild_min_interval_secs, + DEFAULT_REBUILD_MIN_INTERVAL_SECS + ); + } + + #[test] + fn resolve_env_rebuild_enables_include_rebuild() { + let _g = env_guard(); + clear_auto_gc_env(); + unsafe { std::env::set_var(ENV_AUTO_GC_REBUILD, "1") }; + let p = ResolvedWorktreeAutoGc::from_env_only(); + assert!( + p.include_rebuild, + "env REBUILD=1 must enable include_rebuild" + ); + clear_auto_gc_env(); + + let local_off = WorktreeAutoGcLayer { + include_rebuild: Some(false), + ..Default::default() + }; + unsafe { std::env::set_var(ENV_AUTO_GC_REBUILD, "1") }; + let p = resolve_worktree_auto_gc_from_layers(Some(&local_off), None); + assert!(p.include_rebuild, "env REBUILD=1 wins over local false"); + clear_auto_gc_env(); + } + + #[test] + fn resolve_local_include_rebuild_and_interval() { + let _g = env_guard(); + clear_auto_gc_env(); + let local = WorktreeAutoGcLayer { + include_rebuild: Some(true), + rebuild_min_interval_secs: Some(120), + ..Default::default() + }; + let p = resolve_worktree_auto_gc_from_layers(Some(&local), None); + assert!(p.include_rebuild); + assert_eq!(p.rebuild_min_interval_secs, 120); + } + + #[test] + fn from_env_only_honors_dry_run_env() { + let _g = env_guard(); + clear_auto_gc_env(); + unsafe { std::env::set_var(ENV_AUTO_GC_DRY_RUN, "1") }; + let p = ResolvedWorktreeAutoGc::from_env_only(); + assert!(p.dry_run); + assert!(p.enabled); + clear_auto_gc_env(); + } + + #[test] + fn clamps_numeric_bounds() { + assert_eq!(clamp_max_age_secs(1), MAX_AGE_SECS_MIN); + assert_eq!(clamp_max_age_secs(u64::MAX), MAX_AGE_SECS_MAX); + assert_eq!(clamp_max_age_secs(604800), 604800); + assert_eq!(clamp_min_interval_secs(1), MIN_INTERVAL_SECS_MIN); + assert_eq!(clamp_min_interval_secs(u64::MAX), MIN_INTERVAL_SECS_MAX); + } + + #[test] + fn get_set_meta_round_trip() { + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + assert_eq!(db.get_meta("k").unwrap(), None); + db.set_meta("k", "v").unwrap(); + assert_eq!(db.get_meta("k").unwrap().as_deref(), Some("v")); + db.set_meta("k", "v2").unwrap(); + assert_eq!(db.get_meta("k").unwrap().as_deref(), Some("v2")); + } + + #[test] + fn set_meta_err_after_gc_still_returns_ran() { + // Stamp write failure must not turn a successful GC into Err for hooks. + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + db.execute_batch_for_test( + "CREATE TRIGGER block_meta_write BEFORE INSERT ON meta BEGIN + SELECT RAISE(ABORT, 'blocked'); + END;", + ) + .unwrap(); + let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); + assert_eq!( + report.outcome, + AutoGcOutcome::Ran, + "set_meta Err after GC Ok must still return Ok(Ran)" + ); + assert!(!report.stamped, "failed set_meta must report stamped=false"); + } + + #[test] + fn unparseable_stamp_fails_open_and_restamps() { + let _g = env_guard(); + clear_auto_gc_env(); + let tmp = tempfile::TempDir::new().unwrap(); + let db = WorktreeDb::open(tmp.path()).unwrap(); + db.set_meta(META_LAST_AUTO_GC_AT, "not-a-number").unwrap(); + let report = maybe_auto_gc(&db, &opts_enabled_no_orphans(false)).unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!(report.stamped); + let stamp = db.get_meta(META_LAST_AUTO_GC_AT).unwrap().unwrap(); + assert!( + stamp.parse::().is_ok(), + "must restamp a parseable epoch after unparseable prior value" + ); + } + + #[test] + fn include_rebuild_false_skips_rebuild_and_does_not_stamp_rebuild_meta() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + + let wt = fx.home.join("worktrees/repo/untracked-sess"); + std::fs::create_dir_all(wt.join(".git")).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!(report.rebuild.is_none()); + assert!(!report.rebuild_stamped); + assert_eq!(report.stale_registrations_cleaned, 0); + assert!( + db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_none(), + "include_rebuild=false must not stamp last_auto_rebuild_at" + ); + assert!( + db.get(&wt.to_string_lossy()).unwrap().is_none(), + "untracked dir must not be registered when rebuild disabled" + ); + } + + #[test] + fn include_rebuild_true_registers_untracked_under_grok_home() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + + let wt = fx.home.join("worktrees/repo/untracked-sess"); + std::fs::create_dir_all(wt.join(".git")).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + let rebuild = report + .rebuild + .as_ref() + .expect("rebuild must run when enabled and due"); + assert_eq!(rebuild.discovered, 1); + assert_eq!(rebuild.registered, 1); + assert!(report.rebuild_stamped); + assert!( + db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_some(), + "successful rebuild must stamp last_auto_rebuild_at" + ); + assert!( + db.get(&wt.to_string_lossy()).unwrap().is_some(), + "untracked dir under grok_home/worktrees must be registered" + ); + } + + #[test] + fn rebuild_throttled_independently_of_gc() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + + let opts = AutoGcOptions { + min_interval_secs: 0, // GC always due + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 3600, + ..AutoGcOptions::default() + }; + + let first = maybe_auto_gc(&db, &opts).unwrap(); + assert_eq!(first.outcome, AutoGcOutcome::Ran); + assert!(first.rebuild.is_some()); + assert!(first.rebuild_stamped); + let rebuild_stamp = db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap(); + assert!(rebuild_stamp.is_some()); + + // Second GC pass still runs (min_interval 0) but rebuild is throttled. + let second = maybe_auto_gc(&db, &opts).unwrap(); + assert_eq!(second.outcome, AutoGcOutcome::Ran); + assert!( + second.rebuild.is_none(), + "rebuild within rebuild_min_interval must skip" + ); + assert!(!second.rebuild_stamped); + assert_eq!( + db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap(), + rebuild_stamp, + "throttled rebuild must not rewrite stamp" + ); + assert!( + second.stamped, + "GC stamp still advances when rebuild is throttled" + ); + } + + #[test] + fn rebuild_failure_does_not_block_dead_record_gc() { + // INSERT-aborting trigger makes rebuild register fail; SELECT/DELETE for + // dead-path GC still work so reclaim continues after rebuild Err. + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + + db.register(&make_rec( + "dead-after-rebuild-err", + PathBuf::from("/nonexistent/dead-wt-rebuild-err"), + WorktreeKind::Session, + 100, + )) + .unwrap(); + + let untracked = fx.home.join("worktrees/repo/untracked-for-fail"); + std::fs::create_dir_all(untracked.join(".git")).unwrap(); + + db.execute_batch_for_test( + "CREATE TRIGGER block_worktree_insert BEFORE INSERT ON worktrees BEGIN + SELECT RAISE(ABORT, 'rebuild-blocked'); + END;", + ) + .unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!( + report.rebuild.is_none(), + "rebuild Err must not populate rebuild report" + ); + assert!( + !report.rebuild_stamped, + "failed rebuild must not stamp last_auto_rebuild_at" + ); + assert!( + db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_none(), + "failed rebuild must leave rebuild meta unset" + ); + assert_eq!( + report.gc.as_ref().unwrap().dead_removed, + 1, + "dead-record GC must still run when rebuild fails" + ); + assert!(report.stamped, "GC Ok must still stamp last_auto_gc_at"); + } + + #[test] + fn dry_run_skips_rebuild_and_prune() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + let wt = fx.home.join("worktrees/repo/dry-sess"); + std::fs::create_dir_all(wt.join(".git")).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + dry_run: true, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!(report.rebuild.is_none()); + assert!(!report.rebuild_stamped); + assert_eq!(report.stale_registrations_cleaned, 0); + assert!(db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_none()); + assert!(db.get(&wt.to_string_lossy()).unwrap().is_none()); + } + + #[test] + fn prune_removes_stale_git_worktree_registration() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + + let repo = fx.home.join("src-repo"); + let wt = fx.home.join("linked-wt"); + plant_stale_git_worktree(&repo, &wt); + let before = count_regs(&repo); + assert!(before >= 1, "expected stale registration before prune"); + + let tracked = fx.home.join("still-there"); + std::fs::create_dir_all(&tracked).unwrap(); + let mut rec = make_rec("tracked", tracked, WorktreeKind::Session, now_epoch_secs()); + rec.source_repo = repo.clone(); + db.register(&rec).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + dry_run: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!( + report.stale_registrations_cleaned >= 1, + "stale registration must be pruned; cleaned={}", + report.stale_registrations_cleaned + ); + assert!( + count_regs(&repo) < before, + "registration count must drop after prune" + ); + } + + #[test] + fn prune_noop_when_no_stale_registrations() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + + let repo = fx.home.join("clean-repo"); + std::fs::create_dir_all(&repo).unwrap(); + let init = std::process::Command::new("git") + .args(["init"]) + .current_dir(&repo) + .output() + .unwrap(); + assert!(init.status.success()); + + let path = fx.home.join("alive-wt"); + std::fs::create_dir_all(&path).unwrap(); + let mut rec = make_rec("alive", path, WorktreeKind::Session, now_epoch_secs()); + rec.source_repo = repo; + db.register(&rec).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert_eq!( + report.stale_registrations_cleaned, 0, + "clean repo must report zero stale prunes" + ); + } + + #[test] + fn dry_run_skips_prune_even_with_stale_registration() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + let repo = fx.home.join("dry-src"); + let wt = fx.home.join("dry-stale-wt"); + plant_stale_git_worktree(&repo, &wt); + let before = count_regs(&repo); + assert!(before >= 1); + + let tracked = fx.home.join("dry-tracked"); + std::fs::create_dir_all(&tracked).unwrap(); + let mut rec = make_rec("dry-t", tracked, WorktreeKind::Session, now_epoch_secs()); + rec.source_repo = repo.clone(); + db.register(&rec).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + dry_run: true, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.stale_registrations_cleaned, 0); + assert_eq!(count_regs(&repo), before, "dry_run must not prune"); + } + + #[test] + fn include_rebuild_false_skips_prune_even_with_stale_registration() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + let repo = fx.home.join("off-src"); + let wt = fx.home.join("off-stale-wt"); + plant_stale_git_worktree(&repo, &wt); + let before = count_regs(&repo); + assert!(before >= 1); + + let tracked = fx.home.join("off-tracked"); + std::fs::create_dir_all(&tracked).unwrap(); + let mut rec = make_rec("off-t", tracked, WorktreeKind::Session, now_epoch_secs()); + rec.source_repo = repo.clone(); + db.register(&rec).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.stale_registrations_cleaned, 0); + assert_eq!( + count_regs(&repo), + before, + "include_rebuild=false must not prune" + ); + } + + #[test] + fn prune_uses_dead_row_source_repo_snapshot() { + // Sole tracked row is dead (path gone); after GC unregisters it, prune must + // still hit the snapshotted source_repo. + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + let repo = fx.home.join("dead-src"); + let wt = fx.home.join("dead-stale-wt"); + plant_stale_git_worktree(&repo, &wt); + let before = count_regs(&repo); + assert!(before >= 1); + + let mut rec = make_rec( + "sole-dead", + PathBuf::from("/nonexistent/sole-dead-wt"), + WorktreeKind::Session, + 100, + ); + rec.source_repo = repo.clone(); + db.register(&rec).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.gc.as_ref().unwrap().dead_removed, 1); + assert!( + report.stale_registrations_cleaned >= 1, + "dead sole-row source_repo must still be pruned; cleaned={}", + report.stale_registrations_cleaned + ); + assert!(count_regs(&repo) < before); + } + + #[test] + fn rebuild_unparseable_stamp_fails_open() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + db.set_meta(META_LAST_AUTO_REBUILD_AT, "not-a-number") + .unwrap(); + let wt = fx.home.join("worktrees/repo/reparse-sess"); + std::fs::create_dir_all(wt.join(".git")).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 3600, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert!( + report.rebuild.is_some(), + "unparseable rebuild stamp must not throttle" + ); + assert!(report.rebuild_stamped); + let stamp = db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().unwrap(); + assert!(stamp.parse::().is_ok()); + } + + #[test] + fn rebuild_set_meta_failure_still_continues_gc() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + db.register(&make_rec( + "dead-stamp", + PathBuf::from("/nonexistent/dead-stamp-wt"), + WorktreeKind::Session, + 100, + )) + .unwrap(); + // Block only INSERT (UPSERT is INSERT OR REPLACE → INSERT path). + db.execute_batch_for_test( + "CREATE TRIGGER block_meta_insert BEFORE INSERT ON meta BEGIN + SELECT RAISE(ABORT, 'meta-blocked'); + END;", + ) + .unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + // Rebuild may succeed registering before stamp; both stamps use set_meta INSERT. + if report.rebuild.is_some() { + assert!(!report.rebuild_stamped); + } + assert_eq!( + report.gc.as_ref().unwrap().dead_removed, + 1, + "GC must continue after rebuild stamp failure" + ); + assert!(!report.stamped, "GC stamp also uses set_meta INSERT"); + } + + #[test] + fn rebuild_not_stamped_when_gc_fails_after_rebuild() { + // Rebuild succeeds (registers untracked), then GC fails on sweep UPDATE. + // Rebuild meta must stay unset so the next pass can re-discover. + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + db.register(&make_rec( + "alive-missing-path", + PathBuf::from("/nonexistent/alive-for-gc-fail"), + WorktreeKind::Session, + 100, + )) + .unwrap(); + let untracked = fx.home.join("worktrees/repo/rebuild-then-gc-fail"); + std::fs::create_dir_all(untracked.join(".git")).unwrap(); + + db.execute_batch_for_test( + "CREATE TRIGGER block_worktree_update BEFORE UPDATE ON worktrees BEGIN + SELECT RAISE(ABORT, 'gc-sweep-blocked'); + END;", + ) + .unwrap(); + + let err = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .expect_err("GC sweep UPDATE must fail the pass"); + let _ = err; + assert!( + db.get_meta(META_LAST_AUTO_REBUILD_AT).unwrap().is_none(), + "rebuild must not stamp when GC fails after a successful rebuild" + ); + assert!( + db.get_meta(META_LAST_AUTO_GC_AT).unwrap().is_none(), + "GC must not stamp when the pass returns Err" + ); + // Rebuild already registered the untracked tree before GC failed. + assert!( + db.get(untracked.to_str().unwrap()).unwrap().is_some(), + "rebuild registration from the failed pass is retained" + ); + } + + #[test] + fn classify_rebuild_meta_err_skips_not_aborts() { + let err = Err(anyhow::anyhow!("meta unavailable")); + assert_eq!( + classify_rebuild_meta(err, 1000, 3600), + RebuildMetaClass::SkipFailed + ); + assert_eq!( + classify_rebuild_meta(Ok(None), 1000, 3600), + RebuildMetaClass::Due + ); + assert_eq!( + classify_rebuild_meta(Ok(Some("900".into())), 1000, 3600), + RebuildMetaClass::Throttled + ); + assert_eq!( + classify_rebuild_meta(Ok(Some("not-a-number".into())), 1000, 3600), + RebuildMetaClass::Due + ); + assert_eq!( + classify_rebuild_meta(Ok(Some("100".into())), 10000, 3600), + RebuildMetaClass::Due + ); + } + + #[test] + fn env_rebuild_reapplied_inside_maybe_auto_gc() { + let _g = env_guard(); + clear_auto_gc_env(); + unsafe { std::env::set_var(ENV_AUTO_GC_REBUILD, "1") }; + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + let wt = fx.home.join("worktrees/repo/env-rebuild-sess"); + std::fs::create_dir_all(wt.join(".git")).unwrap(); + + // opts.include_rebuild false — env must still enable. + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: false, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert!( + report.rebuild.is_some(), + "env REBUILD must re-apply inside maybe_auto_gc" + ); + clear_auto_gc_env(); + } + + #[test] + fn gc_throttled_short_circuits_rebuild() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + // GC recently stamped; rebuild never stamped and would be due. + db.set_meta(META_LAST_AUTO_GC_AT, &now_epoch_secs().to_string()) + .unwrap(); + let wt = fx.home.join("worktrees/repo/throttle-rebuild"); + std::fs::create_dir_all(wt.join(".git")).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 3600, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Throttled); + assert!(report.rebuild.is_none()); + assert!( + db.get(&wt.to_string_lossy()).unwrap().is_none(), + "GC throttle must skip rebuild" + ); + } + + #[cfg(any(target_os = "linux", target_os = "macos"))] + #[test] + fn rebuild_same_pass_does_not_age_expire_new_registration() { + let _g = env_guard(); + let _cwd_lock = crate::api::cwd_test_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + let wt = fx.home.join("worktrees/repo/fresh-rebuild"); + std::fs::create_dir_all(wt.join(".git")).unwrap(); + // Old directory mtime would look expired under max_age=0 without touch. + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + max_age_secs: 0, + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + dry_run: false, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert!(report.age_expiry_enabled); + assert!(report.rebuild.as_ref().is_some_and(|r| r.registered == 1)); + assert!( + wt.exists(), + "just-registered rebuild path must not age-delete same pass" + ); + assert!(db.get(&wt.to_string_lossy()).unwrap().is_some()); + } + + #[cfg(unix)] + #[test] + fn rebuild_refuses_symlink_escape_then_age_safe() { + let _g = env_guard(); + clear_auto_gc_env(); + let fx = crate::db::GrokHomeFixture::new(); + let db = WorktreeDb::open(&fx.home).unwrap(); + let outside = fx.home.join("outside-escape"); + std::fs::create_dir_all(outside.join(".git")).unwrap(); + let parent = fx.home.join("worktrees/repo"); + std::fs::create_dir_all(&parent).unwrap(); + std::os::unix::fs::symlink(&outside, parent.join("escaped")).unwrap(); + + let report = maybe_auto_gc( + &db, + &AutoGcOptions { + min_interval_secs: 0, + include_orphan_snapshots: false, + include_rebuild: true, + rebuild_min_interval_secs: 0, + ..AutoGcOptions::default() + }, + ) + .unwrap(); + assert_eq!(report.outcome, AutoGcOutcome::Ran); + assert!( + report.rebuild.as_ref().is_some_and(|r| r.registered == 0), + "symlink escape must not register" + ); + assert!( + outside.exists(), + "outside target must remain (never registered/deleted)" + ); + assert!(db.list(&ListFilter::default()).unwrap().is_empty()); + } +} diff --git a/crates/codegen/xai-fast-worktree/src/db/mod.rs b/crates/codegen/xai-fast-worktree/src/db/mod.rs index 0006e70..5bb65d1 100644 --- a/crates/codegen/xai-fast-worktree/src/db/mod.rs +++ b/crates/codegen/xai-fast-worktree/src/db/mod.rs @@ -14,7 +14,7 @@ use rusqlite::Connection; use serde::{Deserialize, Serialize}; use xai_sqlite_journal::JournalMode; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum WorktreeKind { Session, @@ -38,14 +38,33 @@ impl WorktreeKind { } pub fn from_str_lossy(s: &str) -> Self { + Self::from_str_exact(s).unwrap_or(Self::Manual) + } + + /// Exact known kind key. Unknown → None (unlike [`Self::from_str_lossy`]). + pub fn from_str_exact(s: &str) -> Option { match s { - "session" => Self::Session, - "ab" => Self::Ab, - "pool" => Self::Pool, - "fork" => Self::Fork, - "manual" => Self::Manual, - "subagent" => Self::Subagent, - _ => Self::Manual, + "session" => Some(Self::Session), + "ab" => Some(Self::Ab), + "pool" => Some(Self::Pool), + "fork" => Some(Self::Fork), + "manual" => Some(Self::Manual), + "subagent" => Some(Self::Subagent), + _ => None, + } + } + + /// Config key parse: trim + case-insensitive; unknown → None. + pub fn from_str_opt(s: &str) -> Option { + let t = s.trim(); + if let Some(k) = Self::from_str_exact(t) { + return Some(k); + } + // Only allocate lowercase when needed. + if t.bytes().any(|b| b.is_ascii_uppercase()) { + Self::from_str_exact(&t.to_ascii_lowercase()) + } else { + None } } } @@ -306,6 +325,35 @@ impl WorktreeDb { pub fn sweep_dead(&self) -> Result { queries::sweep_dead(&self.conn) } + + /// Read a value from the `meta` table. `Ok(None)` when the key is absent. + pub fn get_meta(&self, key: &str) -> Result> { + match self + .conn + .query_row(schema::GET_META, [key], |row| row.get(0)) + { + Ok(v) => Ok(Some(v)), + Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), + Err(e) => Err(e).with_context(|| format!("failed to read meta key {key}")), + } + } + + /// Insert or replace a `meta` table value. + pub fn set_meta(&self, key: &str, value: &str) -> Result<()> { + self.conn + .execute(schema::UPSERT_META, rusqlite::params![key, value]) + .with_context(|| format!("failed to write meta key {key}"))?; + Ok(()) + } + + /// Test-only: run raw SQL (e.g. drop tables to force fail-closed paths). + #[cfg(test)] + pub(crate) fn execute_batch_for_test(&self, sql: &str) -> Result<()> { + self.conn + .execute_batch(sql) + .context("execute_batch_for_test failed")?; + Ok(()) + } } /// Derive a worktree ID from its destination path: `-` diff --git a/crates/codegen/xai-fast-worktree/src/discovery.rs b/crates/codegen/xai-fast-worktree/src/discovery.rs index 6677994..70099b3 100644 --- a/crates/codegen/xai-fast-worktree/src/discovery.rs +++ b/crates/codegen/xai-fast-worktree/src/discovery.rs @@ -154,6 +154,21 @@ pub struct RebuildReport { pub already_tracked: u64, } +fn managed_worktree_roots(grok_home: &Path) -> [PathBuf; 2] { + [grok_home.join("worktrees"), grok_home.join("worktree_pool")] + .map(|root| dunce::canonicalize(&root).unwrap_or(root)) +} + +/// True when `path` is under a managed root (`worktrees/` or `worktree_pool/`). +/// Prefer already-canonical `path`; roots are canonicalized inside. +pub fn path_under_managed_worktree_roots(path: &Path, grok_home: &Path) -> bool { + path_under_roots(path, &managed_worktree_roots(grok_home)) +} + +fn path_under_roots(path: &Path, roots: &[PathBuf]) -> bool { + roots.iter().any(|root| path.starts_with(root)) +} + pub fn rebuild_worktree_db( db: &crate::db::WorktreeDb, grok_home: &Path, @@ -163,16 +178,29 @@ pub fn rebuild_worktree_db( discovered: discovery.found.len() as u64, ..Default::default() }; + let now = now_epoch_secs(); + let roots = managed_worktree_roots(grok_home); for wt in discovery.found { let path = dunce::canonicalize(&wt.path).unwrap_or_else(|_| wt.path.clone()); + // Refuse symlink escape outside managed roots. + if !path_under_roots(&path, &roots) { + tracing::warn!( + path = %path.display(), + "rebuild skipped path outside grok worktrees/worktree_pool" + ); + continue; + } let id = id_from_path(&path); let path_str = path.to_string_lossy(); if db.get_by_id(&id)?.is_some() || db.get(&path_str)?.is_some() { report.already_tracked += 1; continue; } - db.register(&wt.into_record())?; + let mut rec = wt.into_record(); + // Touch so same-pass age GC does not reclaim solely from old FS mtime. + rec.last_accessed_at = Some(now); + db.register(&rec)?; report.registered += 1; } @@ -327,4 +355,45 @@ mod tests { assert_eq!(deser.registered, 3); assert_eq!(deser.already_tracked, 2); } + + #[test] + fn rebuild_sets_last_accessed_at() { + let tmp = tempfile::TempDir::new().unwrap(); + let grok_home = tmp.path(); + let wt = grok_home.join("worktrees/repo/sess"); + make_fake_standalone_worktree(&wt); + let db = crate::db::WorktreeDb::open_in_memory().unwrap(); + rebuild_worktree_db(&db, grok_home).unwrap(); + let rec = db.get(&wt.to_string_lossy()).unwrap().expect("registered"); + assert!( + rec.last_accessed_at.is_some(), + "rebuild must touch last_accessed_at for same-pass age safety" + ); + } + + #[cfg(unix)] + #[test] + fn rebuild_skips_symlink_escape_outside_managed_roots() { + let tmp = tempfile::TempDir::new().unwrap(); + let grok_home = tmp.path().join("grok"); + let outside = tmp.path().join("outside-real"); + make_fake_standalone_worktree(&outside); + let link_parent = grok_home.join("worktrees/repo"); + std::fs::create_dir_all(&link_parent).unwrap(); + std::os::unix::fs::symlink(&outside, link_parent.join("escaped")).unwrap(); + + let db = crate::db::WorktreeDb::open_in_memory().unwrap(); + let report = rebuild_worktree_db(&db, &grok_home).unwrap(); + assert_eq!(report.discovered, 1); + assert_eq!(report.registered, 0, "symlink escape must not register"); + assert!( + db.list(&crate::db::ListFilter::default()) + .unwrap() + .is_empty() + ); + assert!(!path_under_managed_worktree_roots( + &dunce::canonicalize(&outside).unwrap(), + &grok_home + )); + } } diff --git a/crates/codegen/xai-fast-worktree/src/git/checkout.rs b/crates/codegen/xai-fast-worktree/src/git/checkout.rs index 7a7aeaa..ef034d1 100644 --- a/crates/codegen/xai-fast-worktree/src/git/checkout.rs +++ b/crates/codegen/xai-fast-worktree/src/git/checkout.rs @@ -1207,7 +1207,9 @@ mod tests { rehydrate_worktree_from_ref(&dest, &repo_path, &snap, Some("subagent-42")).unwrap(); // Filter to OUR record by path: concurrent open_default writers may add - // other subagent rows since GROK_HOME is process-global. + // other subagent rows since GROK_HOME is process-global. Match the + // canonical path register_worktree stores (/var → /private/var on macOS). + let dest_canon = dunce::canonicalize(&dest).unwrap_or_else(|_| dest.clone()); let db = crate::db::WorktreeDb::open(&fx.home).unwrap(); let mine: Vec<_> = db .list(&crate::db::ListFilter { @@ -1216,7 +1218,7 @@ mod tests { }) .unwrap() .into_iter() - .filter(|r| r.path == dest) + .filter(|r| r.path == dest || r.path == dest_canon) .collect(); assert_eq!(mine.len(), 1, "exactly one rehydrated subagent record"); assert_eq!(mine[0].kind, crate::db::WorktreeKind::Subagent); diff --git a/crates/codegen/xai-fast-worktree/src/lib.rs b/crates/codegen/xai-fast-worktree/src/lib.rs index 696b417..e64d32d 100644 --- a/crates/codegen/xai-fast-worktree/src/lib.rs +++ b/crates/codegen/xai-fast-worktree/src/lib.rs @@ -9,6 +9,8 @@ //! 6. SQLite metadata tracking (behind `metadata` feature) mod api; +#[cfg(feature = "metadata")] +mod auto_gc; #[cfg(target_os = "linux")] pub mod btrfs; mod copy; @@ -31,6 +33,8 @@ pub use api::cleanup_orphaned_btrfs_snapshots; #[cfg(target_os = "linux")] pub use api::cleanup_orphaned_overlay_snapshots; #[cfg(feature = "metadata")] +pub use api::gc::effective_max_age; +#[cfg(feature = "metadata")] pub use api::gc::{GcOptions, GcReport, gc_worktrees, gc_worktrees_with_delegate}; pub use api::{ BtrfsDelegate, BtrfsMode, CleanupReport, CopyReport, CreationMode, DelegateSnapshotResult, @@ -39,6 +43,17 @@ pub use api::{ cleanup_worktrees_in_with_delegate, remove_worktree, remove_worktree_with_delegate, }; #[cfg(feature = "metadata")] +pub use auto_gc::{ + AutoGcOptions, AutoGcOutcome, AutoGcReport, DEFAULT_MAX_AGE_SECS, DEFAULT_MIN_INTERVAL_SECS, + DEFAULT_REBUILD_MIN_INTERVAL_SECS, ENV_AUTO_GC, ENV_AUTO_GC_DRY_RUN, ENV_AUTO_GC_MAX_AGE, + ENV_AUTO_GC_REBUILD, MAX_AGE_SECS_MAX, MAX_AGE_SECS_MIN, META_LAST_AUTO_GC_AT, + META_LAST_AUTO_REBUILD_AT, MIN_INTERVAL_SECS_MAX, MIN_INTERVAL_SECS_MIN, + ResolvedWorktreeAutoGc, WorktreeAutoGcLayer, age_expiry_allowed, build_auto_gc_options, + clamp_max_age_secs, clamp_min_interval_secs, default_max_age_by_kind, env_auto_gc_disabled, + env_auto_gc_dry_run, env_auto_gc_max_age, env_auto_gc_rebuild, maybe_auto_gc, + maybe_auto_gc_default, process_cwd_scan_available, resolve_worktree_auto_gc_from_layers, +}; +#[cfg(feature = "metadata")] pub use db::{ DbStats, ListFilter, WorktreeDb, WorktreeKind, WorktreeRecord, WorktreeStatus, id_from_path, now_epoch_secs, repo_name_from_path, resolve_grok_home, diff --git a/crates/codegen/xai-grok-agent/src/builder.rs b/crates/codegen/xai-grok-agent/src/builder.rs index 230aa3f..ec70383 100644 --- a/crates/codegen/xai-grok-agent/src/builder.rs +++ b/crates/codegen/xai-grok-agent/src/builder.rs @@ -99,6 +99,7 @@ pub struct AgentBuilder { xai_grok_tools::implementations::grok_build::deploy_app::AppBuilderDeployerConfig, write_file_enabled: bool, subagents_enabled: bool, + background_workflows_enabled: bool, ask_user_question_enabled: bool, subagent_toggle: HashMap, task_model_slugs: Vec, @@ -176,6 +177,21 @@ fn merge_tool_params( } } } +fn apply_workflow_tool_gates( + tool_config: &mut xai_grok_tools::registry::types::ToolServerConfig, + background_workflows_enabled: bool, +) { + use xai_grok_tools::types::tool::ToolKind; + if background_workflows_enabled { + tool_config + .tools + .retain(|tool| tool.kind != Some(ToolKind::GoalUpdate)); + } else { + tool_config + .tools + .retain(|tool| tool.kind != Some(ToolKind::Workflow)); + } +} impl AgentBuilder { pub fn new( working_directory: PathBuf, @@ -223,6 +239,7 @@ impl AgentBuilder { app_builder_deployer_config: Default::default(), write_file_enabled: true, subagents_enabled: false, + background_workflows_enabled: false, ask_user_question_enabled: true, subagent_toggle: HashMap::new(), task_model_slugs: Vec::new(), @@ -533,6 +550,10 @@ impl AgentBuilder { self.subagents_enabled = enabled; self } + pub fn with_background_workflows_enabled(mut self, enabled: bool) -> Self { + self.background_workflows_enabled = enabled; + self + } /// Set public model slugs advertised in the GrokBuild Task description. pub fn with_task_model_slugs(mut self, slugs: Vec) -> Self { self.task_model_slugs = slugs; @@ -765,6 +786,7 @@ impl AgentBuilder { ); tool_config.tools.retain(|tc| tc.id != ask_user_id); } + apply_workflow_tool_gates(&mut tool_config, self.background_workflows_enabled); let task_tool_id = format!( "{}:{}", xai_grok_tools::types::tool::ToolNamespace::GrokBuild, diff --git a/crates/codegen/xai-grok-agent/src/config.rs b/crates/codegen/xai-grok-agent/src/config.rs index fe0dfad..fc3765c 100644 --- a/crates/codegen/xai-grok-agent/src/config.rs +++ b/crates/codegen/xai-grok-agent/src/config.rs @@ -280,6 +280,7 @@ fn default_grok_build_toolset() -> ToolServerConfig { (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&grok_build::UpdateGoalTool).into(), + (&grok_build::WorkflowTool).into(), ], behavior_preset: None, } @@ -300,6 +301,7 @@ fn grok_build_concise_toolset() -> ToolServerConfig { (&grok_build::SchedulerListTool).into(), (&grok_build::MonitorTool).into(), (&grok_build::UpdateGoalTool).into(), + (&grok_build::WorkflowTool).into(), ], behavior_preset: None, } @@ -329,6 +331,7 @@ pub fn grok_build_hashline_toolset( (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&grok_build::UpdateGoalTool).into(), + (&grok_build::WorkflowTool).into(), ]); ToolServerConfig { tools, @@ -410,6 +413,7 @@ fn grok_build_plan_toolset() -> ToolServerConfig { (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&grok_build::UpdateGoalTool).into(), + (&grok_build::WorkflowTool).into(), (&grok_build::EnterPlanModeTool).into(), (&grok_build::ExitPlanModeTool).into(), (&grok_build::AskUserQuestionTool).into(), @@ -441,6 +445,7 @@ fn orchestrator_toolset() -> ToolServerConfig { (&grok_build::ExitPlanModeTool).into(), (&grok_build::AskUserQuestionTool).into(), (&grok_build::UpdateGoalTool).into(), + (&grok_build::WorkflowTool).into(), (&grok_build::SchedulerCreateTool).into(), (&grok_build::SchedulerDeleteTool).into(), (&grok_build::SchedulerListTool).into(), @@ -479,6 +484,7 @@ fn grok_build_plan_no_subagents_toolset() -> ToolServerConfig { (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&grok_build::UpdateGoalTool).into(), + (&grok_build::WorkflowTool).into(), (&grok_build::EnterPlanModeTool).into(), (&grok_build::ExitPlanModeTool).into(), (&grok_build::AskUserQuestionTool).into(), @@ -510,6 +516,7 @@ fn grok_build_ask_user_toolset() -> ToolServerConfig { (&search_tool::SearchTool).into(), (&use_tool::UseTool).into(), (&grok_build::UpdateGoalTool).into(), + (&grok_build::WorkflowTool).into(), (&grok_build::AskUserQuestionTool).into(), ], behavior_preset: None, @@ -2020,12 +2027,10 @@ description: Minimal agent assert_eq!(v, McpServerRef::Named("slack".to_string())); let v: McpServerRef = serde_json::from_value(serde_json::json!({ "s" : { "type" : "stdio" } })).unwrap(); - assert!(matches!(v, McpServerRef::Inline { ref name, .. } -if name == "s")); + assert!(matches!(v, McpServerRef::Inline { ref name, .. } if name == "s")); let v: McpServerRef = serde_json::from_value(serde_json::json!({ "name" : "s", "type" : "stdio" })).unwrap(); - assert!(matches!(v, McpServerRef::Inline { ref name, .. } -if name == "s")); + assert!(matches!(v, McpServerRef::Inline { ref name, .. } if name == "s")); assert!( serde_json::from_value::(serde_json::json!({ "type" : "stdio" })) diff --git a/crates/codegen/xai-grok-config-types/src/lib.rs b/crates/codegen/xai-grok-config-types/src/lib.rs index 9c5ab03..993e89a 100644 --- a/crates/codegen/xai-grok-config-types/src/lib.rs +++ b/crates/codegen/xai-grok-config-types/src/lib.rs @@ -51,6 +51,120 @@ pub struct DoomLoopRecoverySettings { #[serde(skip_serializing_if = "Option::is_none")] pub max_retries: Option, } +/// Per-kind age policy for auto-GC: seconds or never. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorktreeKindMaxAge { + Secs(u64), + Never, +} +impl Serialize for WorktreeKindMaxAge { + fn serialize(&self, serializer: S) -> Result { + match self { + Self::Secs(n) => serializer.serialize_u64(*n), + Self::Never => serializer.serialize_str("never"), + } + } +} +impl<'de> Deserialize<'de> for WorktreeKindMaxAge { + fn deserialize>(deserializer: D) -> Result { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = WorktreeKindMaxAge; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("u64 seconds or \"never\"") + } + fn visit_u64(self, v: u64) -> Result { + Ok(WorktreeKindMaxAge::Secs(v)) + } + fn visit_i64(self, v: i64) -> Result { + u64::try_from(v) + .map(WorktreeKindMaxAge::Secs) + .map_err(E::custom) + } + fn visit_str(self, v: &str) -> Result { + if v.eq_ignore_ascii_case("never") { + Ok(WorktreeKindMaxAge::Never) + } else if let Ok(n) = v.parse::() { + Ok(WorktreeKindMaxAge::Secs(n)) + } else { + Err(E::custom("expected \"never\" or integer seconds")) + } + } + fn visit_unit(self) -> Result { + Ok(WorktreeKindMaxAge::Never) + } + fn visit_none(self) -> Result { + Ok(WorktreeKindMaxAge::Never) + } + } + deserializer.deserialize_any(V) + } +} +/// Local `[worktree.auto_gc]` / remote `worktree_auto_gc` policy. +/// Field-wise tolerant deserialize so one bad key cannot drop a sibling kill-switch. +#[derive(Debug, Clone, Default, Deserialize, Serialize, PartialEq, Eq)] +#[serde(default)] +pub struct WorktreeAutoGcSettings { + /// `Some(false)` kill-switch; absent ⇒ default on (env kill still applies). + #[serde( + default, + deserialize_with = "de_opt_bool_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub enabled: Option, + /// Age cutoff seconds when platform age-expiry is allowed; clamped by resolver. + #[serde( + default, + deserialize_with = "de_opt_u64_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub max_age_secs: Option, + /// Min seconds between successful auto-GC stamps; clamped by resolver. + #[serde( + default, + deserialize_with = "de_opt_u64_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub min_interval_secs: Option, + /// Count age candidates without deleting. + #[serde( + default, + deserialize_with = "de_opt_bool_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub dry_run: Option, + /// Linux only. + #[serde( + default, + deserialize_with = "de_opt_bool_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub include_orphan_snapshots: Option, + /// Per-kind max ages (`session`/`ab`/`pool`/`fork`/`manual`/`subagent`). + /// Seconds or `"never"`. Absent keys use defaults (client default: `manual`=never). + /// Remote may set `manual` to a finite TTL — not client-pinned; local TOML can restore `"never"`. + /// Unknown kind keys ignored at resolve. + #[serde( + default, + deserialize_with = "de_opt_max_age_by_kind_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub max_age_by_kind: Option>, + /// Optional discovery rebuild + stale `.git/worktrees/` prune (default off). + #[serde( + default, + deserialize_with = "de_opt_bool_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub include_rebuild: Option, + /// Independent rebuild throttle seconds; absent ⇒ 24h. Clamped like `min_interval_secs`. + #[serde( + default, + deserialize_with = "de_opt_u64_tolerant", + skip_serializing_if = "Option::is_none" + )] + pub rebuild_min_interval_secs: Option, +} /// Display-refresh probe + auto-cadence settings: ONE struct for local /// `[ui.display_refresh]`, remote settings `display_refresh`, and `UiConfig`. /// Field-wise tolerant deserialize (wrong types → `None`); unknown keys kept in @@ -208,6 +322,96 @@ fn de_opt_u32_tolerant<'de, D: serde::Deserializer<'de>>( } deserializer.deserialize_any(V) } +fn de_opt_u64_tolerant<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + struct V; + impl<'de> serde::de::Visitor<'de> for V { + type Value = Option; + fn expecting(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + f.write_str("u64 (wrong types ignored)") + } + fn visit_u64(self, v: u64) -> Result { + Ok(Some(v)) + } + fn visit_i64(self, v: i64) -> Result { + Ok(u64::try_from(v).ok()) + } + fn visit_u32(self, v: u32) -> Result { + Ok(Some(u64::from(v))) + } + fn visit_i32(self, v: i32) -> Result { + Ok(u64::try_from(v).ok()) + } + fn visit_unit(self) -> Result { + Ok(None) + } + fn visit_none(self) -> Result { + Ok(None) + } + fn visit_some>( + self, + d: A, + ) -> Result { + d.deserialize_any(V) + } + fn visit_str(self, _: &str) -> Result { + Ok(None) + } + fn visit_string(self, _: String) -> Result { + Ok(None) + } + fn visit_bool(self, _: bool) -> Result { + Ok(None) + } + fn visit_f64(self, _: f64) -> Result { + Ok(None) + } + } + deserializer.deserialize_any(V) +} +/// Tolerant map: bad whole value → None; per-entry bad values skipped. +fn de_opt_max_age_by_kind_tolerant<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result>, D::Error> { + let value = serde_json::Value::deserialize(deserializer)?; + match value { + serde_json::Value::Null => Ok(None), + serde_json::Value::Object(map) => { + let mut out = std::collections::BTreeMap::new(); + for (k, v) in map { + if let Ok(age) = serde_json::from_value::(v) { + out.insert(k, age); + } + } + Ok(Some(out)) + } + _ => Ok(None), + } +} +/// Nested `worktree_auto_gc` object: present-but-malformed → `None` (warn) so +/// one bad nested value cannot fail the whole [`RemoteSettings`] parse. +fn deserialize_tolerant_worktree_auto_gc<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::::deserialize(deserializer)?; + match value { + None | Some(serde_json::Value::Null) => Ok(None), + Some(v) => match serde_json::from_value::(v) { + Ok(s) => Ok(Some(s)), + Err(e) => { + tracing::warn!( + error = % e, + "ignoring malformed remote worktree_auto_gc; falling through to TOML/defaults" + ); + Ok(None) + } + }, + } +} /// Remote settings fetched from cli-chat-proxy `GET /v1/settings`. /// /// All fields are `Option` with `#[serde(default)]` so that: @@ -348,6 +552,13 @@ pub struct RemoteSettings { /// TOML/defaults; a partial object falls through per-field. #[serde(default)] pub doom_loop_recovery: Option, + /// Automatic worktree GC policy; see [`WorktreeAutoGcSettings`]. + /// Absent ⇒ every knob falls through to TOML/defaults; a partial object + /// falls through per-field. Present-but-malformed nested value is dropped + /// to `None` (does not fail the whole `RemoteSettings` parse). Platform + /// age-expiry policy is client-hardcoded and not remote-overridable. + #[serde(default, deserialize_with = "deserialize_tolerant_worktree_auto_gc")] + pub worktree_auto_gc: Option, /// Enable/disable the runtime turn-end TodoGate remotely. /// Precedence: CLI `--todo-gate` > this field > built-in default (`false`). /// The gate ships disabled; set this to `Some(true)` (via the @@ -456,6 +667,8 @@ pub struct RemoteSettings { skip_serializing_if = "Vec::is_empty" )] pub goal_skeptic_models: Vec, + #[serde(default)] + pub workflows_enabled: Option, /// Remote fallback for managed MCP connector fetching. #[serde(default)] pub managed_mcps_enabled: Option, @@ -940,6 +1153,96 @@ pub struct GoalRoleModel { mod tests { use super::*; #[test] + fn worktree_auto_gc_partial_object_and_round_trip() { + let json = r#"{"worktree_auto_gc":{"enabled":false}}"#; + let s: RemoteSettings = serde_json::from_str(json).unwrap(); + let agc = s.worktree_auto_gc.as_ref().expect("present"); + assert_eq!(agc.enabled, Some(false)); + assert_eq!(agc.max_age_secs, None); + assert_eq!(agc.min_interval_secs, None); + assert_eq!(agc.dry_run, None); + assert_eq!(agc.include_orphan_snapshots, None); + assert_eq!(agc.max_age_by_kind, None); + let full = r#"{ + "worktree_auto_gc": { + "enabled": true, + "max_age_secs": 604800, + "min_interval_secs": 21600, + "dry_run": true, + "include_orphan_snapshots": false, + "max_age_by_kind": { + "session": 604800, + "subagent": 86400, + "manual": "never" + } + } + }"#; + let s: RemoteSettings = serde_json::from_str(full).unwrap(); + let agc = s.worktree_auto_gc.clone().unwrap(); + let mut kind_map = std::collections::BTreeMap::new(); + kind_map.insert("session".into(), WorktreeKindMaxAge::Secs(604800)); + kind_map.insert("subagent".into(), WorktreeKindMaxAge::Secs(86400)); + kind_map.insert("manual".into(), WorktreeKindMaxAge::Never); + assert_eq!( + agc, + WorktreeAutoGcSettings { + enabled: Some(true), + max_age_secs: Some(604800), + min_interval_secs: Some(21600), + dry_run: Some(true), + include_orphan_snapshots: Some(false), + max_age_by_kind: Some(kind_map), + include_rebuild: None, + rebuild_min_interval_secs: None, + } + ); + let out = serde_json::to_string(&s).unwrap(); + let s2: RemoteSettings = serde_json::from_str(&out).unwrap(); + assert_eq!(s2.worktree_auto_gc, s.worktree_auto_gc); + let absent: RemoteSettings = serde_json::from_str("{}").unwrap(); + assert_eq!(absent.worktree_auto_gc, None); + let extra = r#"{"worktree_auto_gc":{"enabled":true,"future_knob":1}}"#; + let s: RemoteSettings = serde_json::from_str(extra).unwrap(); + assert_eq!(s.worktree_auto_gc.unwrap().enabled, Some(true)); + let partial_bad = r#"{"worktree_auto_gc":{"enabled":false,"max_age_secs":"nope"}}"#; + let s: RemoteSettings = serde_json::from_str(partial_bad).unwrap(); + let agc = s.worktree_auto_gc.as_ref().expect("object still present"); + assert_eq!(agc.enabled, Some(false)); + assert_eq!(agc.max_age_secs, None); + let kind_partial = r#"{ + "worktree_auto_gc": { + "max_age_by_kind": { + "subagent": 86400, + "session": {"nested": true}, + "manual": "never" + } + } + }"#; + let s: RemoteSettings = serde_json::from_str(kind_partial).unwrap(); + let map = s + .worktree_auto_gc + .as_ref() + .and_then(|a| a.max_age_by_kind.as_ref()) + .expect("map present"); + assert_eq!(map.get("subagent"), Some(&WorktreeKindMaxAge::Secs(86400))); + assert_eq!(map.get("manual"), Some(&WorktreeKindMaxAge::Never)); + assert!(!map.contains_key("session")); + let null_never = + r#"{"worktree_auto_gc":{"max_age_by_kind":{"manual":null,"pool":172800}}}"#; + let s: RemoteSettings = serde_json::from_str(null_never).unwrap(); + let map = s.worktree_auto_gc.unwrap().max_age_by_kind.unwrap(); + assert_eq!(map.get("manual"), Some(&WorktreeKindMaxAge::Never)); + assert_eq!(map.get("pool"), Some(&WorktreeKindMaxAge::Secs(172800))); + assert_eq!( + serde_json::to_value(&WorktreeKindMaxAge::Never).unwrap(), + serde_json::Value::String("never".into()) + ); + let nested_bad = r#"{"leader_mode":true,"worktree_auto_gc":"not-an-object"}"#; + let s: RemoteSettings = serde_json::from_str(nested_bad).unwrap(); + assert_eq!(s.leader_mode, Some(true)); + assert_eq!(s.worktree_auto_gc, None); + } + #[test] fn remote_settings_vendor_sessions_round_trip_and_default_absent() { let session_flags = |settings: &RemoteSettings| { ( @@ -1418,6 +1721,15 @@ mod tests { assert_eq!(s.contextual_hints, None); } #[test] + fn remote_settings_workflows_flag_round_trips() { + let settings: RemoteSettings = + serde_json::from_str(r#"{"workflows_enabled": true}"#).unwrap(); + assert_eq!(settings.workflows_enabled, Some(true)); + let round: RemoteSettings = + serde_json::from_str(&serde_json::to_string(&settings).unwrap()).unwrap(); + assert_eq!(round.workflows_enabled, Some(true)); + } + #[test] fn remote_settings_goal_planner_enabled_present() { let json = r#"{"goal_planner_enabled": true}"#; let s: RemoteSettings = serde_json::from_str(json).unwrap(); diff --git a/crates/codegen/xai-grok-config/src/config_override.rs b/crates/codegen/xai-grok-config/src/config_override.rs index cf5f808..240ad6b 100644 --- a/crates/codegen/xai-grok-config/src/config_override.rs +++ b/crates/codegen/xai-grok-config/src/config_override.rs @@ -76,8 +76,14 @@ pub fn patch_touches_any(patch: &toml::Table, paths: &[PatchPath]) -> bool { } /// Keys stripped from every applied patch: an override cannot re-inject nested -/// `version_overrides`/`campaigns` or define `[auth_provider.*]` command tables. -pub const PATCH_STRIP_KEYS: &[&str] = &["version_overrides", "campaigns", "auth_provider"]; +/// `version_overrides`/`campaigns` or define `[auth_provider.*]` / +/// `[model_providers.*]` command tables. +pub const PATCH_STRIP_KEYS: &[&str] = &[ + "version_overrides", + "campaigns", + "auth_provider", + "model_providers", +]; /// Deep-merge each patch in iteration order (later wins on a leaf), stripping /// `strip_keys` (top level) first. @@ -135,24 +141,35 @@ mod tests { "auth_provider".into(), toml::Value::Table(toml::Table::new()), ); + p.insert( + "model_providers".into(), + toml::Value::Table(toml::Table::new()), + ); p.insert("keep".into(), toml::Value::Boolean(true)); apply_patches(&mut cfg2, std::iter::once(p), PATCH_STRIP_KEYS); assert!(cfg2.get("version_overrides").is_none()); assert!(cfg2.get("campaigns").is_none()); assert!(cfg2.get("auth_provider").is_none()); + assert!(cfg2.get("model_providers").is_none()); assert_eq!(cfg2["keep"].as_bool(), Some(true)); // Top-level strip only: a model may still reference a local provider by name. let mut cfg3 = toml::Value::Table(toml::Table::new()); let p = table( "[auth_provider.injected]\ncommand = \"evil\"\n\ - [model.x]\nauth_provider = \"local-name\"\n", + [model_providers.injected]\nbase_url = \"https://evil.example/v1\"\n\ + [model.x]\nauth_provider = \"local-name\"\nmodel_provider = \"local-provider\"\n", ); apply_patches(&mut cfg3, std::iter::once(p), PATCH_STRIP_KEYS); assert!(cfg3.get("auth_provider").is_none()); + assert!(cfg3.get("model_providers").is_none()); assert_eq!( cfg3["model"]["x"]["auth_provider"].as_str(), Some("local-name") ); + assert_eq!( + cfg3["model"]["x"]["model_provider"].as_str(), + Some("local-provider") + ); } } diff --git a/crates/codegen/xai-grok-config/src/lib.rs b/crates/codegen/xai-grok-config/src/lib.rs index bc29328..39940b3 100644 --- a/crates/codegen/xai-grok-config/src/lib.rs +++ b/crates/codegen/xai-grok-config/src/lib.rs @@ -19,6 +19,7 @@ pub mod fs_atomic; mod loader; mod macos_managed; mod managed_cache; +pub mod managed_text; mod paths; pub mod shell; pub mod signed_policy; diff --git a/crates/codegen/xai-grok-config/src/managed_text/format.rs b/crates/codegen/xai-grok-config/src/managed_text/format.rs new file mode 100644 index 0000000..55522e3 --- /dev/null +++ b/crates/codegen/xai-grok-config/src/managed_text/format.rs @@ -0,0 +1,505 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use super::{ManagedConfigError, ManagedConfigRequest, ManagedItem}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommentSyntax { + pub(super) prefix: String, +} + +impl CommentSyntax { + pub fn new(prefix: impl Into) -> Result { + let prefix = prefix.into(); + if prefix.is_empty() || prefix.contains(['\r', '\n']) { + return Err(ManagedConfigError::InvalidRequest( + "comment prefix must be one non-empty line".to_owned(), + )); + } + Ok(Self { prefix }) + } + + pub fn hash() -> Self { + Self { + prefix: "#".to_owned(), + } + } +} + +pub(super) struct RenderedUpdate { + pub updated: String, + pub unmanaged_text: String, +} + +pub(super) fn validate_request(request: &ManagedConfigRequest) -> Result<(), ManagedConfigError> { + validate_name(&request.namespace, "namespace")?; + validate_name(&request.owned_item_prefix, "owned item prefix")?; + if request.items.is_empty() { + return Err(ManagedConfigError::InvalidRequest( + "at least one managed item is required".to_owned(), + )); + } + let mut names = HashSet::new(); + for item in &request.items { + validate_name(&item.name, "item name")?; + if !names.insert(&item.name) { + return Err(ManagedConfigError::InvalidRequest(format!( + "duplicate requested item {}", + item.name + ))); + } + if item.body.contains('\r') { + return Err(ManagedConfigError::InvalidRequest(format!( + "item {} contains a carriage return", + item.name + ))); + } + if item + .body + .lines() + .any(|line| marker_candidate(line, &request.comments.prefix).is_some()) + { + return Err(ManagedConfigError::InvalidRequest(format!( + "item {} contains marker-like content", + item.name + ))); + } + } + Ok(()) +} + +fn validate_name(name: &str, label: &str) -> Result<(), ManagedConfigError> { + if name.is_empty() + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-' | b' ')) + { + return Err(ManagedConfigError::InvalidRequest(format!( + "{label} contains unsupported characters" + ))); + } + Ok(()) +} + +pub(super) fn outer_block( + text: &str, + namespace: &str, + owned_item_prefix: &str, + comments: &CommentSyntax, + path: &Path, +) -> Result, ManagedConfigError> { + let parsed = parse_block(text, namespace, owned_item_prefix, comments, path)?; + Ok(parsed + .outer_range + .map(|(start, end)| text[start..end].trim_end_matches(['\r', '\n']).to_owned())) +} + +pub(super) fn render_update( + original: &str, + namespace: &str, + owned_item_prefix: &str, + items: &[ManagedItem], + comments: &CommentSyntax, + path: &Path, +) -> Result { + let initial = parse_block(original, namespace, owned_item_prefix, comments, path)?; + let unmanaged_text = initial.unmanaged_text(original); + let mut updated = original.to_owned(); + for item in items { + let parsed = parse_block(&updated, namespace, owned_item_prefix, comments, path)?; + let section = item_section(item, comments, parsed.newline); + updated = if let Some(range) = parsed.items.get(&item.name) { + let keep_eol = updated[range.start..range.end].ends_with('\n'); + let replacement = if keep_eol { + format!("{section}{}", parsed.newline.as_str()) + } else { + section + }; + replace_range(&updated, range.start, range.end, &replacement) + } else if let Some(close) = parsed.outer_close { + let insertion = format!("{section}{}", parsed.newline.as_str()); + replace_range(&updated, close.start, close.start, &insertion) + } else { + append_outer( + &updated, + namespace, + §ion, + comments, + parsed.newline, + parsed.final_newline, + ) + }; + } + parse_block(&updated, namespace, owned_item_prefix, comments, path)?; + Ok(RenderedUpdate { + updated, + unmanaged_text, + }) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum Newline { + Lf, + CrLf, +} + +impl Newline { + fn as_str(self) -> &'static str { + match self { + Self::Lf => "\n", + Self::CrLf => "\r\n", + } + } +} + +#[derive(Clone, Debug)] +struct Line { + start: usize, + content_end: usize, + end: usize, +} + +impl Line { + fn content<'a>(&self, text: &'a str) -> &'a str { + &text[self.start..self.content_end] + } +} + +#[derive(Clone, Debug)] +struct ItemRange { + start: usize, + end: usize, +} + +#[derive(Clone, Debug)] +struct ParsedBlock { + newline: Newline, + final_newline: bool, + outer_range: Option<(usize, usize)>, + outer_close: Option, + items: HashMap, +} + +impl ParsedBlock { + fn unmanaged_text(&self, text: &str) -> String { + let Some((start, end)) = self.outer_range else { + return text.to_owned(); + }; + let mut unmanaged = String::with_capacity(text.len() - (end - start)); + unmanaged.push_str(&text[..start]); + unmanaged.push_str(&text[end..]); + unmanaged + } +} + +fn replace_range(text: &str, start: usize, end: usize, replacement: &str) -> String { + let mut result = String::with_capacity(text.len() - (end - start) + replacement.len()); + result.push_str(&text[..start]); + result.push_str(replacement); + result.push_str(&text[end..]); + result +} + +fn append_outer( + original: &str, + namespace: &str, + section: &str, + comments: &CommentSyntax, + newline: Newline, + final_newline: bool, +) -> String { + let eol = newline.as_str(); + let block = format!( + "{} >>> {} >>>{eol}{section}{eol}{} <<< {} <<<", + comments.prefix, namespace, comments.prefix, namespace + ); + if original.is_empty() { + return block; + } + if final_newline { + format!("{original}{block}{eol}") + } else { + format!("{original}{eol}{block}") + } +} + +fn item_section(item: &ManagedItem, comments: &CommentSyntax, newline: Newline) -> String { + let eol = newline.as_str(); + let body = item.body.trim_end_matches('\n').replace('\n', eol); + format!( + "{} >>> {} >>>{eol}{body}{eol}{} <<< {} <<<", + comments.prefix, item.name, comments.prefix, item.name + ) +} + +fn parse_block( + text: &str, + namespace: &str, + owned_item_prefix: &str, + comments: &CommentSyntax, + path: &Path, +) -> Result { + let newline = detect_newline(text).map_err(|reason| ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason, + })?; + let lines = lines(text); + let final_newline = text.ends_with('\n'); + let outer_open_text = format!("{} >>> {} >>>", comments.prefix, namespace); + let outer_close_text = format!("{} <<< {} <<<", comments.prefix, namespace); + + for line in &lines { + let content = line.content(text); + let Some(candidate) = marker_candidate(content, &comments.prefix) else { + continue; + }; + let owns_marker = candidate.contains(namespace) || candidate.contains(owned_item_prefix); + if owns_marker + && content != outer_open_text + && content != outer_close_text + && parse_marker(content, &comments.prefix).is_none() + { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: format!("malformed owned marker `{content}`"), + }); + } + } + + let opens = lines + .iter() + .filter(|line| line.content(text) == outer_open_text) + .cloned() + .collect::>(); + let closes = lines + .iter() + .filter(|line| line.content(text) == outer_close_text) + .cloned() + .collect::>(); + if opens.len() != closes.len() || opens.len() > 1 { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: "duplicate or unmatched outer markers".to_owned(), + }); + } + let (open, close) = match (opens.first(), closes.first()) { + (None, None) => { + reject_owned_markers_outside(text, &lines, None, owned_item_prefix, comments, path)?; + return Ok(ParsedBlock { + newline, + final_newline, + outer_range: None, + outer_close: None, + items: HashMap::new(), + }); + } + (Some(open), Some(close)) if open.start < close.start => (open.clone(), close.clone()), + (Some(_), Some(_)) => { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: "outer markers are reversed".to_owned(), + }); + } + _ => unreachable!("outer marker counts were checked"), + }; + + reject_owned_markers_outside( + text, + &lines, + Some((open.start, close.end)), + owned_item_prefix, + comments, + path, + )?; + + let mut items = HashMap::new(); + let mut active: Option<(String, Line)> = None; + for line in lines + .iter() + .filter(|line| line.start > open.start && line.start < close.start) + { + let content = line.content(text); + if content.trim().is_empty() && active.is_none() { + continue; + } + let Some((direction, name)) = parse_marker(content, &comments.prefix) else { + if active.is_none() { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: "content outside a named item section".to_owned(), + }); + } + continue; + }; + match (direction, active.take()) { + (MarkerDirection::Open, None) + if name != namespace && name.starts_with(owned_item_prefix) => + { + active = Some((name, line.clone())); + } + (MarkerDirection::Open, None) => { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: format!("unowned item marker {name} inside managed block"), + }); + } + (MarkerDirection::Open, _) => { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: "nested or duplicate item opening marker".to_owned(), + }); + } + (MarkerDirection::Close, Some((open_name, open))) if open_name == name => { + if items + .insert( + name.clone(), + ItemRange { + start: open.start, + end: line.end, + }, + ) + .is_some() + { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: format!("duplicate item section {name}"), + }); + } + } + (MarkerDirection::Close, Some(_)) => { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: format!("reversed or mismatched item marker {name}"), + }); + } + (MarkerDirection::Close, None) => { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: format!("unmatched item closing marker {name}"), + }); + } + } + } + if let Some((name, _)) = active { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: format!("unmatched item opening marker {name}"), + }); + } + + Ok(ParsedBlock { + newline, + final_newline, + outer_range: Some((open.start, close.end)), + outer_close: Some(close), + items, + }) +} + +fn reject_owned_markers_outside( + text: &str, + lines: &[Line], + outer: Option<(usize, usize)>, + owned_item_prefix: &str, + comments: &CommentSyntax, + path: &Path, +) -> Result<(), ManagedConfigError> { + for line in lines { + if outer.is_some_and(|(start, end)| line.start >= start && line.start < end) { + continue; + } + if let Some((_, name)) = parse_marker(line.content(text), &comments.prefix) + && name.starts_with(owned_item_prefix) + { + return Err(ManagedConfigError::InvalidMarkers { + path: path.to_path_buf(), + reason: format!("owned item marker {name} appears outside the outer block"), + }); + } + } + Ok(()) +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum MarkerDirection { + Open, + Close, +} + +fn marker_candidate<'a>(line: &'a str, prefix: &str) -> Option<&'a str> { + let rest = line.strip_prefix(prefix)?; + let marker = rest.trim_start_matches([' ', '\t']); + if marker.len() == rest.len() { + return None; + } + (marker.starts_with(">>>") || marker.starts_with("<<<")).then_some(marker) +} + +fn parse_marker(line: &str, prefix: &str) -> Option<(MarkerDirection, String)> { + marker_candidate(line, prefix)?; + let open_prefix = format!("{prefix} >>> "); + if let Some(name) = line + .strip_prefix(&open_prefix) + .and_then(|rest| rest.strip_suffix(" >>>")) + .filter(|name| !name.is_empty()) + { + return Some((MarkerDirection::Open, name.to_owned())); + } + let close_prefix = format!("{prefix} <<< "); + line.strip_prefix(&close_prefix) + .and_then(|rest| rest.strip_suffix(" <<<")) + .filter(|name| !name.is_empty()) + .map(|name| (MarkerDirection::Close, name.to_owned())) +} + +fn detect_newline(text: &str) -> Result { + let bytes = text.as_bytes(); + let mut saw_lf = false; + let mut saw_crlf = false; + for (index, byte) in bytes.iter().enumerate() { + if *byte == b'\r' && bytes.get(index + 1) != Some(&b'\n') { + return Err("bare carriage return in config".to_owned()); + } + if *byte == b'\n' { + if index > 0 && bytes[index - 1] == b'\r' { + saw_crlf = true; + } else { + saw_lf = true; + } + } + } + match (saw_lf, saw_crlf) { + (true, true) => Err("mixed line endings in config".to_owned()), + (false, true) => Ok(Newline::CrLf), + _ => Ok(Newline::Lf), + } +} + +fn lines(text: &str) -> Vec { + let bytes = text.as_bytes(); + let mut result = Vec::new(); + let mut start = 0; + for (index, byte) in bytes.iter().enumerate() { + if *byte == b'\n' { + let content_end = if index > start && bytes[index - 1] == b'\r' { + index - 1 + } else { + index + }; + result.push(Line { + start, + content_end, + end: index + 1, + }); + start = index + 1; + } + } + if start < text.len() { + result.push(Line { + start, + content_end: text.len(), + end: text.len(), + }); + } + result +} diff --git a/crates/codegen/xai-grok-config/src/managed_text/mod.rs b/crates/codegen/xai-grok-config/src/managed_text/mod.rs new file mode 100644 index 0000000..fe56674 --- /dev/null +++ b/crates/codegen/xai-grok-config/src/managed_text/mod.rs @@ -0,0 +1,258 @@ +//! Item-addressable edits for marked blocks in line-comment config files. +//! +//! Structured formats such as TOML keep their native editors. + +use std::path::{Path, PathBuf}; + +mod format; +mod source; +mod transaction; +mod validator; + +pub use format::CommentSyntax; +pub use validator::SyntaxValidator; + +use source::{ParentPlan, SourceState}; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ManagedItem { + pub name: String, + pub body: String, +} + +impl ManagedItem { + pub fn new(name: impl Into, body: impl Into) -> Self { + Self { + name: name.into(), + body: body.into(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ManagedConfigRequest { + pub path: PathBuf, + pub namespace: String, + /// Prefix identifying every item marker owned by this writer namespace. + pub owned_item_prefix: String, + pub items: Vec, + pub comments: CommentSyntax, + pub validator: Option, +} + +/// Validated source from the snapshot used to build the plan. +#[derive(Clone, Debug)] +pub struct ManagedTextInspection { + original_text: Option, + unmanaged_text: String, +} + +impl ManagedTextInspection { + pub fn original_text(&self) -> Option<&str> { + self.original_text.as_deref() + } + + /// Source outside the writer-owned outer block. + pub fn unmanaged_text(&self) -> &str { + &self.unmanaged_text + } +} + +/// Immutable source and output state presented before application. +#[derive(Clone, Debug)] +pub struct ManagedConfigPlan { + request: ManagedConfigRequest, + requested_path: PathBuf, + target_path: PathBuf, + parent_plan: ParentPlan, + original: SourceState, + inspection: ManagedTextInspection, + updated: Vec, + backup_path_hint: Option, + temp_path_hint: Option, + lock_path: PathBuf, +} + +impl ManagedConfigPlan { + pub fn requested_path(&self) -> &Path { + &self.requested_path + } + + pub fn target_path(&self) -> &Path { + &self.target_path + } + + pub fn inspection(&self) -> &ManagedTextInspection { + &self.inspection + } + + pub fn updated_bytes(&self) -> &[u8] { + &self.updated + } + + /// Exact complete managed outer block as it will appear after apply. + pub fn managed_block(&self) -> Option { + format::outer_block( + std::str::from_utf8(&self.updated).ok()?, + &self.request.namespace, + &self.request.owned_item_prefix, + &self.request.comments, + &self.target_path, + ) + .ok() + .flatten() + } + + /// Proposed backup path shown during confirmation. Apply first tries this + /// exact path, then atomically retries nearby names if it was claimed in + /// the meantime. [`ManagedConfigOutcome::backup_path`] is authoritative. + pub fn backup_path_hint(&self) -> Option<&Path> { + self.backup_path_hint.as_deref() + } + + pub fn changes_file(&self) -> bool { + self.original.bytes.as_deref() != Some(self.updated.as_slice()) + || self.original.bytes.is_none() + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ManagedConfigStatus { + Applied, + NoChange, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct ManagedConfigOutcome { + pub status: ManagedConfigStatus, + pub requested_path: PathBuf, + pub target_path: PathBuf, + /// Actual collision-free backup path retained for an applied change. + pub backup_path: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum ManagedConfigError { + #[error("invalid managed-config request: {0}")] + InvalidRequest(String), + #[error("refusing unsafe config path {path}: {reason}")] + UnsafePath { path: PathBuf, reason: String }, + #[error("could not read config {path}: {source}")] + Read { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("invalid managed markers in {path}: {reason}")] + InvalidMarkers { path: PathBuf, reason: String }, + #[error("config changed after confirmation; run the fix again: {0}")] + StalePlan(PathBuf), + #[error("config parent changed after confirmation; run the fix again: {0}")] + ParentChanged(PathBuf), + #[error("could not lock config transaction {path}: {source}")] + Lock { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("could not write config artifact {path}: {source}")] + Write { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("syntax validation failed for {path}: {reason}")] + Validation { path: PathBuf, reason: String }, + #[error("could not atomically publish {path}: {source}")] + Publish { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("could not sync config directory {path}: {source}")] + Sync { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("post-write verification failed for {path}: {reason}")] + Verification { path: PathBuf, reason: String }, + #[error("transaction failed: {primary}; recovery also failed: {recovery}")] + Recovery { + primary: Box, + recovery: Box, + }, + #[error("transaction phase {phase} failed: {source}")] + Phase { + phase: &'static str, + #[source] + source: std::io::Error, + }, +} + +pub struct ManagedConfig; + +impl ManagedConfig { + pub fn plan(request: ManagedConfigRequest) -> Result { + format::validate_request(&request)?; + let requested_path = source::absolute_lexical(&request.path)?; + let target_path = source::resolve_final_symlink(&requested_path)?; + let parent = target_path + .parent() + .ok_or_else(|| ManagedConfigError::UnsafePath { + path: target_path.clone(), + reason: "target has no parent directory".to_owned(), + })?; + let parent_plan = ParentPlan::capture(parent)?; + let original = source::read_source(&target_path)?; + let text = original.text(&target_path)?; + let rendered = format::render_update( + text, + &request.namespace, + &request.owned_item_prefix, + &request.items, + &request.comments, + &target_path, + )?; + let inspection = ManagedTextInspection { + original_text: original.bytes.as_ref().map(|_| text.to_owned()), + unmanaged_text: rendered.unmanaged_text, + }; + let updated = rendered.updated.into_bytes(); + let changes = + original.bytes.as_deref() != Some(updated.as_slice()) || original.bytes.is_none(); + let backup_path_hint = (changes && original.bytes.is_some()) + .then(|| transaction::artifact_hint(&target_path, "grok-backup")); + let temp_path_hint = changes.then(|| transaction::artifact_hint(&target_path, "grok-tmp")); + let lock_path = transaction::sibling_artifact(&target_path, "grok.lock"); + + Ok(ManagedConfigPlan { + request, + requested_path, + target_path, + parent_plan, + original, + inspection, + updated, + backup_path_hint, + temp_path_hint, + lock_path, + }) + } + + pub fn apply(plan: ManagedConfigPlan) -> Result { + transaction::apply(plan, &transaction::NoopObserver) + } + + #[cfg(test)] + fn apply_with_observer( + plan: ManagedConfigPlan, + observer: &dyn transaction::TransactionObserver, + ) -> Result { + transaction::apply(plan, observer) + } +} + +#[cfg(test)] +#[path = "tests.rs"] +mod tests; diff --git a/crates/codegen/xai-grok-config/src/managed_text/source.rs b/crates/codegen/xai-grok-config/src/managed_text/source.rs new file mode 100644 index 0000000..704b115 --- /dev/null +++ b/crates/codegen/xai-grok-config/src/managed_text/source.rs @@ -0,0 +1,421 @@ +use std::collections::HashSet; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +use super::{ManagedConfigError, ManagedConfigPlan}; + +pub(super) const MAX_SYMLINKS: usize = 40; +pub(super) const MAX_CONFIG_BYTES: u64 = 4 * 1024 * 1024; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct SourceState { + pub bytes: Option>, + pub hash: String, + pub mode: Option, + pub identity: Option, +} + +impl SourceState { + pub fn text<'a>(&'a self, path: &Path) -> Result<&'a str, ManagedConfigError> { + match self.bytes.as_deref() { + Some(bytes) => std::str::from_utf8(bytes).map_err(|_| ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: "file is not valid UTF-8".to_owned(), + }), + None => Ok(""), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct ParentPlan { + parent: PathBuf, + existing_chain: Vec, + first_missing: Option, +} + +impl ParentPlan { + pub fn capture(parent: &Path) -> Result { + let mut chain = Vec::new(); + let mut current = PathBuf::new(); + let mut first_missing = None; + for component in parent.components() { + current.push(component.as_os_str()); + if matches!(component, Component::Prefix(_) | Component::RootDir) { + continue; + } + match fs::symlink_metadata(¤t) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + return Err(ManagedConfigError::UnsafePath { + path: current, + reason: "symlinked parent directory is not allowed".to_owned(), + }); + } + if !metadata.is_dir() { + return Err(ManagedConfigError::UnsafePath { + path: current, + reason: "parent component is not a directory".to_owned(), + }); + } + chain.push(PathIdentity { + path: current.clone(), + identity: FileIdentity::from_metadata(&metadata), + }); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + first_missing = Some(current.clone()); + break; + } + Err(source) => { + return Err(ManagedConfigError::Read { + path: current, + source, + }); + } + } + } + Ok(Self { + parent: parent.to_path_buf(), + existing_chain: chain, + first_missing, + }) + } + + pub fn ensure_and_anchor(&self) -> Result { + self.revalidate_existing()?; + fs::create_dir_all(&self.parent).map_err(|source| ManagedConfigError::Write { + path: self.parent.clone(), + source, + })?; + self.revalidate_existing()?; + let current = Self::capture(&self.parent)?; + if current.first_missing.is_some() + || !current.existing_chain.starts_with(&self.existing_chain) + { + return Err(ManagedConfigError::ParentChanged(self.parent.clone())); + } + ParentAnchor::capture(&self.parent) + } + + pub fn revalidate_planned(&self) -> Result<(), ManagedConfigError> { + self.revalidate_existing()?; + if self.first_missing.is_none() { + let current = Self::capture(&self.parent)?; + if current.existing_chain != self.existing_chain { + return Err(ManagedConfigError::ParentChanged(self.parent.clone())); + } + } + Ok(()) + } + + fn revalidate_existing(&self) -> Result<(), ManagedConfigError> { + for expected in &self.existing_chain { + let metadata = fs::symlink_metadata(&expected.path) + .map_err(|_| ManagedConfigError::ParentChanged(expected.path.clone()))?; + if metadata.file_type().is_symlink() + || !metadata.is_dir() + || FileIdentity::from_metadata(&metadata) != expected.identity + { + return Err(ManagedConfigError::ParentChanged(expected.path.clone())); + } + } + Ok(()) + } +} + +#[derive(Debug)] +pub(super) struct ParentAnchor { + path: PathBuf, + identity: FileIdentity, + directory: fs::File, +} + +impl ParentAnchor { + fn capture(path: &Path) -> Result { + let metadata = fs::symlink_metadata(path).map_err(|source| ManagedConfigError::Read { + path: path.to_path_buf(), + source, + })?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(ManagedConfigError::ParentChanged(path.to_path_buf())); + } + let directory = fs::File::open(path).map_err(|source| ManagedConfigError::Read { + path: path.to_path_buf(), + source, + })?; + Ok(Self { + path: path.to_path_buf(), + identity: FileIdentity::from_metadata(&metadata), + directory, + }) + } + + pub fn revalidate(&self) -> Result<(), ManagedConfigError> { + let current = Self::capture(&self.path)?; + if current.identity != self.identity { + return Err(ManagedConfigError::ParentChanged(self.path.clone())); + } + Ok(()) + } + + pub fn sync(&self) -> Result<(), ManagedConfigError> { + #[cfg(unix)] + { + self.directory + .sync_all() + .map_err(|source| ManagedConfigError::Sync { + path: self.path.clone(), + source, + }) + } + #[cfg(not(unix))] + { + Ok(()) + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct PathIdentity { + path: PathBuf, + identity: FileIdentity, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) struct FileIdentity { + #[cfg(unix)] + dev: u64, + #[cfg(unix)] + ino: u64, + #[cfg(not(unix))] + len: u64, + #[cfg(not(unix))] + modified: Option, +} + +impl FileIdentity { + fn from_metadata(metadata: &fs::Metadata) -> Self { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt as _; + Self { + dev: metadata.dev(), + ino: metadata.ino(), + } + } + #[cfg(not(unix))] + { + Self { + len: metadata.len(), + modified: metadata.modified().ok(), + } + } + } +} + +pub(super) fn absolute_lexical(path: &Path) -> Result { + let absolute = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .map_err(|source| ManagedConfigError::Read { + path: path.to_path_buf(), + source, + })? + .join(path) + }; + Ok(normalize_lexically(&absolute)) +} + +fn normalize_lexically(path: &Path) -> PathBuf { + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + other => normalized.push(other.as_os_str()), + } + } + normalized +} + +pub(super) fn resolve_final_symlink(path: &Path) -> Result { + let mut current = physicalize_parent(path)?; + let mut followed = false; + let mut seen = HashSet::new(); + for _ in 0..MAX_SYMLINKS { + if !seen.insert(current.clone()) { + return Err(ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: "symlink cycle detected".to_owned(), + }); + } + match fs::symlink_metadata(¤t) { + Ok(metadata) if metadata.file_type().is_symlink() => { + followed = true; + let link = fs::read_link(¤t).map_err(|source| ManagedConfigError::Read { + path: current.clone(), + source, + })?; + current = if link.is_absolute() { + normalize_lexically(&link) + } else { + normalize_lexically( + ¤t + .parent() + .unwrap_or_else(|| Path::new("/")) + .join(link), + ) + }; + } + Ok(_) => return Ok(current), + Err(error) if error.kind() == std::io::ErrorKind::NotFound && !followed => { + return Ok(current); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Err(ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: "symlink target does not exist".to_owned(), + }); + } + Err(source) => { + return Err(ManagedConfigError::Read { + path: current, + source, + }); + } + } + } + Err(ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: format!("symlink chain exceeds {MAX_SYMLINKS} links"), + }) +} + +fn physicalize_parent(path: &Path) -> Result { + let Some(parent) = path.parent() else { + return Ok(path.to_path_buf()); + }; + let mut probe = parent; + let mut missing = Vec::new(); + loop { + match dunce::canonicalize(probe) { + Ok(canonical) => { + let mut physical = canonical; + for component in missing.iter().rev() { + physical.push(component); + } + if let Some(name) = path.file_name() { + physical.push(name); + } + return Ok(physical); + } + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + let name = probe + .file_name() + .ok_or_else(|| ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: "could not resolve config parent".to_owned(), + })?; + missing.push(name.to_os_string()); + probe = probe + .parent() + .ok_or_else(|| ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: "could not resolve config parent".to_owned(), + })?; + } + Err(source) => { + return Err(ManagedConfigError::Read { + path: probe.to_path_buf(), + source, + }); + } + } + } +} + +pub(super) fn read_source(path: &Path) -> Result { + let metadata = match fs::metadata(path) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok(SourceState { + bytes: None, + hash: blake3::hash(&[]).to_hex().to_string(), + mode: default_mode(), + identity: None, + }); + } + Err(source) => { + return Err(ManagedConfigError::Read { + path: path.to_path_buf(), + source, + }); + } + }; + if !metadata.file_type().is_file() { + return Err(ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: "target is not a regular file".to_owned(), + }); + } + if metadata.len() > MAX_CONFIG_BYTES { + return Err(ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: format!("file exceeds {MAX_CONFIG_BYTES} bytes"), + }); + } + let bytes = fs::read(path).map_err(|source| ManagedConfigError::Read { + path: path.to_path_buf(), + source, + })?; + if bytes.contains(&0) { + return Err(ManagedConfigError::UnsafePath { + path: path.to_path_buf(), + reason: "file contains NUL bytes".to_owned(), + }); + } + Ok(SourceState { + hash: blake3::hash(&bytes).to_hex().to_string(), + bytes: Some(bytes), + mode: file_mode(&metadata), + identity: Some(FileIdentity::from_metadata(&metadata)), + }) +} + +pub(super) fn revalidate(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> { + plan.parent_plan.revalidate_planned()?; + let target = resolve_final_symlink(&plan.requested_path)?; + if target != plan.target_path { + return Err(ManagedConfigError::StalePlan(plan.requested_path.clone())); + } + let current = read_source(&target)?; + if current != plan.original { + return Err(ManagedConfigError::StalePlan(plan.requested_path.clone())); + } + Ok(()) +} + +#[cfg(unix)] +fn file_mode(metadata: &fs::Metadata) -> Option { + use std::os::unix::fs::PermissionsExt as _; + Some(metadata.permissions().mode() & 0o7777) +} + +#[cfg(not(unix))] +fn file_mode(_: &fs::Metadata) -> Option { + None +} + +#[cfg(unix)] +fn default_mode() -> Option { + Some(0o644) +} + +#[cfg(not(unix))] +fn default_mode() -> Option { + None +} diff --git a/crates/codegen/xai-grok-config/src/managed_text/tests.rs b/crates/codegen/xai-grok-config/src/managed_text/tests.rs new file mode 100644 index 0000000..605fc52 --- /dev/null +++ b/crates/codegen/xai-grok-config/src/managed_text/tests.rs @@ -0,0 +1,632 @@ +use std::collections::HashSet; +use std::fs; +use std::io; +use std::path::Path; +use std::sync::{Arc, Barrier, Mutex}; +use std::time::{Duration, Instant}; + +use super::transaction::{TransactionObserver, TransactionPhase}; +use super::*; + +fn request(path: &Path, items: &[(&str, &str)]) -> ManagedConfigRequest { + ManagedConfigRequest { + path: path.to_path_buf(), + namespace: "grok doctor".to_owned(), + owned_item_prefix: "terminal.".to_owned(), + items: items + .iter() + .map(|(name, body)| { + let name = if name.starts_with("terminal.") { + (*name).to_owned() + } else { + format!("terminal.{name}") + }; + ManagedItem::new(name, *body) + }) + .collect(), + comments: CommentSyntax::hash(), + validator: None, + } +} + +fn expected(body: &str, newline: &str) -> String { + [ + "# >>> grok doctor >>>", + "# >>> terminal.ssh-wrap >>>", + body, + "# <<< terminal.ssh-wrap <<<", + "# <<< grok doctor <<<", + ] + .join(newline) +} + +fn artifacts(directory: &Path) -> HashSet { + fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|name| name.contains(".grok-")) + .collect() +} + +#[test] +fn missing_empty_normal_no_final_newline_and_crlf_are_preserved() { + let temp = tempfile::tempdir().unwrap(); + let missing = temp.path().join("missing.rc"); + let plan = ManagedConfig::plan(request( + &missing, + &[("terminal.ssh-wrap", "alias ssh='grok wrap ssh'")], + )) + .unwrap(); + assert_eq!( + plan.updated_bytes(), + expected("alias ssh='grok wrap ssh'", "\n").as_bytes() + ); + assert!(plan.backup_path_hint().is_none()); + ManagedConfig::apply(plan).unwrap(); + assert_eq!( + fs::read_to_string(&missing).unwrap(), + expected("alias ssh='grok wrap ssh'", "\n") + ); + + let empty = temp.path().join("empty.rc"); + fs::write(&empty, "").unwrap(); + let plan = ManagedConfig::plan(request( + &empty, + &[("terminal.ssh-wrap", "alias ssh='grok wrap ssh'")], + )) + .unwrap(); + assert!(plan.backup_path_hint().is_some()); + ManagedConfig::apply(plan).unwrap(); + + let normal = temp.path().join("normal.rc"); + fs::write(&normal, "export KEEP=1\n").unwrap(); + let plan = ManagedConfig::plan(request( + &normal, + &[("terminal.ssh-wrap", "alias ssh='grok wrap ssh'")], + )) + .unwrap(); + assert_eq!( + String::from_utf8(plan.updated_bytes().to_vec()).unwrap(), + format!( + "export KEEP=1\n{}\n", + expected("alias ssh='grok wrap ssh'", "\n") + ) + ); + + let no_final = temp.path().join("no-final.rc"); + fs::write(&no_final, "export KEEP=1").unwrap(); + let plan = ManagedConfig::plan(request(&no_final, &[("item", "body")])).unwrap(); + assert!( + !String::from_utf8(plan.updated_bytes().to_vec()) + .unwrap() + .ends_with('\n') + ); + + let crlf = temp.path().join("crlf.rc"); + fs::write(&crlf, b"set -x KEEP 1\r\n").unwrap(); + let plan = ManagedConfig::plan(request(&crlf, &[("item", "body")])).unwrap(); + let rendered = String::from_utf8(plan.updated_bytes().to_vec()).unwrap(); + assert!(!rendered.replace("\r\n", "").contains('\n')); +} + +#[test] +fn typed_inspection_and_item_updates_share_one_validated_parse() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write( + &path, + "before\n# >>> grok doctor >>>\n# >>> terminal.old >>>\nold\n# <<< terminal.old <<<\n# <<< grok doctor <<<\nafter\n", + ) + .unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("new", "new body")])).unwrap(); + let original = fs::read_to_string(&path).unwrap(); + assert_eq!(plan.inspection().original_text(), Some(original.as_str())); + assert_eq!(plan.inspection().unmanaged_text(), "before\nafter\n"); + let block = plan.managed_block().unwrap(); + assert!(block.contains("# >>> terminal.old >>>\nold\n# <<< terminal.old <<<")); + assert!(block.contains("# >>> terminal.new >>>\nnew body\n# <<< terminal.new <<<")); + ManagedConfig::apply(plan).unwrap(); + + let plan = ManagedConfig::plan(request(&path, &[("old", "replaced")])).unwrap(); + let rendered = String::from_utf8(plan.updated_bytes().to_vec()).unwrap(); + assert!(rendered.contains("# >>> terminal.old >>>\nreplaced\n# <<< terminal.old <<<")); + assert!(rendered.starts_with("before\n")); + assert!(rendered.ends_with("after\n")); +} + +#[test] +fn prose_and_exports_with_owned_words_and_chevrons_are_inert() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("inert"); + let content = [ + "# Terminal.app note: terminal. support >>> may vary <<< by host", + "# grok doctor docs say >>> run this later <<<", + "export NOTE='terminal.ssh-wrap >>> not a marker'", + "printf '%s\\n' 'grok doctor <<< prose >>>'", + "#terminal.future prose >>> lacks marker grammar", + "echo '# >>> terminal.future >>> embedded text'", + ] + .join("\n"); + fs::write(&path, &content).unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("terminal.current", "body")])).unwrap(); + assert!(String::from_utf8_lossy(plan.updated_bytes()).starts_with(&content)); +} + +#[test] +fn malformed_structural_owned_near_markers_are_rejected() { + let temp = tempfile::tempdir().unwrap(); + for (index, content) in [ + "# >>> terminal.future >>\n", + "# <<< terminal.future <<\n", + "# >>> terminal.future >> extra\n", + "#\t<<< terminal.future <<< extra\n", + "# >>> grok doctor >>\n", + ] + .iter() + .enumerate() + { + let path = temp.path().join(format!("near-{index}")); + fs::write(&path, content).unwrap(); + assert!(matches!( + ManagedConfig::plan(request(&path, &[("terminal.current", "body")])), + Err(ManagedConfigError::InvalidMarkers { .. }) + )); + } +} + +#[test] +fn owned_future_markers_are_rejected_independent_of_requested_items() { + let temp = tempfile::tempdir().unwrap(); + for (index, content) in [ + "# >>> terminal.future >>>\nbody\n# <<< terminal.future <<<\n", + "# >>> grok doctor >>>\n# >>> terminal.current >>>\nbody\n# <<< terminal.current <<<\n# <<< grok doctor <<<\n# >>> terminal.future >>>\nbody\n# <<< terminal.future <<<\n", + ] + .iter() + .enumerate() + { + let path = temp.path().join(format!("future-{index}")); + fs::write(&path, content).unwrap(); + assert!(matches!( + ManagedConfig::plan(request(&path, &[("terminal.current", "body")])), + Err(ManagedConfigError::InvalidMarkers { .. }) + )); + } + + let unrelated = temp.path().join("unrelated"); + fs::write( + &unrelated, + "# >>> user custom >>>\nnot ours\n# <<< user custom <<<\n", + ) + .unwrap(); + assert!(ManagedConfig::plan(request(&unrelated, &[("terminal.current", "body")])).is_ok()); +} + +#[test] +fn exact_noop_creates_no_transaction_artifacts_or_rewrite() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + let content = expected("body", "\n"); + fs::write(&path, &content).unwrap(); + let before = fs::metadata(&path).unwrap().modified().unwrap(); + let outcome = ManagedConfig::apply( + ManagedConfig::plan(request(&path, &[("terminal.ssh-wrap", "body")])).unwrap(), + ) + .unwrap(); + assert_eq!(outcome.status, ManagedConfigStatus::NoChange); + assert_eq!(fs::read_to_string(&path).unwrap(), content); + assert_eq!(fs::metadata(&path).unwrap().modified().unwrap(), before); + assert!(artifacts(temp.path()).is_empty()); +} + +#[test] +fn invalid_inputs_and_all_marker_shapes_are_refused() { + let temp = tempfile::tempdir().unwrap(); + let oversize = temp.path().join("oversize"); + fs::write( + &oversize, + vec![b'x'; super::source::MAX_CONFIG_BYTES as usize + 1], + ) + .unwrap(); + let nul = temp.path().join("nul"); + fs::write(&nul, b"a\0b").unwrap(); + let non_utf8 = temp.path().join("non-utf8"); + fs::write(&non_utf8, [0xff]).unwrap(); + for path in [&oversize, &nul, &non_utf8] { + assert!(matches!( + ManagedConfig::plan(request(path, &[("item", "body")])), + Err(ManagedConfigError::UnsafePath { .. }) + )); + } + + let cases = [ + "# >>> grok doctor >>>\n", + "# <<< grok doctor <<<\n# >>> grok doctor >>>\n", + "# >>> grok doctor >>\n", + "# >>> grok doctor >>>\nraw\n# <<< grok doctor <<<\n", + "# >>> grok doctor >>>\n# <<< terminal.item <<<\n# <<< grok doctor <<<\n", + "# >>> grok doctor >>>\n# >>> terminal.item >>>\nbody\n# <<< terminal.other <<<\n# <<< grok doctor <<<\n", + "# >>> grok doctor >>>\n# >>> terminal.item >>>\nbody\n# <<< terminal.item <<<\n# >>> terminal.item >>>\nbody\n# <<< terminal.item <<<\n# <<< grok doctor <<<\n", + "# >>> terminal.item >>>\nbody\n# <<< terminal.item <<<\n", + ]; + for (index, content) in cases.iter().enumerate() { + let path = temp.path().join(format!("marker-{index}")); + fs::write(&path, content).unwrap(); + assert!(matches!( + ManagedConfig::plan(request(&path, &[("item", "new")])), + Err(ManagedConfigError::InvalidMarkers { .. }) + )); + } +} + +#[cfg(unix)] +#[test] +fn symlink_resolution_depth_cycles_and_parent_symlinks_are_refused() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let physical = temp.path().join("physical"); + fs::write(&physical, "keep\n").unwrap(); + let relative = temp.path().join("relative"); + symlink("physical", &relative).unwrap(); + let plan = ManagedConfig::plan(request(&relative, &[("item", "body")])).unwrap(); + assert_eq!( + plan.target_path(), + fs::canonicalize(&physical).unwrap().as_path() + ); + ManagedConfig::apply(plan).unwrap(); + assert!( + fs::symlink_metadata(&relative) + .unwrap() + .file_type() + .is_symlink() + ); + + let cycle_a = temp.path().join("cycle-a"); + let cycle_b = temp.path().join("cycle-b"); + symlink("cycle-b", &cycle_a).unwrap(); + symlink("cycle-a", &cycle_b).unwrap(); + assert!(ManagedConfig::plan(request(&cycle_a, &[("item", "body")])).is_err()); + + let mut last = temp.path().join("depth-target"); + fs::write(&last, "body").unwrap(); + for index in 0..=super::source::MAX_SYMLINKS { + let next = temp.path().join(format!("depth-{index}")); + symlink(&last, &next).unwrap(); + last = next; + } + assert!(ManagedConfig::plan(request(&last, &[("item", "body")])).is_err()); + + let real_parent = temp.path().join("real-parent"); + fs::create_dir(&real_parent).unwrap(); + let linked_parent = temp.path().join("linked-parent"); + symlink(&real_parent, &linked_parent).unwrap(); + let plan = + ManagedConfig::plan(request(&linked_parent.join("rc"), &[("item", "body")])).unwrap(); + assert_eq!( + plan.target_path().parent(), + Some(fs::canonicalize(&real_parent).unwrap().as_path()) + ); +} + +#[cfg(unix)] +#[test] +fn bytes_mode_and_actual_backup_are_exact() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + let original = b"export KEEP=1\r\n"; + fs::write(&path, original).unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o640)).unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + let hint = plan.backup_path_hint().unwrap().to_path_buf(); + let outcome = ManagedConfig::apply(plan).unwrap(); + let backup = outcome.backup_path.unwrap(); + assert_eq!(backup, hint); + assert_eq!(fs::read(&backup).unwrap(), original); + assert_eq!( + fs::metadata(&backup).unwrap().permissions().mode() & 0o777, + 0o640 + ); + assert_eq!( + fs::metadata(&path).unwrap().permissions().mode() & 0o777, + 0o640 + ); +} + +#[test] +fn stale_source_and_parent_swap_are_rejected_before_publication() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("parent/config.rc"); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + fs::write(&path, "before\n").unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + fs::write(&path, "changed\n").unwrap(); + assert!(matches!( + ManagedConfig::apply(plan), + Err(ManagedConfigError::StalePlan(_)) + )); + + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + let old_parent = temp.path().join("old-parent"); + fs::rename(path.parent().unwrap(), &old_parent).unwrap(); + fs::create_dir(path.parent().unwrap()).unwrap(); + assert!(matches!( + ManagedConfig::apply(plan), + Err(ManagedConfigError::ParentChanged(_)) + )); + assert!(!path.exists()); +} + +#[cfg(unix)] +#[test] +fn missing_parent_revalidation_rejects_new_symlink_component() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().unwrap(); + let root = dunce::canonicalize(temp.path()).unwrap(); + let path = root.join("missing/child/config.rc"); + let parent_plan = super::source::ParentPlan::capture(path.parent().unwrap()).unwrap(); + let target = root.join("redirected"); + fs::create_dir(&target).unwrap(); + fs::create_dir(root.join("missing")).unwrap(); + symlink(&target, root.join("missing/child")).unwrap(); + + assert!(matches!( + parent_plan.ensure_and_anchor(), + Err(ManagedConfigError::UnsafePath { .. }) + )); +} + +#[test] +fn backup_and_temp_hint_collisions_retry_under_lock() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write(&path, "original\n").unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + let backup_hint = plan.backup_path_hint().unwrap().to_path_buf(); + let temp_hint = plan.temp_path_hint.as_ref().unwrap().clone(); + fs::write(&backup_hint, "unrelated backup").unwrap(); + fs::write(&temp_hint, "unrelated temp").unwrap(); + let outcome = ManagedConfig::apply(plan).unwrap(); + assert_ne!(outcome.backup_path.as_deref(), Some(backup_hint.as_path())); + assert_eq!( + fs::read_to_string(&backup_hint).unwrap(), + "unrelated backup" + ); + assert_eq!(fs::read_to_string(&temp_hint).unwrap(), "unrelated temp"); +} + +struct FailAt(TransactionPhase); +impl TransactionObserver for FailAt { + fn phase(&self, phase: TransactionPhase, _: &ManagedConfigPlan) -> io::Result<()> { + if phase == self.0 { + Err(io::Error::other(format!("injected {}", phase.name()))) + } else { + Ok(()) + } + } +} + +struct CorruptTemp; +impl TransactionObserver for CorruptTemp { + fn mutate_written_temp(&self, path: &Path, _: &ManagedConfigPlan) -> io::Result<()> { + fs::write(path, "corrupt") + } +} + +#[test] +fn all_precommit_phase_failures_cleanup_and_preserve_original() { + let phases = [ + TransactionPhase::BeforeBackupReserve, + TransactionPhase::AfterBackupReserved, + TransactionPhase::BeforeTempReserve, + TransactionPhase::BeforeTempWrite, + TransactionPhase::AfterTempWritten, + TransactionPhase::AfterValidation, + TransactionPhase::BeforePublish, + ]; + for phase in phases { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write(&path, "original\n").unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + assert!(matches!( + ManagedConfig::apply_with_observer(plan, &FailAt(phase)), + Err(ManagedConfigError::Phase { .. }) + )); + assert_eq!(fs::read_to_string(&path).unwrap(), "original\n"); + assert!(artifacts(temp.path()).is_empty()); + } +} + +#[test] +fn post_publish_failures_rollback_existing_and_remove_new_target() { + let phases = [ + TransactionPhase::AfterPublish, + TransactionPhase::BeforeParentSync, + TransactionPhase::AfterParentSync, + TransactionPhase::BeforeVerify, + ]; + for phase in phases { + for existing in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + if existing { + fs::write(&path, "original\n").unwrap(); + } + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + assert!(ManagedConfig::apply_with_observer(plan, &FailAt(phase)).is_err()); + if existing { + assert_eq!(fs::read_to_string(&path).unwrap(), "original\n"); + } else { + assert!(!path.exists()); + } + assert!(artifacts(temp.path()).is_empty()); + } + } +} + +#[test] +fn verification_failure_rolls_back_exact_original() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write(&path, "original\n").unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + let error = ManagedConfig::apply_with_observer(plan, &CorruptTemp).unwrap_err(); + assert!(matches!(error, ManagedConfigError::Verification { .. })); + assert_eq!(fs::read_to_string(&path).unwrap(), "original\n"); + assert!(artifacts(temp.path()).is_empty()); +} + +#[test] +fn primary_and_rollback_errors_are_both_reported() { + struct FailBoth; + impl TransactionObserver for FailBoth { + fn phase(&self, phase: TransactionPhase, _: &ManagedConfigPlan) -> io::Result<()> { + if matches!( + phase, + TransactionPhase::AfterPublish | TransactionPhase::BeforeRollback + ) { + Err(io::Error::other("injected failure")) + } else { + Ok(()) + } + } + } + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write(&path, "original\n").unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + assert!(matches!( + ManagedConfig::apply_with_observer(plan, &FailBoth), + Err(ManagedConfigError::Recovery { .. }) + )); +} + +#[test] +fn publish_and_parent_sync_failures_are_injected_at_the_real_operations() { + struct PublishFailure; + impl TransactionObserver for PublishFailure { + fn publish(&self, _: &Path, _: &Path) -> io::Result<()> { + Err(io::Error::other("injected publish failure")) + } + } + struct SyncFailure; + impl TransactionObserver for SyncFailure { + fn sync_parent( + &self, + parent: &super::source::ParentAnchor, + rollback: bool, + ) -> Result<(), ManagedConfigError> { + if rollback { + parent.sync() + } else { + Err(ManagedConfigError::Sync { + path: Path::new("injected-parent").to_path_buf(), + source: io::Error::other("injected sync failure"), + }) + } + } + } + + for observer in [ + &PublishFailure as &dyn TransactionObserver, + &SyncFailure as &dyn TransactionObserver, + ] { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write(&path, "original\n").unwrap(); + let plan = ManagedConfig::plan(request(&path, &[("item", "body")])).unwrap(); + assert!(ManagedConfig::apply_with_observer(plan, observer).is_err()); + assert_eq!(fs::read_to_string(&path).unwrap(), "original\n"); + assert!(artifacts(temp.path()).is_empty()); + } +} + +#[test] +fn failed_validator_cleans_reserved_backup_and_temp() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write(&path, "original\n").unwrap(); + let mut request = request(&path, &[("item", "body")]); + request.validator = Some(SyntaxValidator { + program: "/bin/sh".into(), + args: vec!["-c".into(), "exit 7".into()], + timeout: Duration::from_secs(1), + }); + assert!(matches!( + ManagedConfig::apply(ManagedConfig::plan(request).unwrap()), + Err(ManagedConfigError::Validation { .. }) + )); + assert_eq!(fs::read_to_string(&path).unwrap(), "original\n"); + assert!(artifacts(temp.path()).is_empty()); +} + +#[test] +fn transaction_lock_blocks_second_apply_then_stale_revalidation_wins() { + struct BlockAfterLock { + reached: Arc, + release: Arc, + } + impl TransactionObserver for BlockAfterLock { + fn phase(&self, phase: TransactionPhase, _: &ManagedConfigPlan) -> io::Result<()> { + if phase == TransactionPhase::AfterLock { + self.reached.wait(); + self.release.wait(); + } + Ok(()) + } + } + + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write(&path, "original\n").unwrap(); + let first = ManagedConfig::plan(request(&path, &[("one", "one")])).unwrap(); + let second = ManagedConfig::plan(request(&path, &[("two", "two")])).unwrap(); + let reached = Arc::new(Barrier::new(2)); + let release = Arc::new(Barrier::new(2)); + let observer = BlockAfterLock { + reached: reached.clone(), + release: release.clone(), + }; + let first_thread = + std::thread::spawn(move || ManagedConfig::apply_with_observer(first, &observer)); + reached.wait(); + + let result = Arc::new(Mutex::new(None)); + let result_thread = result.clone(); + let second_thread = std::thread::spawn(move || { + *result_thread.lock().unwrap() = Some(ManagedConfig::apply(second)); + }); + std::thread::sleep(Duration::from_millis(50)); + assert!( + result.lock().unwrap().is_none(), + "second apply must block on lock" + ); + release.wait(); + assert!(first_thread.join().unwrap().is_ok()); + second_thread.join().unwrap(); + assert!(matches!( + result.lock().unwrap().take().unwrap(), + Err(ManagedConfigError::StalePlan(_)) + )); +} + +#[test] +fn validator_timeout_is_bounded() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config.rc"); + fs::write(&path, "original\n").unwrap(); + let mut request = request(&path, &[("item", "body")]); + request.validator = Some(SyntaxValidator { + program: "/bin/sh".into(), + args: vec!["-c".into(), "sleep 5".into()], + timeout: Duration::from_millis(20), + }); + let started = Instant::now(); + assert!(ManagedConfig::apply(ManagedConfig::plan(request).unwrap()).is_err()); + assert!(started.elapsed() < Duration::from_secs(1)); + assert_eq!(fs::read_to_string(&path).unwrap(), "original\n"); +} diff --git a/crates/codegen/xai-grok-config/src/managed_text/transaction.rs b/crates/codegen/xai-grok-config/src/managed_text/transaction.rs new file mode 100644 index 0000000..1a73b74 --- /dev/null +++ b/crates/codegen/xai-grok-config/src/managed_text/transaction.rs @@ -0,0 +1,454 @@ +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Write as _}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +use super::source; +use super::{ManagedConfigError, ManagedConfigOutcome, ManagedConfigPlan, ManagedConfigStatus}; + +static ARTIFACT_NONCE: AtomicU64 = AtomicU64::new(0); +const ARTIFACT_RESERVATION_ATTEMPTS: usize = 128; + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(super) enum TransactionPhase { + AfterLock, + BeforeBackupReserve, + AfterBackupReserved, + BeforeTempReserve, + BeforeTempWrite, + AfterTempWritten, + AfterValidation, + BeforePublish, + AfterPublish, + BeforeParentSync, + AfterParentSync, + BeforeVerify, + BeforeRollback, + BeforeRollbackSync, + AfterRollback, +} + +impl TransactionPhase { + pub(super) fn name(self) -> &'static str { + match self { + Self::AfterLock => "after-lock", + Self::BeforeBackupReserve => "before-backup-reserve", + Self::AfterBackupReserved => "after-backup-reserved", + Self::BeforeTempReserve => "before-temp-reserve", + Self::BeforeTempWrite => "before-temp-write", + Self::AfterTempWritten => "after-temp-written", + Self::AfterValidation => "after-validation", + Self::BeforePublish => "before-publish", + Self::AfterPublish => "after-publish", + Self::BeforeParentSync => "before-parent-sync", + Self::AfterParentSync => "after-parent-sync", + Self::BeforeVerify => "before-verify", + Self::BeforeRollback => "before-rollback", + Self::BeforeRollbackSync => "before-rollback-sync", + Self::AfterRollback => "after-rollback", + } + } +} + +pub(super) trait TransactionObserver: Send + Sync { + fn phase(&self, _phase: TransactionPhase, _plan: &ManagedConfigPlan) -> std::io::Result<()> { + Ok(()) + } + + fn mutate_written_temp(&self, _path: &Path, _plan: &ManagedConfigPlan) -> std::io::Result<()> { + Ok(()) + } + + fn publish(&self, temp: &Path, target: &Path) -> std::io::Result<()> { + fs::rename(temp, target) + } + + fn sync_parent( + &self, + parent: &source::ParentAnchor, + _rollback: bool, + ) -> Result<(), ManagedConfigError> { + parent.sync() + } +} + +pub(super) struct NoopObserver; +impl TransactionObserver for NoopObserver {} + +pub(super) fn sibling_artifact(path: &Path, suffix: &str) -> PathBuf { + let name = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| "config".to_owned()); + path.with_file_name(format!("{name}.{suffix}")) +} + +pub(super) fn artifact_hint(path: &Path, kind: &str) -> PathBuf { + artifact_candidate(path, kind, 0) +} + +fn artifact_candidate(path: &Path, kind: &str, attempt: usize) -> PathBuf { + let suffix = if attempt == 0 { + format!("{kind}.{}", std::process::id()) + } else { + let nonce = ARTIFACT_NONCE.fetch_add(1, Ordering::Relaxed); + format!("{kind}.{}.{}", std::process::id(), nonce) + }; + sibling_artifact(path, &suffix) +} + +pub(super) fn apply( + plan: ManagedConfigPlan, + observer: &dyn TransactionObserver, +) -> Result { + let parent_anchor = plan.parent_plan.ensure_and_anchor()?; + if !plan.changes_file() { + parent_anchor.revalidate()?; + source::revalidate(&plan)?; + return Ok(ManagedConfigOutcome { + status: ManagedConfigStatus::NoChange, + requested_path: plan.requested_path, + target_path: plan.target_path, + backup_path: None, + }); + } + + let lock = open_lock(&plan.lock_path)?; + lock.lock().map_err(|source| ManagedConfigError::Lock { + path: plan.lock_path.clone(), + source, + })?; + observe(observer, TransactionPhase::AfterLock, &plan)?; + parent_anchor.revalidate()?; + source::revalidate(&plan)?; + + let mut backup = None; + let mut temp = None; + let precommit = (|| { + if let Some(bytes) = &plan.original.bytes { + observe(observer, TransactionPhase::BeforeBackupReserve, &plan)?; + let (path, mut file) = reserve_artifact( + &plan.target_path, + "grok-backup", + plan.backup_path_hint.as_deref(), + plan.original.mode, + )?; + backup = Some(path.clone()); + write_reserved(&path, &mut file, bytes, plan.original.mode)?; + observe(observer, TransactionPhase::AfterBackupReserved, &plan)?; + } + + observe(observer, TransactionPhase::BeforeTempReserve, &plan)?; + let (temp_path, mut temp_file) = reserve_artifact( + &plan.target_path, + "grok-tmp", + plan.temp_path_hint.as_deref(), + plan.original.mode, + )?; + temp = Some(temp_path.clone()); + observe(observer, TransactionPhase::BeforeTempWrite, &plan)?; + write_reserved( + &temp_path, + &mut temp_file, + &plan.updated, + plan.original.mode, + )?; + observe(observer, TransactionPhase::AfterTempWritten, &plan)?; + + if let Some(validator) = &plan.request.validator { + super::validator::validate_temp(validator, &temp_path)?; + } + observe(observer, TransactionPhase::AfterValidation, &plan)?; + parent_anchor.revalidate()?; + source::revalidate(&plan)?; + observe(observer, TransactionPhase::BeforePublish, &plan)?; + parent_anchor.revalidate()?; + apply_exact_path_mode(&temp_path, plan.original.mode)?; + observer + .publish(&temp_path, &plan.target_path) + .map_err(|source| ManagedConfigError::Publish { + path: plan.target_path.clone(), + source, + })?; + temp = None; + Ok::<(), ManagedConfigError>(()) + })(); + + if let Err(error) = precommit { + cleanup(temp.as_deref()); + cleanup(backup.as_deref()); + return Err(error); + } + + let post_publish = (|| { + observe(observer, TransactionPhase::AfterPublish, &plan)?; + parent_anchor.revalidate()?; + observe(observer, TransactionPhase::BeforeParentSync, &plan)?; + observer.sync_parent(&parent_anchor, false)?; + observe(observer, TransactionPhase::AfterParentSync, &plan)?; + parent_anchor.revalidate()?; + observe(observer, TransactionPhase::BeforeVerify, &plan)?; + observer + .mutate_written_temp(&plan.target_path, &plan) + .map_err(|source| ManagedConfigError::Phase { + phase: "mutate-published-target", + source, + })?; + verify_published(&plan)?; + Ok::<(), ManagedConfigError>(()) + })(); + + if let Err(primary) = post_publish { + match rollback(&plan, observer, &parent_anchor) { + Ok(()) => { + cleanup(backup.as_deref()); + return Err(primary); + } + Err(recovery) => { + return Err(ManagedConfigError::Recovery { + primary: Box::new(primary), + recovery: Box::new(recovery), + }); + } + } + } + + Ok(ManagedConfigOutcome { + status: ManagedConfigStatus::Applied, + requested_path: plan.requested_path, + target_path: plan.target_path, + backup_path: backup, + }) +} + +fn observe( + observer: &dyn TransactionObserver, + phase: TransactionPhase, + plan: &ManagedConfigPlan, +) -> Result<(), ManagedConfigError> { + observer + .phase(phase, plan) + .map_err(|source| ManagedConfigError::Phase { + phase: phase.name(), + source, + }) +} + +fn reserve_artifact( + target: &Path, + kind: &str, + hint: Option<&Path>, + mode: Option, +) -> Result<(PathBuf, File), ManagedConfigError> { + for attempt in 0..ARTIFACT_RESERVATION_ATTEMPTS { + let candidate = if attempt == 0 { + hint.map(Path::to_path_buf) + .unwrap_or_else(|| artifact_candidate(target, kind, attempt)) + } else { + artifact_candidate(target, kind, attempt) + }; + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + if let Some(mode) = mode { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(mode); + } + #[cfg(not(unix))] + let _ = mode; + match options.open(&candidate) { + Ok(file) => return Ok((candidate, file)), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(source) => { + return Err(ManagedConfigError::Write { + path: candidate, + source, + }); + } + } + } + Err(ManagedConfigError::Write { + path: target.to_path_buf(), + source: std::io::Error::new( + std::io::ErrorKind::AlreadyExists, + format!("could not reserve a unique {kind} artifact"), + ), + }) +} + +fn write_reserved( + path: &Path, + file: &mut File, + bytes: &[u8], + mode: Option, +) -> Result<(), ManagedConfigError> { + file.write_all(bytes) + .and_then(|()| apply_exact_mode(file, mode)) + .and_then(|()| file.sync_all()) + .map_err(|source| ManagedConfigError::Write { + path: path.to_path_buf(), + source, + }) +} + +#[cfg(unix)] +fn apply_exact_mode(file: &File, mode: Option) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt as _; + if let Some(mode) = mode { + file.set_permissions(fs::Permissions::from_mode(mode))?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn apply_exact_mode(_: &File, _: Option) -> io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn apply_exact_path_mode(path: &Path, mode: Option) -> Result<(), ManagedConfigError> { + use std::os::unix::fs::PermissionsExt as _; + if let Some(mode) = mode { + fs::set_permissions(path, fs::Permissions::from_mode(mode)).map_err(|source| { + ManagedConfigError::Write { + path: path.to_path_buf(), + source, + } + })?; + } + Ok(()) +} + +#[cfg(not(unix))] +fn apply_exact_path_mode(_: &Path, _: Option) -> Result<(), ManagedConfigError> { + Ok(()) +} + +fn verify_published(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> { + let published = fs::read(&plan.target_path).map_err(|source| ManagedConfigError::Read { + path: plan.target_path.clone(), + source, + })?; + if published != plan.updated { + return Err(ManagedConfigError::Verification { + path: plan.target_path.clone(), + reason: "published bytes differ from the confirmed plan".to_owned(), + }); + } + if current_mode(&plan.target_path)? != plan.original.mode { + return Err(ManagedConfigError::Verification { + path: plan.target_path.clone(), + reason: "published mode differs from the confirmed source mode".to_owned(), + }); + } + Ok(()) +} + +fn rollback( + plan: &ManagedConfigPlan, + observer: &dyn TransactionObserver, + parent_anchor: &source::ParentAnchor, +) -> Result<(), ManagedConfigError> { + observe(observer, TransactionPhase::BeforeRollback, plan)?; + parent_anchor.revalidate()?; + if let Some(original) = &plan.original.bytes { + let (rollback_path, mut rollback_file) = + reserve_artifact(&plan.target_path, "grok-rollback", None, plan.original.mode)?; + if let Err(error) = write_reserved( + &rollback_path, + &mut rollback_file, + original, + plan.original.mode, + ) { + cleanup(Some(&rollback_path)); + return Err(error); + } + apply_exact_path_mode(&rollback_path, plan.original.mode)?; + if let Err(source) = fs::rename(&rollback_path, &plan.target_path) { + cleanup(Some(&rollback_path)); + return Err(ManagedConfigError::Publish { + path: plan.target_path.clone(), + source, + }); + } + } else { + match fs::remove_file(&plan.target_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(source) => { + return Err(ManagedConfigError::Publish { + path: plan.target_path.clone(), + source, + }); + } + } + } + observe(observer, TransactionPhase::BeforeRollbackSync, plan)?; + observer.sync_parent(parent_anchor, true)?; + verify_rollback(plan)?; + observe(observer, TransactionPhase::AfterRollback, plan) +} + +fn verify_rollback(plan: &ManagedConfigPlan) -> Result<(), ManagedConfigError> { + match &plan.original.bytes { + Some(original) => { + let restored = + fs::read(&plan.target_path).map_err(|source| ManagedConfigError::Read { + path: plan.target_path.clone(), + source, + })?; + if &restored != original || current_mode(&plan.target_path)? != plan.original.mode { + return Err(ManagedConfigError::Verification { + path: plan.target_path.clone(), + reason: "rollback did not restore the original bytes and mode".to_owned(), + }); + } + } + None if plan.target_path.exists() => { + return Err(ManagedConfigError::Verification { + path: plan.target_path.clone(), + reason: "rollback did not remove the newly created target".to_owned(), + }); + } + None => {} + } + Ok(()) +} + +#[cfg(unix)] +fn current_mode(path: &Path) -> Result, ManagedConfigError> { + use std::os::unix::fs::PermissionsExt as _; + fs::metadata(path) + .map(|metadata| Some(metadata.permissions().mode() & 0o7777)) + .map_err(|source| ManagedConfigError::Read { + path: path.to_path_buf(), + source, + }) +} + +#[cfg(not(unix))] +fn current_mode(_: &Path) -> Result, ManagedConfigError> { + Ok(None) +} + +fn open_lock(path: &Path) -> Result { + let mut options = OpenOptions::new(); + options.read(true).write(true).create(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt as _; + options.mode(0o600); + } + options + .open(path) + .map_err(|source| ManagedConfigError::Lock { + path: path.to_path_buf(), + source, + }) +} + +fn cleanup(path: Option<&Path>) { + if let Some(path) = path { + let _ = fs::remove_file(path); + } +} diff --git a/crates/codegen/xai-grok-config/src/managed_text/validator.rs b/crates/codegen/xai-grok-config/src/managed_text/validator.rs new file mode 100644 index 0000000..ca8f2fc --- /dev/null +++ b/crates/codegen/xai-grok-config/src/managed_text/validator.rs @@ -0,0 +1,244 @@ +use std::ffi::OsString; +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; + +use super::ManagedConfigError; + +/// Optional syntax checker. `path` is appended after `args`, matching the +/// `bash -n FILE`, `zsh -n FILE`, and `fish -n FILE` interfaces. +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SyntaxValidator { + pub program: PathBuf, + pub args: Vec, + pub timeout: Duration, +} + +pub(super) fn validate_temp( + validator: &SyntaxValidator, + path: &Path, +) -> Result<(), ManagedConfigError> { + validate_with_ops(validator, path, &RealProcessOps) +} + +trait ProcessOps { + fn attach_group(&self, child: &Child) -> Result; + fn try_wait(&self, child: &mut Child) -> std::io::Result>; + fn teardown( + &self, + child: &mut Child, + group: Option<&xai_tty_utils::ProcessGroup>, + ) -> Result<(), String>; +} + +struct RealProcessOps; +impl ProcessOps for RealProcessOps { + fn attach_group(&self, child: &Child) -> Result { + let mut group = xai_tty_utils::ProcessGroup::new()?; + group.attach_std(child)?; + Ok(group) + } + + fn try_wait(&self, child: &mut Child) -> std::io::Result> { + child.try_wait() + } + + fn teardown( + &self, + child: &mut Child, + group: Option<&xai_tty_utils::ProcessGroup>, + ) -> Result<(), String> { + teardown_child(child, group) + } +} + +fn validate_with_ops( + validator: &SyntaxValidator, + path: &Path, + ops: &dyn ProcessOps, +) -> Result<(), ManagedConfigError> { + let mut command = Command::new(&validator.program); + command + .args(&validator.args) + .arg(path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .envs(xai_tty_utils::pager_env()); + xai_tty_utils::detach_std_command(&mut command); + let mut child = command + .spawn() + .map_err(|source| ManagedConfigError::Validation { + path: path.to_path_buf(), + reason: format!("could not start {}: {source}", validator.program.display()), + })?; + let group = ops.attach_group(&child).ok(); + + let started = Instant::now(); + loop { + match ops.try_wait(&mut child) { + Ok(Some(status)) if status.success() => return Ok(()), + Ok(Some(status)) => { + return Err(validation_error( + path, + format!("{} exited with {status}", validator.program.display()), + None, + )); + } + Ok(None) if started.elapsed() < validator.timeout => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + let teardown = ops.teardown(&mut child, group.as_ref()).err(); + return Err(validation_error( + path, + format!("timed out after {:?}", validator.timeout), + teardown, + )); + } + Err(source) => { + let teardown = ops.teardown(&mut child, group.as_ref()).err(); + return Err(validation_error(path, source.to_string(), teardown)); + } + } + } +} + +fn validation_error(path: &Path, primary: String, teardown: Option) -> ManagedConfigError { + let reason = match teardown { + Some(teardown) => format!("{primary}; process teardown also failed: {teardown}"), + None => primary, + }; + ManagedConfigError::Validation { + path: path.to_path_buf(), + reason, + } +} + +fn teardown_child( + child: &mut Child, + group: Option<&xai_tty_utils::ProcessGroup>, +) -> Result<(), String> { + let mut errors = Vec::new(); + if let Some(group) = group { + if let Err(error) = group.terminate() { + errors.push(format!("terminate group: {error}")); + } + std::thread::sleep(Duration::from_millis(50)); + if let Err(error) = group.kill() { + errors.push(format!("kill group: {error}")); + } + } + if let Err(error) = child.kill() + && error.kind() != std::io::ErrorKind::InvalidInput + { + errors.push(format!("kill child: {error}")); + } + let deadline = Instant::now() + Duration::from_secs(1); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(10)); + } + Ok(None) => { + errors.push("child did not reap within 1s".to_owned()); + break; + } + Err(error) => { + errors.push(format!("reap child: {error}")); + break; + } + } + } + if errors.is_empty() { + Ok(()) + } else { + Err(errors.join(", ")) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicBool, Ordering}; + + use super::*; + + struct InjectedOps { + attach_fails: bool, + wait_fails: bool, + teardown_called: AtomicBool, + } + + impl ProcessOps for InjectedOps { + fn attach_group( + &self, + child: &Child, + ) -> Result { + if self.attach_fails { + Err(std::io::Error::other("injected attach failure")) + } else { + RealProcessOps.attach_group(child) + } + } + + fn try_wait(&self, child: &mut Child) -> std::io::Result> { + if self.wait_fails { + Err(std::io::Error::other("injected try_wait failure")) + } else { + child.try_wait() + } + } + + fn teardown( + &self, + child: &mut Child, + group: Option<&xai_tty_utils::ProcessGroup>, + ) -> Result<(), String> { + self.teardown_called.store(true, Ordering::SeqCst); + teardown_child(child, group) + } + } + + #[cfg(unix)] + #[test] + fn attach_failure_falls_back_to_bounded_direct_child_teardown() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config"); + std::fs::write(&path, "body").unwrap(); + let validator = SyntaxValidator { + program: "/bin/sh".into(), + args: vec!["-c".into(), "sleep 5".into()], + timeout: Duration::from_millis(20), + }; + let ops = InjectedOps { + attach_fails: true, + wait_fails: false, + teardown_called: AtomicBool::new(false), + }; + let started = Instant::now(); + assert!(validate_with_ops(&validator, &path, &ops).is_err()); + assert!(ops.teardown_called.load(Ordering::SeqCst)); + assert!(started.elapsed() < Duration::from_secs(2)); + } + + #[cfg(unix)] + #[test] + fn try_wait_error_still_tears_down_child() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("config"); + std::fs::write(&path, "body").unwrap(); + let validator = SyntaxValidator { + program: "/bin/sh".into(), + args: vec!["-c".into(), "sleep 5".into()], + timeout: Duration::from_secs(1), + }; + let ops = InjectedOps { + attach_fails: false, + wait_fails: true, + teardown_called: AtomicBool::new(false), + }; + assert!(validate_with_ops(&validator, &path, &ops).is_err()); + assert!(ops.teardown_called.load(Ordering::SeqCst)); + } +} diff --git a/crates/codegen/xai-grok-hooks/src/dispatcher.rs b/crates/codegen/xai-grok-hooks/src/dispatcher.rs index fb14219..d396b6c 100644 --- a/crates/codegen/xai-grok-hooks/src/dispatcher.rs +++ b/crates/codegen/xai-grok-hooks/src/dispatcher.rs @@ -738,8 +738,7 @@ mod tests { ); assert_eq!(result.results.len(), 1); assert!( - matches!(&result.results[0], HookRunResult::Failed { hook_name, .. } -if hook_name == "crasher"), + matches!(&result.results[0], HookRunResult::Failed { hook_name, .. } if hook_name == "crasher"), "the failure must still appear in run_results for UI scrollback, got {:?}", result.results ); diff --git a/crates/codegen/xai-grok-markdown/src/mermaid.rs b/crates/codegen/xai-grok-markdown/src/mermaid.rs index 84bc3fc..61d17f8 100644 --- a/crates/codegen/xai-grok-markdown/src/mermaid.rs +++ b/crates/codegen/xai-grok-markdown/src/mermaid.rs @@ -3807,14 +3807,11 @@ mod tests { ) .unwrap(); assert!(s.items.iter().any(|it| matches!(it, - SeqItem::Message { text: Some(t), .. } -if t.contains("call ") && !t.contains("<")))); + SeqItem::Message { text: Some(t), .. } if t.contains("call ") && !t.contains("<")))); assert!(s.items.iter().any(|it| matches!(it, - SeqItem::Note { text, .. } -if text.contains("memo ") && !text.contains("<")))); + SeqItem::Note { text, .. } if text.contains("memo ") && !text.contains("<")))); assert!(s.items.iter().any(|it| matches!(it, - SeqItem::Divider { text } -if text.contains("c ") && !text.contains("<")))); + SeqItem::Divider { text } if text.contains("c ") && !text.contains("<")))); // Class members and ER attributes have no clean quoted form (splitter // fragments unquoted `;`; ER drops quoted text as a comment), so exercise diff --git a/crates/codegen/xai-grok-mcp/src/servers.rs b/crates/codegen/xai-grok-mcp/src/servers.rs index d4e1d46..2fec66d 100644 --- a/crates/codegen/xai-grok-mcp/src/servers.rs +++ b/crates/codegen/xai-grok-mcp/src/servers.rs @@ -170,8 +170,7 @@ impl InitProgress { /// True iff every per-server handshake has settled and `finish_init` /// has fired. Pairs with [`Self::is_in_progress`]. pub fn is_complete(&self) -> bool { - matches!(self, Self::Finished { handshaking } -if handshaking.is_empty()) + matches!(self, Self::Finished { handshaking } if handshaking.is_empty()) } /// True iff any init work is outstanding — either we are pre- @@ -1461,7 +1460,8 @@ impl xai_tool_runtime::Tool for McpErasedTool { mime_type, blob, .. - } if mime_type + } +if mime_type .as_deref() .is_some_and(|m| m.starts_with("image/")) => { diff --git a/crates/codegen/xai-grok-memory/src/dream.rs b/crates/codegen/xai-grok-memory/src/dream.rs index b5920f6..a10d110 100644 --- a/crates/codegen/xai-grok-memory/src/dream.rs +++ b/crates/codegen/xai-grok-memory/src/dream.rs @@ -867,8 +867,7 @@ mod tests { let result = execute_dream(&lock, &storage, response, 5, 300, &sdir, &[]); assert!( - matches!(result.status, DreamStatus::Completed { chars_written } -if chars_written == response.chars().count()) + matches!(result.status, DreamStatus::Completed { chars_written } if chars_written == response.chars().count()) ); assert_eq!(result.sessions_eligible, 5); assert_eq!(result.cleaned_stems.len(), 0); diff --git a/crates/codegen/xai-grok-pager-bin/Cargo.toml b/crates/codegen/xai-grok-pager-bin/Cargo.toml index 7b67736..8bd59f8 100644 --- a/crates/codegen/xai-grok-pager-bin/Cargo.toml +++ b/crates/codegen/xai-grok-pager-bin/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "xai-grok-pager-bin" -version = "0.2.106" +version = "0.2.109" edition.workspace = true license = "Apache-2.0" authors = ["xAI"] diff --git a/crates/codegen/xai-grok-pager-bin/src/main.rs b/crates/codegen/xai-grok-pager-bin/src/main.rs index 8f28eed..b8ab8ba 100644 --- a/crates/codegen/xai-grok-pager-bin/src/main.rs +++ b/crates/codegen/xai-grok-pager-bin/src/main.rs @@ -1604,7 +1604,46 @@ fn install_heap_profile_hooks() { prof_available: jemalloc_prof_available, }); } +fn version_text(channel_label: &str) -> String { + format!( + "grok {}\n", + xai_grok_version::display_version_with_commit(env!("VERSION_WITH_COMMIT"), channel_label,) + ) +} +fn write_version(writer: &mut impl std::io::Write, channel_label: &str) -> std::io::Result<()> { + writer.write_all(version_text(channel_label).as_bytes()) +} +fn dispatch_version_if_requested(args: &PagerArgs) -> bool { + if !args.version { + return false; + } + if let Err(error) = write_version( + &mut std::io::stdout().lock(), + xai_grok_update::channel_label(), + ) { + eprintln!("Error: {error}"); + std::process::exit(1); + } + true +} +fn dispatch_doctor_if_requested(args: &PagerArgs) -> bool { + let Some(Command::Doctor(doctor_args)) = &args.command else { + return false; + }; + if let Err(error) = xai_grok_pager::doctor_cmd::run(doctor_args.clone()) { + eprintln!("Error: {error:#}"); + std::process::exit(1); + } + true +} fn main() { + if let Some(code) = xai_grok_pager::app::mermaid_worker::maybe_run_render_subprocess() { + std::process::exit(code); + } + let args = PagerArgs::parse_cli(); + if dispatch_version_if_requested(&args) || dispatch_doctor_if_requested(&args) { + return; + } xai_grok_pager_minimal::install(); #[cfg(all(feature = "jemalloc", unix))] xai_grok_pager::memory_release::install_release_hook(purge_jemalloc_retained_pages); @@ -1615,9 +1654,6 @@ fn main() { } #[cfg(all(feature = "jemalloc", unix))] install_heap_profile_hooks(); - if let Some(code) = xai_grok_pager::app::mermaid_worker::maybe_run_render_subprocess() { - std::process::exit(code); - } xai_grok_pager::memory_trace::start( xai_grok_shell::util::grok_home::grok_home().join("memtrace"), ); @@ -1669,7 +1705,7 @@ fn main() { .enable_all() .build() .unwrap_or_else(|e| panic!("failed to start tokio runtime: {e}")); - let result = run_and_shutdown(runtime, async_main(), RUNTIME_SHUTDOWN_GRACE); + let result = run_and_shutdown(runtime, async_main(args), RUNTIME_SHUTDOWN_GRACE); xai_grok_telemetry::debug_log::flush(); if let Err(e) = result { xai_tty_utils::restore_native_stderr(); @@ -1678,9 +1714,9 @@ fn main() { std::process::exit(1); } } -async fn async_main() -> Result<()> { +async fn async_main(args: PagerArgs) -> Result<()> { let _ = rustls::crypto::ring::default_provider().install_default(); - let mut args = PagerArgs::parse_and_apply_cwd()?; + let mut args = args.apply_cwd()?; if let Some(ref mode) = args.compaction_mode { unsafe { std::env::set_var("GROK_COMPACTION_MODE", mode) }; } @@ -1755,13 +1791,10 @@ async fn async_main() -> Result<()> { ); println!("{}", serde_json::to_string(&payload)?); } else { - println!( - "grok {}", - xai_grok_version::display_version_with_commit( - env!("VERSION_WITH_COMMIT"), - xai_grok_update::channel_label(), - ) - ); + write_version( + &mut std::io::stdout().lock(), + xai_grok_update::channel_label(), + )?; } return Ok(()); } @@ -1788,6 +1821,9 @@ async fn async_main() -> Result<()> { ) .await; } + Command::Doctor(_) => { + unreachable!("doctor was consumed before runtime startup") + } Command::Inspect { json } => { let cwd = std::env::current_dir().unwrap_or_default(); xai_grok_shell::inspect::inspect(&cwd, json).await?; @@ -1952,16 +1988,10 @@ async fn async_main() -> Result<()> { .as_deref() .map(xai_grok_pager::headless::parse_json_schema) .transpose()?; - if json_schema.is_some() { - if args.output_format == xai_grok_pager::headless::OutputFormat::Plain { - args.output_format = xai_grok_pager::headless::OutputFormat::Json; - } - if args.self_verify { - anyhow::bail!( - "--json-schema and --self-verify cannot be used together: \ - verification output would corrupt the structured response" - ); - } + if json_schema.is_some() + && args.output_format == xai_grok_pager::headless::OutputFormat::Plain + { + args.output_format = xai_grok_pager::headless::OutputFormat::Json; } return xai_grok_pager::headless::run_single_turn( prompt, @@ -1991,8 +2021,6 @@ async fn async_main() -> Result<()> { max_turns: args.max_turns, permission_mode_flag: args.permission_mode_flag.clone(), reasoning_effort: args.reasoning_effort.clone(), - self_verify: args.self_verify, - best_of_n: args.best_of_n, wait_for_background: !args.no_wait_for_background, background_wait_timeout: std::time::Duration::from_secs( args.background_wait_timeout_secs, @@ -2278,6 +2306,36 @@ async fn signal_leaders_to_relaunch(installed_version: &str) { #[cfg(test)] mod tests { use super::*; + #[test] + fn version_output_writer_preserves_channel_aware_contract() { + for (label, expected_suffix) in [ + (" [alpha]", " [alpha]\n"), + (" [stable]", " [stable]\n"), + ("", ")\n"), + ] { + let mut output = Vec::new(); + write_version(&mut output, label).unwrap(); + let output = String::from_utf8(output).unwrap(); + assert!(output.starts_with("grok ")); + assert!(output.contains(env!("VERSION_WITH_COMMIT"))); + assert!(output.ends_with(expected_suffix), "{output:?}"); + } + } + #[test] + fn version_flags_and_doctor_are_distinct_early_intents() { + let version = PagerArgs::try_parse_from(["grok", "--version"]).unwrap(); + assert!(version.version); + assert!(version.command.is_none()); + let short = PagerArgs::try_parse_from(["grok", "-v"]).unwrap(); + assert!(short.version); + assert!(short.command.is_none()); + let subcommand = PagerArgs::try_parse_from(["grok", "version"]).unwrap(); + assert!(!subcommand.version); + assert!(matches!( + subcommand.command, + Some(Command::Version { json: false }) + )); + } #[cfg(all(feature = "jemalloc", unix))] struct TempHeapDump(std::path::PathBuf); #[cfg(all(feature = "jemalloc", unix))] diff --git a/crates/codegen/xai-grok-pager-minimal/src/live.rs b/crates/codegen/xai-grok-pager-minimal/src/live.rs index cfee242..0cdc612 100644 --- a/crates/codegen/xai-grok-pager-minimal/src/live.rs +++ b/crates/codegen/xai-grok-pager-minimal/src/live.rs @@ -499,7 +499,7 @@ fn minimal_advance_phase_timer( /// Reuses the full-TUI [`turn_status::render_turn_status`] widget so minimal /// surfaces the same rich activity detail (`Run …` / `Thinking…` / /// `Waiting on subagent…` / `Retrying (attempt N)…` / `Cancelling…`), the -/// per-phase + turn timers, and the "watching · …" cue (running commands / +/// per-phase + turn timers, and the "… still running" cue (running commands / /// monitors / loops / background subagents, shown while idle or parked) — /// instead of collapsing everything to "working…". Keyboard-only, so the /// mouse `[stop]` / `[↓]` buttons are suppressed (`None`), and @@ -883,7 +883,10 @@ mod tests { let mut buf = Buffer::empty(area); render_minimal_status(&mut buf, area, &a, &None, None, &theme); let text = read(&buf); - assert!(text.contains("watching"), "watching cue: {text:?}"); + assert!( + text.contains("1 loop still running"), + "watching cue: {text:?}" + ); assert!(!text.contains("/help"), "not the idle hint: {text:?}"); } #[test] diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/content.rs b/crates/codegen/xai-grok-pager-pty-harness/src/content.rs index 3e7838b..8a3a2fd 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/content.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/content.rs @@ -17,12 +17,86 @@ use xai_grok_test_support::MockInferenceServer; pub use xai_grok_test_support::mock_server::LogEntry; pub use xai_grok_test_support::mock_server::MockModelEntry as MockModel; pub use xai_grok_test_support::mock_server::StorageUpload; -// SSE event builders for `enqueue_response` scripts (reasoning turns etc.). pub use xai_grok_test_support::sse; pub use xai_grok_test_support::{ InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher, ScriptedResponse, SseEvent, }; +/// The endpoint-specific expectations for one logical foreground agent turn. +#[must_use = "keep the handle to synchronize or verify the logical agent turn"] +pub struct AgentTurnExpectation { + expectations: [InferenceExpectation; 2], +} + +impl AgentTurnExpectation { + /// Wait until either supported pager backend claims the turn. + pub async fn wait_received(&mut self) { + let [responses, chat_completions] = &mut self.expectations; + tokio::select! { + _ = responses.wait_received() => {} + _ = chat_completions.wait_received() => {} + } + } + + /// Wait until the active backend reaches this turn's terminal barrier. + pub async fn wait_blocked(&mut self) { + let [responses, chat_completions] = &mut self.expectations; + tokio::select! { + _ = responses.wait_blocked() => {} + _ = chat_completions.wait_blocked() => {} + } + } + + /// Release both endpoint variants of this turn's terminal barrier. + pub fn release(&self) { + for expectation in &self.expectations { + expectation.release(); + } + } + + /// Wait until the active backend completes this turn. + pub async fn wait_satisfied(&mut self) { + let [responses, chat_completions] = &mut self.expectations; + tokio::select! { + _ = responses.wait_satisfied() => {} + _ = chat_completions.wait_satisfied() => {} + } + } + + /// Whether either supported endpoint completed this logical turn. + pub fn is_satisfied(&self) -> bool { + self.expectations + .iter() + .any(InferenceExpectation::is_satisfied) + } + + /// Panic unless one supported backend completed this turn. + pub fn assert_satisfied(&self) { + assert!( + self.is_satisfied(), + "logical agent turn was not satisfied: {}", + self.diagnostic(), + ); + } + + /// Describe both endpoint variants for aggregated failure output. + pub fn diagnostic(&self) -> String { + format!( + "{}; {}", + self.expectations[0].diagnostic(), + self.expectations[1].diagnostic(), + ) + } + + /// Endpoint expectations that were not claimed by the active backend. + pub fn unsatisfied_diagnostics(&self) -> impl Iterator + '_ { + self.expectations + .iter() + .filter(|expectation| !expectation.is_satisfied()) + .map(InferenceExpectation::diagnostic) + } +} + /// Drives content into the pager by serving a mock inference endpoint that /// the bundled shell agent hits for `/v1/chat/completions` and `/v1/responses`. /// @@ -100,8 +174,6 @@ impl ContentController { ("GROK_TRACE_UPLOAD".into(), "false".into()), // Keep unrelated autocomplete work out of PTY timing assertions. ("GROK_PROMPT_SUGGESTIONS".into(), "false".into()), - // Compatibility set_turns remains request-FIFO, so retries stay off. - ("GROK_MAX_RETRIES".into(), "0".into()), ] } @@ -111,9 +183,9 @@ impl ContentController { self.server.set_response(text); } - /// Queue a byte-exact scripted response for the next request on `path` - /// (e.g. `"/v1/responses"`). Consumed FIFO per path; falls back to the - /// active fixed/echo mode when the queue is empty. + /// Queue a compatibility response for the next request on `path`. + /// Inference callers should use a matched expectation; this remains for + /// non-inference one-shots such as `"/v1/settings"`. pub fn enqueue_response(&self, path: impl Into, response: ScriptedResponse) { self.server.enqueue_response(path, response); } @@ -130,23 +202,6 @@ impl ContentController { self.server.set_chunk_delay(delay); } - /// Hold foreground completions until [`release_agent_completions`]. - /// Prefer [`expect_response_blocked`] for new tests. - /// - /// [`release_agent_completions`]: Self::release_agent_completions - /// [`expect_response_blocked`]: Self::expect_response_blocked - pub fn hold_agent_completions(&self) { - self.server.hold_agent_completions(); - } - - /// Release a hold set by [`hold_agent_completions`], letting the gated - /// turn complete. - /// - /// [`hold_agent_completions`]: Self::hold_agent_completions - pub fn release_agent_completions(&self) { - self.server.release_agent_completions(); - } - /// Register a named response for the next matching inference request. pub fn expect_response( &self, @@ -167,9 +222,81 @@ impl ContentController { self.server.expect_response_blocked(name, matcher, response) } - /// Queue one compatibility response per foreground turn. - pub fn set_turns(&self, turns: impl IntoIterator) { - self.server.set_agent_turns(turns); + /// Register the same named foreground text turn for both pager backends. + pub fn expect_agent_turn( + &self, + name: impl AsRef, + text: impl AsRef, + ) -> AgentTurnExpectation { + self.expect_agent_turn_with_responses( + name, + ScriptedResponse::sse(sse::responses_api_script_exact(text.as_ref(), "test-model")), + ScriptedResponse::sse(sse::chat_completion_script_exact( + text.as_ref(), + "test-model", + )), + ) + } + + /// Register the same named foreground text turn for both pager backends, + /// blocked immediately before its terminal event. + pub fn expect_agent_turn_blocked( + &self, + name: impl AsRef, + text: impl AsRef, + ) -> AgentTurnExpectation { + self.expect_agent_turn_with_responses_inner( + name.as_ref(), + ScriptedResponse::sse(sse::responses_api_script_exact(text.as_ref(), "test-model")), + ScriptedResponse::sse(sse::chat_completion_script_exact( + text.as_ref(), + "test-model", + )), + true, + ) + } + + /// Register endpoint-specific responses for one logical foreground turn. + pub fn expect_agent_turn_with_responses( + &self, + name: impl AsRef, + responses: ScriptedResponse, + chat_completions: ScriptedResponse, + ) -> AgentTurnExpectation { + self.expect_agent_turn_with_responses_inner( + name.as_ref(), + responses, + chat_completions, + false, + ) + } + + fn expect_agent_turn_with_responses_inner( + &self, + name: &str, + responses: ScriptedResponse, + chat_completions: ScriptedResponse, + blocked: bool, + ) -> AgentTurnExpectation { + let register = |endpoint, suffix: &str, response| { + let name = format!("{name} ({suffix})"); + let matcher = InferenceRequestMatcher::foreground(endpoint); + if blocked { + self.expect_response_blocked(name, matcher, response) + } else { + self.expect_response(name, matcher, response) + } + }; + AgentTurnExpectation { + expectations: [ + register(InferenceEndpoint::Responses, "responses", responses), + register( + InferenceEndpoint::ChatCompletions, + "chat completions", + chat_completions, + ), + ], + } } /// Number of inference requests the pager has made so far. @@ -217,6 +344,43 @@ fn default_response_text() -> String { mod tests { use super::*; + const EXPECTATION_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + + fn foreground_request(endpoint: InferenceEndpoint) -> (&'static str, serde_json::Value) { + match endpoint { + InferenceEndpoint::ChatCompletions => ( + "/chat/completions", + serde_json::json!({ + "model": "test-model", + "messages": [{ "role": "user", "content": "hello" }] + }), + ), + InferenceEndpoint::Responses => ( + "/responses", + serde_json::json!({ + "model": "test-model", + "input": [{ "role": "user", "content": "hello" }] + }), + ), + InferenceEndpoint::Messages => unreachable!("pager helper supports two backends"), + } + } + + async fn read_foreground(url: String, endpoint: InferenceEndpoint) -> String { + let (path, body) = foreground_request(endpoint); + reqwest::Client::new() + .post(format!("{url}{path}")) + .header("x-grok-turn-idx", "1") + .header("x-grok-req-id", format!("direct-{endpoint:?}")) + .json(&body) + .send() + .await + .expect("send direct foreground request") + .text() + .await + .expect("read direct foreground response") + } + /// The pre-delegation mock always served 200 `{"allow_access": true}`; /// the shared server defaults to 404-until-set. A 404 strands the pager /// on the SuperGrok upsell screen and breaks every PTY test. @@ -270,6 +434,111 @@ mod tests { assert!(content.has_chat_completion()); } + #[tokio::test] + async fn logical_turn_accepts_either_supported_endpoint() { + for endpoint in [ + InferenceEndpoint::ChatCompletions, + InferenceEndpoint::Responses, + ] { + let content = ContentController::start().await.unwrap(); + let mut turn = content.expect_agent_turn("either endpoint", "MATCHED_TURN"); + let request = tokio::spawn(read_foreground(content.url(), endpoint)); + + tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_received()) + .await + .expect("logical turn received through active endpoint"); + let body = tokio::time::timeout(EXPECTATION_TIMEOUT, request) + .await + .expect("active endpoint response completed") + .expect("direct request task completed"); + tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_satisfied()) + .await + .expect("logical turn satisfied through active endpoint"); + + assert!(body.contains("MATCHED_TURN"), "body: {body}"); + assert!(turn.is_satisfied()); + turn.assert_satisfied(); + let diagnostics: Vec<_> = turn.unsatisfied_diagnostics().collect(); + assert_eq!(diagnostics.len(), 1, "{diagnostics:#?}"); + let unused = match endpoint { + InferenceEndpoint::Responses => "chat completions", + InferenceEndpoint::ChatCompletions => "responses", + InferenceEndpoint::Messages => unreachable!(), + }; + assert!(diagnostics[0].contains(unused), "{diagnostics:#?}"); + assert!(diagnostics[0].contains("Pending"), "{diagnostics:#?}"); + } + } + + #[tokio::test] + async fn logical_blocked_turn_observes_release_and_satisfaction() { + for endpoint in [ + InferenceEndpoint::ChatCompletions, + InferenceEndpoint::Responses, + ] { + let content = ContentController::start().await.unwrap(); + let mut turn = + content.expect_agent_turn_blocked("blocked turn", "BLOCKED_MATCHED_TURN"); + let mut request = tokio::spawn(read_foreground(content.url(), endpoint)); + + tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_received()) + .await + .expect("logical blocked turn received"); + tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_blocked()) + .await + .expect("logical blocked turn reached terminal barrier"); + assert!(!turn.is_satisfied()); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(50), &mut request) + .await + .is_err(), + "response completed before release" + ); + + turn.release(); + let body = tokio::time::timeout(EXPECTATION_TIMEOUT, request) + .await + .expect("response completed after release") + .expect("direct request task completed"); + tokio::time::timeout(EXPECTATION_TIMEOUT, turn.wait_satisfied()) + .await + .expect("logical blocked turn satisfied after release"); + + assert!(body.contains("BLOCKED_MATCHED_TURN"), "body: {body}"); + turn.assert_satisfied(); + } + } + + #[tokio::test] + async fn unused_logical_turn_reports_both_endpoint_variants_and_fails_contract() { + let content = ContentController::start().await.unwrap(); + let turn = content.expect_agent_turn("unused logical turn", "unused"); + + assert!(!turn.is_satisfied()); + let diagnostics: Vec<_> = turn.unsatisfied_diagnostics().collect(); + assert_eq!(diagnostics.len(), 2, "{diagnostics:#?}"); + assert!( + diagnostics.iter().any(|d| d.contains("responses")), + "{diagnostics:#?}" + ); + assert!( + diagnostics.iter().any(|d| d.contains("chat completions")), + "{diagnostics:#?}" + ); + assert!(diagnostics.iter().all(|d| d.contains("Pending"))); + let aggregate = turn.diagnostic(); + assert!(aggregate.contains("responses"), "{aggregate}"); + assert!(aggregate.contains("chat completions"), "{aggregate}"); + + let panic = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + turn.assert_satisfied(); + })); + assert!( + panic.is_err(), + "unused logical turn must fail one-of-two contract" + ); + } + /// `env_for_pager` keeps the exact sandbox + endpoint env contract the /// pager spawn path depends on. #[tokio::test] @@ -294,7 +563,7 @@ mod tests { assert_eq!(get("GROK_FEEDBACK_ENABLED").as_deref(), Some("false")); assert_eq!(get("GROK_TRACE_UPLOAD").as_deref(), Some("false")); assert_eq!(get("GROK_PROMPT_SUGGESTIONS").as_deref(), Some("false")); - assert_eq!(get("GROK_MAX_RETRIES").as_deref(), Some("0")); - assert_eq!(env.len(), 10, "env list must not silently grow or shrink"); + assert_eq!(get("GROK_MAX_RETRIES"), None); + assert_eq!(env.len(), 9, "env list must not silently grow or shrink"); } } diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs b/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs index 7b70558..734d315 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/lib.rs @@ -36,8 +36,8 @@ pub mod scroll_matrix; pub mod timing; pub use content::{ - ContentController, InferenceEndpoint, InferenceExpectation, InferenceRequestMatcher, MockModel, - ScriptedResponse, SseEvent, sse, + AgentTurnExpectation, ContentController, InferenceEndpoint, InferenceExpectation, + InferenceRequestMatcher, MockModel, ScriptedResponse, SseEvent, sse, }; pub use env::pager_binary; pub use flows::{ diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/scenarios/empty_enter_send_now.rs b/crates/codegen/xai-grok-pager-pty-harness/src/scenarios/empty_enter_send_now.rs index 9c4dc77..8791a7e 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/scenarios/empty_enter_send_now.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/scenarios/empty_enter_send_now.rs @@ -53,11 +53,12 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> { .context("start ContentController")?; // Gate turn 1's terminal event so the queue + empty-Enter provably land // mid-turn — a paced-chunk window races turn end on slow (remote) workers. - content.hold_agent_completions(); - content.set_turns([ - slow_turn_text("TURNONE"), - "TURNTWO reply to the promoted follow-up.".to_owned(), - ]); + let mut turn_one = content + .expect_agent_turn_blocked("running turn before send-now", slow_turn_text("TURNONE")); + let mut turn_two = content.expect_agent_turn( + "promoted queued follow-up", + "TURNTWO reply to the promoted follow-up.", + ); let binary = pager_binary().context("resolve pager binary")?; let mut harness = @@ -70,6 +71,9 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> { harness .wait_for_text("TURNONE", Duration::from_secs(30)) .context("turn 1 streaming")?; + tokio::time::timeout(Duration::from_secs(10), turn_one.wait_blocked()) + .await + .context("turn 1 completion-barrier timeout")?; harness .inject_keys(b"please also check the logs\r") @@ -81,8 +85,7 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> { harness.inject_keys(b"\r").context("empty Enter send-now")?; // Cancel-and-send: the shell cancels turn 1 (its held completion is // irrelevant — the abort wins) and promotes the row to run as turn 2. - // Release the gate so any completion race resolves rather than hangs. - content.release_agent_completions(); + turn_one.release(); // The promoted row renders as a standard user prompt block ("❯ " prefix // distinguishes the committed block from the prefix-less queue row) with // the new turn's reply below it. @@ -95,6 +98,9 @@ pub async fn assert_empty_enter_force_sends_top_queued() -> Result<()> { harness .wait_for_text("TURNTWO", Duration::from_secs(40)) .context("promoted turn reply")?; + tokio::time::timeout(Duration::from_secs(10), turn_two.wait_satisfied()) + .await + .context("promoted turn expectation timeout")?; // A send-now cancel is silent: no "Turn cancelled by user" marker may // appear between the partial turn-1 output and the promoted prompt. diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/scenarios/plan_approval_resume.rs b/crates/codegen/xai-grok-pager-pty-harness/src/scenarios/plan_approval_resume.rs index 954c426..c1aed1e 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/scenarios/plan_approval_resume.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/scenarios/plan_approval_resume.rs @@ -41,13 +41,14 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> { let content = ContentController::start() .await .context("start ContentController")?; - // One response per agent turn (FIFO, 2+-tool requests only — aux requests - // never steal one). Turn 1 is consumed by the first pager; turn 2 by the - // implement turn the shell starts after approval. - content.set_turns([ + let mut setup_turn = content.expect_agent_turn( + "initial plan-drafting turn", format!("{SETUP_SENTINEL}: drafted a plan for the user to review."), + ); + let mut implement_turn = content.expect_agent_turn( + "implementation after approval", format!("{IMPLEMENT_SENTINEL}: implementing the approved plan."), - ]); + ); let project = tempfile::tempdir().context("project dir")?; std::fs::create_dir_all(project.path().join(".git")).context("create .git")?; @@ -69,6 +70,9 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> { first .wait_for_text(SETUP_SENTINEL, Duration::from_secs(30)) .context("setup turn rendered")?; + tokio::time::timeout(Duration::from_secs(10), setup_turn.wait_satisfied()) + .await + .context("setup turn expectation timeout")?; // Quit and reap BEFORE seeding so the still-live shell cannot re-persist // and clobber the seeded state. @@ -121,6 +125,9 @@ pub async fn assert_plan_approval_restored_after_resume() -> Result<()> { resumed .wait_for_text(IMPLEMENT_SENTINEL, Duration::from_secs(30)) .context("approve must leave plan mode and start the implement turn")?; + tokio::time::timeout(Duration::from_secs(10), implement_turn.wait_satisfied()) + .await + .context("implement turn expectation timeout")?; resumed.quit().context("quit resumed pager")?; Ok(()) diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/scripted.rs b/crates/codegen/xai-grok-pager-pty-harness/src/scripted.rs index 01909f1..78b48fe 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/scripted.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/scripted.rs @@ -7,12 +7,14 @@ use std::fs; use std::path::{Component, Path, PathBuf}; -use std::time::{Duration, SystemTime, UNIX_EPOCH}; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result, anyhow, bail}; use serde::{Deserialize, Serialize}; -use crate::{ContentController, PtyHarness, StyledLine, pager_binary, parse_keys}; +use crate::{ + AgentTurnExpectation, ContentController, PtyHarness, StyledLine, pager_binary, parse_keys, +}; const SGR_LEFT_BUTTON: u16 = 0; const SGR_MIDDLE_BUTTON: u16 = 1; @@ -26,6 +28,7 @@ pub const SGR_SCROLL_DOWN: u16 = 65; const DEFAULT_ROWS: u16 = 50; const DEFAULT_COLS: u16 = 120; const DEFAULT_WAIT_TIMEOUT_MS: u64 = 15_000; +const EXPECTATION_SETTLE_TIMEOUT: Duration = Duration::from_secs(10); /// Declarative scenario consumed by [`ScriptedScenarioRunner`]. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -172,11 +175,12 @@ pub struct WorkspaceConfig { pub struct MockConfig { #[serde(default = "default_mock_response")] pub response: String, - /// Optional per-agent-turn responses, consumed FIFO (one per real agent - /// turn; aux requests don't consume one — see - /// `MockInferenceServer::set_agent_turns`). Lets a scenario give each - /// turn a distinct sentinel, e.g. to prove a transcript tail was - /// truncated and re-generated. Falls back to `response` when exhausted. + /// Required per-agent-turn responses, registered as ordered foreground + /// expectations on both supported pager inference backends. Every listed + /// turn must be satisfied before the runner reports success. Lets a + /// scenario give each turn a distinct sentinel, e.g. to prove a transcript + /// tail was truncated and re-generated. Falls back to `response` when + /// exhausted. #[serde(default)] pub turns: Vec, #[serde(default)] @@ -531,9 +535,15 @@ impl ScriptedScenarioRunner { .await .context("start mock content")?; content.set_response(&scenario.mock.response); - if !scenario.mock.turns.is_empty() { - content.set_turns(scenario.mock.turns.iter().cloned()); - } + let turn_expectations: Vec<_> = scenario + .mock + .turns + .iter() + .enumerate() + .map(|(index, turn)| { + content.expect_agent_turn(format!("scenario turn {}", index + 1), turn) + }) + .collect(); if let Some(config_toml) = &scenario.environment.config_toml { let grok_home = content.home().join(".grok"); @@ -636,6 +646,34 @@ impl ScriptedScenarioRunner { report.status = ScriptedRunStatus::Failed; } + if report.status == ScriptedRunStatus::Running { + let settle_deadline = Instant::now() + EXPECTATION_SETTLE_TIMEOUT; + while turn_expectations + .iter() + .any(|expectation| !expectation.is_satisfied()) + && Instant::now() < settle_deadline + { + harness.update(Duration::from_millis(100)); + } + } + let unsatisfied_turns: Vec<_> = turn_expectations + .iter() + .filter(|expectation| !expectation.is_satisfied()) + .map(AgentTurnExpectation::diagnostic) + .collect(); + if !unsatisfied_turns.is_empty() { + report.bugs.push(BugFinding { + step: scenario.steps.len(), + severity: BugSeverity::Bug, + message: format!( + "required mock.turns expectations were not satisfied:\n- {}", + unsatisfied_turns.join("\n- ") + ), + screen_text: harness.screen_contents(), + }); + report.status = ScriptedRunStatus::Failed; + } + let _ = harness.quit(); if report.status == ScriptedRunStatus::Running { report.status = ScriptedRunStatus::Passed; diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/scroll_matrix/runner.rs b/crates/codegen/xai-grok-pager-pty-harness/src/scroll_matrix/runner.rs index 3f5c130..e623284 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/scroll_matrix/runner.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/scroll_matrix/runner.rs @@ -165,9 +165,7 @@ async fn run_cell_inner(cell: MatrixCell, binary: &Path, log_path: &Path) -> Res let mut env: Vec<(&str, &str)> = cell.env.to_vec(); env.push(("GROK_SCROLL_LOG", log_value)); - // Live bindings on purpose: `content` owns the mock server (and the - // streaming completion gate) — see the session module's footgun docs. - let (mut harness, content, baseline) = + let (mut harness, content, baseline, streaming_turn) = spawn_marker_session(binary, cell.session, MARKER_COUNT, &env).await; // Replay the gesture table: sleep each step's pre-delay (host-side lower @@ -194,19 +192,18 @@ async fn run_cell_inner(cell: MatrixCell, binary: &Path, log_path: &Path) -> Res let quiet_frames = harness.frame_count(); let marker_after = topmost_visible_marker(&harness); - // Streaming teardown: the CALLER owns the gate release (session-module - // contract) — release after the gesture so the pager exits a completed - // turn, and prove the release took (the held gate is the alternative - // explanation for almost any streaming-cell wedge). - if cell.session == SessionKind::Streaming { - content.release_agent_completions(); + if let Some(mut streaming_turn) = streaming_turn { + streaming_turn.release(); let deadline = Instant::now() + COMPLETION_TIMEOUT; while harness.contains_text("Responding") { if Instant::now() >= deadline { - bail!("teardown: turn never completed after the gate release"); + bail!("teardown: turn never completed after the expectation release"); } harness.update(Duration::from_millis(200)); } + tokio::time::timeout(COMPLETION_TIMEOUT, streaming_turn.wait_satisfied()) + .await + .context("teardown: streaming expectation was not satisfied")?; } harness.quit().context("teardown: quit pager")?; drop(content); @@ -397,18 +394,21 @@ mod tests { assert!(check_quiet(0).is_pass()); assert!(check_quiet(QUIET_MAX_FRAMES).is_pass()); let result = check_quiet(QUIET_MAX_FRAMES + 1); - assert!(matches!(result, InvariantResult::Violated { ref detail } -if detail.contains("churn"))); + assert!( + matches!(result, InvariantResult::Violated { ref detail } if detail.contains("churn")) + ); } #[test] fn screen_rejects_streaming_sessions_and_marker_loss() { let streaming = check_screen(SessionKind::Streaming, 100, Some(100), &[]); - assert!(matches!(streaming, InvariantResult::Violated { ref detail } -if detail.contains("streaming"))); + assert!( + matches!(streaming, InvariantResult::Violated { ref detail } if detail.contains("streaming")) + ); let lost = check_screen(SessionKind::BottomPinned, 100, None, &[]); - assert!(matches!(lost, InvariantResult::Violated { ref detail } -if detail.contains("no marker"))); + assert!( + matches!(lost, InvariantResult::Violated { ref detail } if detail.contains("no marker")) + ); // Empty capture ⇒ no movement expected; a matching marker passes. assert!(check_screen(SessionKind::BottomPinned, 100, Some(100), &[]).is_pass()); let moved = check_screen(SessionKind::BottomPinned, 100, Some(97), &[]); diff --git a/crates/codegen/xai-grok-pager-pty-harness/src/scroll_matrix/session.rs b/crates/codegen/xai-grok-pager-pty-harness/src/scroll_matrix/session.rs index a647fcc..c8af02b 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/src/scroll_matrix/session.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/src/scroll_matrix/session.rs @@ -12,19 +12,16 @@ //! mock server mid-session and surfacing as a confusing 60s stream timeout //! instead of an obvious failure. //! -//! ## Gate-release ownership (streaming sessions) +//! ## Streaming sessions //! -//! [`spawn_streaming_marker_session`] holds every agent completion -//! (`hold_agent_completions`) and paces deltas, so the turn provably cannot -//! finish while the gesture runs. The CALLER owns the release: call -//! `content.release_agent_completions()` after the gesture (before quitting, -//! so the pager exits a completed turn rather than an aborted stream). +//! [`spawn_streaming_marker_session`] returns a blocked turn expectation. The +//! caller releases it after the gesture and before quitting. use std::path::Path; use std::time::Duration; use crate::PtyHarness; -use crate::content::ContentController; +use crate::content::{AgentTurnExpectation, ContentController}; /// Transcript state a cell's gesture starts from. #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -127,18 +124,23 @@ pub const STREAMING_CHUNK_DELAY: Duration = Duration::from_millis(30); /// `GROK_SCROLL_LOG` — the PTY spawn strips host-terminal identity first, /// so injected markers always win). /// -/// Returns `(harness, controller, baseline)` where `baseline` is the -/// topmost visible marker index — see the footgun notes in the module docs -/// before binding the controller. +/// `blocked_turn` is present only for a streaming session. pub async fn spawn_marker_session( binary: &Path, kind: SessionKind, marker_count: usize, extra_env: &[(&str, &str)], -) -> (PtyHarness, ContentController, usize) { +) -> ( + PtyHarness, + ContentController, + usize, + Option, +) { match kind { SessionKind::Settled | SessionKind::BottomPinned => { - spawn_settled_marker_session(binary, marker_count, extra_env).await + let (harness, content, baseline) = + spawn_settled_marker_session(binary, marker_count, extra_env).await; + (harness, content, baseline, None) } SessionKind::Streaming => { spawn_streaming_marker_session( @@ -232,29 +234,29 @@ pub async fn spawn_settled_marker_session( /// [`SessionKind::Streaming`] preamble: the whole fenced marker block rides /// the first delta (the mock splits deltas on single spaces and the block /// contains none), the space-separated tail streams word-by-word at -/// `chunk_delay`, and the completion gate holds the turn's terminal event — -/// mid-turn by construction until the caller releases the gate (see the -/// module docs). Setup guards: transcript overflows the viewport and -/// [`STREAM_END_SENTINEL`] is not on screen (bottom-pinned follow would -/// render it if the stream had finished). +/// `chunk_delay`, and the matched expectation prevents terminal completion +/// until the caller releases it. Setup guards: transcript overflows the +/// viewport and [`STREAM_END_SENTINEL`] is not on screen. pub async fn spawn_streaming_marker_session( binary: &Path, marker_count: usize, tail_words: usize, chunk_delay: Duration, extra_env: &[(&str, &str)], -) -> (PtyHarness, ContentController, usize) { +) -> ( + PtyHarness, + ContentController, + usize, + Option, +) { let content = ContentController::start().await.expect("start content"); content.set_chunk_delay(Some(chunk_delay)); - content.hold_agent_completions(); - // set_turns (not set_response): only agent turns ride the completion - // gate; aux title/classifier requests fall through untouched. let mut turn = marker_response(marker_count); for i in 0..tail_words { turn.push_str(&format!("TAIL-{i:04} ")); } turn.push_str(STREAM_END_SENTINEL); - content.set_turns([turn]); + let turn = content.expect_agent_turn_blocked("streaming marker turn", turn); let mut harness = spawn_pager(binary, &content, extra_env); // The last marker rides the first delta, so this waits only for the @@ -272,7 +274,7 @@ pub async fn spawn_streaming_marker_session( ); let baseline = assert_scrollable_baseline(&harness, "mid-stream"); - (harness, content, baseline) + (harness, content, baseline, Some(turn)) } #[cfg(test)] diff --git a/crates/codegen/xai-grok-pager-pty-harness/tests/scroll_correctness_ptyctl.rs b/crates/codegen/xai-grok-pager-pty-harness/tests/scroll_correctness_ptyctl.rs index 154427d..0cfaa4c 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/tests/scroll_correctness_ptyctl.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/tests/scroll_correctness_ptyctl.rs @@ -56,9 +56,13 @@ async fn scroll_up_from_follow_bottom_then_back_down() -> Result<()> { .context("welcome")?; harness.inject_keys(b"scroll test\r")?; + // Follow mode pins the viewport to the bottom, so the top marker only + // flashes on-screen before scrolling above the viewport — polling for it + // races a fast stream. Wait for the bottom marker, which stays visible in + // follow mode once the response reaches it. harness - .wait_for_text("MARKER_TOP_OF_RESPONSE", Duration::from_secs(30)) - .context("top marker while streaming")?; + .wait_for_text("MARKER_BOTTOM_OF_RESPONSE", Duration::from_secs(30)) + .context("response reached bottom while following")?; // Wait for stream end. Follow mode pins the viewport to the bottom. let settle_deadline = Instant::now() + Duration::from_secs(45); diff --git a/crates/codegen/xai-grok-pager-pty-harness/tests/scroll_matrix_curated.rs b/crates/codegen/xai-grok-pager-pty-harness/tests/scroll_matrix_curated.rs index c041702..e923456 100644 --- a/crates/codegen/xai-grok-pager-pty-harness/tests/scroll_matrix_curated.rs +++ b/crates/codegen/xai-grok-pager-pty-harness/tests/scroll_matrix_curated.rs @@ -22,8 +22,19 @@ use xai_grok_pager_pty_harness::scroll_matrix::{ /// across awaits; no poisoning, so one failed cell doesn't cascade). static SERIAL: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); +/// Per-cell captures land here. Prefer Bazel's `TEST_TMPDIR` (unique and +/// isolated per test action) over the shared system temp dir: this target is +/// `tags = ["local"]`, so concurrent executions on a CI host would otherwise +/// share a stable `/tmp/scroll-matrix-curated/.jsonl` path — a +/// second run's stale-capture `remove_file` (and its pager's `GROK_SCROLL_LOG` +/// writer) then corrupts the first run's in-flight capture, surfacing as a +/// `parse capture: No such file or directory` or a mid-record parse error. +/// Falls back to the system temp dir for plain `cargo test`. fn artifacts_dir() -> PathBuf { - std::env::temp_dir().join("scroll-matrix-curated") + std::env::var_os("TEST_TMPDIR") + .map(PathBuf::from) + .unwrap_or_else(std::env::temp_dir) + .join("scroll-matrix-curated") } /// Run one curated cell by id and return its report (panics on unknown ids diff --git a/crates/codegen/xai-grok-pager-render/src/appearance/cache.rs b/crates/codegen/xai-grok-pager-render/src/appearance/cache.rs index ac27cf4..dd23d33 100644 --- a/crates/codegen/xai-grok-pager-render/src/appearance/cache.rs +++ b/crates/codegen/xai-grok-pager-render/src/appearance/cache.rs @@ -31,6 +31,8 @@ const TIMESTAMPS_DEFAULT: bool = true; /// const context and the effective-config fallback read. const TIMELINE_DEFAULT: bool = UiConfig::SHOW_TIMELINE_DEFAULT; const PAGE_FLIP_ON_SEND_DEFAULT: bool = UiConfig::PAGE_FLIP_ON_SEND_DEFAULT; +/// Combine-queued-prompts rollout flag defaults OFF (opt-in). +const COMBINE_QUEUED_PROMPTS_DEFAULT: bool = false; const SIMPLE_MODE_DEFAULT: bool = true; /// Vim-mode scrollback default — matches the previous on-disk default. const VIM_MODE_DEFAULT: bool = false; @@ -164,6 +166,35 @@ pub fn set_page_flip_on_send(enabled: bool) { PAGE_FLIP_ON_SEND_LOADED.with(|l| l.set(true)); } +// -- Combine queued prompts --------------------------------------------------- + +thread_local! { + static COMBINE_QUEUED_PROMPTS_CURRENT: Cell = + const { Cell::new(COMBINE_QUEUED_PROMPTS_DEFAULT) }; + static COMBINE_QUEUED_PROMPTS_LOADED: Cell = const { Cell::new(false) }; +} + +/// Cached `combine_queued_prompts`, seeding from `[ui]` on first call. +pub fn load_combine_queued_prompts() -> bool { + COMBINE_QUEUED_PROMPTS_LOADED.with(|loaded| { + if !loaded.get() { + COMBINE_QUEUED_PROMPTS_CURRENT.with(|c| { + c.set(load_bool_from_effective_config( + "combine_queued_prompts", + COMBINE_QUEUED_PROMPTS_DEFAULT, + )) + }); + loaded.set(true); + } + }); + COMBINE_QUEUED_PROMPTS_CURRENT.with(|c| c.get()) +} + +pub fn set_combine_queued_prompts(enabled: bool) { + COMBINE_QUEUED_PROMPTS_CURRENT.with(|c| c.set(enabled)); + COMBINE_QUEUED_PROMPTS_LOADED.with(|l| l.set(true)); +} + // -- Simple mode -------------------------------------------------------------- thread_local! { @@ -575,6 +606,10 @@ pub fn prime(ui: &UiConfig) { set_timestamps(ui.show_timestamps.unwrap_or(TIMESTAMPS_DEFAULT)); set_show_timeline(ui.show_timeline_enabled()); set_page_flip_on_send(ui.page_flip_on_send_enabled()); + set_combine_queued_prompts( + ui.combine_queued_prompts + .unwrap_or(COMBINE_QUEUED_PROMPTS_DEFAULT), + ); set_simple_mode(ui.simple_mode.unwrap_or(SIMPLE_MODE_DEFAULT)); set_keep_text_selection(text_selection_from_ui(ui)); // Layered-config keys (not the `UiConfig` arg) — seed so the first frame @@ -687,6 +722,11 @@ mod tests { assert_eq!(TIMESTAMPS_DEFAULT, ui.show_timestamps.unwrap_or(true)); assert_eq!(TIMELINE_DEFAULT, ui.show_timeline_enabled()); assert_eq!(PAGE_FLIP_ON_SEND_DEFAULT, ui.page_flip_on_send_enabled()); + assert_eq!( + COMBINE_QUEUED_PROMPTS_DEFAULT, + ui.combine_queued_prompts + .unwrap_or(COMBINE_QUEUED_PROMPTS_DEFAULT) + ); assert_eq!(SIMPLE_MODE_DEFAULT, ui.simple_mode.unwrap_or(true)); assert_eq!(VIM_MODE_DEFAULT, ui.vim_mode.unwrap_or(false)); assert_eq!( @@ -769,6 +809,18 @@ mod tests { .unwrap(); } + #[test] + fn set_then_load_round_trips_combine_queued_prompts() { + std::thread::spawn(|| { + set_combine_queued_prompts(true); + assert!(load_combine_queued_prompts()); + set_combine_queued_prompts(false); + assert!(!load_combine_queued_prompts()); + }) + .join() + .unwrap(); + } + #[test] fn set_then_load_round_trips_simple_mode() { std::thread::spawn(|| { diff --git a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs index 8e592c0..68b0632 100644 --- a/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs +++ b/crates/codegen/xai-grok-pager-render/src/clipboard/mod.rs @@ -367,7 +367,7 @@ impl ClipboardFeedback { Self::VsCodeSshNonAscii => { "Copied. VS Code over SSH may garble non-ASCII; use /minimal if needed." } - Self::FailedRemote | Self::Failed => "Copy failed. Try /terminal-setup or /minimal.", + Self::FailedRemote | Self::Failed => "Copy failed. Try /doctor or /minimal.", } } @@ -2064,14 +2064,14 @@ mod tests { ( ClipboardFeedback::FailedRemote, ClipboardDelivery::Failed, - "Copy failed. Try /terminal-setup or /minimal.", + "Copy failed. Try /doctor or /minimal.", "failed_remote", 120, ), ( ClipboardFeedback::Failed, ClipboardDelivery::Failed, - "Copy failed. Try /terminal-setup or /minimal.", + "Copy failed. Try /doctor or /minimal.", "failed", 120, ), diff --git a/crates/codegen/xai-grok-pager-render/src/glyphs.rs b/crates/codegen/xai-grok-pager-render/src/glyphs.rs index 224e559..172266a 100644 --- a/crates/codegen/xai-grok-pager-render/src/glyphs.rs +++ b/crates/codegen/xai-grok-pager-render/src/glyphs.rs @@ -135,7 +135,7 @@ pub fn token_arrow() -> &'static str { /// U+25CE BULLSEYE, U+25C9 FISHEYE, U+25CE BULLSEYE) normally; a 1-column /// dot pulse (`·`, `○`, `•`, `○`) on legacy ConHost. /// -/// Animates the "watching · N monitors" cue in the turn-status line: a +/// Animates the "N monitors still running" cue in the turn-status line: a /// concentric circle that breathes open → shut like a scanning scope. Of /// the fancy frames only the white circle `○` (U+25CB, CP437 `0x09`) is /// part of CP437 — the bullseye `◎` and fisheye `◉` live in the Geometric diff --git a/crates/codegen/xai-grok-pager-render/src/terminal/keyboard.rs b/crates/codegen/xai-grok-pager-render/src/terminal/keyboard.rs index c09eb46..8846030 100644 --- a/crates/codegen/xai-grok-pager-render/src/terminal/keyboard.rs +++ b/crates/codegen/xai-grok-pager-render/src/terminal/keyboard.rs @@ -2,8 +2,8 @@ //! //! Classifies keyboard delivery semantics so input-handling code can consume one struct //! instead of branching on brand. The classification depends on the -//! current `HostOs`, queried internally — today only macOS rows are -//! populated. Extend [`KeyboardCapabilities`] with new fields (paste +//! host OS — today only macOS rows are populated. Extend +//! [`KeyboardCapabilities`] with new fields (paste //! protocol, focus reporting, custom escapes) instead of adding more //! `match self.brand` sites scattered through the pager. @@ -81,13 +81,18 @@ impl KeyboardCapabilities { } } -/// Classify keyboard capabilities for a given `(brand, os, display_server)`. +/// Classify keyboard capabilities for the current host. /// /// Today the table is populated only for macOS; other OSes return the /// default (all-`Unknown`). When a Linux/Windows probe lands, add a /// per-OS arm here rather than forking the function. pub fn keyboard_capabilities(brand: TerminalName) -> KeyboardCapabilities { - match HostOs::current() { + keyboard_capabilities_for_host(brand, HostOs::current()) +} + +/// Classify keyboard capabilities for explicit host evidence. +pub fn keyboard_capabilities_for_host(brand: TerminalName, host: HostOs) -> KeyboardCapabilities { + match host { HostOs::Macos => macos_capabilities(brand), HostOs::Linux | HostOs::Windows | HostOs::Other => KeyboardCapabilities::default(), } diff --git a/crates/codegen/xai-grok-pager-render/src/terminal/mod.rs b/crates/codegen/xai-grok-pager-render/src/terminal/mod.rs index 54dbff1..9a72beb 100644 --- a/crates/codegen/xai-grok-pager-render/src/terminal/mod.rs +++ b/crates/codegen/xai-grok-pager-render/src/terminal/mod.rs @@ -4,7 +4,6 @@ //! Pure env-map helpers (`detect_*_from_env`) enable full matrix testing. use std::collections::HashMap; -use std::process::Command; use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; @@ -16,6 +15,7 @@ pub mod image; pub mod keyboard; pub mod overlay; pub(crate) mod probe; +pub mod tmux_probe; pub mod xtversion; pub use embedded_editor::{EmbeddedEditor, embedded_editor_from_env}; @@ -23,7 +23,10 @@ pub use hyperlinks::{ HyperlinkCapabilities, Osc8Support, SchemeFilter, SetDefaultCursor, SetPointerCursor, hyperlink_capabilities, }; -pub use keyboard::{KeyboardCapabilities, ModifierDelivery, ModifierFate, keyboard_capabilities}; +pub use keyboard::{ + KeyboardCapabilities, ModifierDelivery, ModifierFate, keyboard_capabilities, + keyboard_capabilities_for_host, +}; #[cfg(test)] mod test; @@ -64,29 +67,6 @@ pub fn take_kitty_flags_pushed() -> bool { KITTY_FLAGS_PUSHED.swap(false, Ordering::AcqRel) } -/// Run `tmux show-option -gqv