diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 13207b8147..4a0f2eb8f9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -368,7 +368,10 @@ jobs: libayatana-appindicator3-dev \ librsvg2-dev \ xdg-utils \ - patchelf + patchelf \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-alsa - name: Verify toolchain versions run: | @@ -411,10 +414,68 @@ jobs: if: matrix.platform == 'win' run: node scripts/run-msvc.mjs cargo test -p bibcode-server --test git_system_config -j 2 + # #341 was reported on Windows: background Git must not lock the index there either. + - name: Run background Git index lock tests (Windows) + if: matrix.platform == 'win' + run: node scripts/run-msvc.mjs cargo test -p bibcode-server --lib -j 2 -- background_status_ git_stop_ index_lock_cleanup_ + + # #293: a review prompt reaches node through an npm-style .cmd shim with every class intact. + - name: Run review prompt argument tests (Windows) + if: matrix.platform == 'win' + run: node scripts/run-msvc.mjs cargo test -p bibcode-server --test windows_terminal_shims -j 2 -- review_prompt_shim_ + - name: Run Git system configuration tests (macOS) if: matrix.platform == 'mac' run: cargo test -p bibcode-server --test git_system_config -j 2 + - name: Run background Git index lock tests (macOS) + if: matrix.platform == 'mac' + run: cargo test -p bibcode-server --lib -j 2 -- background_status_ git_stop_ index_lock_cleanup_ + + # #292: the host collector, its exit with a disk read held, and its runtime start and + # quiesce. Each command fails the step when its filter matches no test. + - name: Run host metrics tests (Windows) + if: matrix.platform == 'win' + shell: bash + run: | + counted() { + local expected="$1"; shift + local log; log="$(mktemp)" + "$@" 2>&1 | tee "$log" + local passed + passed="$(sed -n 's/^test result: ok\. \([0-9]*\) passed.*/\1/p' "$log" | awk '{ total += $1 } END { print total + 0 }')" + if [ "$expected" = exactly-one ] && [ "$passed" -ne 1 ]; then + echo "::error::expected exactly 1 passed test, got $passed"; exit 1 + fi + if [ "$passed" -lt 1 ]; then + echo "::error::the filter matched no test"; exit 1 + fi + } + counted at-least-one node scripts/run-msvc.mjs cargo test -p bibcode-server --lib -j 2 -- host_metrics:: + counted exactly-one node scripts/run-msvc.mjs cargo test -p bibcode-server --test host_metrics_exit -j 2 -- --exact a_held_disk_read_does_not_block_exit + counted exactly-one node scripts/run-msvc.mjs cargo test -p bibcode-server --lib -j 2 -- --exact production::runtime::tests::host_metrics_start_with_the_runtime_and_stop_in_quiesce + + - name: Run host metrics tests (macOS) + if: matrix.platform == 'mac' + shell: bash + run: | + counted() { + local expected="$1"; shift + local log; log="$(mktemp)" + "$@" 2>&1 | tee "$log" + local passed + passed="$(sed -n 's/^test result: ok\. \([0-9]*\) passed.*/\1/p' "$log" | awk '{ total += $1 } END { print total + 0 }')" + if [ "$expected" = exactly-one ] && [ "$passed" -ne 1 ]; then + echo "::error::expected exactly 1 passed test, got $passed"; exit 1 + fi + if [ "$passed" -lt 1 ]; then + echo "::error::the filter matched no test"; exit 1 + fi + } + counted at-least-one cargo test -p bibcode-server --lib -j 2 -- host_metrics:: + counted exactly-one cargo test -p bibcode-server --test host_metrics_exit -j 2 -- --exact a_held_disk_read_does_not_block_exit + counted exactly-one cargo test -p bibcode-server --lib -j 2 -- --exact production::runtime::tests::host_metrics_start_with_the_runtime_and_stop_in_quiesce + # build-desktop-artifact.ts --symbols runs dump_syms on the unstripped binary. Rust Cache # keeps ~/.cargo/bin, so a restored runner skips the compile. - name: Install dump_syms diff --git a/.github/workflows/desktop-ui-smoke.yml b/.github/workflows/desktop-ui-smoke.yml index 8fa85777e8..f35681c10a 100644 --- a/.github/workflows/desktop-ui-smoke.yml +++ b/.github/workflows/desktop-ui-smoke.yml @@ -93,8 +93,12 @@ jobs: librsvg2-dev \ xdg-utils \ patchelf \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-alsa \ xvfb \ - xdotool + xdotool \ + lsof - name: Install frozen dependencies run: vp install --frozen-lockfile @@ -139,6 +143,24 @@ jobs: exit 1 fi + # Audio in Files needs the bundled GStreamer core to find its own plugins: exactly the + # wrapper's allowlist, nothing else, plus the scanner and the AppRun hook. + for required in \ + usr/lib/gstreamer1.0/gstreamer-1.0/gst-plugin-scanner \ + apprun-hooks/linuxdeploy-plugin-gstreamer.sh; do + if [[ ! -f "$extract_directory/squashfs-root/$required" ]]; then + printf 'Linux AppImage is missing GStreamer file: %s\n' "$required" >&2 + exit 1 + fi + done + expected_plugins="$(bash scripts/tauri/linuxdeploy-plugin-gstreamer.sh --list-plugins | LC_ALL=C sort)" + bundled_plugins="$(ls -1 "$extract_directory/squashfs-root/usr/lib/gstreamer-1.0" | LC_ALL=C sort)" + if [[ "$bundled_plugins" != "$expected_plugins" ]]; then + printf 'Linux AppImage GStreamer plugins differ from the allowlist:\n' >&2 + diff <(printf '%s\n' "$expected_plugins") <(printf '%s\n' "$bundled_plugins") >&2 || true + exit 1 + fi + - name: Install macOS application from DMG build if: matrix.platform == 'mac' shell: bash diff --git a/.github/workflows/desktop-upgrade-smoke.yml b/.github/workflows/desktop-upgrade-smoke.yml index 223a9d6e78..735cb9cb11 100644 --- a/.github/workflows/desktop-upgrade-smoke.yml +++ b/.github/workflows/desktop-upgrade-smoke.yml @@ -118,7 +118,9 @@ jobs: s''udo apt-get install -y \ build-essential curl wget file libxdo-dev libssl-dev \ libgtk-3-dev libwebkit2gtk-4.1-dev libayatana-appindicator3-dev \ - librsvg2-dev xdg-utils patchelf xvfb xdotool + librsvg2-dev xdg-utils patchelf xvfb xdotool \ + gstreamer1.0-plugins-base gstreamer1.0-plugins-good \ + gstreamer1.0-alsa - name: Install frozen dependencies run: vp install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 2f13fc8db2..79e47b3f6c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -470,7 +470,10 @@ jobs: libayatana-appindicator3-dev \ librsvg2-dev \ xdg-utils \ - patchelf + patchelf \ + gstreamer1.0-plugins-base \ + gstreamer1.0-plugins-good \ + gstreamer1.0-alsa - name: Verify stable updater signing configuration if: needs.preflight.outputs.is_update_candidate == 'true' diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f7c571c85..21dc024e79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,55 @@ # Changelog +## Unreleased + +### Diagnostics + +- Each environment has a Host dashboard (the environment card's menu, Settings > Remote Servers, and the status bar's Resource Manager) with its host's CPU, memory and volumes, live and since the server started. It lists the volumes it hides and why, shows per-volume statistics, never reads network volumes, and says when the volume list is not answering. Agents can read the same figures with the `host_metrics` MCP tool. (#292) + +### Pipelines + +- Stage, layer and Jobs headers in the pipeline detail view now read as group labels on their own strip, apart from the job rows below them, in the wide and the stacked layout; each is still one heading with its status, name and job count. (#340) + +### Git + +- A push that fails for missing credentials on a remote server now says that Git ran on that server, names it, and tells you to run `git push` in a terminal there; Source Control and the Git Manager show the same guidance. (#343) +- Background status reads no longer take Git's index lock, so your own `git add` or `git commit` in a terminal no longer fails with "index.lock: File exists" while BiBCode is open. On Linux and macOS BiBCode asks Git to stop before it forces it; on Windows it forces it as before. Either way it then removes an `index.lock` that its own stopped process left and that nothing else is using, including when it quits. (#341) +- Local status for a worktree is delivered while a stacked action or a Git Manager push, publish, force push or remote branch delete waits on the network. Writes stay one at a time; pull and fetch still wait. (#345) +- The profiler warns about a leftover worktree folder only when it shows worktree evidence, one finding per folder, and the warning ends when the folder is gone. BiBCode records the worktrees it creates, so folders that were never worktrees no longer warn every ten minutes. (#344) +- Create worktree starts fresh every time it opens, after a creation or a close: the last Name, Create From, mode, base override, Advanced, Create more and agent pick no longer come back. Input stays while the dialog is open, and Create more still keeps the project, mode, agent and base override for its batch. (#346) +- Source Control has a **Review changes** button that starts a code review of your branch and its uncommitted changes in a chat panel beside the thread or in an AI Terminal. A Claude review starts in Claude's plan mode, a Codex review runs in Codex's read-only sandbox, other chat agents ask before edits or are only told not to edit, and Settings → Agents and the button's tooltip say which applies. A review's findings offer no Implement. The review prompt can be set per user in Settings → Agents and per project from the button's menu, which also starts a review once with another agent or with added instructions. Mod+Alt+Shift+R starts a review from the keyboard. On Windows, a terminal review gets the prompt with its line breaks as spaces. (#293) + +### Chats + +- A new worktree's or project's chat lists only its own provider's models in the composer, before the first message too; to use another provider, add a chat panel from the chat header `+`. A new chat that has not been sent still lists every ready provider. A chat whose provider is disabled or removed now says so above the composer and keeps Send unavailable, and **Add as project** in Files opens the new Main on your Default Agent instead of Codex. (#347) + +### Files + +- Markdown opens editable in rendered view, with a formatting group in the Files toolbar (paragraph, headings 1 to 5, bold, italic, strikethrough, bullet, numbered and task lists, block quote, a link dialog, Insert image next to the file, collapsible section). Undo and Redo work in both views and a save changes only what you edited; frontmatter and constructs the editor cannot represent stay as written or read-only. (#342) +- Images a Markdown file links by a relative path show in the editable rendered view, read from the project relative to the file, in the desktop app, the browser and remote environments; an inserted image shows at once, and a path outside the project is not read. (#345) +- Ticking a task checkbox in a read-only Markdown or `.mdx` file keeps the file's source Undo history. (#342) +- Mermaid diagrams in an editable rendered Markdown file are drawn again, as one block that is not edited in place; the file's source is kept as written. (#342) +- The Linux AppImage now carries its own GStreamer plugins for the audio Files previews in WAV, MP3, Ogg, WebM and FLAC files, instead of relying on the system's plugins, which its bundled GStreamer cannot load on newer distributions. M4A is not covered: its AAC decoder is not bundled. +- A PDF preview in the Files panel is no longer blocked by the page's content security policy in Chromium-based webviews and browsers: the Windows desktop app, and browser mode in Chrome or Edge. + +### Appearance + +- Settings > Appearance lists six built-in themes (Tokyo Night, Matrix Dark, Matrix Light, Verdant Light and the existing Light and Dark) and lets you duplicate, edit with a live preview, import, export and delete custom themes, with contrast warnings. New terminal sessions and the pipeline job log can follow the theme's terminal colors, and a theme can also set scrollbars, focus rings, control shadows and selected rows. (#290) +- Clicking a theme row applies it and, when the theme's base differs from the mode on screen, switches the mode with an Undo that restores the previous mode and theme. (#290) +- The left panel draws its repository, environment, primary checkout, worktree and chat rows as five visually distinct levels in every built-in theme, and a theme can style each level. (#290) +- On the desktop app, exporting a theme or plan opens the native Save dialog, and closing, quitting, restarting or installing an update with unsaved Files edits asks first with Save all, Discard and Cancel. (#290, #342) + +### Terminals + +- When you return to a Claude AI Terminal on a macOS or Linux server after it printed more than BiBCode keeps for a redraw (1 MiB or 5,000 lines, or 512 KiB in the client), BiBCode asks Claude to redraw its screen, the way it does when resumed, so characters missing from the replay come back. This is a best-effort repaint for Claude terminals BiBCode starts, not a rebuild of the terminal's state; other programs and Windows servers still need a panel resize, and a Claude session you suspended in that terminal resumes. (#348) +- A terminal that kept an old row count, so an AI CLI lost its bottom lines or footer until you changed the panel's width, now corrects itself: the server confirms each size in order with the output, a size it did not apply never resizes the view, and a window whose connection dropped gets the terminal's size back when it reconnects, unless someone typed in the other window meanwhile. (#348) +- A terminal laid out with no width or height, such as a center terminal behind a maximized right panel, keeps its size instead of resizing its program to 2 columns or 1 row. (#348) +- A terminal refits when the display scale changes, for example after moving the window to another display, so a bottom row clipped by the new scale's row height no longer stays cut off until a panel is resized. (#348) +- When resizing a terminal's program fails, BiBCode retries it a few times, so the program no longer keeps its old size, with its bottom rows cut off, until the next panel resize or keypress. (#348) +- Opening, collapsing or dragging a panel no longer resizes its terminal's program to 2 or 5 columns on the way: a terminal takes a new size once its panel holds still for a moment, and a small panel that holds still still sizes it. (#348) +- A terminal that was off-screen or hidden while its program kept printing after its rows shrank comes back showing its last lines and footer, instead of a view stuck rows above them until you changed a panel's size; a terminal you had scrolled up to read stays where you left it. (#348) +- When two windows, for example on two machines, show the same terminal, the window you last used sizes it and the other one shows the whole terminal scaled down to fit instead of cutting off its bottom rows; a window nobody is clicking or typing in, such as an idle server host window, no longer takes the size back when it regains focus, where its web view reports recent input. A click or **Fit to this window** that takes the size leaves the terminal focused, so the first keys you type reach the program. (#348) + ## [v0.15.3] - 2026-11-08 BiBCode v0.15.3 guards checkpoint restores in shared checkouts, offers Git Credential Manager sign-in on the desktop, keeps named MCP clients across runs, reworks the Pipelines and Files views, loads pipelines for self-hosted GitLab on any port, and hardens load-sensitive tests and secret scanning. This release includes PR #337 since v0.15.2. diff --git a/Cargo.toml b/Cargo.toml index 3327148d09..530d4329e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,10 @@ serde_json = "1" sha2 = "0.11.0" snow = "0.10" socket2 = "0.6.5" -sysinfo = "0.39.6" +# Network and tmpfs mounts are listed so the host collector's own filter can hide and disclose +# them (R81); listing makes no `statvfs` call. On macOS sysinfo still skips network volumes, so +# the collector adds them from `getfsstat(MNT_NOWAIT)`. +sysinfo = { version = "0.39.6", features = ["linux-netdevs", "linux-tmpfs"] } subtle = "2.6.1" bibcode-server = { path = "apps/server" } tauri = { version = "~2.11.6", features = ["unstable", "devtools", "image-png"] } diff --git a/UI.md b/UI.md index 974364f22d..bf901d349c 100644 --- a/UI.md +++ b/UI.md @@ -170,7 +170,7 @@ Optimize for user control, predictability, and task completion. Every surprise, ### Typography - The smallest text in the app is `text-xs` (12px). Never write `text-[9px]`, `text-[10px]`, `text-[11px]`, or another arbitrary size below it; badges, key hints, counts, and timestamps use `text-xs`. -- Navigation rows are 13px medium and thread titles 13px regular, both on `text-foreground/80`; project titles are 13px medium on `text-foreground/90`. +- Navigation rows are 13px medium on `text-foreground/80`; project titles are 13px medium on `text-foreground/90`. Sidebar chat rows are 12px regular on `sidebar-chat-foreground`, so a chat reads as lighter than the checkout or worktree it belongs to; an unread chat is drawn at `foreground`. - `text-muted-foreground` is already the secondary color. Do not alpha-reduce it further for text (`text-muted-foreground/70` and similar); badges, subtitles, timestamps, and empty states use the solid token. Alpha is fine on icon tints. - Do not add global letter-spacing to body text; under hinted rendering the fractional advances become uneven letter gaps. - A new scrollable reading surface must mark its content wrapper with `data-text-surface`, or its text renders grayscale on Linux (see the architecture overview, Linux webview text rendering). diff --git a/apps/desktop/THIRD_PARTY_NOTICES.md b/apps/desktop/THIRD_PARTY_NOTICES.md new file mode 100644 index 0000000000..f66fd18077 --- /dev/null +++ b/apps/desktop/THIRD_PARTY_NOTICES.md @@ -0,0 +1,102 @@ +# Third-Party Notices: Linux AppImage native components + +The Linux AppImage carries shared libraries copied unmodified from its Ubuntu +22.04 build host, except that linuxdeploy and the GStreamer plugin rewrite their +run paths with `patchelf`. BiBCode and WebKitGTK load them dynamically. Each +component's corresponding source is the Ubuntu 22.04 (jammy) source package +named below, available from and from +the upstream project. + +This file lives in the repository; it is not yet shipped inside the AppImage. + +This file covers the GStreamer plugins and the libraries bundled for them. It +does not yet cover the components every AppImage already carried before them +(WebKitGTK, GTK, GLib, the GStreamer core and base libraries, and their +dependencies); see [Not yet recorded](#not-yet-recorded). + +## GStreamer plugins + +The plugin list is `allowed_plugins` in +`scripts/tauri/linuxdeploy-plugin-gstreamer.sh`. Every plugin comes from one of +three source packages, and each source package's Ubuntu copyright file is the +licence source: + +| Source package (jammy version) | Plugins | Licence | Copyright file | +| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------- | --------------------------------------------------------------------------------------------------------------------------- | +| `gstreamer1.0` (1.20.3-0ubuntu1.1) | `libgstcoreelements.so` | LGPL-2+ | | +| `gst-plugins-base1.0` (1.20.1-1ubuntu0.7) | `libgstplayback.so`, `libgsttypefindfunctions.so`, `libgstapp.so`, `libgstaudioconvert.so`, `libgstaudioresample.so`, `libgstvolume.so`, `libgstogg.so`, `libgstvorbis.so`, `libgstopus.so`, `libgstalsa.so` | LGPL-2+ | | +| `gst-plugins-good1.0` (1.20.3-0ubuntu1.10) | `libgstautodetect.so`, `libgstpulseaudio.so`, `libgstaudiofx.so`, `libgstwavparse.so`, `libgstaudioparsers.so`, `libgstid3demux.so`, `libgstmpg123.so`, `libgstflac.so`, `libgstmatroska.so` | LGPL-2+ | | + +In those copyright files the only non-LGPL entries are build tooling +(`ltmain.sh`, `common/coverage/coverage-report-entry.pl`, GPL-2+) and, in the +core, the Bison-generated `gst/parse/grammar.tab.h` (GPL-3+), which belongs to +the core library rather than to `libgstcoreelements.so`. Whether that header +carries Bison's output exception is unverified; the lab confirms it, and that +no listed plugin is built from a GPL file, against +`/usr/share/doc//copyright` on the build host. + +## Libraries bundled for the plugins + +linuxdeploy copies each plugin's shared-library dependencies that its exclude +list does not leave to the host. The libraries below were new in the AppImage +once the plugins were bundled: confirmed from the built AppImage at 34f309e4d in +the Linux lab, by comparing its bundled libraries with the AppImage built before +the plugins. Each licence is the one the package's jammy copyright file states. + +| Library (binary package) | Needed by | Licence (jammy copyright file) | Copyright file (jammy) | +| ---------------------------------------------------------------------- | -------------------------------------- | --------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `libmpg123.so.0` (`libmpg123-0`) | `libgstmpg123.so` | LGPL-2.1 | | +| `libFLAC.so.8` (`libflac8`) | `libgstflac.so` | See [FLAC](#flac) | | +| `libogg.so.0` (`libogg0`) | `libgstogg.so`, `libgstvorbis.so` | BSD-style ("Redistribution and use in source and binary forms, with or without modification") | | +| `libopus.so.0` (`libopus0`) | `libgstopus.so` | BSD-style ("Redistribution and use in source and binary forms, with or without modification") | | +| `libvorbis.so.0`, `libvorbisenc.so.2` (`libvorbis0a`, `libvorbisenc2`) | `libgstvorbis.so` | BSD-3-Clause | | +| `libpulse.so.0`, `libpulsecommon-15.99.so` (`libpulse0`) | `libgstpulseaudio.so` | LGPL-2.1+ | | +| `libsndfile.so.1` (`libsndfile1`) | `libpulse.so.0` | LGPL-2.1+ | | +| `libasyncns.so.0` (`libasyncns0`) | `libpulse.so.0` | LGPL-2.1+ | | +| `libapparmor.so.1` (`libapparmor1`) | `libpulse.so.0` | LGPL-2.1+ (`libraries/libapparmor/*`) | | +| `libgstriff-1.0.so.0` (`libgstreamer-plugins-base1.0-0`) | `libgstwavparse.so` and other demuxers | LGPL-2+ | | + +Other files in some of these source packages carry other licences but are not +part of the bundled libraries: + +- `pulseaudio`: `src/modules/bluetooth/proximity-helper.c` is GPL-2+; it is a + PulseAudio module, not part of `libpulse` or `libpulsecommon`. +- `libsndfile`: besides the LGPL-2.1+ library sources, the package holds the + GSM610 code (its own `gsm` licence), the ALAC code (Apache-2.0) and the G72x + code (Sun's licence), which are compiled into `libsndfile.so.1`, and a GPL-2+ + test file (`src/G72x/g72x_test.c`), which is not. +- `apparmor`: the package's `Files: *` stanza is GPL-2+; `libapparmor` is + covered by the `libraries/libapparmor/*` stanza, LGPL-2.1+. + +### FLAC + +Ubuntu's jammy copyright file for `flac` (1.3.3-2ubuntu0.2) gives `Files: *` +as "GPL-2+ or LGPL-2.1+", `examples/*` as GPL-2+ and `doc/*` as GFDL-1.1+, and +has a separate stanza listing `src/libFLAC/*` (with `include/FLAC/*` and +`src/libFLAC++/*`) as BSD-3-clause. Upstream FLAC 1.3.3's `README` says the +same: "The libraries (libFLAC, libFLAC++) are licensed under Xiph.org's BSD-like +license (see COPYING.Xiph)". The lab's reading of the copyright file on its +build host showed only the `Files: *` stanza; if a host's file really lacks the +`src/libFLAC/*` stanza, the libFLAC licence rests on the upstream statement. + +### Libraries the plugins use from the host + +linuxdeploy's exclude list keeps these on the host, so the AppImage does not +carry them: `libz.so.1`, `libasound.so.2` (the ALSA plugin therefore uses the +host's `libasound.so.2`), `libpulse-mainloop-glib.so.0` and `libwrap.so.0`. + +### Already bundled before the plugins + +`liborc-0.4.so.0` (`liborc-0.4-0`) and `libbz2.so.1.0` (`libbz2-1.0`), which +some plugins use, were already in the AppImage before the plugins were added; +they fall under [Not yet recorded](#not-yet-recorded). + +`libmp3lame` (LAME) is not bundled, as the lab confirmed: its only consumer, +the `lame` encoder plugin, is not on the allowlist, and its jammy copyright file +lists a GPL file (`libmp3lame/fft.c`). + +## Not yet recorded + +The AppImage already bundled WebKitGTK, GTK, GLib, the GStreamer core and base +libraries, and their dependencies before these plugins were added, and no +notice records them. That gap predates this file and remains open. diff --git a/apps/desktop/e2e/specs/appearance-themes.e2e.ts b/apps/desktop/e2e/specs/appearance-themes.e2e.ts new file mode 100644 index 0000000000..81c5d75874 --- /dev/null +++ b/apps/desktop/e2e/specs/appearance-themes.e2e.ts @@ -0,0 +1,275 @@ +import { refreshDesktopUiDocument } from "../support/document-navigation.ts"; +import { desktopUiFixture } from "../support/test-project.ts"; +import { ensureMainSidebarOpen, mockDesktopUiFolderPicker } from "../support/ui-state.ts"; + +const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH; +if (!projectPath) { + throw new Error("The packaged desktop themes fixture environment was not prepared."); +} +const preparedProjectPath: string = projectPath; +const visibleSurfaceSelector = '[data-center-surface-host][data-visible="true"]'; +const composerEditorSelector = `${visibleSurfaceSelector} [data-chat-composer-form="true"] [data-testid="composer-editor"]`; + +type Mode = "System" | "Light" | "Dark"; + +async function openFixtureWorkspace(): Promise { + // Settings shows its own sidebar; the project list is on the app's start route. + await browser.execute(() => { + if (window.location.hash.startsWith("#/settings")) window.location.hash = "#/"; + }); + await ensureMainSidebarOpen(); + const project = browser.$( + `//button[.//span[normalize-space()="${desktopUiFixture.projectName}"]]`, + ); + if (!(await project.isDisplayed())) { + const projectDataLoading = browser.$("//*[normalize-space()='Project data is still loading']"); + if (await projectDataLoading.isExisting()) { + await projectDataLoading.waitForDisplayed({ reverse: true }); + } + const addProject = browser.$('[data-testid="sidebar-add-project-trigger"]'); + await addProject.waitForDisplayed(); + await addProject.click(); + const browseFolder = browser.$( + "//button[@data-add-project-action='true'][.//span[normalize-space()='Browse folder']]", + ); + await browseFolder.waitForDisplayed(); + await mockDesktopUiFolderPicker(preparedProjectPath); + await browseFolder.click(); + } + await project.waitForDisplayed(); + const primaryWorkspace = browser.$( + '//*[@data-thread-item="true"][.//span[normalize-space()="main"]]', + ); + if ((await project.getAttribute("aria-expanded")) !== "true") { + await project.click(); + } + await primaryWorkspace.waitForDisplayed(); + await primaryWorkspace.click(); + await browser.$(composerEditorSelector).waitForDisplayed(); +} + +async function openAppearance(): Promise { + await browser.execute(() => { + window.location.hash = "#/settings/appearance"; + }); + await browser.$('[role="group"][aria-label="Mode"]').waitForDisplayed(); +} + +async function setMode(mode: Mode): Promise { + const button = await browser.$( + `//*[@role="group" and @aria-label="Mode"]//button[normalize-space()="${mode}"]`, + ); + await button.scrollIntoView(); + await button.click(); + await waitForMode(mode); +} + +async function waitForMode(mode: Mode): Promise { + await browser.waitUntil( + async () => + (await browser + .$(`//*[@role="group" and @aria-label="Mode"]//button[normalize-space()="${mode}"]`) + .getAttribute("aria-pressed")) === "true", + { timeoutMsg: `The Mode group never showed ${mode} as pressed.` }, + ); +} + +function themeRow(name: string): ReturnType { + return browser.$( + `//li[@data-theme-row]/button[@data-theme-select][.//span[normalize-space()="${name}"]]`, + ); +} + +async function selectTheme(name: string): Promise { + const row = await themeRow(name); + await row.scrollIntoView(); + await row.click(); + await browser.waitUntil( + async () => (await themeRow(name).getAttribute("aria-pressed")) === "true", + { + timeoutMsg: `The ${name} row never showed as in use.`, + }, + ); +} + +async function chooseRowAction(themeName: string, action: string): Promise { + const more = await browser.$(`button[aria-label="More actions for ${themeName}"]`); + await more.scrollIntoView(); + await more.click(); + const item = await browser.$(`//*[@role="menuitem" and normalize-space()="${action}"]`); + await item.waitForDisplayed(); + await item.click(); +} + +async function rootToken(name: string): Promise { + return browser.execute( + (property: string) => + getComputedStyle(document.documentElement).getPropertyValue(property).trim(), + name, + ); +} + +async function waitForRootToken(name: string, value: string): Promise { + await browser.waitUntil(async () => (await rootToken(name)) === value, { + timeoutMsg: `The root ${name} never became ${value} (last ${await rootToken(name)}).`, + }); +} + +async function rootIsDark(): Promise { + return browser.execute(() => document.documentElement.classList.contains("dark")); +} + +async function themeField(token: string) { + const input = await browser.$(`[data-theme-field="ui.${token}"] input:not([type="color"])`); + await input.scrollIntoView(); + return input; +} + +async function editorButton(label: string) { + const button = await browser.$(`//*[@data-theme-editor]//button[normalize-space()="${label}"]`); + await button.scrollIntoView(); + return button; +} + +async function sidebarLevels(): Promise<{ repo: boolean; checkout: boolean; chat: boolean }> { + return browser.execute(() => ({ + repo: document.querySelector('[data-sidebar-level="repo"]') !== null, + checkout: document.querySelector('[data-sidebar-level="checkout"][data-primary]') !== null, + chat: document.querySelector('[data-sidebar-level="chat"]') !== null, + })); +} + +/** + * A chat level row shows for another chat panel in a checkout ("1 more chat") or a worktree row. + * A fresh fixture has neither, so open a chat panel in the primary checkout and send to it. + */ +async function ensureChatLevelRow(): Promise { + if ((await sidebarLevels()).chat) return; + const newPanel = await browser.$('button[aria-label="New panel"]'); + await newPanel.waitForEnabled(); + await newPanel.click(); + // Exist, not displayed: a menu's opening transition ends on an animation frame, and an + // occluded macOS webview may run none. + const codex = await browser.$('//*[@role="menuitem"][.//span[normalize-space()="Codex"]]'); + await codex.waitForExist(); + await browser.execute((item: HTMLElement) => item.click(), codex); + await browser.$('button[aria-label="Close Codex"]').waitForExist(); + const editor = browser.$(composerEditorSelector); + await editor.waitForDisplayed(); + await editor.click(); + await editor.addValue("Second fixture chat"); + await expect(editor).toHaveText("Second fixture chat"); + await browser.keys("Enter"); + await browser.waitUntil(async () => (await sidebarLevels()).chat, { + timeoutMsg: "The sidebar never showed a chat level row.", + }); +} + +async function expectSidebarLevels(): Promise { + await browser.waitUntil( + async () => { + const levels = await sidebarLevels(); + return levels.repo && levels.checkout && levels.chat; + }, + { + timeoutMsg: `The sidebar levels are not all marked: ${JSON.stringify(await sidebarLevels())}`, + }, + ); +} + +describe("packaged appearance themes", () => { + it("the left panel marks its levels", async () => { + await openAppearance(); + await setMode("Light"); + await openFixtureWorkspace(); + await ensureChatLevelRow(); + expect(await rootIsDark()).toBe(false); + await expectSidebarLevels(); + + await openAppearance(); + await setMode("Dark"); + await selectTheme("Tokyo Night"); + await waitForRootToken("--background", "#1a1b26"); + await openFixtureWorkspace(); + expect(await rootIsDark()).toBe(true); + await expectSidebarLevels(); + }); + + it("a dark theme row clicked in Light mode switches to Dark, and Undo restores Light", async () => { + await openAppearance(); + await setMode("Light"); + expect(await themeRow("Dark").getAttribute("aria-pressed")).toBe("true"); + + await (await themeRow("Matrix Dark")).click(); + await waitForMode("Dark"); + expect(await rootIsDark()).toBe(true); + const undo = await browser.$( + '//*[@role="group" and @aria-label="Dark themes"]//*[@role="status"]//button[normalize-space()="Undo"]', + ); + await undo.waitForDisplayed(); + await expect( + browser.$('//*[@role="group" and @aria-label="Dark themes"]//*[@role="status"]'), + ).toHaveText(expect.stringContaining("Switched to dark mode to show Matrix Dark.")); + + await undo.click(); + await waitForMode("Light"); + expect(await rootIsDark()).toBe(false); + expect(await themeRow("Dark").getAttribute("aria-pressed")).toBe("true"); + expect(await themeRow("Matrix Dark").getAttribute("aria-pressed")).toBe("false"); + }); + + it("Tokyo Night repaints the app and Dark restores it", async () => { + await openAppearance(); + await setMode("Dark"); + await selectTheme("Tokyo Night"); + await waitForRootToken("--background", "#1a1b26"); + + await selectTheme("Dark"); + await browser.waitUntil( + () => + browser.execute( + () => document.documentElement.style.getPropertyValue("--background") === "", + ), + { timeoutMsg: "Dark left an inline --background on the root element." }, + ); + expect(await rootToken("--background")).not.toBe("#1a1b26"); + }); + + it("a duplicated theme can be edited with live preview and cancelled", async () => { + await openAppearance(); + await setMode("Dark"); + await chooseRowAction("Tokyo Night", "Duplicate"); + await themeRow("Tokyo Night copy").waitForDisplayed(); + await selectTheme("Tokyo Night copy"); + await waitForRootToken("--primary", "#7aa2f7"); + + await chooseRowAction("Tokyo Night copy", "Edit"); + await browser.$('[data-theme-editor][aria-label="Edit Tokyo Night copy"]').waitForDisplayed(); + const primary = await themeField("primary"); + await primary.setValue("#ff0000"); + await waitForRootToken("--primary", "#ff0000"); + + await (await editorButton("Cancel")).click(); + await themeRow("Tokyo Night copy").waitForDisplayed(); + await waitForRootToken("--primary", "#7aa2f7"); + }); + + it("a saved custom theme is painted again after a reload", async () => { + await openAppearance(); + await setMode("Dark"); + await (await browser.$("button=New theme")).click(); + await (await browser.$('input[name="theme-base"][value="dark"]')).click(); + await (await browser.$('input[name="theme-name"]')).setValue("Fixture Dark"); + await (await browser.$("button=Create theme")).click(); + await browser.$('[data-theme-editor][aria-label="Edit Fixture Dark"]').waitForDisplayed(); + await (await themeField("background")).setValue("#101820"); + await (await editorButton("Save")).click(); + + await selectTheme("Fixture Dark"); + await waitForRootToken("--background", "#101820"); + + await refreshDesktopUiDocument(); + await waitForRootToken("--background", "#101820"); + expect(await rootIsDark()).toBe(true); + }); +}); diff --git a/apps/desktop/e2e/specs/chat-activity-panel.e2e.ts b/apps/desktop/e2e/specs/chat-activity-panel.e2e.ts index dbe300a247..e9cc27cb54 100644 --- a/apps/desktop/e2e/specs/chat-activity-panel.e2e.ts +++ b/apps/desktop/e2e/specs/chat-activity-panel.e2e.ts @@ -729,13 +729,12 @@ async function selectActualTheme( threadId: string, ): Promise<{ readonly dock: SurfacePresentation; readonly panel: SurfacePresentation }> { const origin = await browser.execute(() => window.location.origin); - await browser.url(`${origin}/#/settings/general`); - const themePreference = browser.$('[aria-label="Theme preference"]'); - await themePreference.waitForDisplayed(); - await themePreference.click(); - const option = browser.$(`//*[@role="option" and normalize-space()="${theme}"]`); - await option.waitForDisplayed(); - await option.click(); + await browser.url(`${origin}/#/settings/appearance`); + const modeButton = browser.$( + `//*[@role="group" and @aria-label="Mode"]//button[normalize-space()="${theme}"]`, + ); + await modeButton.waitForDisplayed(); + await modeButton.click(); await browser.waitUntil( async () => { const rootClass = (await browser.$("html").getAttribute("class")) ?? ""; @@ -888,9 +887,9 @@ async function restoreRuntimeMotionEmulation(): Promise { async function remountActivityDockWithRuntimeMotionEmulation(threadId: string): Promise { await installRuntimeMotionEmulation(false); await browser.execute(() => { - window.location.hash = "#/settings/general"; + window.location.hash = "#/settings/appearance"; }); - await browser.$('[aria-label="Theme preference"]').waitForDisplayed(); + await browser.$('[role="group"][aria-label="Mode"]').waitForDisplayed(); await expectMissing('[data-testid="activity-dock"]'); await browser.execute((nextThreadId: string) => { window.location.hash = `#/primary/${encodeURIComponent(nextThreadId)}`; diff --git a/apps/desktop/e2e/specs/composer-native-triggers.e2e.ts b/apps/desktop/e2e/specs/composer-native-triggers.e2e.ts index d9c9c850da..7e475e5549 100644 --- a/apps/desktop/e2e/specs/composer-native-triggers.e2e.ts +++ b/apps/desktop/e2e/specs/composer-native-triggers.e2e.ts @@ -445,7 +445,7 @@ async function openProviderPanel(displayName: string): Promise { }, ); const activeProviderTab = browser.$( - `//*[contains(concat(" ", normalize-space(@class), " "), " bg-accent ") and ` + + `//*[@data-active-tab="true" and ` + `.//button[@aria-label="Close ${displayName}"] and ` + `.//span[normalize-space()="${displayName}"]]`, ); diff --git a/apps/desktop/e2e/specs/desktop-save-and-close.e2e.ts b/apps/desktop/e2e/specs/desktop-save-and-close.e2e.ts new file mode 100644 index 0000000000..feba872713 --- /dev/null +++ b/apps/desktop/e2e/specs/desktop-save-and-close.e2e.ts @@ -0,0 +1,383 @@ +// @effect-diagnostics nodeBuiltinImport:off - Packaged UI tests write disposable fixture files and read them back. +// @effect-diagnostics globalFetch:off - The spec checks that the stopped server no longer answers. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { + chooseDesktopUiQuit, + desktopUiApplicationPid, + desktopUiListeningPorts, + desktopUiPlatform, + isDesktopUiApplicationRunning, + isProcessAlive, + relaunchDesktopUiApplication, + requestDesktopUiWindowClose, + waitForDesktopUiApplicationExit, +} from "../support/app-lifecycle.ts"; +import { desktopUiFixture } from "../support/test-project.ts"; +import { + ensureMainSidebarOpen, + mockDesktopUiFolderPicker, + setDesktopUiWindowSize, +} from "../support/ui-state.ts"; + +const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH; +const savedTextPath = process.env.BIBCODE_E2E_SAVE_TEXT_PATH; +if (!projectPath || !savedTextPath) { + throw new Error("The packaged desktop save-and-close fixture environment was not prepared."); +} +const preparedProjectPath: string = projectPath; +const preparedSavedTextPath: string = savedTextPath; +const fileViewSelector = "[data-preview-panel-mode]"; +const richEditorSelector = `${fileViewSelector} .rich-markdown-editor [contenteditable="true"]`; +const leaveDialogSelector = + '//*[@role="alertdialog"][.//*[starts-with(normalize-space(), "Save your changes before")]]'; +const originalContents = "# Close fixture\n\nHello world\n"; +/** The close, quit and exit checks wait at most this long for the app to end. */ +const EXIT_TIMEOUT_MS = 15_000; + +function writeFixture(fileName: string): string { + const path = NodePath.join(preparedProjectPath, fileName); + NodeFS.writeFileSync(path, originalContents); + return path; +} + +async function openFixtureWorkspace(): Promise { + // Settings shows its own sidebar; the project list is on the app's start route. + await browser.execute(() => { + if (window.location.hash.startsWith("#/settings")) window.location.hash = "#/"; + }); + await ensureMainSidebarOpen(); + const project = browser.$( + `//button[.//span[normalize-space()="${desktopUiFixture.projectName}"]]`, + ); + if (!(await project.isDisplayed())) { + const projectDataLoading = browser.$("//*[normalize-space()='Project data is still loading']"); + if (await projectDataLoading.isExisting()) { + await projectDataLoading.waitForDisplayed({ reverse: true }); + } + const addProject = browser.$('[data-testid="sidebar-add-project-trigger"]'); + await addProject.waitForDisplayed(); + await addProject.click(); + const browseFolder = browser.$( + "//button[@data-add-project-action='true'][.//span[normalize-space()='Browse folder']]", + ); + await browseFolder.waitForDisplayed(); + await mockDesktopUiFolderPicker(preparedProjectPath); + await browseFolder.click(); + } + await project.waitForDisplayed(); + const primaryWorkspace = browser.$( + '//*[@data-thread-item="true"][.//span[normalize-space()="main"]]', + ); + if ((await project.getAttribute("aria-expanded")) !== "true") { + await project.click(); + } + await primaryWorkspace.waitForDisplayed(); + await primaryWorkspace.click(); +} + +async function openFilesSurface(): Promise { + const rightPanel = browser.$("[data-right-panel-tabbar]"); + if (!(await rightPanel.isDisplayed())) { + const toggle = browser.$('button[aria-label^="Toggle right panel"]'); + await toggle.waitForDisplayed(); + await toggle.click(); + await rightPanel.waitForDisplayed(); + } + const existingTab = browser.$( + '//*[@data-right-panel-tab-list]//button[normalize-space()="Files"]', + ); + if (await existingTab.isDisplayed()) { + await existingTab.click(); + return; + } + const emptyStateAction = browser.$( + '//button[.//span[normalize-space()="Files"] and not(@aria-disabled="true")]', + ); + if (await emptyStateAction.isDisplayed()) { + await emptyStateAction.click(); + return; + } + const addSurface = browser.$('button[aria-label="Add panel surface"]'); + await addSurface.waitForDisplayed(); + await addSurface.click(); + const menuItem = browser.$('//*[@role="menuitem" and normalize-space()="Files"]'); + await menuItem.waitForDisplayed(); + await menuItem.click(); +} + +async function openWorkspaceFile(fileName: string): Promise { + const treeHostSelector = `${fileViewSelector} file-tree-container`; + const itemSelector = `[role="treeitem"][data-item-path="${fileName}"]`; + await browser.waitUntil( + () => + browser.execute( + (hostQuery: string, targetQuery: string) => { + const host = [...document.querySelectorAll(hostQuery)].find( + (candidate) => candidate.getBoundingClientRect().height > 0, + ); + const target = host?.shadowRoot?.querySelector(targetQuery); + if (!target) return false; + target.click(); + return true; + }, + treeHostSelector, + itemSelector, + ), + { timeoutMsg: `The Files tree did not list ${fileName}.` }, + ); + await browser.waitUntil( + () => + browser.execute( + (editorQuery: string) => + [...document.querySelectorAll(editorQuery)].some((editor) => + editor.textContent?.includes("Hello world"), + ), + richEditorSelector, + ), + { timeoutMsg: `The rendered Markdown view did not show ${fileName} as editable.` }, + ); +} + +/** Opens `fileName` in the rendered view and types an edit that is not saved. */ +async function leaveUnsavedEdit(fileName: string): Promise { + await setDesktopUiWindowSize(1_200, 720); + await openFixtureWorkspace(); + await openFilesSurface(); + await openWorkspaceFile(fileName); + await browser.execute((editorQuery: string) => { + const editor = document.querySelector(editorQuery); + const paragraph = [...(editor?.querySelectorAll("p") ?? [])].find( + (candidate) => candidate.textContent === "Hello world", + ); + if (!editor || !paragraph) throw new Error("The fixture paragraph is missing."); + editor.focus(); + const range = document.createRange(); + range.selectNodeContents(paragraph); + range.collapse(false); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + }, richEditorSelector); + const editor = await browser.$(richEditorSelector); + await editor.addValue(" edited"); + await browser.waitUntil( + async () => + (await browser.$("[data-file-editor-toolbar] [aria-live='polite']").getText()) === + "Unsaved changes", + { timeoutMsg: "The edit never showed as unsaved." }, + ); +} + +async function leaveDialogButton(label: string) { + const button = await browser.$(`${leaveDialogSelector}//button[normalize-space()="${label}"]`); + await button.waitForDisplayed(); + return button; +} + +async function waitForLeaveDialog(fileName: string): Promise { + const dialog = browser.$(leaveDialogSelector); + await dialog.waitForDisplayed({ timeoutMsg: "The close request never showed the leave dialog." }); + await expect(dialog).toHaveText(expect.stringContaining(fileName)); +} + +async function focusedElement(): Promise { + return browser.execute(() => { + const active = document.activeElement; + return `${active?.tagName ?? "none"} "${active?.textContent?.slice(0, 40) ?? ""}" in ${ + active?.closest("[role]")?.getAttribute("role") ?? "page" + }`; + }); +} + +function skipUnlessMac(context: Mocha.Context): void { + // Native close and quit go through the macOS menu bar; other systems have no driver for it. + if (desktopUiPlatform() !== "mac") context.skip(); +} + +describe("packaged desktop export and the close dialog", () => { + afterEach(async () => { + // Each closing case runs in its own app session. + if (!(await isDesktopUiApplicationRunning())) await relaunchDesktopUiApplication(); + }); + + it("exports a custom theme through the real save command", async () => { + NodeFS.rmSync(preparedSavedTextPath, { force: true }); + await browser.execute(() => { + window.location.hash = "#/settings/appearance"; + }); + await (await browser.$("button=New theme")).click(); + await (await browser.$('input[name="theme-base"][value="dark"]')).click(); + await (await browser.$('input[name="theme-name"]')).setValue("Export Fixture"); + await (await browser.$("button=Create theme")).click(); + const save = await browser.$('//*[@data-theme-editor]//button[normalize-space()="Save"]'); + await save.scrollIntoView(); + await save.click(); + const more = await browser.$('button[aria-label="More actions for Export Fixture"]'); + await more.scrollIntoView(); + await more.click(); + const exportItem = await browser.$('//*[@role="menuitem" and normalize-space()="Export"]'); + await exportItem.waitForDisplayed(); + await exportItem.click(); + + await browser.waitUntil(() => NodeFS.existsSync(preparedSavedTextPath), { + timeoutMsg: "Export never wrote the file through saveTextFile.", + }); + const exported = JSON.parse(NodeFS.readFileSync(preparedSavedTextPath, "utf8")) as { + readonly version: number; + readonly id: string; + readonly name: string; + readonly base: string; + }; + expect(exported).toMatchObject({ + version: 1, + id: "export-fixture", + name: "Export Fixture", + base: "dark", + }); + }); + + it("the leave dialog takes focus over the update dialog and Cancel returns it", async () => { + writeFixture("close-update.md"); + await leaveUnsavedEdit("close-update.md"); + // No install may start: the e2e updater is disabled, and the install command is mocked. + const install = await browser.tauri.mock("desktop_bridge_install_update"); + await install.mockImplementation(() => { + throw new Error("The packaged test never installs an update."); + }); + await browser.execute(async () => { + const tauri = ( + window as unknown as { + __TAURI__: { event: { emit: (event: string, payload: unknown) => Promise } }; + } + ).__TAURI__; + await tauri.event.emit("desktop:update-state", { + enabled: true, + status: "downloaded", + currentVersion: "0.0.1", + hostArch: "arm64", + appArch: "arm64", + runningUnderArm64Translation: false, + availableVersion: "99.0.0", + downloadedVersion: "99.0.0", + downloadPercent: null, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + requestedBy: null, + }); + }); + const pill = await browser.$("button.update-main"); + await pill.waitForDisplayed({ timeoutMsg: "The injected update state showed no update." }); + await pill.click(); + const updateDialog = browser.$( + '//*[@role="dialog"][.//*[normalize-space()="Protect projects before updating"]]', + ); + await updateDialog.waitForDisplayed(); + const installButton = await browser.$( + '//*[@role="dialog"]//button[normalize-space()="Protect projects and install"]', + ); + // Focused in the page, not by the driver's click: a driver click need not move focus, and the + // question returns focus to the element that had it when it opened. + await browser.execute((button: HTMLElement) => { + button.focus(); + button.click(); + }, installButton); + + await waitForLeaveDialog("close-update.md"); + await browser + .waitUntil(() => + browser.execute( + () => + document.activeElement?.closest('[role="alertdialog"]') !== null && + document.activeElement?.textContent === "Save all", + ), + ) + .catch(async () => { + throw new Error( + `The leave dialog did not take focus over the update dialog (focus: ${await focusedElement()}).`, + ); + }); + await (await leaveDialogButton("Cancel")).click(); + await browser.$(leaveDialogSelector).waitForDisplayed({ reverse: true }); + await browser.waitUntil( + () => + browser.execute((button: HTMLElement) => document.activeElement === button, installButton), + { timeoutMsg: "Cancel did not return focus to the button that asked." }, + ); + await expect(updateDialog).toBeDisplayed(); + await expect(browser.$("[data-file-editor-toolbar] [aria-live='polite']")).toHaveText( + "Unsaved changes", + ); + }); + + it("Cancel keeps the window, the server and the edit, and Save all saves and exits", async function () { + skipUnlessMac(this); + const path = writeFixture("close-save.md"); + await leaveUnsavedEdit("close-save.md"); + const pid = desktopUiApplicationPid(); + const ports = desktopUiListeningPorts(pid); + + await requestDesktopUiWindowClose(); + await waitForLeaveDialog("close-save.md"); + await (await leaveDialogButton("Cancel")).click(); + await browser.$(leaveDialogSelector).waitForDisplayed({ reverse: true }); + expect(isProcessAlive(pid)).toBe(true); + expect(desktopUiListeningPorts(pid)).toEqual(ports); + await expect(browser.$(richEditorSelector)).toHaveText( + expect.stringContaining("Hello world edited"), + ); + await expect(browser.$("[data-file-editor-toolbar] [aria-live='polite']")).toHaveText( + "Unsaved changes", + ); + expect(NodeFS.readFileSync(path, "utf8")).toBe(originalContents); + + await requestDesktopUiWindowClose(); + await waitForLeaveDialog("close-save.md"); + await (await leaveDialogButton("Save all")).click(); + await waitForDesktopUiApplicationExit(pid, EXIT_TIMEOUT_MS); + expect(NodeFS.readFileSync(path, "utf8")).toBe("# Close fixture\n\nHello world edited\n"); + }); + + it("Discard exits and leaves the file on disk unchanged", async function () { + skipUnlessMac(this); + const path = writeFixture("close-discard.md"); + await leaveUnsavedEdit("close-discard.md"); + const pid = desktopUiApplicationPid(); + + await requestDesktopUiWindowClose(); + await waitForLeaveDialog("close-discard.md"); + await (await leaveDialogButton("Discard")).click(); + await waitForDesktopUiApplicationExit(pid, EXIT_TIMEOUT_MS); + expect(NodeFS.readFileSync(path, "utf8")).toBe(originalContents); + }); + + it("Quit from the menu with nothing unsaved exits promptly and stops the server", async function () { + skipUnlessMac(this); + await openFixtureWorkspace(); + const pid = desktopUiApplicationPid(); + const ports = desktopUiListeningPorts(pid); + expect(ports.length).toBeGreaterThan(1); + + await chooseDesktopUiQuit(); + const elapsed = await waitForDesktopUiApplicationExit(pid, EXIT_TIMEOUT_MS); + expect(elapsed).toBeLessThan(5_000); + expect(desktopUiListeningPorts(pid)).toEqual([]); + for (const port of ports) { + await expect( + fetch(`http://127.0.0.1:${port}/`, { signal: AbortSignal.timeout(2_000) }), + ).rejects.toThrow(); + } + }); + + it("with nothing unsaved, a close exits at once", async function () { + skipUnlessMac(this); + await openFixtureWorkspace(); + const pid = desktopUiApplicationPid(); + + await requestDesktopUiWindowClose(); + const elapsed = await waitForDesktopUiApplicationExit(pid, EXIT_TIMEOUT_MS); + expect(elapsed).toBeLessThan(5_000); + }); +}); diff --git a/apps/desktop/e2e/specs/files-rich-markdown.e2e.ts b/apps/desktop/e2e/specs/files-rich-markdown.e2e.ts new file mode 100644 index 0000000000..f7792ae946 --- /dev/null +++ b/apps/desktop/e2e/specs/files-rich-markdown.e2e.ts @@ -0,0 +1,387 @@ +// @effect-diagnostics nodeBuiltinImport:off - Packaged UI tests write disposable fixture files and read them back. +// @effect-diagnostics globalTimers:off - The spec waits out the rendered view's edit delay. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { desktopUiFixture } from "../support/test-project.ts"; +import { + ensureMainSidebarOpen, + mockDesktopUiFolderPicker, + setDesktopUiWindowSize, +} from "../support/ui-state.ts"; + +const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH; +if (!projectPath) { + throw new Error("The packaged desktop rich Markdown fixture environment was not prepared."); +} +const preparedProjectPath: string = projectPath; +const fileViewSelector = "[data-preview-panel-mode]"; +const richEditorSelector = `${fileViewSelector} .rich-markdown-editor [contenteditable="true"]`; +const sourceEditorHostSelector = `${fileViewSelector} diffs-container`; + +const frontmatter = "---\ntitle: Fixture notes\ntags: [e2e]\n---\n"; +const fence = "```ts\nconst answer = 42; // spacing kept\n```\n"; +/** Frontmatter, the paragraph "Hello world" and a fenced code block (R25, R31). */ +const notesContents = `${frontmatter}\nHello world\n\n${fence}`; + +const untouchedContents = [ + "---", + "title: Untouched fixture", + "---", + "", + "Hello world", + "", + "| Name | Value |", + "|:-----|------:|", + "| one | 1 |", + "", + "```ts", + "const answer = 42;", + "```", + "", + "* star item", + "- dash item", + "+ plus item", +].join("\n"); + +/** Records, from now on, whether the Files toolbar ever says "Unsaved changes". */ +async function watchForUnsavedIndicator(): Promise { + await browser.execute(() => { + const record = window as unknown as { __bibcodeUnsavedSeen?: boolean }; + record.__bibcodeUnsavedSeen = false; + const check = () => { + const status = document.querySelector("[data-file-editor-toolbar] [aria-live='polite']"); + if (status?.textContent === "Unsaved changes") record.__bibcodeUnsavedSeen = true; + }; + new MutationObserver(check).observe(document.body, { + childList: true, + subtree: true, + characterData: true, + }); + check(); + }); +} + +async function unsavedIndicatorAppeared(): Promise { + return browser.execute( + () => (window as unknown as { __bibcodeUnsavedSeen?: boolean }).__bibcodeUnsavedSeen === true, + ); +} + +function writeNotes(fileName: string): string { + const path = NodePath.join(preparedProjectPath, fileName); + NodeFS.writeFileSync(path, notesContents); + return path; +} + +async function openFixtureWorkspace(): Promise { + await ensureMainSidebarOpen(); + const project = browser.$( + `//button[.//span[normalize-space()="${desktopUiFixture.projectName}"]]`, + ); + if (!(await project.isDisplayed())) { + const projectDataLoading = browser.$("//*[normalize-space()='Project data is still loading']"); + if (await projectDataLoading.isExisting()) { + await projectDataLoading.waitForDisplayed({ reverse: true }); + } + const addProject = browser.$('[data-testid="sidebar-add-project-trigger"]'); + await addProject.waitForDisplayed(); + await addProject.click(); + const browseFolder = browser.$( + "//button[@data-add-project-action='true'][.//span[normalize-space()='Browse folder']]", + ); + await browseFolder.waitForDisplayed(); + await mockDesktopUiFolderPicker(preparedProjectPath); + await browseFolder.click(); + } + await project.waitForDisplayed(); + const primaryWorkspace = browser.$( + '//*[@data-thread-item="true"][.//span[normalize-space()="main"]]', + ); + if ((await project.getAttribute("aria-expanded")) !== "true") { + await project.click(); + } + await primaryWorkspace.waitForDisplayed(); + await primaryWorkspace.click(); +} + +async function openFilesSurface(): Promise { + const rightPanel = browser.$("[data-right-panel-tabbar]"); + if (!(await rightPanel.isDisplayed())) { + const toggle = browser.$('button[aria-label^="Toggle right panel"]'); + await toggle.waitForDisplayed(); + await toggle.click(); + await rightPanel.waitForDisplayed(); + } + const existingTab = browser.$( + '//*[@data-right-panel-tab-list]//button[normalize-space()="Files"]', + ); + if (await existingTab.isDisplayed()) { + await existingTab.click(); + return; + } + const emptyStateAction = browser.$( + '//button[.//span[normalize-space()="Files"] and not(@aria-disabled="true")]', + ); + if (await emptyStateAction.isDisplayed()) { + await emptyStateAction.click(); + return; + } + const addSurface = browser.$('button[aria-label="Add panel surface"]'); + await addSurface.waitForDisplayed(); + await addSurface.click(); + const menuItem = browser.$('//*[@role="menuitem" and normalize-space()="Files"]'); + await menuItem.waitForDisplayed(); + await menuItem.click(); +} + +/** Opens a workspace file from the Files tree, which renders inside a shadow root. */ +async function openWorkspaceFile(fileName: string): Promise { + const treeHostSelector = `${fileViewSelector} file-tree-container`; + const itemSelector = `[role="treeitem"][data-item-path="${fileName}"]`; + await browser.waitUntil( + () => + browser.execute( + (hostQuery: string, targetQuery: string) => { + const host = [...document.querySelectorAll(hostQuery)].find( + (candidate) => candidate.getBoundingClientRect().height > 0, + ); + const target = host?.shadowRoot?.querySelector(targetQuery); + if (!target) return false; + target.click(); + return true; + }, + treeHostSelector, + itemSelector, + ), + { timeoutMsg: `The Files tree did not list ${fileName}.` }, + ); + // The editable rendered view, not the read-only view shown while the editor loads. + await browser.waitUntil( + () => + browser.execute( + (editorQuery: string) => + [...document.querySelectorAll(editorQuery)].some((editor) => + editor.textContent?.includes("Hello world"), + ), + richEditorSelector, + ), + { timeoutMsg: `The rendered Markdown view did not show ${fileName} as editable.` }, + ); +} + +async function openNotes(fileName: string): Promise { + await setDesktopUiWindowSize(1_200, 720); + await openFixtureWorkspace(); + await openFilesSurface(); + await openWorkspaceFile(fileName); +} + +/** Puts the caret at the end of the paragraph that holds `text`. */ +async function placeCaretAfter(text: string): Promise { + const placed = await browser.execute( + (editorQuery: string, needle: string) => { + const editor = document.querySelector(editorQuery); + const block = [...(editor?.querySelectorAll("p, h1, h2, h3") ?? [])].find( + (candidate) => candidate.textContent?.includes(needle), + ); + if (!editor || !block) return false; + editor.focus(); + const range = document.createRange(); + range.selectNodeContents(block); + range.collapse(false); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + return true; + }, + richEditorSelector, + text, + ); + if (!placed) throw new Error(`No rendered block holds "${text}".`); +} + +async function typeInRichEditor(text: string): Promise { + const editor = await browser.$(richEditorSelector); + await editor.addValue(text); +} + +async function toolbarButton(label: string) { + const button = await browser.$(`[data-file-editor-toolbar] button[aria-label="${label}"]`); + await button.waitForDisplayed(); + return button; +} + +async function clickToolbar(label: string): Promise { + const button = await toolbarButton(label); + await button.waitForEnabled(); + await button.click(); +} + +async function waitForStatus(status: string): Promise { + await browser.waitUntil( + async () => + (await browser.$("[data-file-editor-toolbar] [aria-live='polite']").getText()) === status, + { timeoutMsg: `The Files toolbar never said "${status}".` }, + ); +} + +async function renderedBlocks(): Promise { + return browser.execute((editorQuery: string) => { + const editor = document.querySelector(editorQuery); + return [...(editor?.children ?? [])].map( + (block) => `${block.tagName.toLowerCase()}:${block.textContent ?? ""}`, + ); + }, richEditorSelector); +} + +async function sourceEditorText(fileName: string): Promise { + return browser.execute( + (hostQuery: string, targetQuery: string) => { + const host = [...document.querySelectorAll(hostQuery)].find( + (candidate) => candidate.getBoundingClientRect().height > 0, + ); + const root = host?.shadowRoot; + if (!root) return null; + const textbox = root.querySelector(targetQuery); + return (textbox ?? root.querySelector("pre"))?.textContent ?? null; + }, + sourceEditorHostSelector, + `[role="textbox"][aria-label="${fileName}"]`, + ); +} + +async function waitForSourceEditor(fileName: string): Promise { + await browser.waitUntil(async () => (await sourceEditorText(fileName)) !== null, { + timeoutMsg: `The source view of ${fileName} did not open.`, + }); +} + +async function switchView(label: "Show markdown source" | "Show rendered markdown") { + const toggle = await browser.$(`[data-file-editor-toolbar] button[aria-label="${label}"]`); + await toggle.waitForDisplayed(); + await toggle.click(); +} + +describe("packaged rich Markdown editing", () => { + it("edits in rendered view, saves and keeps the rest of the file", async () => { + const path = writeNotes("notes.md"); + await openNotes("notes.md"); + + await placeCaretAfter("Hello world"); + await typeInRichEditor(" again"); + await placeCaretAfter("Hello world again"); + await clickToolbar("Heading 2"); + await waitForStatus("Unsaved changes"); + expect(NodeFS.readFileSync(path, "utf8")).toBe(notesContents); + + await clickToolbar("Save file"); + await browser.waitUntil(() => NodeFS.readFileSync(path, "utf8") !== notesContents, { + timeoutMsg: "Save file never wrote notes.md.", + }); + const saved = NodeFS.readFileSync(path, "utf8"); + expect(saved.startsWith(frontmatter)).toBe(true); + expect(saved.endsWith(fence)).toBe(true); + expect(saved).toBe(`${frontmatter}\n## Hello world again\n\n${fence}`); + }); + + it("undo in rendered view and one-step undo in source view", async () => { + writeNotes("notes-undo.md"); + await openNotes("notes-undo.md"); + + await placeCaretAfter("Hello world"); + await typeInRichEditor(" again"); + // The editor's history joins changes made within half a second into one undo step. + await new Promise((resolve) => setTimeout(resolve, 800)); + await placeCaretAfter("Hello world again"); + await clickToolbar("Heading 2"); + await browser.waitUntil(async () => (await renderedBlocks())[0] === "h2:Hello world again", { + timeoutMsg: "Heading 2 did not apply to the paragraph.", + }); + await clickToolbar("Undo"); + await browser.waitUntil(async () => (await renderedBlocks())[0] === "p:Hello world again", { + timeoutMsg: "Undo in rendered view did not remove the heading.", + }); + + // The source editor exists from here on, so the next rendered visit reaches it as one step. + await switchView("Show markdown source"); + await waitForSourceEditor("notes-undo.md"); + expect(await sourceEditorText("notes-undo.md")).toContain("Hello world again"); + await switchView("Show rendered markdown"); + await browser.$(richEditorSelector).waitForDisplayed(); + + await placeCaretAfter("Hello world again"); + await typeInRichEditor(" twice"); + await placeCaretAfter("Hello world again twice"); + await clickToolbar("Heading 2"); + await browser.waitUntil( + async () => (await renderedBlocks())[0] === "h2:Hello world again twice", + { timeoutMsg: "The second visit's edits did not apply." }, + ); + + await switchView("Show markdown source"); + await browser.waitUntil( + async () => (await sourceEditorText("notes-undo.md"))?.includes("## Hello world again twice"), + { timeoutMsg: "The source view did not show the rendered visit's edits." }, + ); + await clickToolbar("Undo"); + await browser.waitUntil( + async () => { + const text = (await sourceEditorText("notes-undo.md")) ?? ""; + return ( + text.includes("Hello world again") && !text.includes("twice") && !text.includes("##") + ); + }, + { timeoutMsg: "One Undo in source view did not remove both rendered edits." }, + ); + }); + + it("shows an image the file links by a relative path (#345)", async () => { + // A 1x1 placeholder PNG next to the Markdown file's own folder. + const imagesPath = NodePath.join(preparedProjectPath, "images"); + NodeFS.mkdirSync(imagesPath, { recursive: true }); + NodeFS.writeFileSync( + NodePath.join(imagesPath, "pixel.png"), + Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", + ), + ); + NodeFS.writeFileSync( + NodePath.join(preparedProjectPath, "notes-image.md"), + "Hello world\n\n![Placeholder pixel](images/pixel.png)\n", + ); + await openNotes("notes-image.md"); + + await browser.waitUntil( + () => + browser.execute((editorQuery: string) => { + const image = document.querySelector(`${editorQuery} img`); + return image !== null && image.complete && image.naturalWidth > 0; + }, richEditorSelector), + { timeoutMsg: "The relative image in notes-image.md never loaded." }, + ); + }); + + it("opening and saving without editing leaves the file byte-identical", async () => { + // R31: frontmatter, a table, a fenced code block, mixed list markers, no final newline. + const path = NodePath.join(preparedProjectPath, "notes-untouched.md"); + NodeFS.writeFileSync(path, untouchedContents); + const before = NodeFS.readFileSync(path); + await openNotes("notes-untouched.md"); + await watchForUnsavedIndicator(); + + await switchView("Show markdown source"); + await waitForSourceEditor("notes-untouched.md"); + await switchView("Show rendered markdown"); + await browser.$(richEditorSelector).waitForDisplayed(); + await placeCaretAfter("Hello world"); + // A clean file's Save file is disabled, so save through the Files shortcut. + await browser.keys(["Meta", "s"]); + // Longer than the rendered view's 300 ms edit delay and a save round trip. + await new Promise((resolve) => setTimeout(resolve, 1_500)); + + expect(NodeFS.readFileSync(path).equals(before)).toBe(true); + expect(await unsavedIndicatorAppeared()).toBe(false); + }); +}); diff --git a/apps/desktop/e2e/specs/host-dashboard.e2e.ts b/apps/desktop/e2e/specs/host-dashboard.e2e.ts new file mode 100644 index 0000000000..e5db1ece19 --- /dev/null +++ b/apps/desktop/e2e/specs/host-dashboard.e2e.ts @@ -0,0 +1,91 @@ +// Shapes only: no value of the test machine (a size, a mount point, a host name) is asserted. +const sheetSelector = '[data-testid="host-dashboard"]'; +const cpuSelector = `${sheetSelector} [data-testid="host-dashboard-cpu"]`; +const sinceSelector = `${sheetSelector} [data-testid="host-dashboard-since"]`; +const hiddenReason = + /(Network volume|Container layer|File mount|Empty|System mount|Same storage as .+|Hidden by the volume filter)$/; + +async function openFromResourceManager(): Promise { + // The status bar sits on every route; leave Settings so no full-page surface covers it. + await browser.execute(() => { + if (window.location.hash.startsWith("#/settings")) window.location.hash = "#/"; + }); + const trigger = browser.$('button[aria-label^="Combined monitored resources"]'); + await trigger.waitForDisplayed(); + await trigger.click(); + const open = browser.$('//button[normalize-space()="Host dashboard"]'); + await open.waitForDisplayed(); + await open.click(); + await browser.$(sheetSelector).waitForDisplayed(); +} + +async function text(selector: string): Promise { + return (await browser.$(selector).getText()).trim(); +} + +async function texts(selector: string): Promise { + return browser.execute( + (query: string) => + [...document.querySelectorAll(query)].map((node) => (node.textContent ?? "").trim()), + selector, + ); +} + +describe("packaged Host dashboard", () => { + it("opens for the local computer, updates live, discloses hidden volumes and closes", async () => { + await openFromResourceManager(); + + const title = await text(`${sheetSelector} [data-slot="sheet-title"]`); + // The fallback title means the local environment was not resolved. + expect(title.length).toBeGreaterThan(0); + expect(title).not.toBe("Host dashboard"); + + await browser.waitUntil(async () => /^\d+(\.\d)?%$/.test(await text(cpuSelector)), { + timeoutMsg: "The CPU figure never showed a percentage.", + }); + expect(await text(`${sheetSelector} [data-testid="host-dashboard-memory"]`)).toMatch( + / of .*, \d+%$/, + ); + await browser.waitUntil( + async () => + (await texts(`${sheetSelector} [data-testid="host-dashboard-volume"]`)).length > 0, + { timeoutMsg: "No volume was listed." }, + ); + expect(await text(`${sinceSelector} h3`)).toMatch(/^Since/); + // A shown volume gets its own statistics row once a volume reading has been recorded. + await browser.waitUntil( + async () => + (await texts(`${sinceSelector} tbody th`)).some((label) => label.endsWith(" used")), + { timeoutMsg: "No per-volume statistics row appeared." }, + ); + + const firstSampledAt = await browser.$(cpuSelector).getAttribute("data-sampled-at"); + expect(firstSampledAt).toBeTruthy(); + await browser.pause(3_000); + expect(await browser.$(cpuSelector).getAttribute("data-sampled-at")).not.toBe(firstSampledAt); + + // One case only: every case starts from a reset connection and route, which closes the sheet. + const disclosure = browser.$( + '//*[@data-testid="host-dashboard"]//button[starts-with(normalize-space(), "Hidden volumes (")]', + ); + // Whether a host hides a volume depends on its mounts (a container's file mounts, the macOS + // data volume); a host that hides none shows no disclosure. + if (await disclosure.isExisting()) { + expect(await disclosure.getAttribute("aria-expanded")).toBe("false"); + await disclosure.click(); + await browser.waitUntil( + async () => (await disclosure.getAttribute("aria-expanded")) === "true", + { timeoutMsg: "The hidden volumes disclosure never opened." }, + ); + const rows = await texts(`${sheetSelector} [data-testid="host-dashboard-hidden-volume"]`); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row).toMatch(hiddenReason); + expect(row).not.toMatch(/\d\s?(B|KB|MB|GB|TB)\b/); + } + } + + await browser.keys("Escape"); + await browser.$(sheetSelector).waitForExist({ reverse: true }); + }); +}); diff --git a/apps/desktop/e2e/specs/platform-capabilities.e2e.ts b/apps/desktop/e2e/specs/platform-capabilities.e2e.ts index 8674999ad0..a96e48c839 100644 --- a/apps/desktop/e2e/specs/platform-capabilities.e2e.ts +++ b/apps/desktop/e2e/specs/platform-capabilities.e2e.ts @@ -28,14 +28,13 @@ describe("packaged preferences, native integrations, and platform capabilities", await expect(checkForUpdates).toBeDisplayed(); await expect(checkForUpdates).toBeDisabled(); - await browser.url(`${appOrigin}/#/settings/general`); - const themePreference = browser.$('[aria-label="Theme preference"]'); - await themePreference.scrollIntoView(); - await expect(themePreference).toBeDisplayed(); - await themePreference.click(); - const darkTheme = browser.$('//*[@role="option" and normalize-space()="Dark"]'); - await darkTheme.waitForDisplayed(); - await darkTheme.click(); + await browser.url(`${appOrigin}/#/settings/appearance`); + const darkMode = browser.$( + '//*[@role="group" and @aria-label="Mode"]//button[normalize-space()="Dark"]', + ); + await darkMode.scrollIntoView(); + await expect(darkMode).toBeDisplayed(); + await darkMode.click(); await expect(browser.$("html")).toHaveElementClass(expect.stringContaining("dark")); const providers = browser.$( diff --git a/apps/desktop/e2e/specs/source-control-review.e2e.ts b/apps/desktop/e2e/specs/source-control-review.e2e.ts new file mode 100644 index 0000000000..7b84ea4f99 --- /dev/null +++ b/apps/desktop/e2e/specs/source-control-review.e2e.ts @@ -0,0 +1,311 @@ +// @effect-diagnostics nodeBuiltinImport:off - Packaged review tests edit the fixture checkout, read fixture logs and save screenshots. +// @effect-diagnostics globalDate:off - The standalone WDIO spec waits on a host-side fixture file. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { refreshDesktopUiDocument } from "../support/document-navigation.ts"; +import { readProviderInputLog } from "../support/provider-input-log.ts"; +import { desktopUiFixture } from "../support/test-project.ts"; +import { + ensureMainSidebarOpen, + mockDesktopUiFolderPicker, + setDesktopUiWindowSize, +} from "../support/ui-state.ts"; + +/** + * Review changes in Source Control (#293, R101): a review opens as a chat panel of the current + * thread whose first message is the review prompt, and a review that ends in a plan offers no + * Implement, before and after a reload. The second case is the one the `codeReview` flag + * decides: without it the same plan offers Implement and an empty Enter implements it. + */ + +const artifactDirectory = process.env.BIBCODE_E2E_ARTIFACT_DIR; +const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH; +const providerInputLogPath = process.env.BIBCODE_E2E_PROVIDER_INPUT_LOG; +if (!artifactDirectory || !projectPath || !providerInputLogPath) { + throw new Error("The packaged desktop review fixture environment was not prepared."); +} +const preparedArtifactDirectory: string = artifactDirectory; +const preparedProjectPath: string = projectPath; +const preparedProviderInputLogPath: string = providerInputLogPath; +const visibleSurfaceSelector = '[data-center-surface-host][data-visible="true"]'; +const visibleComposerFormSelector = `${visibleSurfaceSelector} [data-chat-composer-form="true"]`; +const UNCOMMITTED_SCOPE = "Scope: the staged, unstaged and untracked changes in the working tree"; +const REVIEW_LEAD_IN = "Code review requested from BiBCode."; + +function composerForm() { + return browser.$(visibleComposerFormSelector); +} +function composerEditor() { + return composerForm().$('[data-testid="composer-editor"]'); +} + +async function waitForComposerDisplayed(): Promise { + const form = browser.$(visibleComposerFormSelector); + await form.waitForExist(); + await form.waitForDisplayed(); + await form.$('[data-testid="composer-editor"]').waitForDisplayed(); +} + +async function ensureFixtureProjectImported(): Promise { + const appOrigin = await browser.execute(() => window.location.origin); + await browser.url(`${appOrigin}/#/`); + await ensureMainSidebarOpen(); + const projectSelector = `//button[@data-sidebar="menu-button"][.//span[normalize-space()="${desktopUiFixture.projectName}"]]`; + const hasVisibleProject = async () => { + for (const candidate of await browser.$$(projectSelector)) { + if (await candidate.isDisplayed()) return true; + } + return false; + }; + if (await hasVisibleProject()) return; + + const projectDataLoading = browser.$("//*[normalize-space()='Project data is still loading']"); + if (await projectDataLoading.isExisting()) { + await projectDataLoading.waitForDisplayed({ + reverse: true, + timeoutMsg: "The primary project catalog did not become ready for fixture import.", + }); + } + const addProject = browser.$('[data-testid="sidebar-add-project-trigger"]'); + await expect(addProject).toBeDisplayed(); + await addProject.click(); + await browser.$('[role="dialog"]').waitForExist(); + const browseFolder = browser.$( + "//button[@data-add-project-action='true'][.//span[normalize-space()='Browse folder']]", + ); + await browseFolder.waitForDisplayed(); + await mockDesktopUiFolderPicker(preparedProjectPath); + await browseFolder.click(); + await browser.waitUntil(hasVisibleProject, { + timeoutMsg: "The imported fixture project did not become visible in the main sidebar.", + }); +} + +async function openPrimaryThread(): Promise { + const projectSelector = `//button[@data-sidebar="menu-button"][.//span[normalize-space()="${desktopUiFixture.projectName}"]]`; + const primaryWorkspace = browser.$( + '//*[@data-thread-item="true"][.//span[normalize-space()="main"]]', + ); + if (!(await primaryWorkspace.isDisplayed())) { + for (const project of await browser.$$(projectSelector)) { + if (await project.isDisplayed()) { + await project.click(); + break; + } + } + await primaryWorkspace.waitForDisplayed(); + } + await primaryWorkspace.click(); + await waitForComposerDisplayed(); +} + +async function replaceComposerText(text: string): Promise { + const editor = composerEditor(); + await editor.click(); + await browser.execute(() => { + const editor = document.activeElement; + if (!(editor instanceof HTMLElement) || editor.dataset.testid !== "composer-editor") { + throw new Error("The composer editor did not receive focus."); + } + const range = document.createRange(); + range.selectNodeContents(editor); + const selection = window.getSelection(); + selection?.removeAllRanges(); + selection?.addRange(range); + }); + await browser.keys("Backspace"); + if (text.length > 0) await editor.addValue(text); + await expect(composerEditor()).toHaveText(text); +} + +/** A draft has no panel host, so the thread gets a first message and becomes a server thread. */ +async function sendFirstMessage(): Promise { + const baseline = readProviderInputLog(preparedProviderInputLogPath).length; + const prompt = "get ready for a review"; + await replaceComposerText(prompt); + await browser.keys("Enter"); + await browser.waitUntil( + () => + readProviderInputLog(preparedProviderInputLogPath) + .slice(baseline) + .some((entry) => entry.kind === "start" && entry.prompt === prompt), + { timeoutMsg: "The primary thread's first message did not reach its provider." }, + ); + await expect(browser.$(visibleSurfaceSelector)).toHaveText( + expect.stringContaining(desktopUiFixture.streamedResponse), + ); +} + +/** One tracked file differs from HEAD, so the review scope is the uncommitted changes. */ +function modifyTrackedFile(): void { + NodeFS.appendFileSync( + NodePath.join(preparedProjectPath, "README.md"), + `\nReview fixture change ${Date.now()}.\n`, + ); +} + +async function openSourceControl(): Promise { + const rightPanel = browser.$("[data-right-panel-tabbar]"); + if (!(await rightPanel.isDisplayed())) { + const toggle = browser.$('button[aria-label^="Toggle right panel"]'); + await toggle.waitForDisplayed(); + await toggle.click(); + await rightPanel.waitForDisplayed(); + } + const existingTab = browser.$( + '//*[@data-right-panel-tab-list]//button[normalize-space()="Source Control"]', + ); + if (await existingTab.isDisplayed()) { + await existingTab.click(); + } else { + const emptyStateAction = browser.$( + '//button[.//span[normalize-space()="Source Control"] and not(@aria-disabled="true")]', + ); + if (await emptyStateAction.isDisplayed()) { + await emptyStateAction.click(); + } else { + const addSurface = browser.$('button[aria-label="Add panel surface"]'); + await addSurface.waitForDisplayed(); + await addSurface.click(); + const menuItem = browser.$('//*[@role="menuitem" and normalize-space()="Source Control"]'); + await menuItem.waitForExist(); + await browser.execute((item: HTMLElement) => item.click(), await menuItem); + } + } + // Enabled once the status sees the modified file and the thread can host a panel. + await browser.$('button[aria-label="Review changes"][aria-disabled="false"]').waitForDisplayed({ + timeout: 30_000, + timeoutMsg: "Review changes did not become enabled for the modified fixture checkout.", + }); +} + +function reviewTab(provider: string) { + return browser.$(`//button[@role="tab"][.//span[normalize-space()="Code review · ${provider}"]]`); +} + +async function waitForReviewStart(provider: "codex" | "claudeAgent", baseline: number) { + let prompt: string | undefined; + await browser.waitUntil( + () => { + prompt = readProviderInputLog(preparedProviderInputLogPath) + .slice(baseline) + .find( + (entry) => + entry.provider === provider && + entry.kind === "start" && + entry.prompt.startsWith(REVIEW_LEAD_IN), + )?.prompt; + return prompt !== undefined; + }, + { timeoutMsg: `The review's first message did not reach ${provider}.` }, + ); + return prompt!; +} + +/** No Implement, Refine or "Implement in a new thread", and an empty Enter sends nothing. */ +async function expectNoImplementActions(): Promise { + const surface = () => browser.$(visibleSurfaceSelector); + await expect(surface().$('[data-chat-composer-implement-actions="true"]')).not.toExist(); + await expect(surface().$('button[aria-label^="Implementation actions"]')).not.toExist(); + await expect(surface().$('//button[normalize-space()="Implement"]')).not.toExist(); + await expect(surface()).not.toHaveText(expect.stringContaining("Implement in a new thread")); + + // Refine replaces Implement once the composer holds text. + await replaceComposerText("tighten the wording"); + await expect(surface().$('//button[normalize-space()="Refine"]')).not.toExist(); + await replaceComposerText(""); + + const before = readProviderInputLog(preparedProviderInputLogPath).length; + await composerEditor().click(); + await browser.keys("Enter"); + // Nothing to wait for when nothing is sent: give a send time to reach the provider. + await browser.pause(2_000); + expect(readProviderInputLog(preparedProviderInputLogPath).length).toBe(before); + await expect(composerEditor()).toHaveText(""); +} + +async function expectReviewFindings(): Promise { + // The plan card's text first, so a missing flag fails at the Implement check below. + await expect(browser.$(visibleSurfaceSelector)).toHaveText( + expect.stringContaining(desktopUiFixture.codeReviewFinding), + { wait: 30_000 }, + ); + await expectNoImplementActions(); + await expect(browser.$(visibleSurfaceSelector)).toHaveText( + expect.stringContaining("Review findings"), + ); +} + +// Each test sets itself up: the harness resets the connection catalog and reloads the document +// before each one. +describe("packaged Source Control review", () => { + it("opens a Codex review panel whose first message is the review prompt", async () => { + await setDesktopUiWindowSize(1_280, 900); + await ensureFixtureProjectImported(); + await openPrimaryThread(); + await sendFirstMessage(); + modifyTrackedFile(); + await openSourceControl(); + + const baseline = readProviderInputLog(preparedProviderInputLogPath).length; + // The saved reviewer is the default, chat Codex. + await browser.$('button[aria-label="Review changes"]').click(); + const tab = reviewTab("Codex"); + await tab.waitForDisplayed({ timeoutMsg: "The Codex review tab did not open." }); + const prompt = await waitForReviewStart("codex", baseline); + expect(prompt).toContain(UNCOMMITTED_SCOPE); + + // The review starts supervised (approval required), the Codex read-only sandbox. + await waitForComposerDisplayed(); + await expect(composerForm().$('[aria-label="Supervised"]')).toExist(); + await browser.saveScreenshot( + NodePath.join(preparedArtifactDirectory, "review-codex-panel.png"), + ); + }); + + it("shows a Claude review's plan as Review findings with no Implement, also after a reload", async () => { + await setDesktopUiWindowSize(1_280, 900); + await ensureFixtureProjectImported(); + await openPrimaryThread(); + await sendFirstMessage(); + modifyTrackedFile(); + await openSourceControl(); + + // A one-off Claude chat review from the review menu; the saved reviewer stays chat Codex. + const baseline = readProviderInputLog(preparedProviderInputLogPath).length; + await browser.$('button[aria-label="Review options"]').click(); + const claude = browser.$( + '//*[@role="menuitem"][.//span[normalize-space()="Claude"]][not(@aria-disabled="true")]', + ); + await claude.waitForExist(); + await browser.execute((item: HTMLElement) => item.click(), await claude); + await reviewTab("Claude").waitForDisplayed({ + timeoutMsg: "The Claude review tab did not open.", + }); + const prompt = await waitForReviewStart("claudeAgent", baseline); + expect(prompt).toContain(UNCOMMITTED_SCOPE); + await waitForComposerDisplayed(); + // Claude reviews start in plan mode. + await expect(composerForm().$('button[aria-label="Disable plan mode"]')).toExist(); + + await expectReviewFindings(); + await browser.saveScreenshot( + NodePath.join(preparedArtifactDirectory, "review-claude-findings.png"), + ); + + await refreshDesktopUiDocument(); + const restoredTab = reviewTab("Claude"); + await restoredTab.waitForExist({ + timeoutMsg: "The Claude review tab did not survive a reload.", + }); + if ((await restoredTab.getAttribute("aria-selected")) !== "true") { + await browser.execute((tab: HTMLElement) => tab.click(), await reviewTab("Claude")); + } + await waitForComposerDisplayed(); + await expectReviewFindings(); + await browser.saveScreenshot( + NodePath.join(preparedArtifactDirectory, "review-claude-findings-after-reload.png"), + ); + }); +}); diff --git a/apps/desktop/e2e/specs/terminal-footer-invariant.e2e.ts b/apps/desktop/e2e/specs/terminal-footer-invariant.e2e.ts new file mode 100644 index 0000000000..8d8baa8a66 --- /dev/null +++ b/apps/desktop/e2e/specs/terminal-footer-invariant.e2e.ts @@ -0,0 +1,667 @@ +// @effect-diagnostics nodeBuiltinImport:off - Packaged UI tests read the fixture TUI's size log and retain failure artifacts. +// @effect-diagnostics globalDate:off - The seeded loop stamps its own failure report. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { refreshDesktopUiDocument } from "../support/document-navigation.ts"; +import { + fixtureTuiLogPath, + openFixtureCodexTerminal, + openFixtureTerminalThread, + readFixtureTuiSize, + settleFixtureTui, + type FixtureTuiSize, +} from "../support/fixture-terminal.ts"; +import { installDesktopUiMotionGuard } from "../support/motion-guard.ts"; +import { clearDesktopActivityMarker } from "../support/test-project.ts"; +import { setDesktopUiWindowSize } from "../support/ui-state.ts"; + +/** + * #348: an agent CLI in a center-panel terminal suddenly loses its bottom lines + * or its whole footer until a layout change repaints it. This loop drives + * seeded layout actions around a Codex Terminal running the fixture TUI, which + * draws a footer on its last three rows and logs every PTY size it is given, + * and after each step (and again 1.5 s later) checks that: + * - the PTY's rows (the TUI's last logged size) equal the rows xterm draws; + * - the rows xterm draws equal the rows that fit its mount; + * - the last row's bottom edge is inside every clipping ancestor; and + * - with the DOM renderer, the footer text is on the last row (or, with + * BIBCODE_E2E_FOOTER_LAYOUT=floating, appears exactly once at the current size). + * BIBCODE_E2E_FOOTER_LAYOUT=cli runs an Ink-like program that rewrites a bottom frame every + * 100 ms above a long transcript, and also checks that the viewport stays at the bottom. + * BIBCODE_E2E_FOOTER_RENDERER=dom turns the WebGL renderer off, so every check reads the text. + * BIBCODE_E2E_FOOTER_CHAT=1 adds chat sends and a growing composer to the actions. + * BIBCODE_E2E_FOOTER_SEED and BIBCODE_E2E_FOOTER_STEPS replay or lengthen a run. + */ + +const artifactDirectory = process.env.BIBCODE_E2E_ARTIFACT_DIR; +const projectPath = process.env.BIBCODE_E2E_PROJECT_PATH; +const shimDirectory = process.env.BIBCODE_E2E_SHIM_DIRECTORY; +if (!artifactDirectory || !projectPath || !shimDirectory) { + throw new Error("The packaged desktop UI fixture environment was not prepared."); +} +const preparedArtifactDirectory: string = artifactDirectory; +const preparedProjectPath: string = projectPath; +const isWindowsE2e = process.env.BIBCODE_E2E_PLATFORM === "win"; +const preparedCodexExecutable = NodePath.join(shimDirectory, isWindowsE2e ? "codex.cmd" : "codex"); +const tuiLogPath = fixtureTuiLogPath(preparedProjectPath); + +function readPositiveInteger(name: string, fallback: number): number { + const raw = process.env[name]?.trim(); + if (!raw) return fallback; + const value = Number(raw); + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`${name} must be a non-negative integer, got ${raw}.`); + } + return value; +} +const seed = readPositiveInteger("BIBCODE_E2E_FOOTER_SEED", 348); +const stepCount = readPositiveInteger("BIBCODE_E2E_FOOTER_STEPS", 40); +/** "floating" runs a frame drawn in the normal buffer (like Claude's CLI), not pinned to the last row. */ +const floatingLayout = process.env.BIBCODE_E2E_FOOTER_LAYOUT === "floating"; +const cliLayout = process.env.BIBCODE_E2E_FOOTER_LAYOUT === "cli"; +const domRenderer = process.env.BIBCODE_E2E_FOOTER_RENDERER === "dom"; +const chatActions = process.env.BIBCODE_E2E_FOOTER_CHAT === "1"; + +/** mulberry32: small, deterministic, and enough to replay a failing sequence. */ +function createRandom(initial: number): () => number { + let state = initial >>> 0; + return () => { + state = (state + 0x6d2b79f5) >>> 0; + let t = state; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +type TuiSize = FixtureTuiSize; + +function readTuiSize(): TuiSize | null { + return readFixtureTuiSize(tuiLogPath); +} + +interface TerminalGeometry { + readonly found: boolean; + readonly renderer: "dom" | "webgl" | "none"; + readonly mountHeight: number; + readonly mountPaddingY: number; + readonly screenHeight: number; + readonly screenBottom: number; + readonly domRows: number | null; + readonly lastRows: ReadonlyArray; + /** Every drawn row that holds the footer's last line, whatever its position. */ + readonly footerRows: ReadonlyArray; + readonly clippedBy: ReadonlyArray<{ readonly element: string; readonly overflow: number }>; + readonly notice: boolean; + /** + * Pixels between the end of xterm's scrollbar slider and the end of its track, or null when + * the slider or a track with height is missing (a renamed class must not pass as 0). + */ + readonly scrollGap: number | null; + readonly viewport: { readonly width: number; readonly height: number }; +} + +function readTerminalGeometry(terminalId: string): Promise { + return browser.execute((id: string): TerminalGeometry => { + const empty: TerminalGeometry = { + found: false, + renderer: "none", + mountHeight: 0, + mountPaddingY: 0, + screenHeight: 0, + screenBottom: 0, + domRows: null, + lastRows: [], + footerRows: [], + clippedBy: [], + notice: false, + scrollGap: null, + viewport: { width: window.innerWidth, height: window.innerHeight }, + }; + const mount = document.querySelector( + `[data-terminal-xterm-mount="${CSS.escape(id)}"]`, + ); + // A center column collapsed behind a maximized right panel keeps its height but has no + // width: nothing is shown, and the terminal deliberately keeps its last size (c192a96c7). + const mountRect = mount?.getBoundingClientRect(); + if (!mount || !mountRect || mountRect.height <= 0 || mountRect.width <= 0) return empty; + if (mount.closest('[data-visible="false"]') || getComputedStyle(mount).visibility === "hidden") + return empty; + // A maximized right panel can also cover a center column that keeps its size: what is + // drawn at the mount's corner then belongs to the panel, and the terminal is not shown. + const probe = document.elementFromPoint( + mountRect.left + Math.min(8, mountRect.width / 2), + mountRect.top + Math.min(8, mountRect.height / 2), + ); + if (!probe || !mount.contains(probe)) return empty; + const xterm = mount.querySelector(".xterm"); + const screen = mount.querySelector(".xterm-screen"); + if (!xterm || !screen) return empty; + const rowsElement = mount.querySelector(".xterm-rows"); + const webglCanvas = [...screen.querySelectorAll("canvas")].some( + (canvas) => !canvas.classList.contains("xterm-link-layer") && canvas.style.display !== "none", + ); + const domRows = !webglCanvas && rowsElement ? rowsElement.children.length : null; + const xtermStyle = getComputedStyle(xterm); + const screenRect = screen.getBoundingClientRect(); + const clippedBy: Array<{ element: string; overflow: number }> = []; + for ( + let element: HTMLElement | null = mount; + element && element !== document.documentElement; + element = element.parentElement + ) { + const overflowY = getComputedStyle(element).overflowY; + if (element !== mount && !/(hidden|clip|auto|scroll)/.test(overflowY)) continue; + const overflow = screenRect.bottom - element.getBoundingClientRect().bottom; + if (overflow > 0.5) { + clippedBy.push({ + element: `${element.tagName.toLowerCase()}.${String(element.className).slice(0, 80)}`, + overflow, + }); + } + } + if (screenRect.bottom - window.innerHeight > 0.5) { + clippedBy.push({ element: "viewport", overflow: screenRect.bottom - window.innerHeight }); + } + return { + found: true, + renderer: webglCanvas ? "webgl" : rowsElement ? "dom" : "none", + mountHeight: mount.clientHeight, + mountPaddingY: + Number.parseFloat(xtermStyle.paddingTop) + Number.parseFloat(xtermStyle.paddingBottom), + screenHeight: screenRect.height, + screenBottom: screenRect.bottom, + domRows, + lastRows: + domRows === null + ? [] + : [...rowsElement!.children].slice(-3).map((row) => (row.textContent ?? "").trimEnd()), + footerRows: + domRows === null + ? [] + : [...rowsElement!.children] + .map((row) => (row.textContent ?? "").trimEnd()) + .filter((text) => text.includes("fixture-footer-z")), + clippedBy, + notice: (mount.parentElement?.textContent ?? "").includes("Sized for another window"), + // xterm 6 scrolls a virtual element, not .xterm-viewport: its vertical slider ends at + // the bottom of its track exactly when the viewport is at the bottom. + // xterm 6.1 renames the classes to `.xterm-scrollbar` and `.xterm-slider`. + scrollGap: (() => { + const slider = mount.querySelector( + ".scrollbar.vertical > .slider, .xterm-scrollbar.vertical > .xterm-slider, .xterm-scrollbar > .xterm-slider", + ); + const track = slider?.parentElement; + return slider && track && track.clientHeight > 0 + ? Math.round(track.clientHeight - slider.offsetTop - slider.offsetHeight) + : null; + })(), + viewport: { width: window.innerWidth, height: window.innerHeight }, + }; + }, terminalId); +} + +interface Check { + readonly at: string; + readonly tui: TuiSize | null; + readonly geometry: TerminalGeometry; + readonly xtermRows: number | null; + readonly fitRows: number | null; + readonly violations: ReadonlyArray; +} + +/** + * The cell height is fixed for a session (font, size and scale do not change + * here), so it is measured once from a settled terminal and then turns screen + * and mount heights into row counts for either renderer. + */ +let cellHeight: number | null = null; + +function evaluate(at: string, tui: TuiSize | null, geometry: TerminalGeometry): Check { + const violations: string[] = []; + if (!geometry.found) { + return { at, tui, geometry, xtermRows: null, fitRows: null, violations }; + } + const xtermRows = + geometry.domRows ?? (cellHeight ? Math.round(geometry.screenHeight / cellHeight) : null); + const fitRows = cellHeight + ? Math.floor((geometry.mountHeight - geometry.mountPaddingY) / cellHeight + 1e-6) + : null; + if (tui === null) violations.push("the fixture TUI never logged a size"); + else if (xtermRows !== null && tui.rows !== xtermRows) + violations.push(`PTY has ${tui.rows} rows but xterm draws ${xtermRows}`); + if (xtermRows !== null && fitRows !== null && xtermRows !== fitRows && !geometry.notice) + violations.push(`xterm draws ${xtermRows} rows but ${fitRows} fit its mount`); + for (const clip of geometry.clippedBy) + violations.push(`the last row is ${clip.overflow.toFixed(1)}px below ${clip.element}`); + // A program writing a transcript keeps the viewport pinned to the bottom. + if (cliLayout && geometry.scrollGap === null) + violations.push("xterm's scroll bar slider or its track was not found"); + else if (cliLayout && geometry.scrollGap !== null && geometry.scrollGap > 1) + violations.push(`the viewport is scrolled up (slider ${geometry.scrollGap}px above its end)`); + if (floatingLayout) { + // A frame drawn in the normal buffer sits wherever the cursor was, so only its count is + // known: the footer must appear exactly once at the current size, never stale or doubled. + if (geometry.renderer === "dom" && tui !== null) { + const expected = `fixture-footer-z ${tui.rows}`; + // A narrow terminal truncates the footer; a cut-off prefix still counts as it. + const current = geometry.footerRows.filter( + (text) => text.startsWith(expected) || expected.startsWith(text), + ); + if (current.length !== 1) + violations.push( + `the footer appears ${current.length} times at ${tui.rows} rows: ${JSON.stringify(geometry.footerRows)}`, + ); + } + } else if (geometry.renderer === "dom" && tui !== null && tui.rows >= 5) { + // A narrower terminal wraps the footer's lines (the full-screen TUI; a later reflow then + // merges rows) or cuts them at the width less one column (the cli program), so the text is + // checked only where its longest line fits. + const expected = `fixture-footer-z ${tui.rows}`; + const longest = `fixture-footer-a ${tui.cols}x${tui.rows}`; + if (tui.cols > longest.length && !(geometry.lastRows.at(-1) ?? "").startsWith(expected)) + violations.push(`the footer is not on the last row: ${JSON.stringify(geometry.lastRows)}`); + } + return { at, tui, geometry, xtermRows, fitRows, violations }; +} + +async function settle(): Promise { + await settleFixtureTui(tuiLogPath); +} + +interface Session { + readonly terminalId: string; + readonly steps: string[]; + readonly checks: Check[]; +} + +async function failWith(session: Session, check: Check): Promise { + const stamp = `${seed}-${session.steps.length}-${Date.now()}`; + const screenshot = NodePath.join(preparedArtifactDirectory, `terminal-footer-${stamp}.png`); + await browser.saveScreenshot(screenshot).catch(() => undefined); + const report = { + seed, + stepCount, + stepsSoFar: session.steps, + failing: check, + recentChecks: session.checks.slice(-6), + cellHeight, + screenshot, + }; + NodeFS.writeFileSync( + NodePath.join(preparedArtifactDirectory, `terminal-footer-${stamp}.json`), + JSON.stringify(report, null, 2), + ); + throw new Error( + `#348 footer invariant broke after step ${session.steps.length} (seed ${seed}): ${check.violations.join("; ")}. ` + + `Steps: ${session.steps.join(" | ")}. Report: ${JSON.stringify(report)}`, + ); +} + +/** + * A settling check only records: a size change is in flight for a moment after + * a reload, a split, or a reconnect. The check 1.5 s later is the one that fails. + */ +async function assertInvariant(session: Session, at: string, final = true): Promise { + const check = evaluate(at, readTuiSize(), await readTerminalGeometry(session.terminalId)); + session.checks.push(check); + if (final && check.violations.length > 0) await failWith(session, check); +} + +const terminalTab = + '//*[@data-center-panel-tab-list]//button[.//span[normalize-space()="Codex Terminal"]]'; +const chatTab = '//*[@data-center-panel-tab-list]//button[.//span[normalize-space()="Codex"]]'; + +async function clickIfUsable(selector: string): Promise { + const element = browser.$(selector); + if (!(await element.isExisting()) || !(await element.isDisplayed())) return false; + if (!(await element.isEnabled())) return false; + await element.click(); + return true; +} + +async function showTerminal(): Promise { + await clickIfUsable(terminalTab); +} + +/** Rewrites the persisted center layout for the fixture thread, then reloads to apply it. */ +async function setSplit(terminalId: string, split: boolean): Promise { + await browser.execute( + (id: string, wantSplit: boolean) => { + const key = "bibcode:center-panel-state:v1"; + const persisted = JSON.parse(window.localStorage.getItem(key) ?? "{}") as { + state?: { + byThreadKey?: Record< + string, + { + surfaces: ReadonlyArray<{ id: string }>; + groups: unknown; + layout: unknown; + focusedGroupId: unknown; + } + >; + }; + }; + const surfaceId = `terminal:${id}`; + for (const thread of Object.values(persisted.state?.byThreadKey ?? {})) { + if (!thread.surfaces.some((surface) => surface.id === surfaceId)) continue; + Object.assign( + thread, + wantSplit + ? { + groups: [ + { id: "center:root", surfaceIds: ["chat:host"], activeSurfaceId: "chat:host" }, + { + id: "center-group:footer-split", + surfaceIds: [surfaceId], + activeSurfaceId: surfaceId, + }, + ], + layout: { + type: "split", + direction: "horizontal", + ratio: 0.5, + first: { type: "leaf", groupId: "center:root" }, + second: { type: "leaf", groupId: "center-group:footer-split" }, + }, + focusedGroupId: "center-group:footer-split", + } + : { + groups: [ + { + id: "center:root", + surfaceIds: ["chat:host", surfaceId], + activeSurfaceId: surfaceId, + }, + ], + layout: { type: "leaf", groupId: "center:root" }, + focusedGroupId: "center:root", + }, + ); + } + window.localStorage.setItem(key, JSON.stringify(persisted)); + }, + terminalId, + split, + ); + await reloadDocument(); +} + +/** A chat action can leave the chat tab active, and the terminal mounts only when shown. */ +async function showTerminalAfterChat(): Promise { + if (!chatActions) return; + await browser + .$(terminalTab) + .waitForExist({ timeout: 15_000 }) + .catch(() => undefined); + await showTerminal(); +} + +async function reloadDocument(): Promise { + await refreshDesktopUiDocument(); + await installDesktopUiMotionGuard(); + await showTerminalAfterChat(); + await browser.$(`[data-terminal-xterm-mount]`).waitForExist({ + timeout: 30_000, + timeoutMsg: "The terminal did not mount again after a reload.", + }); +} + +async function dragRightPanelEdge(dx: number): Promise { + const handle = browser.$('[role="separator"][aria-orientation="vertical"].cursor-col-resize'); + if (!(await handle.isExisting()) || !(await handle.isDisplayed())) return false; + const location = await handle.getLocation(); + const size = await handle.getSize(); + const x = Math.round(location.x + size.width / 2); + const y = Math.round(location.y + size.height / 2); + try { + // A real pointer drag: the handle captures the pointer, which synthetic events cannot. + await browser + .action("pointer", { parameters: { pointerType: "mouse" } }) + .move({ x, y }) + .down() + .move({ x: x + Math.round(dx / 2), y, duration: 100 }) + .move({ x: x + dx, y, duration: 100 }) + .up() + .perform(); + return true; + } catch { + // The embedded driver may not implement pointer actions; the step is skipped. + return false; + } +} + +type Action = (session: Session, random: () => number) => Promise; + +const actions: ReadonlyArray = [ + [ + "right panel toggle", + async () => + (await clickIfUsable('button[aria-label="Toggle right panel"]')) + ? "toggle right panel" + : null, + ], + [ + "right panel maximize", + async () => { + if (await clickIfUsable('button[aria-label="Maximize panel"]')) return "maximize right panel"; + if (await clickIfUsable('button[aria-label="Restore panel size"]')) + return "restore right panel"; + return null; + }, + ], + [ + "right panel edge drag", + async (_session, random) => { + const dx = Math.round((random() - 0.5) * 400); + return (await dragRightPanelEdge(dx)) ? `drag right panel edge ${dx}px` : null; + }, + ], + [ + "activity panel", + async (session) => { + const button = `[data-provider-terminal-activity-host="${session.terminalId}"] [data-testid="activity-dock"] button`; + return (await clickIfUsable(button)) ? "toggle terminal Activity panel" : null; + }, + ], + [ + "center split", + async (session, random) => { + const split = random() < 0.5; + await setSplit(session.terminalId, split); + return split ? "split center (reload)" : "unsplit center (reload)"; + }, + ], + [ + "tab switch", + async () => { + if (!(await clickIfUsable(chatTab))) return null; + await browser.pause(300); + await showTerminal(); + return "switch to chat tab and back"; + }, + ], + [ + "route switch", + async () => { + const current = await browser.getUrl(); + const origin = await browser.execute(() => window.location.origin); + await browser.url(`${origin}/#/settings/terminal`); + await browser.pause(500); + await browser.url(current); + await showTerminalAfterChat(); + await browser.$(`[data-terminal-xterm-mount]`).waitForExist({ timeout: 15_000 }); + await showTerminal(); + return "open settings and come back"; + }, + ], + [ + "sidebar", + async () => + (await clickIfUsable('button[aria-label="Toggle main sidebar"]')) + ? "toggle main sidebar" + : null, + ], + [ + "window resize", + async (session, random) => { + const viewport = session.checks.at(-1)?.geometry.viewport ?? { width: 1_200, height: 720 }; + const width = 960 + Math.round(random() * 440); + const height = 560 + Math.round(random() * 300); + const mode = random(); + const next = + mode < 1 / 3 + ? { width: viewport.width, height } + : mode < 2 / 3 + ? { width, height: viewport.height } + : { width, height }; + try { + await setDesktopUiWindowSize(next.width, next.height); + } catch { + // Some window managers (GTK under Xvfb) settle near the size but never on it; the + // invariant holds at whatever size the window reached, so the step records that. + const reached = await browser.execute(() => `${window.innerWidth}x${window.innerHeight}`); + return `resize window toward ${next.width}x${next.height} (reached ${reached})`; + } + return `resize window to ${next.width}x${next.height}`; + }, + ], + [ + "reload", + async () => { + await reloadDocument(); + await showTerminal(); + return "reload and reattach"; + }, + ], +]; + +let chatMessages = 0; +const visibleComposer = + '[data-center-surface-host][data-visible="true"] [data-testid="composer-editor"]'; + +/** Uses the chat beside (or instead of) the terminal: a send, or a composer that grows and empties. */ +function chatAction(kind: "send" | "grow"): Action { + return async (_session, random) => { + let composer = browser.$(visibleComposer); + let switched = false; + if (!(await composer.isExisting()) || !(await composer.isDisplayed())) { + if (!(await clickIfUsable(chatTab))) return null; + switched = true; + composer = browser.$(visibleComposer); + if (!(await composer.isDisplayed().catch(() => false))) return null; + } + await composer.click(); + if (kind === "send") { + chatMessages += 1; + await composer.addValue(`fixture message ${chatMessages}`); + await browser.keys("Enter"); + await browser.pause(400 + Math.round(random() * 1_200)); + } else { + for (let line = 0; line < 6; line += 1) { + await composer.addValue(`grow line ${line}`); + await browser.keys(["Shift", "Enter"]); + } + await browser.pause(600); + // Select the draft without a platform shortcut (Control+A is not select-all on macOS). + await browser.execute(() => { + const editor = document.activeElement; + if (!(editor instanceof HTMLElement)) return; + const range = document.createRange(); + range.selectNodeContents(editor); + window.getSelection()?.removeAllRanges(); + window.getSelection()?.addRange(range); + }); + await browser.keys("Backspace"); + } + if (switched && random() < 0.7) await showTerminal(); + return `chat ${kind}${switched ? " via the chat tab" : " beside the terminal"}`; + }; +} + +const actionList: ReadonlyArray = chatActions + ? [ + ...actions, + ["chat send", chatAction("send")], + ["chat send", chatAction("send")], + // The center split already puts the chat beside the terminal. + ["composer grow", chatAction("grow")], + ] + : actions; + +async function openFixtureThread(): Promise { + await openFixtureTerminalThread(preparedProjectPath, preparedCodexExecutable, { + webglEnabled: !domRenderer, + }); +} + +async function openCodexTerminal(): Promise { + return await openFixtureCodexTerminal(); +} + +describe("packaged AI terminal footer invariant (#348)", () => { + it("keeps the PTY, xterm and the visible mount in agreement across seeded layout changes", async function () { + // Each step takes about 3 s; the configured case timeout alone ends the loop near step 40. + // Setup and the chat mode's turns add up to a minute on a loaded host before the first step. + this.timeout(Math.max(this.timeout(), 120_000 + stepCount * 20_000)); + clearDesktopActivityMarker(preparedProjectPath); + await setDesktopUiWindowSize(1_200, 720); + await openFixtureThread(); + const logStart = readTuiSize()?.bytes ?? 0; + const terminalId = await openCodexTerminal(); + await browser.waitUntil(async () => (readTuiSize()?.bytes ?? 0) > logStart, { + timeout: 30_000, + timeoutMsg: "The fixture TUI never drew in the Codex Terminal.", + }); + await settle(); + // The Activity dock mounts 0.4 s or so after the terminal and shrinks it, and the refit + // lands about 100 ms later: the baseline waits for the dock, then for that size to hold. + const dockMounted = await browser + .$(`[data-provider-terminal-activity-host="${terminalId}"] [data-testid="activity-dock"]`) + .waitForExist({ timeout: 5_000 }) + .then( + () => true, + () => false, + ); + if (dockMounted) await settle(); + + const baseline = await readTerminalGeometry(terminalId); + const baselineTui = readTuiSize(); + if (!baseline.found || baselineTui === null) { + throw new Error(`The terminal did not settle: ${JSON.stringify({ baseline, baselineTui })}`); + } + // The first settled frame defines the cell height every later check uses. + cellHeight = baseline.screenHeight / (baseline.domRows ?? baselineTui.rows); + + const session: Session = { terminalId, steps: [], checks: [] }; + await assertInvariant(session, "baseline"); + + const random = createRandom(seed); + for (let index = 0; index < stepCount; index += 1) { + const [name, run] = actionList[Math.floor(random() * actionList.length)]!; + const description = await run(session, random); + session.steps.push(description ?? `${name} (unavailable, skipped)`); + await settle(); + await assertInvariant(session, `step ${index + 1}`, false); + await browser.pause(1_500); + await assertInvariant(session, `step ${index + 1} +1.5s`); + } + NodeFS.writeFileSync( + NodePath.join(preparedArtifactDirectory, `terminal-footer-${seed}-passed.json`), + JSON.stringify( + { + seed, + stepCount, + steps: session.steps, + cellHeight, + // Settling checks that disagreed and cleared within 1.5 s, and the renderers seen. + transients: session.checks.filter((check) => check.violations.length > 0), + renderers: [...new Set(session.checks.map((check) => check.geometry.renderer))], + }, + null, + 2, + ), + ); + }); +}); diff --git a/apps/desktop/e2e/support/activity-session.test.ts b/apps/desktop/e2e/support/activity-session.test.ts index f964307a83..b091c02c38 100644 --- a/apps/desktop/e2e/support/activity-session.test.ts +++ b/apps/desktop/e2e/support/activity-session.test.ts @@ -378,6 +378,22 @@ describe("materializeDesktopActivitySession", () => { ); }); + it("turns the WebGL terminal renderer off in the same settings update when asked", async () => { + installHarness(); + + await configureDesktopActivityCodexExecutable("/fixture/provider-shims/codex", { + webglEnabled: false, + }); + + expect(sentRequests).toHaveLength(1); + expect(sentRequests[0]?.payload).toEqual({ + patch: expect.objectContaining({ + enableTerminalAgentActivity: true, + terminal: { webglEnabled: false }, + }), + }); + }); + it("synchronizes the activity terminal with the exact configured Codex executable", async () => { installHarness(); diff --git a/apps/desktop/e2e/support/activity-session.ts b/apps/desktop/e2e/support/activity-session.ts index 371fc86373..33bb3a6c2c 100644 --- a/apps/desktop/e2e/support/activity-session.ts +++ b/apps/desktop/e2e/support/activity-session.ts @@ -347,13 +347,19 @@ export async function stopDesktopActivitySession(): Promise { * Refreshes the server config stream with the same absolute Codex executable * that the disposable provider inventory authorizes for the terminal launch. */ -export async function configureDesktopActivityCodexExecutable(executable: string): Promise { +export async function configureDesktopActivityCodexExecutable( + executable: string, + options: { readonly webglEnabled?: boolean } = {}, +): Promise { await dispatchDesktopActivityCommands([], null, [ { tag: "server.updateSettings", payload: { patch: { enableTerminalAgentActivity: true, + ...(options.webglEnabled === undefined + ? {} + : { terminal: { webglEnabled: options.webglEnabled } }), providers: { codex: { enabled: true, diff --git a/apps/desktop/e2e/support/app-lifecycle.test.ts b/apps/desktop/e2e/support/app-lifecycle.test.ts index ddc5b50be6..605b8791b9 100644 --- a/apps/desktop/e2e/support/app-lifecycle.test.ts +++ b/apps/desktop/e2e/support/app-lifecycle.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vite-plus/test"; -import { dispatchDesktopUiApplicationExit, isFinalDesktopUiSpec } from "./app-lifecycle.ts"; +import { + dispatchDesktopUiApplicationExit, + isDeadDesktopUiWebviewError, + isFinalDesktopUiSpec, + recoverDeadDesktopUiWebview, +} from "./app-lifecycle.ts"; describe("dispatchDesktopUiApplicationExit", () => { it("waits for native runtime shutdown before closing the main Tauri window", async () => { @@ -51,3 +56,66 @@ describe("isFinalDesktopUiSpec", () => { ).toBe(true); }); }); + +describe("isDeadDesktopUiWebviewError", () => { + it.each([ + "invalid session id", + "no such window: target window already closed", + "Error: connect ECONNREFUSED 127.0.0.1:4445", + "socket hang up", + // WebKitGTK after its web process was killed (Linux lab, 2026-11-12). + 'javascript error: WebDriverError: Unsupported result type when running "execute/sync" with method "POST"', + ])("recognises %s as a dead webview", (message) => { + expect(isDeadDesktopUiWebviewError(new Error(message))).toBe(true); + }); + + it("leaves an ordinary script failure alone", () => { + expect(isDeadDesktopUiWebviewError(new Error("TypeError: x is not a function"))).toBe(false); + expect(isDeadDesktopUiWebviewError("invalid session id")).toBe(false); + }); +}); + +describe("recoverDeadDesktopUiWebview", () => { + const deps = (probe: () => Promise, running: boolean) => ({ + probe: vi.fn(probe), + isRunning: vi.fn(async () => running), + stopApplication: vi.fn(async () => undefined), + relaunch: vi.fn(async () => undefined), + }); + + it("does nothing when the probe answers", async () => { + const live = deps(async () => true, true); + await expect(recoverDeadDesktopUiWebview(live)).resolves.toBe(false); + expect(live.relaunch).not.toHaveBeenCalled(); + expect(live.stopApplication).not.toHaveBeenCalled(); + }); + + it("relaunches an app whose process is gone", async () => { + const dead = deps(() => Promise.reject(new Error("invalid session id")), false); + await expect(recoverDeadDesktopUiWebview(dead)).resolves.toBe(true); + expect(dead.stopApplication).not.toHaveBeenCalled(); + expect(dead.relaunch).toHaveBeenCalledOnce(); + }); + + it("stops an app that still runs behind a dead webview before relaunching it", async () => { + const order: string[] = []; + const dead = deps(() => Promise.reject(new Error("no such window")), true); + dead.stopApplication.mockImplementation(async () => void order.push("stop")); + dead.relaunch.mockImplementation(async () => void order.push("relaunch")); + await expect(recoverDeadDesktopUiWebview(dead)).resolves.toBe(true); + expect(order).toEqual(["stop", "relaunch"]); + }); + + it("rethrows a probe failure that is not a dead webview, without relaunching", async () => { + const odd = deps(() => Promise.reject(new Error("TypeError: boom")), true); + await expect(recoverDeadDesktopUiWebview(odd)).rejects.toThrow("boom"); + expect(odd.relaunch).not.toHaveBeenCalled(); + }); + + it("relaunches once: a relaunch that fails is reported, not retried", async () => { + const dead = deps(() => Promise.reject(new Error("invalid session id")), false); + dead.relaunch.mockRejectedValue(new Error("never served WebDriver")); + await expect(recoverDeadDesktopUiWebview(dead)).rejects.toThrow("never served WebDriver"); + expect(dead.relaunch).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/desktop/e2e/support/app-lifecycle.ts b/apps/desktop/e2e/support/app-lifecycle.ts index eb6642be9f..0a69ee070e 100644 --- a/apps/desktop/e2e/support/app-lifecycle.ts +++ b/apps/desktop/e2e/support/app-lifecycle.ts @@ -1,3 +1,13 @@ +// @effect-diagnostics nodeBuiltinImport:off - The lifecycle helper relaunches and observes the packaged app. +// @effect-diagnostics globalTimers:off - The helper polls the native process between WebDriver sessions. +// @effect-diagnostics globalDate:off - The standalone helper bounds its native-process waits. +// @effect-diagnostics globalFetch:off - The helper probes the embedded WebDriver server's status. +import * as NodeChildProcess from "node:child_process"; +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { resolveDesktopAppPath, type DesktopUiPlatform } from "./app-path.ts"; + interface DesktopUiApplicationHost { readonly __TAURI__?: { readonly core?: { @@ -6,6 +16,9 @@ interface DesktopUiApplicationHost { }; } +/** The bundle identifier of `tauri.e2e.conf.json`, never the user's own BiBCode. */ +export const DESKTOP_UI_BUNDLE_IDENTIFIER = "com.bibcode.desktop.e2e"; + function desktopUiSpecFileName(path: string): string | undefined { return path.replaceAll("\\", "/").split(/[?#]/, 1)[0]?.split("/").at(-1); } @@ -26,16 +39,315 @@ export async function dispatchDesktopUiApplicationExit( const invoke = host.__TAURI__?.core?.invoke; if (invoke === undefined) return false; + // Disarms the close question first, so an edit a spec left unsaved cannot hold the exit. await invoke("desktop_e2e_prepare_for_exit"); void invoke("plugin:window|close", { label: "main" }).catch(() => undefined); return true; } +function webDriverPort(environment: NodeJS.ProcessEnv = process.env): number { + return Number(environment.BIBCODE_E2E_WEBDRIVER_PORT ?? 4_445); +} + +/** Whether the packaged app's embedded WebDriver server answers, so the app is running. */ +export async function isDesktopUiApplicationRunning( + port: number = webDriverPort(), +): Promise { + try { + const response = await fetch(`http://127.0.0.1:${port}/status`, { + signal: AbortSignal.timeout(2_000), + }); + return response.ok; + } catch { + return false; + } +} + +/** Ends the app at teardown. An app a closing case already ended is accepted as done. */ export async function requestDesktopUiApplicationExit(): Promise { - const requested = await browser.execute( - dispatchDesktopUiApplicationExit as () => Promise, - ); + if (!(await isDesktopUiApplicationRunning())) return; + let requested: boolean; + try { + requested = await browser.execute(dispatchDesktopUiApplicationExit as () => Promise); + } catch (error) { + // The exit can tear the page down under the request. + if (!(await isDesktopUiApplicationRunning())) return; + throw error; + } if (!requested) { throw new Error("The Tauri bridge was unavailable during desktop UI application shutdown."); } } + +/** The process that owns the WebDriver port: the running packaged app. */ +export function desktopUiApplicationPid(port: number = webDriverPort()): number { + const output = NodeChildProcess.execFileSync( + "lsof", + ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], + { encoding: "utf8" }, + ).trim(); + const pid = Number(output.split("\n")[0]); + if (!Number.isInteger(pid) || pid <= 0) { + throw new Error(`No packaged app listens on WebDriver port ${port}.`); + } + return pid; +} + +/** Every TCP port `pid` listens on, the embedded BiBCode server's among them. */ +export function desktopUiListeningPorts(pid: number): number[] { + let output: string; + try { + output = NodeChildProcess.execFileSync( + "lsof", + ["-nP", "-a", "-p", String(pid), "-iTCP", "-sTCP:LISTEN", "-Fn"], + { encoding: "utf8" }, + ); + } catch { + return []; + } + return [...output.matchAll(/^n.*:(\d+)$/gm)].map((match) => Number(match[1])); +} + +/** Processes running exactly this packaged binary: only the isolated e2e install. */ +function runningBinaryPids(appBinaryPath: string): number[] { + try { + return NodeChildProcess.execFileSync("pgrep", ["-f", "-x", appBinaryPath], { + encoding: "utf8", + }) + .trim() + .split("\n") + .map(Number) + .filter((pid) => Number.isInteger(pid) && pid > 0); + } catch { + // pgrep exits 1 when nothing matches. + return []; + } +} + +export function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code === "EPERM"; + } +} + +/** Waits until the process is gone; returns how long that took. */ +export async function waitForDesktopUiApplicationExit( + pid: number, + timeoutMs: number, +): Promise { + const started = Date.now(); + while (isProcessAlive(pid)) { + if (Date.now() - started > timeoutMs) { + throw new Error(`The packaged app (pid ${pid}) was still running after ${timeoutMs} ms.`); + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + return Date.now() - started; +} + +function runSystemEvents(script: string): string { + return NodeChildProcess.execFileSync("osascript", ["-e", script], { encoding: "utf8" }).trim(); +} + +/** + * Chooses a native menu item of the packaged app, the way a person does: the native close and + * quit protocol, with the backend still running. The process is picked by its bundle identifier, + * so the user's own BiBCode is never touched. + */ +export async function chooseDesktopUiMenuItem(itemPrefix: string): Promise { + if (desktopUiPlatform() !== "mac") { + throw new Error("Native menu items are only driven on macOS."); + } + // The service updates every registered mock after each command; once the item ends the app, + // that update fails the case, so drop the mocks first. + await browser.tauri.restoreAllMocks(); + // A freshly launched app publishes its menu bar to accessibility a moment after it serves. + for (let attempt = 1; ; attempt += 1) { + try { + clickDesktopUiMenuItem(itemPrefix); + return; + } catch (error) { + if (attempt >= 20) throw error; + await new Promise((resolve) => setTimeout(resolve, 500)); + } + } +} + +function clickDesktopUiMenuItem(itemPrefix: string): void { + runSystemEvents( + [ + 'tell application "System Events"', + ` tell (first application process whose bundle identifier is "${DESKTOP_UI_BUNDLE_IDENTIFIER}")`, + // The menu bar belongs to the active app; a relaunched copy starts in the background. + " set frontmost to true", + // macOS shows the first submenu (BiBCode's File menu) as the app menu, under the app's + // name, so the bar item is found by the item it holds rather than by its title. + " repeat with barItem in menu bar items of menu bar 1", + ` if exists (first menu item of menu 1 of barItem whose name starts with "${itemPrefix}") then`, + ` click (first menu item of menu 1 of barItem whose name starts with "${itemPrefix}")`, + " return", + " end if", + " end repeat", + ` error "No menu item starts with ${itemPrefix}."`, + " end tell", + "end tell", + ].join("\n"), + ); +} + +/** Asks the main window to close through its native Close Window item, with the backend up. */ +export function requestDesktopUiWindowClose(): Promise { + return chooseDesktopUiMenuItem("Close Window"); +} + +export function chooseDesktopUiQuit(): Promise { + return chooseDesktopUiMenuItem("Quit"); +} + +export function desktopUiPlatform(environment: NodeJS.ProcessEnv = process.env): DesktopUiPlatform { + const configured = environment.BIBCODE_E2E_PLATFORM; + if (configured === "mac" || configured === "linux" || configured === "win") return configured; + // oxlint-disable-next-line bibcode/no-global-process-runtime -- The worker follows WDIO's host fallback when no platform was supplied. + const host = process.platform; + return host === "darwin" ? "mac" : host === "win32" ? "win" : "linux"; +} + +let relaunched: NodeChildProcess.ChildProcess | null = null; +let relaunchCount = 0; + +/** + * Starts the packaged app again after a closing case ended it, with the launcher's environment + * and port, and opens a new WebDriver session on it. The WDIO service only restarts the app + * between spec files, so each closing case of one spec gets its own app session here. + */ +export async function relaunchDesktopUiApplication(): Promise { + const port = webDriverPort(); + if (await isDesktopUiApplicationRunning(port)) { + throw new Error("The packaged app is still running; it cannot be relaunched."); + } + const appBinaryPath = resolveDesktopAppPath({ + platform: desktopUiPlatform(), + environment: process.env, + }); + // The single-instance plugin hands a new launch to a copy that is still exiting, which then + // ends both, so wait until no process runs this packaged binary. + const started = Date.now(); + while (runningBinaryPids(appBinaryPath).length > 0) { + if (Date.now() - started > 30_000) { + throw new Error("The previous packaged app was still exiting after 30 s."); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + const artifactDirectory = process.env.BIBCODE_E2E_ARTIFACT_DIR; + if (!artifactDirectory) throw new Error("BIBCODE_E2E_ARTIFACT_DIR is required."); + relaunchCount += 1; + // A file, not a pipe: an unread pipe would stall the app's debug logging. + const log = NodeFS.openSync( + NodePath.join(artifactDirectory, `relaunched-app-${relaunchCount}.log`), + "a", + ); + try { + relaunched = NodeChildProcess.spawn(appBinaryPath, [], { + env: { ...process.env, TAURI_WEBDRIVER_PORT: String(port), WDIO_EMBEDDED_SERVER: "true" }, + stdio: ["ignore", log, log], + detached: false, + }); + } finally { + NodeFS.closeSync(log); + } + const launched = Date.now(); + while (!(await isDesktopUiApplicationRunning(port))) { + if (relaunched.exitCode !== null) { + throw new Error(`The relaunched packaged app exited with ${relaunched.exitCode}.`); + } + if (Date.now() - launched > 90_000) { + throw new Error("The relaunched packaged app never served WebDriver."); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } + try { + await browser.reloadSession(); + } catch (error) { + // The service copies the old session's mocks onto the new one after it opens; the new app + // never had them, and each spec installs the mocks it needs again. + if (!(error instanceof Error) || !error.message.startsWith("Mock updates failed")) throw error; + } + // Drop the old app's mocks, or every later mock call retries them against the new app. + await browser.tauri.restoreAllMocks(); + await browser.waitUntil( + () => + browser.execute(() => { + const host = window as Window & { readonly wdioTauri?: { readonly execute?: unknown } }; + return typeof host.wdioTauri?.execute === "function"; + }), + { timeoutMsg: "The relaunched app's Tauri WebDriver plugin did not initialize." }, + ); +} + +/** Ends an app this helper relaunched, if it still runs: only a process this helper started. */ +export async function stopRelaunchedDesktopUiApplication(): Promise { + const child = relaunched; + relaunched = null; + if (child?.pid === undefined || child.exitCode !== null) return; + // The service ends the app it started the same way: teardown already stopped the backend, and + // the webview may not close its own window. + child.kill("SIGTERM"); + await waitForDesktopUiApplicationExit(child.pid, 10_000).catch(() => child.kill("SIGKILL")); +} + +/** + * Driver errors that mean the webview or its session is gone, not that a script misbehaved. + * WebKitGTK answers every script with "Unsupported result type" once its web process has died; + * the probe's `() => true` cannot produce that from a live page. + */ +export function isDeadDesktopUiWebviewError(error: unknown): boolean { + return ( + error instanceof Error && + /invalid session id|no such (window|session)|session (deleted|not created)|window (was )?already closed|web ?view (not found|crashed|terminated)|unsupported result type|ECONNREFUSED|ECONNRESET|socket hang up/i.test( + error.message, + ) + ); +} + +export interface DeadWebviewRecovery { + readonly probe: () => Promise; + readonly isRunning: () => Promise; + readonly stopApplication: () => Promise; + readonly relaunch: () => Promise; +} + +/** + * One trivial script before a spec file. When it fails with a dead-session error, the app is + * relaunched once (after ending a process that survived its webview), so a webview crash fails + * only the spec that hit it. Returns whether it relaunched. + */ +export async function recoverDeadDesktopUiWebview(deps: DeadWebviewRecovery): Promise { + try { + await deps.probe(); + return false; + } catch (error) { + if (!isDeadDesktopUiWebviewError(error)) throw error; + } + if (await deps.isRunning()) await deps.stopApplication(); + await deps.relaunch(); + return true; +} + +async function stopRunningDesktopUiApplication(): Promise { + const pid = desktopUiApplicationPid(); + process.kill(pid, "SIGTERM"); + await waitForDesktopUiApplicationExit(pid, 10_000).catch(() => process.kill(pid, "SIGKILL")); +} + +/** The per-spec-file probe of the shared app; see {@link recoverDeadDesktopUiWebview}. */ +export function ensureLiveDesktopUiWebview(): Promise { + return recoverDeadDesktopUiWebview({ + probe: () => browser.execute(() => true), + isRunning: () => isDesktopUiApplicationRunning(), + stopApplication: stopRunningDesktopUiApplication, + relaunch: relaunchDesktopUiApplication, + }); +} diff --git a/apps/desktop/e2e/support/fixture-terminal.ts b/apps/desktop/e2e/support/fixture-terminal.ts new file mode 100644 index 0000000000..0b9fa28b17 --- /dev/null +++ b/apps/desktop/e2e/support/fixture-terminal.ts @@ -0,0 +1,93 @@ +// @effect-diagnostics nodeBuiltinImport:off - Packaged UI tests read the fixture TUI's size log. +// @effect-diagnostics globalDate:off - The size log is polled against a wall-clock deadline. +import * as NodeFS from "node:fs"; +import * as NodePath from "node:path"; + +import { + configureDesktopActivityCodexExecutable, + materializeDesktopActivitySession, +} from "./activity-session.ts"; +import { desktopActivityFixture } from "./activity-events.ts"; +import { refreshDesktopUiDocument } from "./document-navigation.ts"; +import { installDesktopUiMotionGuard } from "./motion-guard.ts"; +import { ensureMainSidebarOpen } from "./ui-state.ts"; + +export interface FixtureTuiSize { + readonly cols: number; + readonly rows: number; + readonly bytes: number; +} + +/** The fixture TUI appends every PTY size it is given inside `.git`, out of the worktree. */ +export function fixtureTuiLogPath(projectPath: string): string { + return NodePath.join(projectPath, ".git", "bibcode-fixture-tui-sizes.log"); +} + +export function readFixtureTuiSize(logPath: string): FixtureTuiSize | null { + if (!NodeFS.existsSync(logPath)) return null; + const log = NodeFS.readFileSync(logPath); + const last = log.toString("utf8").trim().split("\n").at(-1); + const match = last ? /^(\d+)x(\d+)$/.exec(last) : null; + return match ? { cols: Number(match[1]), rows: Number(match[2]), bytes: log.length } : null; +} + +/** The TUI redraws on every SIGWINCH; a size that holds for 400 ms is final. */ +export async function settleFixtureTui(logPath: string): Promise { + const deadline = Date.now() + 5_000; + let previous = readFixtureTuiSize(logPath)?.bytes ?? 0; + while (Date.now() < deadline) { + await browser.pause(400); + const current = readFixtureTuiSize(logPath)?.bytes ?? 0; + if (current === previous) return; + previous = current; + } +} + +/** Opens the seeded fixture thread whose Codex executable is the fixture TUI. */ +export async function openFixtureTerminalThread( + projectPath: string, + codexExecutable: string, + options: { readonly webglEnabled?: boolean } = {}, +): Promise { + await ensureMainSidebarOpen(); + await materializeDesktopActivitySession(projectPath); + await configureDesktopActivityCodexExecutable( + codexExecutable, + options.webglEnabled === false ? { webglEnabled: false } : {}, + ); + await refreshDesktopUiDocument(); + await installDesktopUiMotionGuard(); + const threadRow = `//*[starts-with(@data-testid, "thread-row-") and .//*[normalize-space()="${desktopActivityFixture.thread.title}"]]`; + if (!(await browser.$(threadRow).isDisplayed())) { + const project = browser.$( + `//button[@data-sidebar="menu-button" and .//*[normalize-space()="${desktopActivityFixture.project.title}"]]`, + ); + await project.waitForDisplayed({ timeoutMsg: "The fixture project did not appear." }); + if ((await project.getAttribute("aria-expanded")) !== "true") await project.click(); + } + for (const candidate of await browser.$$(threadRow)) { + if (await candidate.isDisplayed()) { + await candidate.click(); + break; + } + } + await browser.$('[data-testid="composer-editor"]').waitForDisplayed({ + timeoutMsg: "The fixture thread did not open.", + }); +} + +/** Opens a Codex Terminal in a new center panel and returns its terminal id. */ +export async function openFixtureCodexTerminal(): Promise { + const newPanel = browser.$('button[aria-label="New panel"]'); + await newPanel.waitForDisplayed(); + await newPanel.click(); + const item = browser.$('//*[@role="menuitem"][.//span[normalize-space()="Codex Terminal"]]'); + await item.waitForDisplayed(); + await item.waitForEnabled(); + await item.click(); + const mount = browser.$("[data-terminal-xterm-mount]"); + await mount.waitForExist({ timeoutMsg: "The Codex Terminal did not mount." }); + const terminalId = await mount.getAttribute("data-terminal-xterm-mount"); + if (!terminalId) throw new Error("The Codex Terminal mount has no terminal id."); + return terminalId; +} diff --git a/apps/desktop/e2e/support/provider-shims.test.ts b/apps/desktop/e2e/support/provider-shims.test.ts index e05ec6a45a..b0f9ed014a 100644 --- a/apps/desktop/e2e/support/provider-shims.test.ts +++ b/apps/desktop/e2e/support/provider-shims.test.ts @@ -648,6 +648,44 @@ describe("generated provider shims", () => { ]); }); + it("answers a code review prompt with an ExitPlanMode plan (#293)", () => { + const fixture = prepareProviderFixture(); + const prompt = "Code review requested from BiBCode. Do not edit files. Scope: x.\n\nReview."; + const responses = exchangeJsonLines( + fixture, + "claudeAgent", + ["--print", "--input-format", "stream-json", "--output-format", "stream-json"], + [ + { + type: "user", + session_id: "claude-fixture", + message: { content: [{ type: "text", text: prompt }] }, + }, + ], + ); + expect(responses).toContainEqual( + expect.objectContaining({ + type: "assistant", + message: expect.objectContaining({ + content: [ + expect.objectContaining({ + type: "tool_use", + name: "ExitPlanMode", + input: { plan: "## Review findings\n\n- Low: example finding" }, + }), + ], + }), + }), + ); + expect(responses).not.toContainEqual(expect.objectContaining({ type: "stream_event" })); + expect(responses).toContainEqual( + expect.objectContaining({ type: "result", subtype: "success", is_error: false }), + ); + expect(readProviderInputLog(fixture.context.providerInputLogPath)).toMatchObject([ + { provider: "claudeAgent", prompt }, + ]); + }); + it("streams a Claude thinking block, a tool call and an answer for a reasoning prompt", () => { const fixture = prepareProviderFixture(); const responses = exchangeJsonLines( diff --git a/apps/desktop/e2e/support/tauri-service-compat.test.ts b/apps/desktop/e2e/support/tauri-service-compat.test.ts index 0210554bf7..8957f2b77d 100644 --- a/apps/desktop/e2e/support/tauri-service-compat.test.ts +++ b/apps/desktop/e2e/support/tauri-service-compat.test.ts @@ -62,7 +62,9 @@ describe("@wdio/tauri-service compatibility", () => { const wdioConfig = NodeFS.readFileSync(new URL("../wdio.conf.ts", import.meta.url), "utf8"); expect(wdioConfig).toContain("const desktopUiSpecFiles ="); - expect(wdioConfig).toContain("[desktopUiSpecFiles]"); + expect(wdioConfig).toContain( + "[desktopUiSpecFiles.filter((spec) => spec !== closingSpec), closingSpec]", + ); expect(wdioConfig).toContain("connectionRetryCount: 0"); expect(wdioConfig).toContain("commandTimeout: 60_000"); expect(wdioConfig).toContain("beforeTest: async () =>"); diff --git a/apps/desktop/e2e/support/test-project.ts b/apps/desktop/e2e/support/test-project.ts index 3b93d57669..b79a6ae805 100644 --- a/apps/desktop/e2e/support/test-project.ts +++ b/apps/desktop/e2e/support/test-project.ts @@ -35,6 +35,13 @@ const LONG_REPLY_TURN_MARKER = "[[long-reply]]"; const LONG_REPLY_LINES = 1_000; /** Matches the line the Claude shim writes. */ const longReplyLine = (line: number) => `Fixture long reply line ${line} fills the timeline.`; +/** + * A Claude prompt that starts with the code review lead-in (#293) is answered, as Claude in plan + * mode answers, with an `ExitPlanMode` call whose plan holds the findings. + */ +const CODE_REVIEW_LEAD_IN = "Code review requested from BiBCode."; +const CODE_REVIEW_FINDING = "Low: example finding"; +const CODE_REVIEW_PLAN = `## Review findings\n\n- ${CODE_REVIEW_FINDING}`; /** An answer line names its prompt without the marker, so each turn's answer is its own. */ const reasoningAnswerLine = (variant: string, line: number, label: string) => `Fixture ${variant} answer line ${line} (${label}).`; @@ -358,9 +365,71 @@ if (process.argv.includes("app-server") && listenArgumentIndex >= 0) { // Each size is also appended inside .git, out of the worktree's status, where // the packaged scenario reads it: a WebGL terminal has no text in the DOM. if (process.stdout.isTTY) { + // BIBCODE_E2E_FOOTER_LAYOUT=floating draws a short frame in the normal buffer and redraws it + // in place, as Claude's CLI does, instead of pinning a footer to the last row. + const floatingFrame = process.env.BIBCODE_E2E_FOOTER_LAYOUT === "floating"; + let floatingLines = 0; + // BIBCODE_E2E_FOOTER_LAYOUT=cli behaves like an Ink CLI such as Claude's: a long transcript in + // the normal buffer, a six-row frame on the bottom rows erased line by line (never a full clear) + // and rewritten every 80-120 ms, transcript lines streamed above it, lines cut to the width. + const cliFrame = process.env.BIBCODE_E2E_FOOTER_LAYOUT === "cli"; + let cliRows = 0; + let cliTick = 0; + let cliLine = 0; + const drawCliFrame = (transcript) => { + const rows = process.stdout.rows; + const cols = process.stdout.columns; + const size = cols + "x" + rows; + const frame = [ + "|/-\\"[cliTick % 4] + " fixture working " + cliTick, + "> " + "draft".padEnd(Math.max(0, Math.min(cols - 3, 40)), "."), + "", + "fixture-footer-a " + size, + "fixture-footer-b", + "fixture-footer-z " + rows, + ].map((text) => text.slice(0, Math.max(1, cols - 1))); + let erase = ""; + for (let index = 0; index < cliRows; index += 1) erase += "\x1b[2K" + (index < cliRows - 1 ? "\x1b[1A" : ""); + process.stdout.write(erase + "\r" + transcript.map((text) => text + "\r\n").join("") + frame.join("\r\n")); + cliRows = frame.length; + }; + if (cliFrame) { + let intro = ""; + for (; cliLine < 200; cliLine += 1) intro += "transcript " + cliLine + " " + "lorem ipsum ".repeat(4) + "\r\n"; + process.stdout.write(intro); + const redraw = () => { + cliTick += 1; + drawCliFrame(Math.random() < 0.3 ? ["transcript " + cliLine++ + " streamed while the frame redraws"] : []); + setTimeout(redraw, 80 + Math.floor(Math.random() * 41)); + }; + setTimeout(redraw, 100); + } const drawTerminalSize = () => { - const size = process.stdout.columns + "x" + process.stdout.rows; - process.stdout.write("\x1b[2J\x1b[Hfixture-tui " + size + "\r\n"); + const rows = process.stdout.rows; + const size = process.stdout.columns + "x" + rows; + if (cliFrame) { + drawCliFrame([]); + try { + fs.appendFileSync(fixtureProjectPath + "/.git/bibcode-fixture-tui-sizes.log", size + "\n"); + } catch {} + return; + } + if (floatingFrame) { + const frame = ["fixture-tui " + size, "fixture-footer-a " + size, "fixture-footer-b", "fixture-footer-z " + rows]; + process.stdout.write((floatingLines > 0 ? "\x1b[" + floatingLines + "A\r\x1b[J" : "") + frame.join("\r\n") + "\r\n"); + floatingLines = frame.length; + try { + fs.appendFileSync(fixtureProjectPath + "/.git/bibcode-fixture-tui-sizes.log", size + "\n"); + } catch {} + return; + } + // A footer on the last three rows, as an agent CLI draws one, shows a clipped bottom (#348). + const footer = rows >= 5 + ? "\x1b[" + (rows - 2) + ";1Hfixture-footer-a " + size + + "\x1b[" + (rows - 1) + ";1Hfixture-footer-b" + + "\x1b[" + rows + ";1Hfixture-footer-z " + rows + "\x1b[2;1H" + : ""; + process.stdout.write("\x1b[2J\x1b[Hfixture-tui " + size + "\r\n" + footer); try { fs.appendFileSync(fixtureProjectPath + "/.git/bibcode-fixture-tui-sizes.log", size + "\n"); } catch {} @@ -773,6 +842,33 @@ reader.on("line", (line) => { const prompt = promptTextFromParts(message.message?.content); appendProviderInput("claudeAgent", prompt); send(message); + if (prompt.startsWith(${JSON.stringify(CODE_REVIEW_LEAD_IN)})) { + send({ + type: "assistant", + session_id: sessionId, + uuid: "bibcode-ui-claude-review-plan-" + randomUUID(), + parent_tool_use_id: null, + message: { + id: "bibcode-ui-review-" + randomUUID(), + content: [{ + type: "tool_use", + id: "bibcode-ui-review-exit-plan-" + randomUUID(), + name: "ExitPlanMode", + input: { plan: ${JSON.stringify(CODE_REVIEW_PLAN)} } + }] + } + }); + send({ + type: "result", + subtype: "success", + is_error: false, + errors: [], + stop_reason: "end_turn", + session_id: sessionId, + uuid: "bibcode-ui-claude-review-result" + }); + return; + } const slowReasoning = prompt.includes(${JSON.stringify(SLOW_REASONING_TURN_MARKER)}); if (slowReasoning || prompt.includes(${JSON.stringify(REASONING_TURN_MARKER)})) { void streamReasoningTurn(sessionId, slowReasoning, prompt); @@ -1464,6 +1560,7 @@ export const desktopUiFixture = { reasoningTurnMarker: REASONING_TURN_MARKER, slowReasoningTurnMarker: SLOW_REASONING_TURN_MARKER, longReplyTurnMarker: LONG_REPLY_TURN_MARKER, + codeReviewFinding: CODE_REVIEW_FINDING, longReplyLastLine: longReplyLine(LONG_REPLY_LINES), reasoningThoughtStart: "Fixture thinking step 1 weighs the parser.", /** The last line of the answer to a reasoning prompt whose text, markers aside, is `label`. */ diff --git a/apps/desktop/e2e/wdio.conf.ts b/apps/desktop/e2e/wdio.conf.ts index 5285f45651..e3cd53dd1d 100644 --- a/apps/desktop/e2e/wdio.conf.ts +++ b/apps/desktop/e2e/wdio.conf.ts @@ -8,7 +8,13 @@ import { installDesktopUiMotionGuard } from "./support/motion-guard.ts"; import { refreshDesktopUiDocument } from "./support/document-navigation.ts"; import { resolveDesktopAppPath, type DesktopUiPlatform } from "./support/app-path.ts"; -import { isFinalDesktopUiSpec, requestDesktopUiApplicationExit } from "./support/app-lifecycle.ts"; +import { + ensureLiveDesktopUiWebview, + isDesktopUiApplicationRunning, + isFinalDesktopUiSpec, + requestDesktopUiApplicationExit, + stopRelaunchedDesktopUiApplication, +} from "./support/app-lifecycle.ts"; import { deferDesktopUiTestContextCleanupUntilExit, prepareDesktopUiTestContext, @@ -28,11 +34,18 @@ const platform: DesktopUiPlatform = ? "win" : "linux"; const testContext = prepareDesktopUiTestContext(); +// End-to-end builds write an exported file here instead of opening the Save dialog. The app reads +// it at launch, so it is set before the launcher starts the app. +process.env.BIBCODE_E2E_SAVE_TEXT_PATH ??= NodePath.join( + testContext.artifactDirectory, + "saved-text-file.txt", +); const appBinaryPath = resolveDesktopAppPath({ platform, environment: process.env, }); const requestedSpec = process.env.BIBCODE_E2E_SPEC?.trim(); +const closingSpec = "./specs/desktop-save-and-close.e2e.ts"; const desktopUiSpecFiles = requestedSpec && requestedSpec.length > 0 ? [requestedSpec] @@ -51,8 +64,17 @@ const desktopUiSpecFiles = "./specs/pierre-diffs.e2e.ts", "./specs/mermaid-diagrams.e2e.ts", "./specs/files-binary-preview.e2e.ts", + "./specs/files-rich-markdown.e2e.ts", + "./specs/appearance-themes.e2e.ts", + "./specs/source-control-review.e2e.ts", + "./specs/host-dashboard.e2e.ts", + closingSpec, ]; -const desktopUiSpecs = requestedSpec ? desktopUiSpecFiles : [desktopUiSpecFiles]; +// The default specs share one app session. The closing spec ends the app, so it runs last in a +// worker of its own, and the service starts the app again for it if a shared spec ended it. +const desktopUiSpecs = requestedSpec + ? desktopUiSpecFiles + : [desktopUiSpecFiles.filter((spec) => spec !== closingSpec), closingSpec]; if (!NodeFS.existsSync(appBinaryPath)) { throw new Error(`Packaged BiBCode application does not exist: ${appBinaryPath}`); @@ -150,6 +172,8 @@ export const config = { timeout: 120_000, }, before: async () => { + // A webview that crashed in an earlier spec would fail this one with the same dead session. + await ensureLiveDesktopUiWebview(); const pluginReady = await browser.execute(() => { const host = window as Window & { readonly wdioTauri?: { @@ -183,7 +207,8 @@ export const config = { _context: unknown, result: { readonly passed: boolean }, ) => { - if (!result.passed) { + // A closing case may have ended the app; there is no window left to record. + if (!result.passed && (await isDesktopUiApplicationRunning())) { await browser.saveScreenshot(screenshotPath(`failure-${test.title}`)); NodeFS.writeFileSync( screenshotPath(`failure-source-${test.title}`).replace(/\.png$/, ".html"), @@ -196,6 +221,8 @@ export const config = { // Ask Tauri to run its normal ExitRequested path so the in-process backend and provider // children release Windows filesystem handles before the launcher service stops the driver. await requestDesktopUiApplicationExit(); + // The service stops only the app it started; an app a closing case relaunched is stopped here. + await stopRelaunchedDesktopUiApplication(); }, onComplete: () => { // Keep the app's own logs (native page loads, webview process failures, server errors) diff --git a/apps/desktop/src-tauri/permissions/desktop-bridge.toml b/apps/desktop/src-tauri/permissions/desktop-bridge.toml index 829b41683f..d63d61aeae 100644 --- a/apps/desktop/src-tauri/permissions/desktop-bridge.toml +++ b/apps/desktop/src-tauri/permissions/desktop-bridge.toml @@ -45,6 +45,8 @@ commands.allow = [ "desktop_bridge_install_update", "desktop_bridge_pick_folder", "desktop_bridge_save_diagnostic_logs", + "desktop_bridge_save_text_file", + "desktop_bridge_reply_close_request", "desktop_bridge_pick_files", "desktop_bridge_download_to_folder", "desktop_bridge_upload_file", diff --git a/apps/desktop/src-tauri/src/bridge.rs b/apps/desktop/src-tauri/src/bridge.rs index f2cda752f7..283ce6de4e 100644 --- a/apps/desktop/src-tauri/src/bridge.rs +++ b/apps/desktop/src-tauri/src/bridge.rs @@ -48,6 +48,7 @@ use crate::tailscale::{ read_tailscale_status, }; use crate::updates::{DesktopUpdateInstallInput, DesktopUpdateManager}; +use crate::window::{CloseGuard, CloseReply}; #[cfg(test)] pub(crate) type DesktopRuntime = tauri::test::MockRuntime; @@ -1654,6 +1655,92 @@ pub async fn desktop_bridge_save_diagnostic_logs( Ok(Some(path.to_string_lossy().into_owned())) } +/// The largest text `desktop_bridge_save_text_file` writes. +const MAX_SAVED_TEXT_BYTES: usize = 8 * 1024 * 1024; + +/// Checks the web's suggested name and contents, and returns the name's extension for the +/// dialog filter. The name cannot carry a directory, so only the dialog picks where it goes. +fn validate_saved_text(suggested_name: &str, contents: &str) -> Result { + let plain = !suggested_name.is_empty() + && suggested_name.len() <= 255 + && !suggested_name.contains(['/', '\\']) + && !suggested_name.chars().any(char::is_control); + let extension = Path::new(suggested_name) + .extension() + .and_then(|extension| extension.to_str()) + .filter(|extension| !extension.is_empty()); + let Some(extension) = extension.filter(|_| plain) else { + return Err("The file name must be a plain name with an extension.".to_owned()); + }; + if contents.len() > MAX_SAVED_TEXT_BYTES { + return Err("The file is larger than the desktop save limit.".to_owned()); + } + Ok(extension.to_owned()) +} + +/// Writes `contents` where `choose` says. `None` from `choose` means the person cancelled. +async fn save_text_to_chosen_path( + choose: impl std::future::Future, String>>, + contents: &str, +) -> Result, String> { + let Some(path) = choose.await? else { + return Ok(None); + }; + fs::write(&path, contents).map_err(|error| { + tracing::warn!(%error, "could not save a text file the person chose"); + bridge_error("Could not save the file", error) + })?; + Ok(Some(path.to_string_lossy().into_owned())) +} + +#[tauri::command] +pub async fn desktop_bridge_save_text_file( + app: AppHandle, + suggested_name: String, + contents: String, +) -> Result, String> { + let extension = validate_saved_text(&suggested_name, &contents)?; + // End-to-end builds only: replaces the native picker, which WebDriver cannot drive. A release + // build compiles without the feature, so no variable can redirect a write there. + #[cfg(feature = "desktop-e2e")] + if let Some(path) = std::env::var_os("BIBCODE_E2E_SAVE_TEXT_PATH") { + return save_text_to_chosen_path(async { Ok(Some(PathBuf::from(path))) }, &contents).await; + } + let mut dialog = app + .dialog() + .file() + .set_title("Save file") + .set_file_name(&suggested_name) + .add_filter(extension.to_uppercase(), &[extension.as_str()]); + if let Ok(download_dir) = app.path().download_dir() { + dialog = dialog.set_directory(download_dir); + } + save_text_to_chosen_path( + async move { + let Some(selected) = await_dialog(|answer| dialog.save_file(answer)).await? else { + return Ok(None); + }; + selected + .simplified() + .into_path() + .map(Some) + .map_err(|error| bridge_error("Could not normalize the chosen path", error)) + }, + &contents, + ) + .await +} + +/// The web's answer to a close or quit request (R62). +#[tauri::command] +pub fn desktop_bridge_reply_close_request( + guard: State<'_, CloseGuard>, + request_id: u64, + reply: CloseReply, +) { + guard.reply(request_id, reply, std::time::Instant::now()); +} + fn validate_transfer_url(url: &str) -> Result { let parsed = reqwest::Url::parse(url).map_err(|error| bridge_error("Transfer URL is invalid", error))?; @@ -2213,6 +2300,61 @@ mod tests { use std::sync::{Arc, Barrier, Mutex, mpsc}; use std::time::{SystemTime, UNIX_EPOCH}; + #[tokio::test] + async fn save_text_file_writes_the_contents_to_the_chosen_path() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("mine.theme.json"); + let chosen = path.clone(); + let saved = save_text_to_chosen_path(async move { Ok(Some(chosen)) }, "{\"id\":\"mine\"}") + .await + .unwrap(); + assert_eq!(saved, Some(path.to_string_lossy().into_owned())); + assert_eq!(fs::read_to_string(&path).unwrap(), "{\"id\":\"mine\"}"); + } + + #[tokio::test] + async fn save_text_file_returns_none_when_cancelled() { + let temp = tempfile::tempdir().unwrap(); + let saved = save_text_to_chosen_path(async { Ok(None) }, "contents") + .await + .unwrap(); + assert_eq!(saved, None); + assert_eq!(fs::read_dir(temp.path()).unwrap().count(), 0); + } + + #[tokio::test] + async fn save_text_file_reports_a_failed_write() { + let temp = tempfile::tempdir().unwrap(); + // A directory cannot be written as a file. + let chosen = temp.path().to_path_buf(); + let error = save_text_to_chosen_path(async move { Ok(Some(chosen)) }, "contents") + .await + .unwrap_err(); + assert!(error.starts_with("Could not save the file: "), "{error}"); + } + + #[test] + fn save_text_file_accepts_only_plain_names_with_an_extension() { + assert_eq!( + validate_saved_text("mine.theme.json", "{}").unwrap(), + "json" + ); + assert_eq!(validate_saved_text("plan.md", "# Plan").unwrap(), "md"); + for name in [ + "", + "../plan.md", + "dir/plan.md", + "dir\\plan.md", + "plan", + "plan.", + "a\u{7}.md", + ] { + assert!(validate_saved_text(name, "x").is_err(), "{name:?}"); + } + assert!(validate_saved_text(&format!("{}.md", "a".repeat(253)), "x").is_err()); + assert!(validate_saved_text("big.md", &"x".repeat(MAX_SAVED_TEXT_BYTES + 1)).is_err()); + } + #[derive(Default)] struct FakeWslOnlyTransitionState { calls: Vec, @@ -4322,6 +4464,8 @@ mod tests { desktop_bridge_install_update, desktop_bridge_pick_folder, desktop_bridge_save_diagnostic_logs, + desktop_bridge_save_text_file, + desktop_bridge_reply_close_request, desktop_bridge_pick_files, desktop_bridge_download_to_folder, desktop_bridge_upload_file, @@ -4622,6 +4766,13 @@ mod tests { ) .is_err() ); + assert!( + invoke( + "desktop_bridge_save_text_file", + json!({"suggestedName":"../blocked.md","contents":"# Plan"}), + ) + .is_err() + ); for (command, arguments) in [ ( "desktop_bridge_download_to_folder", diff --git a/apps/desktop/src-tauri/src/gstreamer_environment.rs b/apps/desktop/src-tauri/src/gstreamer_environment.rs new file mode 100644 index 0000000000..d8ab663f3a --- /dev/null +++ b/apps/desktop/src-tauri/src/gstreamer_environment.rs @@ -0,0 +1,152 @@ +//! The AppImage launcher (AppImageKit's `AppRun`, shipped as `AppRun.wrapped`) sets +//! `GST_PLUGIN_SYSTEM_PATH_1_0` to `$APPDIR/usr/lib/gstreamer-1.0:` and `GST_PLUGIN_SYSTEM_PATH` +//! to `$APPDIR/usr/lib/gstreamer:`, each followed by the launch's `QT_PLUGIN_PATH` (an upstream +//! slip: it reuses that variable's old value), and drops any GStreamer value the user had. +//! Release AppImages ship their GStreamer plugins in `usr/lib/gstreamer-1.0`, so the first +//! value is kept and the second, whose folder never exists, is removed. This repair is the +//! safety net for a build made without plugins: there GStreamer would scan none, find no +//! `autoaudiosink`, and WebKitGTK's web process would die on the first audio element. +//! Removing such a value lets GStreamer use the system's default plugin path; the web +//! processes inherit the result. + +use std::ffi::OsStr; +use std::path::Path; + +/// The plugin path variables GStreamer 1.x reads, each with the folder the launcher puts first. +#[cfg(target_os = "linux")] +const PLUGIN_SYSTEM_PATH_VARIABLES: [(&str, &str); 2] = [ + ("GST_PLUGIN_SYSTEM_PATH_1_0", "usr/lib/gstreamer-1.0"), + ("GST_PLUGIN_SYSTEM_PATH", "usr/lib/gstreamer"), +]; + +/// Whether one plugin path variable should be removed: only a value the launcher wrote (it +/// starts with `launcher_entry`, which needs `APPDIR`) whose folder the bundle lacks. The rest of +/// such a value is a copy of `QT_PLUGIN_PATH`, never a GStreamer setting. Any other value may be +/// the user's own, so it is kept even when its folders are missing. +pub(crate) fn should_remove_plugin_path( + value: Option<&OsStr>, + launcher_entry: Option<&Path>, + exists: impl Fn(&Path) -> bool, +) -> bool { + let (Some(value), Some(launcher_entry)) = (value, launcher_entry) else { + return false; + }; + std::env::split_paths(value) + .find(|entry| !entry.as_os_str().is_empty()) + .is_some_and(|first| first == launcher_entry && !exists(&first)) +} + +/// Repairs both plugin path variables in this process. Call it only while the process is +/// single-threaded: `main` does, right after the crash hook. +#[cfg(target_os = "linux")] +pub fn repair_process_environment() { + let appdir = std::env::var_os("APPDIR").filter(|appdir| !appdir.is_empty()); + for (name, launcher_folder) in PLUGIN_SYSTEM_PATH_VARIABLES { + let launcher_entry = appdir + .as_ref() + .map(|appdir| Path::new(appdir).join(launcher_folder)); + let value = std::env::var_os(name); + if should_remove_plugin_path(value.as_deref(), launcher_entry.as_deref(), Path::is_dir) { + // SAFETY: `main` calls this before Tauri, Tokio, GTK or WebKit start, and on Linux + // the crash hook attached just before installs signal handlers without spawning a + // thread, so no other thread can read or write the environment concurrently. + unsafe { std::env::remove_var(name) }; + } + } +} + +#[cfg(all(test, unix))] +mod tests { + use super::*; + + const LAUNCHER_ENTRY: &str = "/tmp/.mount_bibcodX/usr/lib/gstreamer-1.0"; + + fn decide(value: Option<&str>, launched: bool, existing: &[&str]) -> bool { + should_remove_plugin_path( + value.map(OsStr::new), + launched.then(|| Path::new(LAUNCHER_ENTRY)), + |path| existing.iter().any(|existing| Path::new(existing) == path), + ) + } + + #[test] + fn removes_the_launchers_value_when_its_folder_is_missing() { + // The real launcher value, with an empty and with a set QT_PLUGIN_PATH after the colon. + assert!(decide(Some(&format!("{LAUNCHER_ENTRY}:")), true, &[])); + assert!(decide( + Some(&format!("{LAUNCHER_ENTRY}:/usr/lib/qt5/plugins")), + true, + &["/usr/lib/qt5/plugins"] + )); + } + + #[test] + fn keeps_the_launchers_value_when_the_bundle_has_the_folder() { + assert!(!decide( + Some(&format!("{LAUNCHER_ENTRY}:")), + true, + &[LAUNCHER_ENTRY] + )); + // A QT_PLUGIN_PATH suffix that names a missing folder does not matter. + assert!(!decide( + Some(&format!("{LAUNCHER_ENTRY}:/missing/qt5/plugins")), + true, + &[LAUNCHER_ENTRY] + )); + } + + #[test] + fn keeps_a_value_the_launcher_did_not_write_even_when_its_folders_are_missing() { + // Without APPDIR nothing identifies a launcher value: the user may have set it on purpose. + assert!(!decide(Some("/missing/one:/missing/two:"), false, &[])); + assert!(!decide(Some(&format!("{LAUNCHER_ENTRY}:")), false, &[])); + // With APPDIR, a value that does not start with the launcher's folder is not its value. + assert!(!decide(Some("/missing/one"), true, &[])); + assert!(!decide( + Some(&format!("/missing/one:{LAUNCHER_ENTRY}")), + true, + &[] + )); + } + + #[test] + fn keeps_a_value_with_one_existing_directory_outside_the_launcher() { + assert!(!decide( + Some("/missing/one:/opt/host/gstreamer-1.0"), + false, + &["/opt/host/gstreamer-1.0"] + )); + assert!(!decide( + Some("/missing/one:/opt/host/gstreamer-1.0"), + true, + &["/opt/host/gstreamer-1.0"] + )); + } + + #[test] + fn leaves_unset_empty_and_entry_less_values_untouched() { + for launched in [false, true] { + assert!(!decide(None, launched, &[])); + assert!(!decide(Some(""), launched, &[])); + assert!(!decide(Some(":"), launched, &[])); + } + } + + #[test] + fn reads_raw_byte_entries() { + use std::os::unix::ffi::OsStrExt; + let launcher = Path::new(OsStr::from_bytes(b"/tmp/.mount_\xff/usr/lib/gstreamer-1.0")); + let value = OsStr::from_bytes(b"/tmp/.mount_\xff/usr/lib/gstreamer-1.0:/opt/\xff"); + assert!(should_remove_plugin_path( + Some(value), + Some(launcher), + |_| false + )); + assert!(!should_remove_plugin_path( + Some(value), + Some(launcher), + |path| { path == launcher } + )); + assert!(!should_remove_plugin_path(Some(value), None, |_| false)); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index 213cc1fe13..2448fd6298 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -50,6 +50,8 @@ macro_rules! desktop_bridge_commands { desktop_bridge_install_update, desktop_bridge_pick_folder, desktop_bridge_save_diagnostic_logs, + desktop_bridge_save_text_file, + desktop_bridge_reply_close_request, desktop_bridge_pick_files, desktop_bridge_download_to_folder, desktop_bridge_upload_file, @@ -139,6 +141,8 @@ pub fn run() { restart_handoff.release(); // Built here (not from config) so it can route new-window requests; // first, so everything below still runs after the main window exists. + // The close guard comes before it: the window's close handler asks through it (R62). + app.manage(window::close_guard_for_app(app.handle())); window::build_main_window(app.handle())?; // Remote environments' preview tunnels (#69): each request goes to the renderer. let tunnel_requests = app.handle().clone(); @@ -251,6 +255,8 @@ pub fn run() { bridge::desktop_bridge_install_update, bridge::desktop_bridge_pick_folder, bridge::desktop_bridge_save_diagnostic_logs, + bridge::desktop_bridge_save_text_file, + bridge::desktop_bridge_reply_close_request, bridge::desktop_bridge_pick_files, bridge::desktop_bridge_download_to_folder, bridge::desktop_bridge_upload_file, @@ -353,6 +359,8 @@ async fn prepare_desktop_runtime_for_exit( async fn desktop_e2e_prepare_for_exit( app_handle: tauri::AppHandle, ) -> Result<(), String> { + // The teardown closes the window next; an edit a spec left unsaved must not hold it (R62). + app_handle.state::().disarm(); prepare_desktop_runtime_for_exit(&app_handle).await } @@ -391,6 +399,10 @@ mod desktop_e2e_page_load; #[cfg(target_os = "linux")] mod display_loss; mod firewall; +#[cfg(any(target_os = "linux", all(test, unix)))] +mod gstreamer_environment; +#[cfg(target_os = "linux")] +pub use gstreamer_environment::repair_process_environment as repair_gstreamer_environment; #[cfg(target_os = "linux")] mod linux_text_rendering; #[cfg(target_os = "linux")] diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs index e71a64030b..b8ad837321 100644 --- a/apps/desktop/src-tauri/src/main.rs +++ b/apps/desktop/src-tauri/src/main.rs @@ -8,6 +8,9 @@ fn main() { } // Once, before WebKit installs its signal handlers, so they chain real crashes down to it. bibcode_server::diagnostics::profiler::crash::attach(); + // Still single-threaded: before any thread, Tauri or WebKit, whose web processes inherit it. + #[cfg(target_os = "linux")] + bibcode_desktop_lib::repair_gstreamer_environment(); // The open-url shim, or the Windows `bibcode-open-url.exe` alias: no Tauri, no window, // and the single-instance plugin never sees it. if let Some(url) = bibcode_server::open_url::current_open_url_invocation() { diff --git a/apps/desktop/src-tauri/src/remote_update_delegate.rs b/apps/desktop/src-tauri/src/remote_update_delegate.rs index 1f125c6694..3f1f3659f1 100644 --- a/apps/desktop/src-tauri/src/remote_update_delegate.rs +++ b/apps/desktop/src-tauri/src/remote_update_delegate.rs @@ -10,7 +10,7 @@ use bibcode_server::remote_update::{ RemoteUpdateSupportReason, }; use serde_json::Value; -use tauri::{AppHandle, Manager, Runtime}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; use crate::backend::BackendSupervisor; use crate::updates::{DesktopUpdateInstallInput, DesktopUpdateManager, RemoteUpdateRequest}; @@ -194,14 +194,57 @@ async fn run_remote_install(app: AppHandle, requester: RemoteUpda return; } } + if !window_allows_install(&app).await { + tracing::warn!( + requester_label = requester.label.as_deref().unwrap_or("Another device"), + requester_session = requester.session_id_prefix.as_deref().unwrap_or(""), + "remote update install stopped because this window kept unsaved Files edits" + ); + return; + } + install_after_allow(&app, &requester).await; +} + +/// Tells the window when a remote install it allowed ends without restarting the app. +pub const UPDATE_INSTALL_ENDED_EVENT: &str = "desktop:update-install-ended"; + +/// Armed once the window allowed the install, which then keeps Files editing blocked. Dropped +/// on any return before `completed`, it lets the window unblock (R62). +struct AllowedInstall { + app: AppHandle, + completed: bool, +} + +impl Drop for AllowedInstall { + fn drop(&mut self) { + if self.completed { + return; + } + if let Err(error) = self.app.emit_to( + crate::window::MAIN_WINDOW_LABEL, + UPDATE_INSTALL_ENDED_EVENT, + (), + ) { + tracing::warn!(%error, "failed to tell the window the update install ended"); + } + } +} + +async fn install_after_allow(app: &AppHandle, requester: &RemoteUpdateRequester) { + let mut allowed = AllowedInstall { + app: app.clone(), + completed: false, + }; + let updates = app.state::(); let backend = app.state::(); // A successful install restarts the host on Linux and macOS, so a result that comes // back without `completed` is a refusal or a failure the host updater has recorded. let installed = updates - .install_update(&app, backend.inner(), DesktopUpdateInstallInput::default()) + .install_update(app, backend.inner(), DesktopUpdateInstallInput::default()) .await; - if installed["completed"].as_bool() != Some(true) { - updates.note_remote_secondary_protection_failure(&app); + allowed.completed = installed["completed"].as_bool() == Some(true); + if !allowed.completed { + updates.note_remote_secondary_protection_failure(app); tracing::warn!( requester_label = requester.label.as_deref().unwrap_or("Another device"), requester_session = requester.session_id_prefix.as_deref().unwrap_or(""), @@ -213,6 +256,19 @@ async fn run_remote_install(app: AppHandle, requester: RemoteUpda } } +/// R62: the install restarts the app, so this window is asked first, the same way as an install +/// started here. Without the guard (no window in this process) nothing can hold an edit. +async fn window_allows_install(app: &AppHandle) -> bool { + let answer = match app.try_state::() { + None => return true, + Some(guard) => guard.ask_before_update(std::time::Instant::now()), + }; + match answer { + Some(answer) => answer.await.unwrap_or(false), + None => false, + } +} + #[cfg(test)] mod tests { use super::*; @@ -318,6 +374,67 @@ mod tests { .expect("mock Tauri app") } + #[tokio::test] + async fn close_request_remote_install_asks_the_window_first() { + let app = host_app("http://127.0.0.1:9/never".to_owned()); + // No guard: nothing in this process can hold an edit. + assert!(window_allows_install(app.handle()).await); + + app.manage(crate::window::close_guard_for_app(app.handle())); + // No main window: the guard allows at once (request 1). + assert!(window_allows_install(app.handle()).await); + + // With a main window the install waits for the web: Stay refuses it, Allow lets it run. + let _window = tauri::WebviewWindowBuilder::new( + &app, + crate::window::MAIN_WINDOW_LABEL, + Default::default(), + ) + .build() + .expect("mock webview"); + let guard = app.state::(); + for (request_id, reply, allowed) in [ + (2, crate::window::CloseReply::Stay, false), + (3, crate::window::CloseReply::Allow, true), + ] { + // `join!` polls the install first, so it has asked before the reply arrives. + let (answer, ()) = tokio::join!(window_allows_install(app.handle()), async { + guard.reply(request_id, reply, std::time::Instant::now()); + }); + assert_eq!(answer, allowed, "{reply:?}"); + } + // Stale ids and a pending close are covered by + // `close_request_on_remote_update_proceeds_only_when_the_web_allows_it` in `window.rs`. + } + + #[tokio::test] + async fn close_request_remote_install_signals_every_end_without_restart_after_allow() { + use tauri::Listener as _; + let app = host_app("http://127.0.0.1:9/never".to_owned()); + let (sender, ended) = std::sync::mpsc::channel(); + app.listen_any(UPDATE_INSTALL_ENDED_EVENT, move |_| { + let _ = sender.send(()); + }); + let requester = RemoteUpdateRequester::default(); + + // Refused, `accepted: false`: nothing was downloaded. + install_after_allow(app.handle(), &requester).await; + assert_eq!(ended.try_recv(), Ok(()), "a refusal without a download"); + + // `completed: true` restarts the app, so the window stays blocked until it goes. + drop(AllowedInstall { + app: app.handle().clone(), + completed: true, + }); + assert!( + ended.try_recv().is_err(), + "no signal once the install completed" + ); + // The other refusals and `accepted: true, completed: false` (a failed install) need a + // backend or a downloaded update this mock cannot provide. They return through the same + // guard, and `updates.rs` tests cover those results. + } + /// Polls `status` the way a remote client does until the background flow settles. async fn settled_status( delegate: &DesktopRemoteUpdateDelegate, diff --git a/apps/desktop/src-tauri/src/window.rs b/apps/desktop/src-tauri/src/window.rs index 424810ce9e..2a582da915 100644 --- a/apps/desktop/src-tauri/src/window.rs +++ b/apps/desktop/src-tauri/src/window.rs @@ -1,6 +1,8 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::path::PathBuf; +use std::sync::{Mutex, MutexGuard, PoisonError}; +use std::time::{Duration, Instant}; use tauri::{ AppHandle, Emitter, Manager, PhysicalPosition, PhysicalSize, Position, Runtime, Size, WebviewWindow, @@ -20,6 +22,7 @@ const MENU_ID_OPEN_SETTINGS: &str = "bibcode:open-settings"; const MENU_ID_CHECK_FOR_UPDATES: &str = "bibcode:check-for-updates"; const MENU_ID_CLOSE_SHORTCUT: &str = "bibcode:close-shortcut"; const MENU_ID_CLOSE_WINDOW: &str = "bibcode:close-window"; +const MENU_ID_QUIT: &str = "bibcode:quit"; const MIN_RESTORED_WINDOW_HEIGHT: u32 = 480; const MIN_RESTORED_WINDOW_WIDTH: u32 = 640; const MAX_RESTORED_WINDOW_HEIGHT: u32 = 10_000; @@ -41,6 +44,7 @@ struct MainWindowState { enum ApplicationMenuAction { Emit(&'static str), CloseWindow, + Quit, } fn menu_action_for_id(id: &str) -> Option { @@ -51,6 +55,7 @@ fn menu_action_for_id(id: &str) -> Option { } MENU_ID_CLOSE_SHORTCUT => Some(ApplicationMenuAction::Emit(MENU_ACTION_CLOSE_SHORTCUT)), MENU_ID_CLOSE_WINDOW => Some(ApplicationMenuAction::CloseWindow), + MENU_ID_QUIT => Some(ApplicationMenuAction::Quit), _ => None, } } @@ -79,6 +84,10 @@ pub fn configure_application_menu(app: &AppHandle) -> tauri::Resu let _ = window.close(); } } + Some(ApplicationMenuAction::Quit) => match app.try_state::() { + Some(guard) => guard.request(LeaveReason::Quit, Instant::now()), + None => app.exit(0), + }, None => {} } }); @@ -95,13 +104,14 @@ fn build_application_menu(app: &AppHandle) -> tauri::Result(app: &AppHandle) -> tauri::Result(app: &AppHandle) -> tauri::Result> { + #[cfg(target_os = "macos")] + let item = MenuItemBuilder::with_id(MENU_ID_QUIT, format!("Quit {}", app.package_info().name)) + .accelerator("CmdOrCtrl+Q"); + #[cfg(windows)] + let item = MenuItemBuilder::with_id(MENU_ID_QUIT, "&Exit"); + #[cfg(not(any(target_os = "macos", windows)))] + let item = MenuItemBuilder::with_id(MENU_ID_QUIT, "&Quit"); + item.build(app) +} + fn window_state_path(app: &AppHandle) -> Result { Ok(state_dir(app)?.join(WINDOW_STATE_FILE_NAME)) } @@ -254,8 +278,16 @@ pub(crate) fn build_main_window(app: &AppHandle) -> tauri::Result let window = builder.build()?; #[cfg(any(target_os = "linux", target_os = "windows"))] watch_web_content_process(&window); - #[cfg(not(any(target_os = "linux", target_os = "windows")))] - let _ = window; + let guard_app = app.clone(); + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event + && let Some(guard) = guard_app.try_state::() + { + // Tauri reads the answer at once, so hold now and destroy the window once allowed. + api.prevent_close(); + guard.request(LeaveReason::Close, Instant::now()); + } + }); Ok(()) } @@ -312,11 +344,571 @@ pub fn persist_main_window_state(app: &AppHandle) -> Result<(), S write_json_file(&path, &main_window_state_to_value(&state)?) } +pub const CLOSE_REQUEST_EVENT: &str = "desktop:close-request"; +/// How long the desktop waits for an acknowledge, and for each keepalive after one. +const CLOSE_ANSWER_TIMEOUT: Duration = Duration::from_secs(5); +const CLOSE_WATCH_INTERVAL: Duration = Duration::from_secs(1); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum LeaveReason { + Close, + Quit, + /// An update install another device requested; it restarts the app once allowed. + Update, +} + +/// How a request ended: `allowed` by the web or the person, or not. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct CloseAnswer { + reason: LeaveReason, + allowed: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub(crate) enum CloseReply { + Acknowledge, + Keepalive, + Allow, + Stay, +} + +#[derive(Debug, Clone, Copy)] +struct PendingClose { + request_id: u64, + reason: LeaveReason, + deadline: Instant, + prompting: bool, +} + +/// One close or quit waiting for the web's answer (R62). +#[derive(Debug, Default)] +struct CloseRequests { + next_id: u64, + pending: Option, +} + +impl CloseRequests { + /// The id to ask the web with, or `None` when a pending request already covers it. + fn begin(&mut self, reason: LeaveReason, now: Instant) -> Option { + if self.pending.is_some() { + return None; + } + self.next_id += 1; + self.pending = Some(PendingClose { + request_id: self.next_id, + reason, + deadline: now + CLOSE_ANSWER_TIMEOUT, + prompting: false, + }); + Some(self.next_id) + } + + /// How the request ended, once the web answered. A stale reply changes nothing. + fn reply(&mut self, request_id: u64, reply: CloseReply, now: Instant) -> Option { + let pending = self + .pending + .as_mut() + .filter(|pending| pending.request_id == request_id)?; + match reply { + CloseReply::Acknowledge | CloseReply::Keepalive => { + pending.deadline = now + CLOSE_ANSWER_TIMEOUT; + None + } + CloseReply::Allow | CloseReply::Stay => { + self.pending.take().map(|pending| CloseAnswer { + reason: pending.reason, + allowed: reply == CloseReply::Allow, + }) + } + } + } + + /// The request whose deadline passed, put to the person once. + fn expired(&mut self, now: Instant) -> Option { + let pending = self.pending.as_mut()?; + if pending.prompting || now < pending.deadline { + return None; + } + pending.prompting = true; + Some(pending.request_id) + } + + /// Close anyway leaves; Keep waiting starts a new wait on the same request. + fn prompt_answered( + &mut self, + request_id: u64, + close_anyway: bool, + now: Instant, + ) -> Option { + let pending = self + .pending + .as_mut() + .filter(|pending| pending.request_id == request_id && pending.prompting)?; + if close_anyway { + return self.pending.take().map(|pending| CloseAnswer { + reason: pending.reason, + allowed: true, + }); + } + pending.prompting = false; + pending.deadline = now + CLOSE_ANSWER_TIMEOUT; + None + } +} + +type AskWeb = Box; +type PromptPerson = Box; +type Leave = Box; + +/// Holds a close or quit until the web allows it. Nothing irreversible runs before `leave`. +/// Closing destroys the main window there, and quitting calls `app.exit(0)` there, whose +/// `ExitRequested` stops the embedded server. +pub(crate) struct CloseGuard { + requests: Mutex, + ask_web: AskWeb, + prompt_person: PromptPerson, + leave: Leave, + /// Waits for the answer to a pending `Update` request. + update_answer: Mutex>>, + /// Set only by the end-to-end teardown, so a session ends without a dialog nobody answers. + disarmed: std::sync::atomic::AtomicBool, +} + +impl CloseGuard { + fn new(ask_web: AskWeb, prompt_person: PromptPerson, leave: Leave) -> Self { + Self { + requests: Mutex::new(CloseRequests::default()), + ask_web, + prompt_person, + leave, + update_answer: Mutex::new(None), + disarmed: std::sync::atomic::AtomicBool::new(false), + } + } + + fn lock(&self) -> MutexGuard<'_, CloseRequests> { + self.requests.lock().unwrap_or_else(PoisonError::into_inner) + } + + /// Asks the web, or joins the request already pending. + pub(crate) fn request(&self, reason: LeaveReason, now: Instant) { + if self.disarmed.load(std::sync::atomic::Ordering::Relaxed) { + (self.leave)(reason); + return; + } + let request_id = self.lock().begin(reason, now); + if let Some(request_id) = request_id { + (self.ask_web)(request_id, reason); + } + } + + /// End-to-end builds only: every later close or quit leaves at once, as if allowed. + #[cfg(any(feature = "desktop-e2e", test))] + pub(crate) fn disarm(&self) { + self.disarmed + .store(true, std::sync::atomic::Ordering::Relaxed); + } + + /// Asks the web before a remote update install restarts the app. Resolves true once allowed, + /// false on stay. `None` when another request is pending, so the install does not start. + pub(crate) fn ask_before_update( + &self, + now: Instant, + ) -> Option> { + let request_id = self.lock().begin(LeaveReason::Update, now)?; + let (sender, receiver) = tokio::sync::oneshot::channel(); + *self + .update_answer + .lock() + .unwrap_or_else(PoisonError::into_inner) = Some(sender); + (self.ask_web)(request_id, LeaveReason::Update); + Some(receiver) + } + + fn finish(&self, answer: CloseAnswer) { + if answer.reason == LeaveReason::Update { + let sender = self + .update_answer + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take(); + if let Some(sender) = sender { + let _ = sender.send(answer.allowed); + } + } else if answer.allowed { + (self.leave)(answer.reason); + } + } + + pub(crate) fn reply(&self, request_id: u64, reply: CloseReply, now: Instant) { + let answer = self.lock().reply(request_id, reply, now); + if let Some(answer) = answer { + self.finish(answer); + } + } + + /// Puts an expired request to the person, and returns whether a request is still pending. + pub(crate) fn tick(&self, now: Instant) -> bool { + let (expired, pending) = { + let mut requests = self.lock(); + (requests.expired(now), requests.pending.is_some()) + }; + if let Some(request_id) = expired { + tracing::warn!( + request_id, + "the window did not answer a close request in time" + ); + (self.prompt_person)(request_id); + } + pending + } + + pub(crate) fn prompt_answered(&self, request_id: u64, close_anyway: bool, now: Instant) { + let answer = self.lock().prompt_answered(request_id, close_anyway, now); + if let Some(answer) = answer { + tracing::warn!(request_id, reason = ?answer.reason, "leaving without the window's answer"); + self.finish(answer); + } + } +} + +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +struct CloseRequestPayload { + request_id: u64, + reason: LeaveReason, +} + +pub(crate) fn close_guard_for_app(app: &AppHandle) -> CloseGuard { + let ask_app = app.clone(); + let prompt_app = app.clone(); + let leave_app = app.clone(); + CloseGuard::new( + Box::new(move |request_id, reason| ask_web_to_leave(&ask_app, request_id, reason)), + Box::new(move |request_id| prompt_close_anyway(&prompt_app, request_id)), + Box::new(move |reason| leave_now(&leave_app, reason)), + ) +} + +fn ask_web_to_leave(app: &AppHandle, request_id: u64, reason: LeaveReason) { + let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) else { + // No page can hold an edit, so leaving loses nothing. + if let Some(guard) = app.try_state::() { + guard.reply(request_id, CloseReply::Allow, Instant::now()); + } + return; + }; + // The person must see the question, and a visible page is not throttled. + let _ = window.unminimize(); + let _ = window.show(); + let _ = window.set_focus(); + if let Err(error) = app.emit_to( + MAIN_WINDOW_LABEL, + CLOSE_REQUEST_EVENT, + CloseRequestPayload { request_id, reason }, + ) { + tracing::warn!(%error, "failed to ask the window before closing"); + } + let watch_app = app.clone(); + tauri::async_runtime::spawn(async move { + loop { + tokio::time::sleep(CLOSE_WATCH_INTERVAL).await; + let Some(guard) = watch_app.try_state::() else { + break; + }; + if !guard.tick(Instant::now()) { + break; + } + } + }); +} + +fn prompt_close_anyway(app: &AppHandle, request_id: u64) { + use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind}; + let answer_app = app.clone(); + app.dialog() + .message("BiBCode is not responding. Unsaved Files edits may be lost.") + .title("BiBCode") + .kind(MessageDialogKind::Warning) + .buttons(MessageDialogButtons::OkCancelCustom( + "Close anyway".to_owned(), + "Keep waiting".to_owned(), + )) + .show(move |close_anyway| { + if let Some(guard) = answer_app.try_state::() { + guard.prompt_answered(request_id, close_anyway, Instant::now()); + } + }); +} + +fn leave_now(app: &AppHandle, reason: LeaveReason) { + match reason { + LeaveReason::Close => { + if let Some(window) = app.get_webview_window(MAIN_WINDOW_LABEL) + && let Err(error) = window.destroy() + { + tracing::warn!(%error, "failed to close the main window"); + } + } + LeaveReason::Quit => app.exit(0), + // The remote install waits on `ask_before_update` and restarts the app itself. + LeaveReason::Update => {} + } +} + #[cfg(test)] mod tests { use super::*; use serde_json::json; + /// A guard whose web, prompt and leave actions are recorded. `leave` is the only path to + /// the window's destruction and, for a quit, to `ExitRequested`, which stops the server. + struct RecordedGuard { + guard: CloseGuard, + asked: std::sync::Arc>>, + prompted: std::sync::Arc>>, + left: std::sync::Arc>>, + } + + impl RecordedGuard { + fn new() -> Self { + let asked = std::sync::Arc::new(Mutex::new(Vec::new())); + let prompted = std::sync::Arc::new(Mutex::new(Vec::new())); + let left = std::sync::Arc::new(Mutex::new(Vec::new())); + let (a, p, l) = (asked.clone(), prompted.clone(), left.clone()); + let guard = CloseGuard::new( + Box::new(move |id, reason| a.lock().unwrap().push((id, reason))), + Box::new(move |id| p.lock().unwrap().push(id)), + Box::new(move |reason| l.lock().unwrap().push(reason)), + ); + Self { + guard, + asked, + prompted, + left, + } + } + + fn request(&self, reason: LeaveReason, now: Instant) -> u64 { + self.guard.request(reason, now); + self.asked + .lock() + .unwrap() + .last() + .expect("the web was asked") + .0 + } + + fn left(&self) -> Vec { + self.left.lock().unwrap().clone() + } + + fn prompted(&self) -> Vec { + self.prompted.lock().unwrap().clone() + } + } + + fn later(start: Instant, seconds: u64) -> Instant { + start + Duration::from_secs(seconds) + } + + #[test] + fn close_request_waits_for_the_web_answer() { + let recorded = RecordedGuard::new(); + let start = Instant::now(); + let id = recorded.request(LeaveReason::Close, start); + recorded.guard.reply(id, CloseReply::Acknowledge, start); + for second in 1..=60 { + recorded + .guard + .reply(id, CloseReply::Keepalive, later(start, second)); + assert!(recorded.guard.tick(later(start, second))); + } + assert!(recorded.left().is_empty()); + assert!(recorded.prompted().is_empty()); + // A second close joins the pending request instead of asking again. + recorded.guard.request(LeaveReason::Close, later(start, 61)); + assert_eq!(recorded.asked.lock().unwrap().len(), 1); + } + + #[test] + fn close_request_closes_when_the_web_allows_it() { + let recorded = RecordedGuard::new(); + let start = Instant::now(); + let id = recorded.request(LeaveReason::Close, start); + recorded.guard.reply(id + 1, CloseReply::Allow, start); + assert!(recorded.left().is_empty(), "a stale id must not close"); + recorded.guard.reply(id, CloseReply::Acknowledge, start); + recorded.guard.reply(id, CloseReply::Allow, start); + assert_eq!(recorded.left(), vec![LeaveReason::Close]); + recorded.guard.reply(id, CloseReply::Allow, start); + assert_eq!( + recorded.left().len(), + 1, + "an answer with no request pending is dropped" + ); + } + + #[test] + fn close_request_keeps_the_window_and_the_server_when_the_web_says_stay() { + let recorded = RecordedGuard::new(); + let start = Instant::now(); + let id = recorded.request(LeaveReason::Close, start); + recorded.guard.reply(id, CloseReply::Acknowledge, start); + recorded.guard.reply(id, CloseReply::Stay, start); + assert!(!recorded.guard.tick(later(start, 30))); + assert!(recorded.left().is_empty()); + assert!(recorded.prompted().is_empty()); + // The next close asks again with a new id. + let next = recorded.request(LeaveReason::Close, later(start, 31)); + assert_ne!(next, id); + } + + #[test] + fn close_request_escapes_when_the_web_never_acknowledges() { + let recorded = RecordedGuard::new(); + let start = Instant::now(); + let id = recorded.request(LeaveReason::Close, start); + assert!(recorded.guard.tick(later(start, 4))); + assert!(recorded.prompted().is_empty()); + assert!(recorded.guard.tick(later(start, 5))); + assert_eq!(recorded.prompted(), vec![id]); + assert!(recorded.guard.tick(later(start, 6))); + assert_eq!(recorded.prompted(), vec![id], "the prompt is shown once"); + // Keep waiting starts a new wait, then Close anyway leaves. + recorded.guard.prompt_answered(id, false, later(start, 7)); + assert!(recorded.left().is_empty()); + assert!(recorded.guard.tick(later(start, 11))); + assert_eq!(recorded.prompted(), vec![id]); + assert!(recorded.guard.tick(later(start, 12))); + assert_eq!(recorded.prompted(), vec![id, id]); + recorded.guard.prompt_answered(id, true, later(start, 13)); + assert_eq!(recorded.left(), vec![LeaveReason::Close]); + } + + #[test] + fn close_request_escapes_when_keepalives_stop_after_an_acknowledge() { + let recorded = RecordedGuard::new(); + let start = Instant::now(); + let id = recorded.request(LeaveReason::Close, start); + recorded.guard.reply(id, CloseReply::Acknowledge, start); + recorded + .guard + .reply(id, CloseReply::Keepalive, later(start, 3)); + assert!(recorded.guard.tick(later(start, 7))); + assert!(recorded.prompted().is_empty()); + assert!(recorded.guard.tick(later(start, 8))); + assert_eq!(recorded.prompted(), vec![id]); + // An answer that arrives while the prompt is open still decides the request. + recorded.guard.reply(id, CloseReply::Stay, later(start, 9)); + recorded.guard.prompt_answered(id, true, later(start, 10)); + assert!(recorded.left().is_empty()); + } + + #[test] + fn close_request_on_quit_waits_for_the_web_before_the_server_stops() { + let recorded = RecordedGuard::new(); + let start = Instant::now(); + let id = recorded.request(LeaveReason::Quit, start); + recorded.guard.reply(id, CloseReply::Acknowledge, start); + recorded + .guard + .reply(id, CloseReply::Keepalive, later(start, 1)); + assert!( + recorded.left().is_empty(), + "nothing stops before the web answers" + ); + recorded.guard.reply(id, CloseReply::Allow, later(start, 2)); + assert_eq!(recorded.left(), vec![LeaveReason::Quit]); + } + + #[test] + fn close_request_on_quit_cancel_keeps_the_server_answering() { + // Desktop unit tests cannot start the embedded server; the packaged run in Task 8.6 + // sends a server request after Cancel. Here: Cancel never reaches the teardown. + let recorded = RecordedGuard::new(); + let start = Instant::now(); + let id = recorded.request(LeaveReason::Quit, start); + recorded.guard.reply(id, CloseReply::Acknowledge, start); + recorded.guard.reply(id, CloseReply::Stay, later(start, 1)); + assert!(!recorded.guard.tick(later(start, 30))); + assert!(recorded.left().is_empty()); + } + + #[test] + fn close_request_on_remote_update_proceeds_only_when_the_web_allows_it() { + let recorded = RecordedGuard::new(); + let start = Instant::now(); + let mut allowed = recorded.guard.ask_before_update(start).expect("asked"); + let (id, reason) = *recorded.asked.lock().unwrap().last().unwrap(); + assert_eq!(reason, LeaveReason::Update); + recorded.guard.reply(id, CloseReply::Acknowledge, start); + assert!( + allowed.try_recv().is_err(), + "nothing is decided before the answer" + ); + recorded.guard.reply(id, CloseReply::Allow, later(start, 1)); + assert_eq!(allowed.try_recv(), Ok(true)); + assert!( + recorded.left().is_empty(), + "an update never destroys the window or quits" + ); + + let mut stayed = recorded + .guard + .ask_before_update(later(start, 2)) + .expect("asked"); + let (id, _) = *recorded.asked.lock().unwrap().last().unwrap(); + recorded.guard.reply(id, CloseReply::Stay, later(start, 3)); + assert_eq!(stayed.try_recv(), Ok(false)); + + // A close already pending means the install does not start. + recorded.guard.request(LeaveReason::Close, later(start, 4)); + assert!(recorded.guard.ask_before_update(later(start, 5)).is_none()); + } + + #[test] + fn close_request_disarmed_for_end_to_end_teardown_leaves_at_once() { + let recorded = RecordedGuard::new(); + recorded.guard.disarm(); + recorded.guard.request(LeaveReason::Close, Instant::now()); + assert_eq!(recorded.left(), vec![LeaveReason::Close]); + assert!(recorded.asked.lock().unwrap().is_empty()); + } + + #[test] + fn close_request_quit_menu_item_replaces_the_predefined_quit() { + assert_eq!( + menu_action_for_id(MENU_ID_QUIT), + Some(ApplicationMenuAction::Quit) + ); + } + + #[test] + fn close_request_payload_and_replies_match_the_web_contract() { + assert_eq!( + serde_json::to_value(CloseRequestPayload { + request_id: 3, + reason: LeaveReason::Quit + }) + .unwrap(), + json!({"requestId": 3, "reason": "quit"}) + ); + for (wire, reply) in [ + ("acknowledge", CloseReply::Acknowledge), + ("keepalive", CloseReply::Keepalive), + ("allow", CloseReply::Allow), + ("stay", CloseReply::Stay), + ] { + assert_eq!( + serde_json::from_value::(json!(wire)).unwrap(), + reply + ); + } + } + #[test] fn main_window_new_windows_open_externally_for_web_and_mail_links() { use super::{MainNewWindowAction, main_window_new_window_action}; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 2101a94ce4..0174e5cbcd 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -5,7 +5,8 @@ "identifier": "com.bibcode.desktop", "build": { "beforeDevCommand": "node ../../scripts/dev-runner.ts dev", - "beforeBuildCommand": "node ../../scripts/prepare-tauri-appimage-tools.ts && vp run --filter @bibcode/web build && node ../../scripts/apply-web-brand-assets.ts production apps/web/dist", + "beforeBuildCommand": "vp run --filter @bibcode/web build && node ../../scripts/apply-web-brand-assets.ts production apps/web/dist", + "beforeBundleCommand": "node ../../scripts/prepare-tauri-appimage-tools.ts", "devUrl": "http://localhost:5733", "frontendDist": "../../web/dist" }, @@ -24,8 +25,8 @@ } ], "security": { - "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* http: ws: https: wss:; img-src 'self' asset: http://asset.localhost http://127.0.0.1:* http://localhost:* data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src blob:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", - "devCsp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* http: ws: https: wss:; img-src 'self' asset: http://asset.localhost http://127.0.0.1:* http://localhost:* data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; font-src 'self' data:; object-src blob:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", + "csp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* http: ws: https: wss:; img-src 'self' asset: http://asset.localhost http://127.0.0.1:* http://localhost:* data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self'; font-src 'self' data:; object-src blob:; frame-src 'self' blob:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", + "devCsp": "default-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:* ws://127.0.0.1:* ws://localhost:* http: ws: https: wss:; img-src 'self' asset: http://asset.localhost http://127.0.0.1:* http://localhost:* data: blob:; media-src 'self' blob:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-eval'; font-src 'self' data:; object-src blob:; frame-src 'self' blob:; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", "dangerousDisableAssetCspModification": ["style-src"] } }, diff --git a/apps/desktop/src-tauri/tauri.linux.conf.json b/apps/desktop/src-tauri/tauri.linux.conf.json index 73a2eb708c..7e2d2743de 100644 --- a/apps/desktop/src-tauri/tauri.linux.conf.json +++ b/apps/desktop/src-tauri/tauri.linux.conf.json @@ -1,6 +1,11 @@ { "$schema": "https://schema.tauri.app/config/2", "bundle": { - "useLocalToolsDir": true + "useLocalToolsDir": true, + "linux": { + "appimage": { + "bundleMediaFramework": true + } + } } } diff --git a/apps/server/src/auth/scope.rs b/apps/server/src/auth/scope.rs index 36ac6c7cd6..da9e880159 100644 --- a/apps/server/src/auth/scope.rs +++ b/apps/server/src/auth/scope.rs @@ -68,6 +68,8 @@ pub(crate) fn required_scope(method: &str) -> Option<&'static str> { | "server.getConfig" | "server.getProcessDiagnostics" | "server.getProcessResourceHistory" + | "server.subscribeHostMetrics" + | "server.getHostMetrics" | "server.getProfilerStatus" | "server.getProviderCapabilities" | "server.getProviderUsage" diff --git a/apps/server/src/config.rs b/apps/server/src/config.rs index cd9e70a669..a436f82ab0 100644 --- a/apps/server/src/config.rs +++ b/apps/server/src/config.rs @@ -24,7 +24,7 @@ pub const DEFAULT_PORT: u16 = 3773; /// The label desktop-owned servers declare, and the default for embedders that /// build a [`ServerConfig`] directly. -const LOCAL_ENVIRONMENT_LABEL: &str = "Local"; +pub(crate) const LOCAL_ENVIRONMENT_LABEL: &str = "Local"; /// This machine's hostname, trimmed, or `None` when the host reports none. pub(crate) fn machine_host_name() -> Option { diff --git a/apps/server/src/device/android/emulator.rs b/apps/server/src/device/android/emulator.rs index 4466623a00..b303407630 100644 --- a/apps/server/src/device/android/emulator.rs +++ b/apps/server/src/device/android/emulator.rs @@ -459,6 +459,7 @@ impl EmulatorLauncher { timeout: ADB_TIMEOUT, cleanup_timeout: ADB_CLEANUP_TIMEOUT, max_output_bytes: ADB_MAX_OUTPUT_BYTES, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Truncate, }; match run_supervised(request, cancel).await { diff --git a/apps/server/src/device/android/scrcpy.rs b/apps/server/src/device/android/scrcpy.rs index 3be9d4709d..f5d5cb020a 100644 --- a/apps/server/src/device/android/scrcpy.rs +++ b/apps/server/src/device/android/scrcpy.rs @@ -669,6 +669,7 @@ async fn run_adb( timeout, cleanup_timeout: ADB_CLEANUP_TIMEOUT, max_output_bytes: ADB_MAX_OUTPUT_BYTES, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Truncate, }; let verb = args.first().copied().unwrap_or_default(); diff --git a/apps/server/src/device/android/sdk.rs b/apps/server/src/device/android/sdk.rs index 2871a48a11..003fbce427 100644 --- a/apps/server/src/device/android/sdk.rs +++ b/apps/server/src/device/android/sdk.rs @@ -219,6 +219,7 @@ async fn run_tool(program: &Path, args: &[&str]) -> Result { timeout: LIST_TIMEOUT, cleanup_timeout: LIST_CLEANUP_TIMEOUT, max_output_bytes: LIST_MAX_OUTPUT_BYTES, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Truncate, }, &CancellationToken::new(), diff --git a/apps/server/src/device/ios/simctl.rs b/apps/server/src/device/ios/simctl.rs index 9e9e51c75a..17c35582ae 100644 --- a/apps/server/src/device/ios/simctl.rs +++ b/apps/server/src/device/ios/simctl.rs @@ -498,6 +498,7 @@ async fn run( timeout, cleanup_timeout: CLEANUP_TIMEOUT, max_output_bytes, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Error, }, cancel, diff --git a/apps/server/src/diagnostics/host_metrics/disk_workers.rs b/apps/server/src/diagnostics/host_metrics/disk_workers.rs new file mode 100644 index 0000000000..0867bf4311 --- /dev/null +++ b/apps/server/src/diagnostics/host_metrics/disk_workers.rs @@ -0,0 +1,296 @@ +//! The disk workers (D32): dedicated OS threads that make every volume list and space read, so a +//! hung mount holds one of them and never a Tokio thread. Shutdown never joins them. + +use std::{ + collections::VecDeque, + panic::AssertUnwindSafe, + path::PathBuf, + sync::{Arc, Condvar, Mutex, MutexGuard, PoisonError}, + time::{Duration, Instant}, +}; + +use tokio::sync::Notify; + +use super::{HostMetricsSource, VolumeDescriptor, VolumeSpace}; + +/// Identifies a disk job: `mount_point` is `None` for the list job; `generation` is the number of +/// the listing that first showed the volume. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub(crate) struct DiskJobKey { + pub mount_point: Option, + pub generation: u64, +} + +#[derive(Clone, Debug)] +pub(crate) struct DiskJob { + pub key: DiskJobKey, + /// What the read weighs in the statistics: the volume interval when it was admitted. + pub weight: Duration, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Admission { + Admitted, + /// That key already has a job queued or running. + AlreadyPending, + /// Every thread is held by a call that has run past the limit. + Paused, +} + +#[derive(Debug)] +pub(crate) enum DiskOutcome { + /// `Err` only when the listing call panicked. + Listed(Result, String>), + Space(Result), +} + +#[derive(Debug)] +pub(crate) struct DiskJobResult { + pub key: DiskJobKey, + pub weight: Duration, + pub outcome: DiskOutcome, +} + +#[derive(Debug)] +struct Running { + id: u64, + key: DiskJobKey, + started: Instant, + retired: bool, +} + +#[derive(Debug, Default)] +struct WorkerState { + queue: VecDeque, + running: Vec, + results: Vec, + next_id: u64, + stopped: bool, +} + +#[derive(Debug)] +struct Inner { + source: Arc, + threads: usize, + hold_limit: Duration, + state: Mutex, + work: Condvar, + results_ready: Notify, +} + +impl Inner { + fn lock(&self) -> MutexGuard<'_, WorkerState> { + self.state.lock().unwrap_or_else(PoisonError::into_inner) + } + + fn run_worker(&self) { + loop { + let (id, job) = { + let mut state = self.lock(); + loop { + if state.stopped { + return; + } + if let Some(job) = state.queue.pop_front() { + let id = state.next_id; + state.next_id += 1; + state.running.push(Running { + id, + key: job.key.clone(), + started: Instant::now(), + retired: false, + }); + break (id, job); + } + state = self + .work + .wait(state) + .unwrap_or_else(PoisonError::into_inner); + } + }; + // A panicking call fails its own job only: its key is freed below and this thread + // keeps serving. The profiler's panic hook has already recorded the panic. + let outcome = + std::panic::catch_unwind(AssertUnwindSafe(|| match &job.key.mount_point { + None => DiskOutcome::Listed(Ok(self.source.list_volumes())), + Some(mount_point) => { + DiskOutcome::Space(self.source.read_volume_space(mount_point)) + } + })) + .unwrap_or_else(|_| { + // R74: the payload may name a path, so only the kind of call is logged. + let listing = job.key.mount_point.is_none(); + tracing::warn!( + call = if listing { "list" } else { "space" }, + "host disk call panicked" + ); + if listing { + DiskOutcome::Listed(Err("the volume listing failed".to_owned())) + } else { + DiskOutcome::Space(Err("the volume read failed".to_owned())) + } + }); + { + let mut state = self.lock(); + let Some(index) = state.running.iter().position(|running| running.id == id) else { + continue; + }; + let running = state.running.swap_remove(index); + if state.stopped { + return; + } + if !running.retired { + state.results.push(DiskJobResult { + key: job.key, + weight: job.weight, + outcome, + }); + } + } + self.results_ready.notify_one(); + } + } +} + +/// A fixed pool of `host-disk-` threads pulling jobs from one queue that holds at most one job +/// per key. +#[derive(Debug)] +pub(crate) struct DiskWorkers { + inner: Arc, +} + +impl DiskWorkers { + pub(crate) fn start( + source: Arc, + threads: usize, + hold_limit: Duration, + ) -> Self { + let inner = Arc::new(Inner { + source, + threads: threads.max(1), + hold_limit, + state: Mutex::new(WorkerState::default()), + work: Condvar::new(), + results_ready: Notify::new(), + }); + for index in 0..inner.threads { + let worker = inner.clone(); + if let Err(error) = std::thread::Builder::new() + .name(format!("host-disk-{index}")) + .spawn(move || worker.run_worker()) + { + tracing::warn!(%error, "host disk worker did not start"); + } + } + Self { inner } + } + + pub(crate) fn admit(&self, job: DiskJob) -> Admission { + let mut state = self.inner.lock(); + let pending = state.queue.iter().any(|queued| queued.key == job.key) + || state + .running + .iter() + .any(|running| !running.retired && running.key == job.key); + if pending { + return Admission::AlreadyPending; + } + if state.stopped || self.all_held(&state) { + return Admission::Paused; + } + state.queue.push_back(job); + drop(state); + self.inner.work.notify_one(); + Admission::Admitted + } + + fn all_held(&self, state: &WorkerState) -> bool { + state + .running + .iter() + .filter(|running| running.started.elapsed() >= self.inner.hold_limit) + .count() + >= self.inner.threads + } + + /// Whether every thread is held by a call that has run past the limit. + pub(crate) fn paused(&self) -> bool { + self.all_held(&self.inner.lock()) + } + + /// Drops queued jobs of these keys and discards the results of their running jobs. + pub(crate) fn retire(&self, keys: &[DiskJobKey]) { + if keys.is_empty() { + return; + } + let mut state = self.inner.lock(); + state.queue.retain(|job| !keys.contains(&job.key)); + for running in &mut state.running { + if keys.contains(&running.key) { + running.retired = true; + } + } + state.results.retain(|result| !keys.contains(&result.key)); + } + + pub(crate) fn running_longer_than(&self, limit: Duration) -> Vec { + self.inner + .lock() + .running + .iter() + .filter(|running| !running.retired && running.started.elapsed() >= limit) + .map(|running| running.key.clone()) + .collect() + } + + /// Whether a job is queued or running, so a read may pass the limit. + pub(crate) fn has_work(&self) -> bool { + let state = self.inner.lock(); + !state.running.is_empty() || !state.queue.is_empty() + } + + pub(crate) fn take_results(&self) -> Vec { + std::mem::take(&mut self.inner.lock().results) + } + + /// Wakes when a worker has stored a result. + pub(crate) async fn results_ready(&self) { + self.inner.results_ready.notified().await; + } + + /// Whether no job is queued or running, retired ones included, and no result waits. + #[cfg(test)] + pub(crate) fn idle(&self) -> bool { + let state = self.inner.lock(); + state.queue.is_empty() && state.running.is_empty() && state.results.is_empty() + } + + /// Whether a job is queued or running on a thread, retired ones included. + #[cfg(test)] + pub(crate) fn busy(&self) -> bool { + let state = self.inner.lock(); + !state.queue.is_empty() || !state.running.is_empty() + } + + /// Queued jobs plus running ones, retired ones included. + #[cfg(test)] + pub(crate) fn held_and_queued(&self) -> usize { + let state = self.inner.lock(); + state.queue.len() + state.running.len() + } + + /// Sets the stop flag and wakes idle threads; never joins a thread held by a call. + pub(crate) fn stop(&self) { + let mut state = self.inner.lock(); + state.stopped = true; + state.queue.clear(); + drop(state); + self.inner.work.notify_all(); + } +} + +impl Drop for DiskWorkers { + fn drop(&mut self) { + self.stop(); + } +} diff --git a/apps/server/src/diagnostics/host_metrics/mod.rs b/apps/server/src/diagnostics/host_metrics/mod.rs new file mode 100644 index 0000000000..643c6edae7 --- /dev/null +++ b/apps/server/src/diagnostics/host_metrics/mod.rs @@ -0,0 +1,989 @@ +//! The host collector: CPU, memory and volume space of the machine the server runs on (#292). + +use std::{ + path::{Path, PathBuf}, + sync::{ + Arc, Mutex as SyncMutex, MutexGuard, PoisonError, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use tokio::{ + sync::{Notify, watch}, + task::JoinHandle, + time::Instant, +}; +use tokio_util::sync::CancellationToken; + +mod disk_workers; +mod native; +#[cfg(test)] +mod probes; +mod stats; +mod volumes; +mod wire; + +pub use crate::diagnostics::SamplingError; +pub use native::NativeHostMetricsSource; +pub use stats::{ChartBucket, ChartPoint, MeasureStatistics}; +pub(crate) use wire::{encode_snapshot, send_host_metrics}; + +use disk_workers::{Admission, DiskJob, DiskJobKey, DiskJobResult, DiskOutcome, DiskWorkers}; +use stats::{ChartHistory, RunningStatistic, clamp_percent}; +use volumes::{current_appimage, merge_shared, pre_filter}; + +/// One volume as the source lists it, before any space read. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VolumeDescriptor { + pub mount_point: PathBuf, + pub name: String, + pub file_system: String, + pub removable: bool, + pub read_only: bool, + /// The mount point is a file, as Docker's `/etc/hosts` bind mount is. + pub is_file: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum VolumeState { + Ok, + NotResponding, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct VolumeReading { + pub descriptor: VolumeDescriptor, + /// `None` until the volume first answers. + pub total_bytes: Option, + pub free_bytes: Option, + pub state: VolumeState, +} + +impl VolumeReading { + pub fn used_bytes(&self) -> Option { + Some(self.total_bytes?.saturating_sub(self.free_bytes?)) + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum HiddenReason { + Network, + Overlay, + FileMount, + /// A memory-backed system file system (`/dev`, `/dev/shm`) or the running app's own image. + System, + Empty, + SameStorage { + as_mount: PathBuf, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HiddenVolume { + pub descriptor: VolumeDescriptor, + pub reason: HiddenReason, +} + +/// Facts about the host that do not change while the server runs. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostFacts { + pub os: &'static str, + pub arch: &'static str, + pub cpu_count: usize, + pub memory_total_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CpuMemoryReading { + pub cpu_percent: f64, + pub memory_total_bytes: u64, + pub memory_used_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VolumeSpace { + pub total_bytes: u64, + pub free_bytes: u64, +} + +/// What the collector reads. Every call may block: CPU and memory are read on the blocking +/// pool and must be short; volume calls are made only on a disk worker. +pub trait HostMetricsSource: std::fmt::Debug + Send + Sync + 'static { + fn host_facts(&self) -> HostFacts; + fn read_cpu_memory(&self) -> Result; + fn list_volumes(&self) -> Vec; + fn read_volume_space(&self, mount_point: &Path) -> Result; +} + +#[derive(Clone, Debug, PartialEq)] +pub struct HostReading { + /// Wall time, a label only. + pub sampled_at_ms: i128, + /// The chart clock: wall time that never runs backwards (`ReadingClock`). + pub chart_ms: i128, + /// Rises by one per reading within this run. + pub sequence: u64, + /// When the reading was taken, on the monotonic clock, for its age on the wire. + pub taken_at: Instant, + pub cpu_percent: f64, + pub memory_total_bytes: u64, + pub memory_used_bytes: u64, + pub memory_available_bytes: u64, + pub volumes: Vec, + pub volumes_paused: bool, + /// The volume listing has run past the limit without answering. + pub volume_listing_not_responding: bool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct HostMetricsRates { + pub background: Duration, + pub live: Duration, + /// Volumes are read on every `volumes_every`-th reading. + pub volumes_every: u32, + /// A volume whose read runs longer than this is shown as not responding. + pub volume_timeout: Duration, + pub disk_threads: usize, +} + +impl Default for HostMetricsRates { + fn default() -> Self { + Self { + background: Duration::from_secs(60), + live: Duration::from_secs(1), + volumes_every: 5, + volume_timeout: Duration::from_secs(2), + disk_threads: 4, + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct HostMetricsIdentity { + pub boot_id: Option, + pub started_at_ms: i128, +} + +/// The uptime chart and the hidden volumes, with a revision that rises when either changes. +#[derive(Clone, Debug, PartialEq)] +pub struct ChartState { + pub revision: u64, + pub uptime: Vec, + pub hidden_volumes: Vec, +} + +#[derive(Clone, Debug)] +pub struct HostMetricsSnapshot { + pub identity: HostMetricsIdentity, + pub facts: HostFacts, + pub statistics_since_ms: Option, + pub current: Option, + pub cpu: Option, + pub memory: Option, + pub recent: Vec, + pub chart: Arc, + /// Each volume's used percent since start, by mount point (R83). + pub volume_statistics: Vec<(PathBuf, MeasureStatistics)>, +} + +/// One live reading; `chart` always points at the latest complete chart state. +#[derive(Clone, Debug)] +pub struct HostMetricsUpdate { + pub reading: HostReading, + pub statistics_since_ms: Option, + pub cpu: MeasureStatistics, + pub memory: MeasureStatistics, + pub chart: Arc, + pub volume_statistics: Vec<(PathBuf, MeasureStatistics)>, +} + +/// Turns a volume's space into a reading; free is clamped to total, so used is never negative. +pub fn volume_reading(descriptor: VolumeDescriptor, space: VolumeSpace) -> VolumeReading { + VolumeReading { + descriptor, + total_bytes: Some(space.total_bytes), + free_bytes: Some(space.free_bytes.min(space.total_bytes)), + state: VolumeState::Ok, + } +} + +const UPTIME_BARS: usize = 240; +/// Mount points with statistics since start; later ones get none (R83). +const MOST_VOLUME_STATISTICS: usize = 64; +const FIRST_READING_WAIT: Duration = Duration::from_secs(1); +/// How often the disk loop looks for reads past the limit while any is running. +const HELD_CHECK: Duration = Duration::from_millis(250); + +type UpdateSender = watch::Sender>>; + +/// Orders readings and places them in the chart whatever the wall clock does: the chart clock +/// follows the wall clock forward (a suspend shows as a gap) but advances at least as fast as the +/// monotonic clock, so a wall clock moved back neither freezes nor folds the chart. +#[derive(Debug, Default)] +struct ReadingClock { + sequence: u64, + last: Option<(Instant, i128)>, +} + +impl ReadingClock { + /// The next sequence and chart time for a reading taken at `now` with wall time `wall_ms`. + fn tick(&mut self, now: Instant, wall_ms: i128) -> (u64, i128) { + let chart_ms = match self.last { + Some((at, previous)) => { + let elapsed = i128::try_from(now.saturating_duration_since(at).as_millis()) + .unwrap_or(i128::MAX); + wall_ms.max(previous.saturating_add(elapsed)) + } + None => wall_ms, + }; + self.last = Some((now, chart_ms)); + self.sequence += 1; + (self.sequence, chart_ms) + } +} + +#[derive(Debug)] +struct Statistics { + cpu: RunningStatistic, + memory: RunningStatistic, + chart: ChartHistory, + since_ms: Option, + current: Option, + last_error: Option, + clock: ReadingClock, +} + +/// A volume the disk loop tracks: the listing that first showed it and its latest reading. +#[derive(Debug)] +struct TrackedVolume { + generation: u64, + reading: VolumeReading, + reported_hung: bool, +} + +#[derive(Debug, Default)] +struct DiskView { + shown: Vec, + paused: bool, + listing_not_responding: bool, + /// Volumes the disk loop tracks: the latest listing's. + #[cfg(test)] + tracked: usize, +} + +#[derive(Debug)] +struct Shared { + source: Arc, + rates: HostMetricsRates, + identity: HostMetricsIdentity, + /// Where the test wall clock starts. + #[cfg(test)] + clock_origin: Instant, + statistics: SyncMutex, + chart: SyncMutex<(u64, Arc)>, + disk_view: SyncMutex, + /// Each kept volume's used percent since start; a volume that goes keeps its entry. + volume_statistics: SyncMutex>, + /// What a volume read admitted now weighs: the nominal time between volume ticks at the + /// current rate. Each job carries the weight it was admitted with. + volume_weight: SyncMutex, + updates: SyncMutex>, + subscribers: watch::Sender, + volume_tick: Notify, + started: AtomicBool, + tasks: SyncMutex>>, + workers: SyncMutex>>, + shutdown: CancellationToken, + #[cfg(test)] + volume_cadence: Option, + /// Added to the test wall clock, so a test can move it as an administrator would. + #[cfg(test)] + wall_offset_ms: std::sync::atomic::AtomicI64, + /// CPU and memory reads still running on the blocking pool, so a test can wait them out. + #[cfg(test)] + cpu_in_flight: Arc, +} + +/// Reads the host's CPU, memory and volumes: in the background from `start_background`, and once +/// a second, shared by every subscriber, while any subscription lives. +#[derive(Debug)] +pub struct HostMetricsCollector { + shared: Arc, +} + +/// Keeps the collector at the live rate while it lives. +#[derive(Debug)] +pub struct HostMetricsSubscription { + receiver: watch::Receiver>>, + shared: Arc, +} + +impl HostMetricsSubscription { + pub fn receiver(&mut self) -> &mut watch::Receiver>> { + &mut self.receiver + } +} + +impl Drop for HostMetricsSubscription { + fn drop(&mut self) { + self.shared + .subscribers + .send_modify(|count| *count = count.saturating_sub(1)); + } +} + +fn lock(mutex: &SyncMutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl HostMetricsCollector { + /// Builds an idle collector: it reads nothing and starts no thread. + pub fn new( + source: Arc, + rates: HostMetricsRates, + identity: HostMetricsIdentity, + ) -> Self { + Self { + shared: Arc::new(Shared { + source, + rates, + identity, + #[cfg(test)] + clock_origin: Instant::now(), + statistics: SyncMutex::new(Statistics { + cpu: RunningStatistic::default(), + memory: RunningStatistic::default(), + chart: ChartHistory::new(), + since_ms: None, + current: None, + last_error: None, + clock: ReadingClock::default(), + }), + chart: SyncMutex::new(( + 0, + Arc::new(ChartState { + revision: 0, + uptime: Vec::new(), + hidden_volumes: Vec::new(), + }), + )), + disk_view: SyncMutex::new(DiskView::default()), + volume_statistics: SyncMutex::new(Vec::new()), + volume_weight: SyncMutex::new(rates.background * rates.volumes_every.max(1)), + updates: SyncMutex::new(Some(watch::Sender::new(None))), + subscribers: watch::Sender::new(0), + volume_tick: Notify::new(), + started: AtomicBool::new(false), + tasks: SyncMutex::new(Vec::new()), + workers: SyncMutex::new(None), + shutdown: CancellationToken::new(), + #[cfg(test)] + volume_cadence: None, + #[cfg(test)] + wall_offset_ms: std::sync::atomic::AtomicI64::new(0), + #[cfg(test)] + cpu_in_flight: Arc::default(), + }), + } + } + + /// Ticks the disk loop on its own cadence too, so a test can run many listings quickly. + #[cfg(test)] + fn with_volume_cadence(mut self, cadence: Duration) -> Self { + Arc::get_mut(&mut self.shared) + .expect("unshared collector") + .volume_cadence = Some(cadence); + self + } + + /// Starts sampling at the background rate. + pub fn start_background(&self) { + self.shared.start(); + } + + /// Switches to the live rate while the subscription lives, starting an idle collector. + pub fn subscribe(&self) -> HostMetricsSubscription { + self.shared.subscribers.send_modify(|count| *count += 1); + let receiver = match lock(&self.shared.updates).as_ref() { + Some(sender) => sender.subscribe(), + // After shutdown: a closed channel, so the subscriber ends at once. + None => watch::Sender::new(None).subscribe(), + }; + self.shared.start(); + HostMetricsSubscription { + receiver, + shared: self.shared.clone(), + } + } + + pub fn live_subscribers(&self) -> usize { + *self.shared.subscribers.borrow() + } + + /// The current state; waits at most 1 s for a first reading. + pub async fn snapshot(&self) -> HostMetricsSnapshot { + let receiver = lock(&self.shared.updates) + .as_ref() + .map(watch::Sender::subscribe); + if let Some(mut receiver) = receiver { + tokio::select! { + _ = receiver.wait_for(Option::is_some) => {} + () = self.shared.shutdown.cancelled() => {} + () = tokio::time::sleep(FIRST_READING_WAIT) => {} + } + } + let source = self.shared.source.clone(); + let facts = tokio::task::spawn_blocking(move || source.host_facts()) + .await + .unwrap_or(HostFacts { + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + cpu_count: 0, + memory_total_bytes: 0, + }); + let chart = lock(&self.shared.chart).1.clone(); + let statistics = lock(&self.shared.statistics); + HostMetricsSnapshot { + identity: self.shared.identity.clone(), + facts, + statistics_since_ms: statistics.since_ms, + current: statistics.current.clone(), + cpu: statistics.cpu.summary(), + memory: statistics.memory.summary(), + recent: statistics.chart.recent(), + chart, + volume_statistics: self.shared.volume_statistics(), + } + } + + /// Stops both loops and the disk workers without waiting for a disk call; subscribers end + /// when the update channel closes. + pub async fn shutdown(&self) { + self.shared.shutdown.cancel(); + let tasks = std::mem::take(&mut *lock(&self.shared.tasks)); + for task in tasks { + if let Err(error) = task.await + && error.is_panic() + { + tracing::warn!("the host metrics collector panicked before shutdown"); + } + } + if let Some(workers) = lock(&self.shared.workers).take() { + workers.stop(); + } + lock(&self.shared.updates).take(); + } +} + +impl Shared { + fn now_ms(&self) -> i128 { + // Tests run on a paused clock, which the wall clock does not follow. + #[cfg(test)] + { + self.identity.started_at_ms + + self.clock_origin.elapsed().as_millis() as i128 + + i128::from(self.wall_offset_ms.load(Ordering::Acquire)) + } + #[cfg(not(test))] + { + time::OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000 + } + } + + fn start(self: &Arc) { + if self.shutdown.is_cancelled() || self.started.swap(true, Ordering::AcqRel) { + return; + } + let workers = Arc::new(DiskWorkers::start( + self.source.clone(), + self.rates.disk_threads, + self.rates.volume_timeout, + )); + *lock(&self.workers) = Some(workers.clone()); + let mut tasks = lock(&self.tasks); + tasks.push(tokio::spawn(self.clone().sampling_loop())); + tasks.push(tokio::spawn(self.clone().disk_loop(workers))); + } + + fn rate(&self, live: bool) -> Duration { + if live { + self.rates.live + } else { + self.rates.background + } + } + + /// Reads CPU and memory every tick at the current rate; never awaits a disk call. + async fn sampling_loop(self: Arc) { + let mut subscribers = self.subscribers.subscribe(); + let mut live = *subscribers.borrow_and_update() > 0; + let mut readings: u64 = 0; + if live { + self.prime_cpu().await; + if self.sleep_or_stop(self.rates.live).await { + return; + } + } + loop { + if readings.is_multiple_of(u64::from(self.rates.volumes_every.max(1))) { + *lock(&self.volume_weight) = self.rate(live) * self.rates.volumes_every.max(1); + self.volume_tick.notify_one(); + } + readings += 1; + let rate = self.rate(live); + // Paced from the start of the read, so the read's own time does not add up. + let mut next = Instant::now() + rate; + let Some(sampled) = self.read_cpu_memory().await else { + return; + }; + self.publish(sampled, rate); + loop { + tokio::select! { + () = self.shutdown.cancelled() => return, + () = tokio::time::sleep_until(next) => break, + changed = subscribers.changed() => { + if changed.is_err() { + return; + } + let now_live = *subscribers.borrow_and_update() > 0; + if now_live == live { + continue; + } + live = now_live; + if live { + // The first live reading covers one second, not the time since + // the last background one. + self.prime_cpu().await; + next = Instant::now() + self.rates.live; + } else { + next = Instant::now() + self.rates.background; + } + } + } + } + } + } + + async fn sleep_or_stop(&self, duration: Duration) -> bool { + tokio::select! { + () = self.shutdown.cancelled() => true, + () = tokio::time::sleep(duration) => false, + } + } + + async fn prime_cpu(&self) { + let _ = self.read_cpu_memory().await; + } + + /// `None` once shutdown began. + async fn read_cpu_memory(&self) -> Option> { + let source = self.source.clone(); + #[cfg(test)] + let in_flight = InFlight::enter(&self.cpu_in_flight); + tokio::select! { + () = self.shutdown.cancelled() => None, + read = tokio::task::spawn_blocking(move || { + #[cfg(test)] + let _in_flight = in_flight; + source.read_cpu_memory() + }) => Some( + read.unwrap_or_else(|error| { + Err(SamplingError::Failed(format!("host metrics read stopped: {error}"))) + }), + ), + } + } + + fn publish(&self, sampled: Result, weight: Duration) { + let sampled = match sampled { + Ok(sampled) => sampled, + Err(error) => { + let error = error.to_string(); + let mut statistics = lock(&self.statistics); + if statistics.last_error.as_ref() != Some(&error) { + tracing::warn!(%error, "host metrics sample failed"); + } + statistics.last_error = Some(error); + return; + } + }; + let (volumes, volumes_paused, volume_listing_not_responding) = { + let view = lock(&self.disk_view); + (view.shown.clone(), view.paused, view.listing_not_responding) + }; + let memory_total_bytes = sampled.memory_total_bytes; + let memory_used_bytes = sampled.memory_used_bytes.min(memory_total_bytes); + let taken_at = Instant::now(); + let sampled_at_ms = self.now_ms(); + let mut statistics = lock(&self.statistics); + let (sequence, chart_ms) = statistics.clock.tick(taken_at, sampled_at_ms); + let reading = HostReading { + sampled_at_ms, + chart_ms, + sequence, + taken_at, + cpu_percent: clamp_percent(sampled.cpu_percent), + memory_total_bytes, + memory_used_bytes, + memory_available_bytes: memory_total_bytes - memory_used_bytes, + volumes, + volumes_paused, + volume_listing_not_responding, + }; + let memory_percent = if memory_total_bytes == 0 { + 0.0 + } else { + memory_used_bytes as f64 * 100.0 / memory_total_bytes as f64 + }; + let (update, chart_revision) = { + statistics.last_error = None; + statistics.cpu.record(reading.cpu_percent, weight); + statistics.memory.record(memory_percent, weight); + statistics.chart.record( + ChartPoint { + sampled_at_ms: reading.chart_ms, + cpu_percent: reading.cpu_percent, + memory_percent, + }, + weight, + ); + let since_ms = *statistics.since_ms.get_or_insert(reading.sampled_at_ms); + statistics.current = Some(reading.clone()); + let cpu = statistics.cpu.summary(); + let memory = statistics.memory.summary(); + let revision = statistics.chart.revision(); + drop(statistics); + ( + cpu.zip(memory) + .map(|(cpu, memory)| (reading, since_ms, cpu, memory)), + revision, + ) + }; + let chart = self.refresh_chart(Some(chart_revision), None); + if let Some((reading, since_ms, cpu, memory)) = update + && let Some(sender) = lock(&self.updates).as_ref() + { + sender.send_replace(Some(Arc::new(HostMetricsUpdate { + reading, + statistics_since_ms: Some(since_ms), + cpu, + memory, + chart, + volume_statistics: self.volume_statistics(), + }))); + } + } + + fn volume_statistics(&self) -> Vec<(PathBuf, MeasureStatistics)> { + lock(&self.volume_statistics) + .iter() + .filter_map(|(mount_point, statistic)| { + Some((mount_point.clone(), statistic.summary()?)) + }) + .collect() + } + + /// Records the used percent of each shown volume that `fresh` names, weighted by the + /// nominal time between volume reads when its read was admitted, so a watched minute does + /// not outweigh an hour. + fn record_volume_statistics(&self, shown: &[VolumeReading], fresh: &[(PathBuf, Duration)]) { + if fresh.is_empty() { + return; + } + let mut statistics = lock(&self.volume_statistics); + for reading in shown { + let mount_point = &reading.descriptor.mount_point; + let (Some(total), Some(used)) = (reading.total_bytes, reading.used_bytes()) else { + continue; + }; + let Some(&(_, weight)) = fresh.iter().find(|(fresh, _)| fresh == mount_point) else { + continue; + }; + if total == 0 || reading.state != VolumeState::Ok { + continue; + } + let percent = used as f64 * 100.0 / total as f64; + match statistics + .iter() + .position(|(known, _)| known == mount_point) + { + Some(index) => statistics[index].1.record(percent, weight), + None if statistics.len() < MOST_VOLUME_STATISTICS => { + let mut statistic = RunningStatistic::default(); + statistic.record(percent, weight); + statistics.push((mount_point.clone(), statistic)); + } + None => {} + } + } + } + + /// Rebuilds the chart state when the uptime chart or the hidden volumes changed. + fn refresh_chart( + &self, + history_revision: Option, + hidden: Option>, + ) -> Arc { + let mut chart = lock(&self.chart); + let (last_history_revision, state) = &mut *chart; + let history_changed = + history_revision.is_some_and(|revision| revision != *last_history_revision); + let hidden_changed = hidden + .as_ref() + .is_some_and(|hidden| *hidden != state.hidden_volumes); + if !history_changed && !hidden_changed { + return state.clone(); + } + if let Some(revision) = history_revision { + *last_history_revision = revision; + } + let uptime = if history_changed { + lock(&self.statistics).chart.uptime(UPTIME_BARS) + } else { + state.uptime.clone() + }; + *state = Arc::new(ChartState { + revision: state.revision + 1, + uptime, + hidden_volumes: hidden.unwrap_or_else(|| state.hidden_volumes.clone()), + }); + state.clone() + } + + /// Sends volume jobs to the disk workers and folds their results into the disk view. + async fn disk_loop(self: Arc, workers: Arc) { + let mut disks = DiskTracker::default(); + #[cfg(test)] + let cadence = self.volume_cadence; + #[cfg(not(test))] + let cadence: Option = None; + let mut cadence = cadence.map(tokio::time::interval); + loop { + let held = workers.has_work(); + tokio::select! { + () = self.shutdown.cancelled() => return, + () = self.volume_tick.notified() => { + disks.admit_all(&workers, *lock(&self.volume_weight)); + } + () = async { + match cadence.as_mut() { + Some(cadence) => { cadence.tick().await; } + None => std::future::pending().await, + } + } => disks.admit_all(&workers, *lock(&self.volume_weight)), + () = workers.results_ready() => {} + () = tokio::time::sleep(HELD_CHECK), if held => {} + } + let mut fresh = Vec::new(); + for result in workers.take_results() { + // Only a read of a volume still tracked counts; a retired one's is discarded. + if let (DiskOutcome::Space(Ok(_)), Some(mount_point)) = + (&result.outcome, &result.key.mount_point) + && disks + .volumes + .iter() + .any(|volume| DiskTracker::key(volume) == result.key) + { + fresh.push((mount_point.clone(), result.weight)); + } + disks.apply(result, &workers, *lock(&self.volume_weight)); + } + disks.mark_hung(&workers, self.rates.volume_timeout); + let (shown, merged) = merge_shared(disks.readings()); + self.record_volume_statistics(&shown, &fresh); + let mut hidden = disks.pre_hidden.clone(); + hidden.extend(merged); + { + let mut view = lock(&self.disk_view); + view.shown = shown; + view.paused = workers.paused(); + view.listing_not_responding = disks.listing_hung; + #[cfg(test)] + { + view.tracked = disks.volumes.len(); + } + } + self.refresh_chart(None, Some(hidden)); + } + } +} + +/// The disk loop's state: the volumes of the latest listing, keyed by mount point and generation. +#[derive(Debug, Default)] +struct DiskTracker { + listings: u64, + /// The listing job has run past the limit and not answered yet. + listing_hung: bool, + volumes: Vec, + pre_hidden: Vec, +} + +impl DiskTracker { + const LISTING: DiskJobKey = DiskJobKey { + mount_point: None, + generation: 0, + }; + + fn key(volume: &TrackedVolume) -> DiskJobKey { + DiskJobKey { + mount_point: Some(volume.reading.descriptor.mount_point.clone()), + generation: volume.generation, + } + } + + fn admit_all(&mut self, workers: &DiskWorkers, weight: Duration) { + if workers.admit(DiskJob { + key: Self::LISTING, + weight, + }) == Admission::Paused + { + return; + } + for volume in &self.volumes { + if workers.admit(DiskJob { + key: Self::key(volume), + weight, + }) == Admission::Paused + { + return; + } + } + } + + /// `weight` is what a read this result admits weighs. + fn apply(&mut self, result: DiskJobResult, workers: &DiskWorkers, weight: Duration) { + if result.key == Self::LISTING && std::mem::take(&mut self.listing_hung) { + tracing::info!("host volume listing answered again"); + } + match result.outcome { + DiskOutcome::Listed(Ok(listed)) => self.apply_listing(listed, workers, weight), + // The listing failed: the volumes of the last listing that answered stay. + DiskOutcome::Listed(Err(_)) => {} + DiskOutcome::Space(space) => { + let Some(volume) = self + .volumes + .iter_mut() + .find(|volume| Self::key(volume) == result.key) + else { + return; + }; + volume.reported_hung = false; + match space { + Ok(space) => { + volume.reading = volume_reading(volume.reading.descriptor.clone(), space); + } + Err(error) => { + tracing::debug!( + %error, + file_system = %volume.reading.descriptor.file_system, + "host volume read failed" + ); + volume.reading.state = VolumeState::NotResponding; + } + } + } + } + } + + fn apply_listing( + &mut self, + listed: Vec, + workers: &DiskWorkers, + weight: Duration, + ) { + self.listings += 1; + let (kept, hidden) = pre_filter( + listed, + std::env::current_exe().ok().as_deref(), + current_appimage().as_deref(), + ); + self.pre_hidden = hidden; + let mut previous = std::mem::take(&mut self.volumes); + for descriptor in kept { + match previous + .iter() + .position(|volume| volume.reading.descriptor == descriptor) + { + Some(index) => self.volumes.push(previous.swap_remove(index)), + None => { + let volume = TrackedVolume { + generation: self.listings, + reading: VolumeReading { + descriptor, + total_bytes: None, + free_bytes: None, + state: VolumeState::Ok, + }, + reported_hung: false, + }; + // A new volume is read at once, not at the next volume tick. + workers.admit(DiskJob { + key: Self::key(&volume), + weight, + }); + self.volumes.push(volume); + } + } + } + let retired = previous.iter().map(Self::key).collect::>(); + workers.retire(&retired); + } + + fn mark_hung(&mut self, workers: &DiskWorkers, limit: Duration) { + let hung = workers.running_longer_than(limit); + if !self.listing_hung && hung.contains(&Self::LISTING) { + self.listing_hung = true; + tracing::warn!("host volume listing did not answer"); + } + for (position, volume) in self.volumes.iter_mut().enumerate() { + if !hung.contains(&Self::key(volume)) { + continue; + } + volume.reading.state = VolumeState::NotResponding; + if !volume.reported_hung { + volume.reported_hung = true; + // R74: never the mount point or the volume name. + tracing::warn!( + file_system = %volume.reading.descriptor.file_system, + position, + "host volume did not answer" + ); + } + } + } + + /// Readings of volumes that have answered at least once, or are known not to respond. + fn readings(&self) -> Vec { + self.volumes + .iter() + .filter(|volume| { + volume.reading.total_bytes.is_some() + || volume.reading.state == VolumeState::NotResponding + }) + .map(|volume| volume.reading.clone()) + .collect() + } +} + +/// Counts one read in flight until it drops, on whatever thread finishes the read. +#[cfg(test)] +struct InFlight(Arc); + +#[cfg(test)] +impl InFlight { + fn enter(count: &Arc) -> Self { + count.fetch_add(1, Ordering::AcqRel); + Self(count.clone()) + } +} + +#[cfg(test)] +impl Drop for InFlight { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::AcqRel); + } +} + +#[cfg(test)] +mod tests; diff --git a/apps/server/src/diagnostics/host_metrics/native.rs b/apps/server/src/diagnostics/host_metrics/native.rs new file mode 100644 index 0000000000..ec36dee721 --- /dev/null +++ b/apps/server/src/diagnostics/host_metrics/native.rs @@ -0,0 +1,412 @@ +//! The native host source over `sysinfo`: CPU and memory refreshes only, never the process table, +//! and one disk at a time for space reads (#292). + +use std::{ + collections::HashMap, + path::{Path, PathBuf}, + sync::{Arc, Mutex, MutexGuard, PoisonError}, +}; + +use sysinfo::{CpuRefreshKind, Disk, DiskRefreshKind, Disks, MemoryRefreshKind, System}; + +use super::{ + CpuMemoryReading, HostFacts, HostMetricsSource, SamplingError, VolumeDescriptor, VolumeSpace, +}; + +#[derive(Debug)] +struct CpuMemory { + system: System, + /// CPU usage is a difference between two refreshes; the first call makes both. + primed: bool, +} + +#[derive(Debug)] +pub struct NativeHostMetricsSource { + cpu_memory: Mutex, + /// The disks of the latest listing, each locked on its own so one hung read holds only it. + disks: Mutex>>>, +} + +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().unwrap_or_else(PoisonError::into_inner) +} + +impl Default for NativeHostMetricsSource { + fn default() -> Self { + Self::new() + } +} + +impl NativeHostMetricsSource { + /// Does no I/O: an empty `System` and no disks. + pub fn new() -> Self { + Self { + cpu_memory: Mutex::new(CpuMemory { + system: System::new(), + primed: false, + }), + disks: Mutex::new(HashMap::new()), + } + } + + fn refresh_cpu_memory(state: &mut CpuMemory) { + let cpu = CpuRefreshKind::nothing().with_cpu_usage(); + if !state.primed { + state.system.refresh_cpu_specifics(cpu); + // Runs on the blocking pool, never on a Tokio worker. + std::thread::sleep(sysinfo::MINIMUM_CPU_UPDATE_INTERVAL); + state.primed = true; + } + state.system.refresh_cpu_specifics(cpu); + state + .system + .refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram()); + } +} + +impl HostMetricsSource for NativeHostMetricsSource { + fn host_facts(&self) -> HostFacts { + let mut state = lock(&self.cpu_memory); + if state.system.cpus().is_empty() { + state.system.refresh_cpu_list(CpuRefreshKind::nothing()); + } + if state.system.total_memory() == 0 { + state + .system + .refresh_memory_specifics(MemoryRefreshKind::nothing().with_ram()); + } + HostFacts { + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + cpu_count: state.system.cpus().len(), + memory_total_bytes: state.system.total_memory(), + } + } + + fn read_cpu_memory(&self) -> Result { + let mut state = lock(&self.cpu_memory); + Self::refresh_cpu_memory(&mut state); + let memory_total_bytes = state.system.total_memory(); + if memory_total_bytes == 0 || state.system.cpus().is_empty() { + return Err(SamplingError::Failed( + "the host reported no CPU or memory".to_owned(), + )); + } + Ok(CpuMemoryReading { + cpu_percent: f64::from(state.system.global_cpu_usage()), + memory_total_bytes, + // The OS's own used figure on every platform (D24). + memory_used_bytes: state.system.used_memory(), + }) + } + + fn list_volumes(&self) -> Vec { + // `nothing()` makes no `statvfs` call, on the first listing too. + let disks: Vec = + Disks::new_with_refreshed_list_specifics(DiskRefreshKind::nothing()).into(); + #[cfg(target_os = "linux")] + let read_only = std::fs::read_to_string("/proc/self/mounts") + .map(|mounts| read_only_mounts(&mounts)) + .unwrap_or_default(); + let mut listed = Vec::with_capacity(disks.len()); + let mut by_mount = HashMap::with_capacity(disks.len()); + for disk in disks { + let mount_point = disk.mount_point().to_owned(); + let file_system = disk.file_system().to_string_lossy().into_owned(); + let is_file = super::volumes::is_file_mount(&file_system, &mount_point, |path| { + std::fs::symlink_metadata(path) + }); + #[cfg(target_os = "linux")] + let disk_read_only = read_only.get(&mount_point).copied().unwrap_or(false); + #[cfg(not(target_os = "linux"))] + let disk_read_only = disk.is_read_only(); + listed.push(VolumeDescriptor { + name: disk.name().to_string_lossy().into_owned(), + file_system, + removable: disk.is_removable(), + read_only: disk_read_only, + is_file, + mount_point: mount_point.clone(), + }); + by_mount.insert(mount_point, Arc::new(Mutex::new(disk))); + } + *lock(&self.disks) = by_mount; + #[cfg(target_os = "macos")] + super::volumes::add_unlisted_network(&mut listed, mounted_file_systems()); + #[cfg(windows)] + super::volumes::add_unlisted_network(&mut listed, remote_drive_letters()); + listed + } + + fn read_volume_space(&self, mount_point: &Path) -> Result { + // Never hold the map lock across the call: a hung read must not block a listing. + let disk = lock(&self.disks) + .get(mount_point) + .cloned() + .ok_or_else(|| "the volume is no longer listed".to_owned())?; + #[cfg(windows)] + let space = { + drop(disk); + caller_space(mount_point).map_err(|error| error.to_string()) + }; + #[cfg(not(windows))] + let space = storage_space(&disk); + space + } +} + +/// Each mounted file system's mount point and type, from the kernel's mount table. `MNT_NOWAIT` +/// returns the figures the kernel already holds, so no mount point is called and a hung network +/// mount cannot hold the listing (D25). Empty when the table cannot be read. +#[cfg(target_os = "macos")] +fn mounted_file_systems() -> Vec<(PathBuf, String)> { + use std::{ffi::CStr, os::unix::ffi::OsStrExt}; + + // SAFETY: a null buffer asks for the count only. + let count = unsafe { libc::getfsstat(std::ptr::null_mut(), 0, libc::MNT_NOWAIT) }; + let Ok(count) = usize::try_from(count) else { + return Vec::new(); + }; + // Room for mounts added between the two calls; `getfsstat` never writes past `size`. + // SAFETY: `statfs` is plain data, valid when zeroed. + let mut entries = vec![unsafe { std::mem::zeroed::() }; count + 8]; + let Ok(size) = libc::c_int::try_from(entries.len() * std::mem::size_of::()) + else { + return Vec::new(); + }; + // SAFETY: `entries` is writable for `size` bytes. + let filled = unsafe { libc::getfsstat(entries.as_mut_ptr(), size, libc::MNT_NOWAIT) }; + let Ok(filled) = usize::try_from(filled) else { + return Vec::new(); + }; + entries.truncate(filled); + entries + .iter() + .map(|entry| { + // SAFETY: the kernel NUL-terminates both fields within their arrays. + let (mount_point, file_system) = unsafe { + ( + CStr::from_ptr(entry.f_mntonname.as_ptr()), + CStr::from_ptr(entry.f_fstypename.as_ptr()), + ) + }; + ( + PathBuf::from(std::ffi::OsStr::from_bytes(mount_point.to_bytes())), + file_system.to_string_lossy().into_owned(), + ) + }) + .collect() +} + +/// The remote (mapped network) drive letters, which `sysinfo` does not list. `GetLogicalDrives` +/// and `GetDriveTypeW` answer from the drive table, so no share is reached (D25). +#[cfg(windows)] +fn remote_drive_letters() -> Vec<(PathBuf, String)> { + use windows_sys::Win32::Storage::FileSystem::{GetDriveTypeW, GetLogicalDrives}; + // `DRIVE_REMOTE` from `Win32_System_WindowsProgramming`, a feature this crate does not enable. + const DRIVE_REMOTE: u32 = 4; + + // SAFETY: takes no arguments. + let mask = unsafe { GetLogicalDrives() }; + super::volumes::remote_drives(mask, |root| { + let wide: Vec = root.encode_utf16().chain(std::iter::once(0)).collect(); + // SAFETY: `wide` is NUL-terminated and outlives the call. + unsafe { GetDriveTypeW(wide.as_ptr()) == DRIVE_REMOTE } + }) +} + +/// Total and available blocks from `statvfs` on Linux; total capacity and available capacity for +/// important usage on macOS. `sysinfo` reports success even when its own query fails and keeps +/// the old figures, so `statvfs` decides whether the volume answered. +#[cfg(not(windows))] +fn storage_space(disk: &Mutex) -> Result { + let mut disk = lock(disk); + let space = statvfs_space(disk.mount_point())?; + if cfg!(target_os = "linux") { + return Ok(space); + } + // macOS: the figures Finder shows, which `statvfs` does not give. + disk.refresh_specifics(DiskRefreshKind::nothing().with_storage()); + Ok(VolumeSpace { + total_bytes: disk.total_space(), + free_bytes: disk.available_space(), + }) +} + +/// Total and available-to-the-user bytes from `statvfs`, or why the call failed. +#[cfg(unix)] +fn statvfs_space(mount_point: &Path) -> Result { + use std::os::unix::ffi::OsStrExt; + + let path = std::ffi::CString::new(mount_point.as_os_str().as_bytes()) + .map_err(|_| "the mount point holds a NUL byte".to_owned())?; + let mut stat = std::mem::MaybeUninit::::uninit(); + loop { + // SAFETY: `path` is NUL-terminated and `stat` is writable for one `statvfs`. + if unsafe { libc::statvfs(path.as_ptr(), stat.as_mut_ptr()) } == 0 { + break; + } + let error = std::io::Error::last_os_error(); + if error.kind() != std::io::ErrorKind::Interrupted { + return Err(error.to_string()); + } + } + // SAFETY: `statvfs` returned 0, so it filled `stat`. + let stat = unsafe { stat.assume_init() }; + // `fsblkcnt_t` is `u32` on macOS and `u64` on Linux. + #[allow(clippy::useless_conversion)] + let (blocks, available, block_size) = ( + u64::from(stat.f_blocks), + u64::from(stat.f_bavail), + u64::from(stat.f_frsize), + ); + Ok(VolumeSpace { + total_bytes: blocks.saturating_mul(block_size), + free_bytes: available.saturating_mul(block_size), + }) +} + +/// Total and free bytes relative to the calling user, so a quota applies to both (D31). +#[cfg(windows)] +fn caller_space(mount_point: &Path) -> std::io::Result { + use std::os::windows::ffi::OsStrExt; + use windows_sys::Win32::Storage::FileSystem::GetDiskFreeSpaceExW; + + let wide: Vec = mount_point + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect(); + let mut free_to_caller = 0_u64; + let mut total_for_caller = 0_u64; + // SAFETY: `wide` is NUL-terminated and outlives the call; the last output may be null. + let succeeded = unsafe { + GetDiskFreeSpaceExW( + wide.as_ptr(), + &raw mut free_to_caller, + &raw mut total_for_caller, + std::ptr::null_mut(), + ) + }; + if succeeded == 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(VolumeSpace { + total_bytes: total_for_caller, + free_bytes: free_to_caller, + }) +} + +/// Whether each mount point of `/proc/self/mounts` has the `ro` option; the last line for a mount +/// point wins, as the latest mount covers the earlier ones (D33). +#[cfg(any(target_os = "linux", test))] +fn read_only_mounts(mounts: &str) -> HashMap { + mounts + .lines() + .filter_map(|line| { + let mut fields = line.split_ascii_whitespace(); + let mount_point = decode_mount_field(fields.nth(1)?); + let options = fields.nth(1)?; + Some((mount_point, options.split(',').any(|option| option == "ro"))) + }) + .collect() +} + +/// Decodes the `\ooo` octal escapes the kernel writes for spaces, tabs, newlines and backslashes. +#[cfg(any(target_os = "linux", test))] +fn decode_mount_field(field: &str) -> PathBuf { + let bytes = field.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + let escape = bytes.get(index + 1..index + 4).filter(|digits| { + bytes[index] == b'\\' && digits.iter().all(|digit| (b'0'..=b'7').contains(digit)) + }); + match escape { + Some(digits) => { + let value = digits + .iter() + .fold(0_u32, |value, digit| value * 8 + u32::from(digit - b'0')); + decoded.push(u8::try_from(value).unwrap_or(b'?')); + index += 4; + } + None => { + decoded.push(bytes[index]); + index += 1; + } + } + } + PathBuf::from(String::from_utf8_lossy(&decoded).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn new_does_no_io() { + let source = NativeHostMetricsSource::new(); + + let state = lock(&source.cpu_memory); + assert!(state.system.cpus().is_empty()); + assert!(state.system.processes().is_empty()); + assert_eq!(state.system.total_memory(), 0); + assert!(lock(&source.disks).is_empty()); + } + + #[test] + fn native_source_reads_without_refreshing_processes() { + let source = NativeHostMetricsSource::new(); + + for _ in 0..10 { + let reading = source.read_cpu_memory().expect("a reading"); + assert!((0.0..=100.0).contains(&reading.cpu_percent), "{reading:?}"); + assert!(reading.memory_used_bytes <= reading.memory_total_bytes); + } + + assert!(lock(&source.cpu_memory).system.processes().is_empty()); + assert!(source.host_facts().cpu_count >= 1); + } + + #[test] + fn read_only_comes_from_the_mount_options() { + let mounts = "overlay / overlay rw,relatime,lowerdir=/x 0 0\n\ + /dev/loop0 /mnt/ro-test ext4 ro,relatime 0 0\n\ + /dev/sdb1 /media/my\\040disk exfat rw,nosuid 0 0\n\ + /dev/sdc1 /mnt/remounted ext4 rw 0 0\n\ + /dev/sdc1 /mnt/remounted ext4 ro,noatime 0 0\n"; + + let read_only = read_only_mounts(mounts); + + assert_eq!(read_only.get(Path::new("/")), Some(&false)); + assert_eq!(read_only.get(Path::new("/mnt/ro-test")), Some(&true)); + assert_eq!(read_only.get(Path::new("/media/my disk")), Some(&false)); + assert_eq!(read_only.get(Path::new("/mnt/remounted")), Some(&true)); + } + + /// #292 review: a failed capacity read is an error, not the volume's old or zero sizes. + #[cfg(unix)] + #[test] + fn statvfs_space_fails_for_a_missing_mount_point() { + let error = statvfs_space(Path::new("/bibcode-missing-volume/inner")) + .expect_err("a missing mount point fails"); + assert!(!error.is_empty()); + + let root = statvfs_space(Path::new("/")).expect("the root volume answers"); + assert!(root.total_bytes > 0); + assert!(root.free_bytes <= root.total_bytes); + } + + #[cfg(windows)] + #[test] + fn caller_space_reads_the_system_drive() { + let drive = std::env::var_os("SystemDrive").unwrap_or_else(|| "C:".into()); + let mut root = PathBuf::from(drive); + root.push("\\"); + + let space = caller_space(&root).expect("the system drive answers"); + + assert!(space.total_bytes > 0); + assert!(space.free_bytes <= space.total_bytes); + } +} diff --git a/apps/server/src/diagnostics/host_metrics/probes.rs b/apps/server/src/diagnostics/host_metrics/probes.rs new file mode 100644 index 0000000000..2ed9060614 --- /dev/null +++ b/apps/server/src/diagnostics/host_metrics/probes.rs @@ -0,0 +1,465 @@ +//! Probes run by hand on a real host (`--ignored --exact`), never in CI. They print to the +//! terminal only; what they print names local volumes, so it never goes into a file or report. + +use std::{ + path::Path, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + time::Duration, +}; + +use tokio_util::sync::CancellationToken; + +use super::{ + ChartState, CpuMemoryReading, HostFacts, HostMetricsCollector, HostMetricsIdentity, + HostMetricsRates, HostMetricsSource, HostMetricsUpdate, SamplingError, VolumeDescriptor, + VolumeReading, VolumeSpace, VolumeState, lock, merge_shared, native::NativeHostMetricsSource, + pre_filter, volume_reading, volumes::current_appimage, wire, +}; + +/// Lists every volume the native source returns and says whether the filter keeps or hides it. +#[test] +#[ignore = "probe: run by hand on a real host"] +fn volume_probe() { + let source = Arc::new(NativeHostMetricsSource::new()); + let listed = source.list_volumes(); + let (kept, mut hidden) = pre_filter( + listed.clone(), + std::env::current_exe().ok().as_deref(), + current_appimage().as_deref(), + ); + let read = kept + .into_iter() + .map( + |descriptor| match source.read_volume_space(&descriptor.mount_point) { + Ok(space) => volume_reading(descriptor, space), + Err(error) => { + println!( + "space read failed for {}: {error}", + descriptor.mount_point.display() + ); + VolumeReading { + descriptor, + total_bytes: None, + free_bytes: None, + state: VolumeState::NotResponding, + } + } + }, + ) + .collect::>(); + let readings = read.clone(); + let (shown, merged) = merge_shared(read); + hidden.extend(merged); + + println!("{} volumes listed", listed.len()); + for descriptor in &listed { + let reading = readings + .iter() + .find(|reading| reading.descriptor.mount_point == descriptor.mount_point); + let verdict = if shown + .iter() + .any(|shown| shown.descriptor.mount_point == descriptor.mount_point) + { + "kept".to_owned() + } else { + hidden + .iter() + .find(|hidden| hidden.descriptor.mount_point == descriptor.mount_point) + .map_or_else( + || "missing".to_owned(), + |hidden| format!("hidden: {:?}", hidden.reason), + ) + }; + println!( + "{} fs={} total={:?} free={:?} removable={} read_only={} file={} -> {verdict}", + descriptor.mount_point.display(), + descriptor.file_system, + reading.and_then(|reading| reading.total_bytes), + reading.and_then(|reading| reading.free_bytes), + descriptor.removable, + descriptor.read_only, + descriptor.is_file, + ); + } +} + +/// This process's CPU time, user plus system. +#[cfg(unix)] +fn process_cpu_time() -> std::time::Duration { + // SAFETY: `rusage` is plain data that `getrusage` fills. + let mut usage: libc::rusage = unsafe { std::mem::zeroed() }; + // SAFETY: `usage` is a valid, writable `rusage`. + let status = unsafe { libc::getrusage(libc::RUSAGE_SELF, &raw mut usage) }; + assert_eq!(status, 0, "getrusage failed"); + let time = |value: libc::timeval| { + std::time::Duration::from_secs(u64::try_from(value.tv_sec).unwrap_or(0)) + + std::time::Duration::from_micros(u64::try_from(value.tv_usec).unwrap_or(0)) + }; + time(usage.ru_utime) + time(usage.ru_stime) +} + +/// This process's CPU time, user plus kernel. +#[cfg(windows)] +fn process_cpu_time() -> std::time::Duration { + use windows_sys::Win32::{ + Foundation::FILETIME, + System::Threading::{GetCurrentProcess, GetProcessTimes}, + }; + + let mut creation = FILETIME::default(); + let mut exit = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + // SAFETY: every pointer refers to initialized FILETIME storage; the pseudo handle of the + // current process needs no closing. + let read = unsafe { + GetProcessTimes( + GetCurrentProcess(), + &raw mut creation, + &raw mut exit, + &raw mut kernel, + &raw mut user, + ) + }; + assert_ne!(read, 0, "GetProcessTimes failed"); + let ticks = + |time: FILETIME| (u64::from(time.dwHighDateTime) << 32) | u64::from(time.dwLowDateTime); + // FILETIME counts 100 ns ticks. + std::time::Duration::from_nanos((ticks(kernel) + ticks(user)) * 100) +} + +/// Counts the native source's CPU and memory reads, so the probe knows how many readings it paid +/// for. +#[derive(Debug)] +struct CountingSource { + inner: NativeHostMetricsSource, + reads: AtomicUsize, +} + +impl CountingSource { + fn new() -> Self { + Self { + inner: NativeHostMetricsSource::new(), + reads: AtomicUsize::new(0), + } + } + + fn reads(&self) -> usize { + self.reads.load(Ordering::Acquire) + } +} + +impl HostMetricsSource for CountingSource { + fn host_facts(&self) -> HostFacts { + self.inner.host_facts() + } + + fn read_cpu_memory(&self) -> Result { + self.reads.fetch_add(1, Ordering::AcqRel); + self.inner.read_cpu_memory() + } + + fn list_volumes(&self) -> Vec { + self.inner.list_volumes() + } + + fn read_volume_space(&self, mount_point: &Path) -> Result { + self.inner.read_volume_space(mount_point) + } +} + +const PROBE_READINGS: usize = 300; +const PROBE_RATE: Duration = Duration::from_millis(250); + +/// CPU time spent while `run` lasts, minus an idle runtime of the same length. +fn cpu_cost( + runtime: &tokio::runtime::Runtime, + run: impl std::future::Future, +) -> Duration { + let wall = std::time::Instant::now(); + let before = process_cpu_time(); + runtime.block_on(run); + let busy = process_cpu_time().saturating_sub(before); + let length = wall.elapsed(); + let before = process_cpu_time(); + runtime.block_on(async move { tokio::time::sleep(length).await }); + let idle = process_cpu_time().saturating_sub(before); + println!( + " run {:.1} s: {:.1} ms CPU, idle {:.1} ms CPU", + length.as_secs_f64(), + busy.as_secs_f64() * 1_000.0, + idle.as_secs_f64() * 1_000.0 + ); + busy.saturating_sub(idle) +} + +fn report(kind: &str, cost: Duration, readings: usize) { + let per_reading_ms = cost.as_secs_f64() * 1_000.0 / readings.max(1) as f64; + println!( + "{kind}: {readings} readings, {:.1} ms CPU above idle, {per_reading_ms:.3} ms per reading; \ + percent of one core at 60 s {:.4}, at 1 s {:.3}, at 2 s {:.3}", + cost.as_secs_f64() * 1_000.0, + per_reading_ms / 60_000.0 * 100.0, + per_reading_ms / 1_000.0 * 100.0, + per_reading_ms / 2_000.0 * 100.0, + ); +} + +/// Task 11.4: measures the real collector over the native source. Background-shaped readings +/// with no subscriber, then live-shaped readings with one subscriber whose items are encoded as on +/// the wire, with the uptime chart rebuilt and sent every fifth reading to over-count chart cost. +/// It asserts nothing about limits: the policy is applied from what it prints. +#[test] +#[ignore = "probe: run by hand on a real host"] +fn cost_probe() { + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("runtime"); + let rates = HostMetricsRates { + background: PROBE_RATE, + live: PROBE_RATE, + ..HostMetricsRates::default() + }; + let identity = HostMetricsIdentity { + boot_id: None, + started_at_ms: 0, + }; + + let source = Arc::new(CountingSource::new()); + let background = HostMetricsCollector::new(source.clone(), rates, identity.clone()); + let cost = cpu_cost(&runtime, async { + background.start_background(); + while source.reads() < PROBE_READINGS { + tokio::time::sleep(Duration::from_millis(20)).await; + } + background.shutdown().await; + }); + report("background", cost, source.reads()); + + let source = Arc::new(CountingSource::new()); + let live = Arc::new(HostMetricsCollector::new(source.clone(), rates, identity)); + let cost = cpu_cost(&runtime, { + let live = live.clone(); + let source = source.clone(); + async move { + let (sender, mut receiver) = tokio::sync::mpsc::channel(64); + let cancellation = CancellationToken::new(); + let stream = tokio::spawn(wire::send_host_metrics( + live.clone(), + sender, + cancellation.clone(), + )); + let mut items = 0_usize; + let mut bytes = 0_usize; + while source.reads() < PROBE_READINGS { + let Some(Ok(values)) = receiver.recv().await else { + break; + }; + items += 1; + bytes += serde_json::to_vec(&values).map_or(0, |encoded| encoded.len()); + if items.is_multiple_of(5) { + // A chart revision every fifth reading: rebuild the uptime chart and send it. + let uptime = lock(&live.shared.statistics).chart.uptime(240); + let latest = lock(&live.shared.statistics).current.clone(); + let summary = { + let statistics = lock(&live.shared.statistics); + statistics.cpu.summary().zip(statistics.memory.summary()) + }; + if let (Some(reading), Some((cpu, memory))) = (latest, summary) { + let update = HostMetricsUpdate { + reading, + statistics_since_ms: lock(&live.shared.statistics).since_ms, + cpu, + memory, + chart: Arc::new(ChartState { + revision: u64::try_from(items).unwrap_or(0), + uptime, + hidden_volumes: Vec::new(), + }), + volume_statistics: live.shared.volume_statistics(), + }; + bytes += serde_json::to_vec(&wire::encode_update(&update, true)) + .map_or(0, |encoded| encoded.len()); + } + } + } + cancellation.cancel(); + let _ = stream.await; + live.shutdown().await; + println!("live: {items} items, {bytes} bytes encoded"); + } + }); + report("live", cost, source.reads()); +} + +/// Runs a system tool and returns its standard output. +#[cfg(unix)] +fn tool_output(program: &str, args: &[&str]) -> String { + let output = std::process::Command::new(program) + .args(args) + .output() + .unwrap_or_else(|error| panic!("{program} did not run: {error}")); + assert!(output.status.success(), "{program} failed: {output:?}"); + String::from_utf8_lossy(&output.stdout).into_owned() +} + +/// The first number in `text` after `label`. +#[cfg(unix)] +fn number_after(text: &str, label: &str) -> f64 { + let start = text + .find(label) + .unwrap_or_else(|| panic!("no {label:?} in the tool output")) + + label.len(); + text[start..] + .split(|c: char| !(c.is_ascii_digit() || c == '.')) + .find(|part| !part.is_empty()) + .and_then(|part| part.parse().ok()) + .unwrap_or_else(|| panic!("no number after {label:?}")) +} + +/// Total and available bytes of `/` from `df -k -P /`: the 1024-blocks and Available columns, +/// the same `statvfs` fields the collector reads. +#[cfg(unix)] +fn df_root() -> (u64, u64) { + let output = tool_output("df", &["-k", "-P", "/"]); + let line = output.lines().nth(1).expect("df printed no volume line"); + let fields: Vec = line + .split_whitespace() + .skip(1) + .take(3) + .map(|field| field.parse().expect("df column is a number")) + .collect(); + (fields[0] * 1024, fields[2] * 1024) +} + +/// Total and idle jiffies of `/proc/stat`'s "cpu" line (iowait counts as idle, as in `sysinfo`). +#[cfg(target_os = "linux")] +fn proc_stat_cpu() -> (u64, u64) { + let stat = std::fs::read_to_string("/proc/stat").expect("read /proc/stat"); + let values: Vec = stat + .lines() + .next() + .expect("cpu line") + .split_whitespace() + .skip(1) + .map(|value| value.parse().expect("number")) + .collect(); + // user nice system idle iowait irq softirq steal; guest time is already inside user. + (values.iter().take(8).sum(), values[3] + values[4]) +} + +#[cfg(unix)] +fn within(name: &str, native: f64, tool: f64, tolerance: f64, unit: &str) { + let difference = (native - tool).abs(); + println!( + "{name}: native {native:.2}{unit}, tool {tool:.2}{unit}, difference {difference:.2}{unit} \ + (tolerance {tolerance:.2}{unit})" + ); + assert!(difference <= tolerance, "{name} is outside its tolerance"); +} + +/// Task 11.10 (R80): compares the native source with the operating system's own tools over one +/// 5 s window, naming only `/`. Windows has no part here: it is covered by CI build and unit +/// tests only. +#[cfg(any(target_os = "linux", target_os = "macos"))] +#[test] +#[ignore = "probe: run by hand on a real host"] +fn os_tools_probe() { + let source = NativeHostMetricsSource::new(); + // Primes the CPU counters, so the next read covers the window below. + source.read_cpu_memory().expect("first read"); + + #[cfg(target_os = "linux")] + let tool_cpu = { + let (total_before, idle_before) = proc_stat_cpu(); + std::thread::sleep(Duration::from_secs(5)); + let (total_after, idle_after) = proc_stat_cpu(); + let total = (total_after - total_before) as f64; + 100.0 * (total - (idle_after - idle_before) as f64) / total + }; + #[cfg(target_os = "macos")] + let tool_cpu = { + // The second sample covers the 5 s after the first. + let top = tool_output("top", &["-l", "2", "-s", "5", "-n", "0"]); + let second = top.rsplit("CPU usage:").next().expect("a CPU usage line"); + 100.0 - number_after(second, "sys, ") + }; + let reading = source.read_cpu_memory().expect("window read"); + within("CPU busy", reading.cpu_percent, tool_cpu, 10.0, " pp"); + + let native_memory = + 100.0 * reading.memory_used_bytes as f64 / reading.memory_total_bytes as f64; + #[cfg(target_os = "linux")] + let tool_memory = { + let meminfo = std::fs::read_to_string("/proc/meminfo").expect("read /proc/meminfo"); + let total = number_after(&meminfo, "MemTotal:"); + 100.0 * (total - number_after(&meminfo, "MemAvailable:")) / total + }; + #[cfg(target_os = "macos")] + let tool_memory = { + let vm_stat = tool_output("vm_stat", &[]); + let pages = |label: &str| number_after(&vm_stat, label); + let used = (pages("Anonymous pages:") - pages("Pages purgeable:") + + pages("Pages wired down:") + + pages("Pages occupied by compressor:")) + * pages("page size of "); + let total: f64 = tool_output("sysctl", &["-n", "hw.memsize"]) + .trim() + .parse() + .expect("hw.memsize"); + 100.0 * used / total + }; + within("memory used", native_memory, tool_memory, 3.0, " pp"); + + source.list_volumes(); + let space = source + .read_volume_space(Path::new("/")) + .expect("space of /"); + let (df_total, df_available) = df_root(); + let percent_of_df_total = |bytes: u64| 100.0 * bytes as f64 / df_total as f64; + within( + "/ total, percent of df's total", + percent_of_df_total(space.total_bytes), + 100.0, + 1.0, + "%", + ); + #[cfg(target_os = "linux")] + let tool_free = df_available; + #[cfg(target_os = "macos")] + let tool_free = { + // The property `sysinfo` reads, with its fallback (`src/unix/apple/disk.rs` 380-391). + let script = "ObjC.import('Foundation'); \ + const url = $.NSURL.fileURLWithPath('/'); \ + const keys = ['NSURLVolumeAvailableCapacityForImportantUsageKey', \ + 'NSURLVolumeAvailableCapacityKey']; \ + const values = url.resourceValuesForKeysError($(keys), null); \ + const important = Number(values.objectForKey(keys[0]).js); \ + important > 0 ? important : Number(values.objectForKey(keys[1]).js)"; + let important: f64 = tool_output("osascript", &["-l", "JavaScript", "-e", script]) + .trim() + .parse() + .expect("available capacity"); + println!( + "/ df Available {:.2}% of df's total; free must be at least that", + percent_of_df_total(df_available) + ); + assert!( + space.free_bytes >= df_available, + "free is below df's Available" + ); + important as u64 + }; + // Free within 1 percent of the tool's value. + within( + "/ free, percent of df's total", + percent_of_df_total(space.free_bytes), + percent_of_df_total(tool_free), + percent_of_df_total(tool_free) / 100.0, + "%", + ); +} diff --git a/apps/server/src/diagnostics/host_metrics/stats.rs b/apps/server/src/diagnostics/host_metrics/stats.rs new file mode 100644 index 0000000000..88d30b62f9 --- /dev/null +++ b/apps/server/src/diagnostics/host_metrics/stats.rs @@ -0,0 +1,619 @@ +//! Statistics since server start and the chart tiers (R69, D28). Sizes are fixed or capped, so +//! nothing grows with uptime. + +use std::{collections::VecDeque, time::Duration}; + +pub const RECENT_WINDOW: Duration = Duration::from_secs(15 * 60); +pub const RECENT_MAX_POINTS: usize = 900; +pub const MINUTE_TIER_BUCKETS: usize = 1_440; +pub const OLDEST_TIER_MAX_BUCKETS: usize = 20_000; +const MINUTE_S: u32 = 60; +const OLDEST_WIDTH_S: u32 = 15 * 60; +const BINS: usize = 101; +/// A reading without a weight still counts, a little. +const MIN_WEIGHT_S: f64 = 0.001; + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct MeasureStatistics { + pub current: f64, + pub minimum: f64, + pub maximum: f64, + pub average: f64, + pub p95: f64, +} + +/// Current, minimum, maximum, a time-weighted average and a p95 from 101 one-point bins. +#[derive(Clone, Debug)] +pub(crate) struct RunningStatistic { + /// Bin `i` holds the weight of readings in `[i, i + 1)`; bin 100 holds 100. + bins: [f64; BINS], + total_weight: f64, + weighted_sum: f64, + current: Option, + minimum: f64, + maximum: f64, +} + +impl Default for RunningStatistic { + fn default() -> Self { + Self { + bins: [0.0; BINS], + total_weight: 0.0, + weighted_sum: 0.0, + current: None, + minimum: f64::INFINITY, + maximum: f64::NEG_INFINITY, + } + } +} + +pub(super) fn clamp_percent(percent: f64) -> f64 { + if percent.is_nan() { + 0.0 + } else { + percent.clamp(0.0, 100.0) + } +} + +fn weight_seconds(weight: Duration) -> f64 { + weight.as_secs_f64().max(MIN_WEIGHT_S) +} + +impl RunningStatistic { + pub(crate) fn record(&mut self, percent: f64, weight: Duration) { + let percent = clamp_percent(percent); + let weight = weight_seconds(weight); + // Truncation is the bin's lower edge; the value is already within 0..=100. + let bin = (percent.floor() as usize).min(BINS - 1); + self.bins[bin] += weight; + self.total_weight += weight; + self.weighted_sum += percent * weight; + self.current = Some(percent); + self.minimum = self.minimum.min(percent); + self.maximum = self.maximum.max(percent); + } + + pub(crate) fn summary(&self) -> Option { + let current = self.current?; + let threshold = self.total_weight * 0.95; + let mut reached = 0.0; + let mut p95 = 100.0; + for (bin, weight) in self.bins.iter().enumerate() { + reached += weight; + // The tolerance keeps a float sum that lands just under the threshold in its bin. + if reached >= threshold - self.total_weight * 1e-12 { + p95 = bin as f64; + break; + } + } + Some(MeasureStatistics { + current, + minimum: self.minimum, + maximum: self.maximum, + average: self.weighted_sum / self.total_weight, + p95, + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ChartPoint { + pub sampled_at_ms: i128, + pub cpu_percent: f64, + pub memory_percent: f64, +} + +/// One bar of the uptime chart, as the stream sends it. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct ChartBucket { + pub started_at_ms: i128, + pub duration_ms: u64, + pub cpu_average: f64, + pub cpu_peak: f64, + pub memory_average: f64, + pub memory_peak: f64, +} + +/// A closed bucket, stored compactly: offsets in seconds from the first reading. `weight_s` is +/// the time its readings observed, which a suspend inside `width_s` does not add to. +#[derive(Clone, Copy, Debug, PartialEq)] +struct StoredBucket { + start_s: u32, + width_s: u32, + weight_s: f32, + cpu_average: f32, + cpu_peak: f32, + memory_average: f32, + memory_peak: f32, +} + +impl StoredBucket { + fn end_s(&self) -> u32 { + self.start_s.saturating_add(self.width_s) + } + + /// Merges consecutive buckets, weighting each by the time it observed. + fn merge(buckets: &[StoredBucket]) -> Option { + let first = buckets.first()?; + let mut open = OpenBucket::starting_at(first.start_s); + for bucket in buckets { + open.add_bucket(bucket); + } + Some(open.close()) + } +} + +/// The open bucket of a tier: the only place that keeps sums and weights. +#[derive(Clone, Copy, Debug)] +struct OpenBucket { + start_s: u32, + end_s: u32, + weight: f64, + cpu_sum: f64, + memory_sum: f64, + cpu_peak: f64, + memory_peak: f64, +} + +impl OpenBucket { + fn starting_at(start_s: u32) -> Self { + Self { + start_s, + end_s: start_s, + weight: 0.0, + cpu_sum: 0.0, + memory_sum: 0.0, + cpu_peak: 0.0, + memory_peak: 0.0, + } + } + + fn add(&mut self, cpu: f64, memory: f64, weight: f64, cpu_peak: f64, memory_peak: f64) { + self.weight += weight; + self.cpu_sum += cpu * weight; + self.memory_sum += memory * weight; + self.cpu_peak = self.cpu_peak.max(cpu_peak); + self.memory_peak = self.memory_peak.max(memory_peak); + } + + fn add_bucket(&mut self, bucket: &StoredBucket) { + self.add( + f64::from(bucket.cpu_average), + f64::from(bucket.memory_average), + f64::from(bucket.weight_s), + f64::from(bucket.cpu_peak), + f64::from(bucket.memory_peak), + ); + self.end_s = self.end_s.max(bucket.end_s()); + } + + fn close(&self) -> StoredBucket { + let weight = self.weight.max(MIN_WEIGHT_S); + // An average never shows above its peak, whatever the float rounding. + let cpu_peak = self.cpu_peak as f32; + let memory_peak = self.memory_peak as f32; + StoredBucket { + start_s: self.start_s, + width_s: self.end_s.saturating_sub(self.start_s), + weight_s: weight as f32, + cpu_average: ((self.cpu_sum / weight) as f32).min(cpu_peak), + cpu_peak, + memory_average: ((self.memory_sum / weight) as f32).min(memory_peak), + memory_peak, + } + } +} + +/// Chart history in three tiers: the last 15 minutes of readings, 1-minute buckets for the last +/// 1,440 minutes, and 15-minute buckets beyond, whose pairs merge when the tier is full. +#[derive(Debug)] +pub(crate) struct ChartHistory { + first_ms: Option, + recent: VecDeque, + open_minute: Option<(u32, OpenBucket)>, + minutes: VecDeque, + oldest: Vec, + open_oldest: Option, + oldest_width_s: u32, + oldest_capacity: usize, + #[cfg(test)] + compactions: u64, + revision: u64, +} + +impl ChartHistory { + pub(crate) fn new() -> Self { + Self::with_capacity(OLDEST_TIER_MAX_BUCKETS) + } + + #[cfg(test)] + pub(crate) fn with_oldest_capacity(capacity: usize) -> Self { + Self::with_capacity(capacity) + } + + fn with_capacity(oldest_capacity: usize) -> Self { + Self { + first_ms: None, + recent: VecDeque::new(), + open_minute: None, + minutes: VecDeque::new(), + oldest: Vec::new(), + open_oldest: None, + oldest_width_s: OLDEST_WIDTH_S, + oldest_capacity: oldest_capacity.max(2), + #[cfg(test)] + compactions: 0, + revision: 0, + } + } + + pub(crate) fn record(&mut self, point: ChartPoint, weight: Duration) { + let point = ChartPoint { + sampled_at_ms: point.sampled_at_ms, + cpu_percent: clamp_percent(point.cpu_percent), + memory_percent: clamp_percent(point.memory_percent), + }; + let first_ms = *self.first_ms.get_or_insert(point.sampled_at_ms); + self.push_recent(point); + + let offset_s = + u32::try_from(((point.sampled_at_ms - first_ms) / 1_000).max(0)).unwrap_or(u32::MAX); + let minute = offset_s / MINUTE_S; + if let Some((open_index, open)) = self.open_minute + && minute > open_index + { + self.close_minute(open); + self.open_minute = None; + } + let (_, open) = self.open_minute.get_or_insert_with(|| { + let start_s = minute * MINUTE_S; + let mut open = OpenBucket::starting_at(start_s); + open.end_s = start_s.saturating_add(MINUTE_S); + (minute, open) + }); + open.add( + point.cpu_percent, + point.memory_percent, + weight_seconds(weight), + point.cpu_percent, + point.memory_percent, + ); + } + + fn push_recent(&mut self, point: ChartPoint) { + self.recent.push_back(point); + let oldest_kept = point.sampled_at_ms - RECENT_WINDOW.as_millis() as i128; + while self.recent.len() > RECENT_MAX_POINTS + || self + .recent + .front() + .is_some_and(|front| front.sampled_at_ms < oldest_kept) + { + self.recent.pop_front(); + } + } + + fn close_minute(&mut self, open: OpenBucket) { + self.minutes.push_back(open.close()); + self.revision += 1; + while self.minutes.len() > MINUTE_TIER_BUCKETS { + if let Some(bucket) = self.minutes.pop_front() { + self.feed_oldest(bucket); + } + } + } + + fn feed_oldest(&mut self, bucket: StoredBucket) { + let open = self + .open_oldest + .get_or_insert_with(|| OpenBucket::starting_at(bucket.start_s)); + open.add_bucket(&bucket); + if open.end_s.saturating_sub(open.start_s) < self.oldest_width_s { + return; + } + let closed = open.close(); + self.open_oldest = None; + if self.oldest.len() < self.oldest_capacity { + self.oldest.push(closed); + return; + } + self.oldest = self + .oldest + .chunks(2) + .filter_map(StoredBucket::merge) + .collect(); + self.oldest_width_s = self.oldest_width_s.saturating_mul(2); + #[cfg(test)] + { + self.compactions += 1; + } + // The bucket that just closed at the old width keeps filling at the new one. + let mut reopened = OpenBucket::starting_at(closed.start_s); + reopened.add_bucket(&closed); + self.open_oldest = Some(reopened); + } + + /// Rises whenever `uptime` would change. + pub(crate) fn revision(&self) -> u64 { + self.revision + } + + pub(crate) fn recent(&self) -> Vec { + self.recent.iter().copied().collect() + } + + /// The closed history at most `max_bars` wide; the open minute is not in it yet. + pub(crate) fn uptime(&self, max_bars: usize) -> Vec { + let closed = self + .oldest + .iter() + .copied() + .chain(self.open_oldest.map(|open| open.close())) + .chain(self.minutes.iter().copied()) + .collect::>(); + if closed.is_empty() || max_bars == 0 { + return Vec::new(); + } + let per_bar = closed.len().div_ceil(max_bars); + closed + .chunks(per_bar) + .filter_map(StoredBucket::merge) + .map(|bucket| self.output(bucket)) + .collect() + } + + fn output(&self, bucket: StoredBucket) -> ChartBucket { + let first_ms = self.first_ms.unwrap_or_default(); + ChartBucket { + started_at_ms: first_ms + i128::from(bucket.start_s) * 1_000, + duration_ms: u64::from(bucket.width_s) * 1_000, + cpu_average: f64::from(bucket.cpu_average), + cpu_peak: f64::from(bucket.cpu_peak), + memory_average: f64::from(bucket.memory_average), + memory_peak: f64::from(bucket.memory_peak), + } + } + + #[cfg(test)] + fn minute_buckets(&self) -> usize { + self.minutes.len() + } + + #[cfg(test)] + fn oldest_stored(&self) -> usize { + self.oldest.len() + } + + #[cfg(test)] + fn oldest_compactions(&self) -> u64 { + self.compactions + } + + #[cfg(test)] + fn oldest_buckets(&self) -> Vec { + self.oldest + .iter() + .copied() + .chain(self.open_oldest.map(|open| open.close())) + .map(|bucket| self.output(bucket)) + .collect() + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + + const MINUTE: Duration = Duration::from_secs(60); + const SECOND: Duration = Duration::from_secs(1); + + fn point(at_s: i128, cpu: f64) -> ChartPoint { + ChartPoint { + sampled_at_ms: at_s * 1_000, + cpu_percent: cpu, + memory_percent: cpu / 2.0, + } + } + + #[test] + fn known_values_give_exact_statistics() { + let mut statistic = RunningStatistic::default(); + for value in [10.0, 20.0, 30.0, 40.0, 90.0] { + statistic.record(value, MINUTE); + } + + let summary = statistic.summary().expect("statistics after readings"); + + assert_eq!(summary.current, 90.0); + assert_eq!(summary.minimum, 10.0); + assert_eq!(summary.maximum, 90.0); + assert!((summary.average - 38.0).abs() < 1e-9, "{summary:?}"); + assert_eq!(summary.p95, 90.0); + } + + #[test] + fn a_watched_minute_does_not_outweigh_an_unwatched_hour() { + let mut statistic = RunningStatistic::default(); + for _ in 0..60 { + statistic.record(10.0, MINUTE); + } + for _ in 0..60 { + statistic.record(90.0, SECOND); + } + + let summary = statistic.summary().expect("statistics after readings"); + + assert!( + (11.3..11.4).contains(&summary.average), + "average {}", + summary.average + ); + assert_eq!(summary.p95, 10.0); + } + + #[test] + fn values_outside_0_to_100_are_clamped_into_the_end_bins() { + let mut statistic = RunningStatistic::default(); + statistic.record(-5.0, MINUTE); + statistic.record(250.0, MINUTE); + + let summary = statistic.summary().expect("statistics after readings"); + + assert_eq!(summary.minimum, 0.0); + assert_eq!(summary.maximum, 100.0); + assert_eq!(summary.current, 100.0); + assert_eq!(summary.p95, 100.0); + assert_eq!(statistic.bins[0], 60.0); + assert_eq!(statistic.bins[100], 60.0); + } + + #[test] + fn no_reading_has_no_statistics() { + assert!(RunningStatistic::default().summary().is_none()); + } + + #[test] + fn a_week_of_background_readings_stays_bounded() { + let mut history = ChartHistory::new(); + let first_s = 1_700_000_000; + for index in 0..10_080 { + let cpu = f64::from(index % 100); + history.record(point(first_s + i128::from(index) * 60, cpu), MINUTE); + } + + assert!(history.recent().len() <= 900); + assert!(history.minute_buckets() <= MINUTE_TIER_BUCKETS); + let uptime = history.uptime(240); + assert!(uptime.len() <= 240, "{} bars", uptime.len()); + assert_eq!(uptime[0].started_at_ms, first_s * 1_000); + for bucket in &uptime { + assert!(bucket.cpu_peak >= bucket.cpu_average, "{bucket:?}"); + assert!(bucket.memory_peak >= bucket.memory_average, "{bucket:?}"); + } + } + + #[test] + fn the_oldest_tier_compacts_and_keeps_its_meaning() { + let mut history = ChartHistory::with_oldest_capacity(8); + let first_s = 1_700_000_000; + let mut at_s = first_s; + let mut feed = |history: &mut ChartHistory, cpu: f64| { + history.record(point(at_s, cpu), MINUTE); + at_s += 60; + }; + // 64 fifteen-minute buckets of 10 percent, then 64 of 30 with one reading of 95. + for _ in 0..64 * 15 { + feed(&mut history, 10.0); + } + for index in 0..64 * 15 { + feed(&mut history, if index == 500 { 95.0 } else { 30.0 }); + } + // A day more pushes all of them out of the minute tier into the oldest tier. + let mut revisions = vec![history.revision()]; + let mut compactions = history.oldest_compactions(); + for _ in 0..MINUTE_TIER_BUCKETS + 1 { + feed(&mut history, 50.0); + if history.oldest_compactions() != compactions { + compactions = history.oldest_compactions(); + revisions.push(history.revision()); + } + } + let compactions_after_day = history.oldest_compactions(); + + let oldest = history.oldest_buckets(); + assert!( + history.oldest_stored() <= 8, + "{} stored", + history.oldest_stored() + ); + assert!( + compactions_after_day >= 3, + "{compactions_after_day} compactions" + ); + assert_eq!(oldest[0].started_at_ms, first_s * 1_000); + let weight: f64 = oldest.iter().map(|bucket| bucket.duration_ms as f64).sum(); + let average = oldest + .iter() + .map(|bucket| bucket.cpu_average * bucket.duration_ms as f64) + .sum::() + / weight; + assert!((average - 20.0).abs() < 0.1, "average {average}"); + let peak = oldest + .iter() + .map(|bucket| bucket.cpu_peak) + .fold(0.0, f64::max); + assert_eq!(peak, 95.0); + assert!( + revisions.windows(2).all(|pair| pair[1] > pair[0]), + "{revisions:?}" + ); + } + + /// #292 review: an oldest-tier bucket that spans a suspend keeps the weight of the time it + /// observed, so merging it again (compaction or the 240-bar chart) never counts the gap. + #[test] + fn a_bucket_spanning_a_sleep_gap_weighs_only_its_observed_time() { + let mut history = ChartHistory::new(); + // Five minutes at 90 percent, three hours asleep, then a day and a half at 10 percent. + for minute in 0..5 { + history.record(point(minute * 60, 90.0), MINUTE); + } + let awake = 200; + let minutes_after = 1_440 + 120; + for minute in awake..awake + minutes_after { + history.record(point(minute * 60, 10.0), MINUTE); + } + + let all = history.uptime(1); + assert_eq!(all.len(), 1); + // Closed minutes: the five at 90 and all but the open one at 10. + let closed_after = (minutes_after - 1) as f64; + let expected = (90.0 * 5.0 + 10.0 * closed_after) / (5.0 + closed_after); + assert!( + (all[0].cpu_average - expected).abs() < 0.05, + "average {} instead of {expected}", + all[0].cpu_average + ); + assert!( + history.oldest_buckets()[0].duration_ms > 3 * 3_600_000, + "the bar still spans the gap" + ); + } + + #[test] + fn the_oldest_tier_fits_in_one_mebibyte() { + assert_eq!(std::mem::size_of::(), 28); + assert!(std::mem::size_of::() * OLDEST_TIER_MAX_BUCKETS < 1_048_576); + } + + #[test] + fn recent_keeps_the_last_fifteen_minutes() { + let mut history = ChartHistory::new(); + for second in 0..2_000 { + history.record(point(second, 5.0), SECOND); + } + + let recent = history.recent(); + + assert_eq!(recent.len(), 900); + assert_eq!( + recent.last().map(|point| point.sampled_at_ms), + Some(1_999_000) + ); + assert!(recent[0].sampled_at_ms >= (1_999 - 900) * 1_000); + } + + #[test] + fn the_revision_rises_when_a_minute_bucket_closes() { + let mut history = ChartHistory::new(); + history.record(point(0, 5.0), SECOND); + let before = history.revision(); + history.record(point(30, 5.0), SECOND); + assert_eq!(history.revision(), before); + history.record(point(61, 5.0), SECOND); + assert!(history.revision() > before); + assert_eq!(history.uptime(240).len(), 1); + } +} diff --git a/apps/server/src/diagnostics/host_metrics/tests.rs b/apps/server/src/diagnostics/host_metrics/tests.rs new file mode 100644 index 0000000000..b97c5eaf1d --- /dev/null +++ b/apps/server/src/diagnostics/host_metrics/tests.rs @@ -0,0 +1,1380 @@ +//! Collector tests (Task 11.2): paused-clock tests with a fast fake, and real-clock tests with a +//! source that holds real threads on channels the test owns. + +use std::{ + collections::{HashMap, VecDeque}, + path::{Path, PathBuf}, + sync::{ + Arc, Mutex, + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + time::Duration, +}; + +use super::*; + +const GB: u64 = 1_000_000_000; + +fn descriptor(mount_point: &str, file_system: &str) -> VolumeDescriptor { + VolumeDescriptor { + mount_point: PathBuf::from(mount_point), + name: String::new(), + file_system: file_system.to_owned(), + removable: false, + read_only: false, + is_file: false, + } +} + +fn cpu(cpu_percent: f64) -> Result { + Ok(CpuMemoryReading { + cpu_percent, + memory_total_bytes: 32 * GB, + memory_used_bytes: 8 * GB, + }) +} + +type ListScript = Box Vec + Send + Sync>; + +/// Returns scripted values at once and counts every call. +pub(super) struct FakeHostMetricsSource { + cpu: Mutex>>, + list: ListScript, + space: Mutex>, + cpu_reads: AtomicUsize, + list_calls: AtomicUsize, + space_reads: Mutex>, +} + +impl std::fmt::Debug for FakeHostMetricsSource { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("FakeHostMetricsSource") + } +} + +impl FakeHostMetricsSource { + pub(super) fn new(volumes: Vec) -> Self { + Self::with_list(Box::new(move |_| volumes.clone())) + } + + fn with_list(list: ListScript) -> Self { + Self { + cpu: Mutex::new(VecDeque::from([cpu(20.0)])), + list, + space: Mutex::new(HashMap::new()), + cpu_reads: AtomicUsize::new(0), + list_calls: AtomicUsize::new(0), + space_reads: Mutex::new(HashMap::new()), + } + } + + fn script_cpu( + self, + readings: impl IntoIterator>, + ) -> Self { + *self.cpu.lock().unwrap() = readings.into_iter().collect(); + self + } + + fn space(self, mount_point: &str, total: u64, free: u64) -> Self { + self.space.lock().unwrap().insert( + PathBuf::from(mount_point), + VolumeSpace { + total_bytes: total, + free_bytes: free, + }, + ); + self + } + + fn cpu_reads(&self) -> usize { + self.cpu_reads.load(Ordering::Acquire) + } + + fn list_calls(&self) -> usize { + self.list_calls.load(Ordering::Acquire) + } + + fn space_reads(&self, mount_point: &str) -> usize { + self.space_reads + .lock() + .unwrap() + .get(Path::new(mount_point)) + .copied() + .unwrap_or(0) + } + + fn all_space_reads(&self) -> usize { + self.space_reads.lock().unwrap().values().sum() + } +} + +impl HostMetricsSource for FakeHostMetricsSource { + fn host_facts(&self) -> HostFacts { + HostFacts { + os: "linux", + arch: "x86_64", + cpu_count: 12, + memory_total_bytes: 32 * GB, + } + } + + fn read_cpu_memory(&self) -> Result { + self.cpu_reads.fetch_add(1, Ordering::AcqRel); + let mut script = self.cpu.lock().unwrap(); + // The last scripted value repeats. + let next = if script.len() > 1 { + script.pop_front() + } else { + script.front().cloned() + }; + next.expect("scripted CPU reading") + .map_err(SamplingError::Failed) + } + + fn list_volumes(&self) -> Vec { + let call = self.list_calls.fetch_add(1, Ordering::AcqRel); + (self.list)(call) + } + + fn read_volume_space(&self, mount_point: &Path) -> Result { + *self + .space_reads + .lock() + .unwrap() + .entry(mount_point.to_owned()) + .or_default() += 1; + Ok(self + .space + .lock() + .unwrap() + .get(mount_point) + .copied() + .unwrap_or(VolumeSpace { + total_bytes: 100 * GB, + free_bytes: 40 * GB, + })) + } +} + +/// Wraps the fake; chosen calls park the calling thread on a channel the test holds, until the +/// test sends the result or drops the sender. +#[derive(Debug)] +struct BlockingHostMetricsSource { + inner: FakeHostMetricsSource, + /// By list call number. + list_gates: Mutex>>>, + /// By mount point and the number of that mount point's space read. + space_gates: Mutex>>, + cpu_gate: Mutex>>, +} + +impl BlockingHostMetricsSource { + fn new(inner: FakeHostMetricsSource) -> Self { + Self { + inner, + list_gates: Mutex::new(HashMap::new()), + space_gates: Mutex::new(HashMap::new()), + cpu_gate: Mutex::new(None), + } + } + + fn hold_list(&self, call: usize) -> mpsc::Sender> { + let (sender, receiver) = mpsc::channel(); + self.list_gates.lock().unwrap().insert(call, receiver); + sender + } + + fn hold_space(&self, mount_point: &str, call: usize) -> mpsc::Sender { + let (sender, receiver) = mpsc::channel(); + self.space_gates + .lock() + .unwrap() + .insert((PathBuf::from(mount_point), call), receiver); + sender + } + + fn hold_cpu(&self) -> mpsc::Sender<()> { + let (sender, receiver) = mpsc::channel(); + *self.cpu_gate.lock().unwrap() = Some(receiver); + sender + } +} + +impl HostMetricsSource for BlockingHostMetricsSource { + fn host_facts(&self) -> HostFacts { + self.inner.host_facts() + } + + fn read_cpu_memory(&self) -> Result { + let gate = self.cpu_gate.lock().unwrap().take(); + if let Some(gate) = gate { + let _ = gate.recv(); + } + self.inner.read_cpu_memory() + } + + fn list_volumes(&self) -> Vec { + let call = self.inner.list_calls(); + let gate = self.list_gates.lock().unwrap().remove(&call); + match gate { + Some(gate) => { + self.inner.list_calls.fetch_add(1, Ordering::AcqRel); + gate.recv().unwrap_or_default() + } + None => self.inner.list_volumes(), + } + } + + fn read_volume_space(&self, mount_point: &Path) -> Result { + let call = self.inner.space_reads(&mount_point.to_string_lossy()); + let gate = self + .space_gates + .lock() + .unwrap() + .remove(&(mount_point.to_owned(), call)); + match gate { + Some(gate) => { + *self + .inner + .space_reads + .lock() + .unwrap() + .entry(mount_point.to_owned()) + .or_default() += 1; + gate.recv().map_err(|_| "released".to_owned()) + } + None => self.inner.read_volume_space(mount_point), + } + } +} + +fn identity() -> HostMetricsIdentity { + HostMetricsIdentity { + boot_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), + started_at_ms: 1_700_000_000_000, + } +} + +fn collector(source: Arc) -> HostMetricsCollector { + HostMetricsCollector::new(source, HostMetricsRates::default(), identity()) +} + +/// Waits until nothing the last tick started is still running: no CPU read on the blocking pool, +/// no disk job on a worker thread and no result the disk loop has not taken. The loops run only +/// while this task yields, and the paused clock does not move while it blocks, so a pass that +/// finds everything idle after yielding means the collector waits on its clock alone. +pub(super) async fn quiesce(collector: &HostMetricsCollector) { + let deadline = std::time::Instant::now() + Duration::from_secs(30); + let workers = lock(&collector.shared.workers).clone(); + let cpu_busy = || collector.shared.cpu_in_flight.load(Ordering::Acquire) > 0; + loop { + for _ in 0..8 { + tokio::task::yield_now().await; + } + if !cpu_busy() && workers.as_ref().is_none_or(|workers| workers.idle()) { + return; + } + while cpu_busy() || workers.as_ref().is_some_and(|workers| workers.busy()) { + assert!( + std::time::Instant::now() < deadline, + "the collector did not settle" + ); + std::thread::sleep(Duration::from_millis(1)); + } + } +} + +/// Runs the paused clock forward in steps, waiting for the collector to settle after each. +pub(super) async fn run_for(collector: &HostMetricsCollector, total: Duration, step: Duration) { + let mut elapsed = Duration::ZERO; + while elapsed < total { + tokio::time::sleep(step).await; + quiesce(collector).await; + elapsed += step; + } +} + +async fn current(collector: &HostMetricsCollector) -> HostReading { + collector + .snapshot() + .await + .current + .expect("a reading was taken") +} + +/// Waits on the real clock for the next update with a reading newer than `after_ms`. +async fn next_update( + subscription: &mut HostMetricsSubscription, + after_ms: i128, +) -> Arc { + tokio::time::timeout(Duration::from_secs(5), async { + loop { + subscription + .receiver() + .changed() + .await + .expect("the collector is running"); + let update = subscription.receiver().borrow_and_update().clone(); + if let Some(update) = update + && update.reading.sampled_at_ms > after_ms + { + return update; + } + } + }) + .await + .expect("an update arrives") +} + +fn volume<'a>(reading: &'a HostReading, mount_point: &str) -> Option<&'a VolumeReading> { + reading + .volumes + .iter() + .find(|volume| volume.descriptor.mount_point == Path::new(mount_point)) +} + +/// On the real clock only liveness is checked, with room for a loaded machine: readings keep +/// coming in order and none is three intervals late. Exact cadence is tested on the paused clock. +fn assert_live(readings: &[Arc], interval_ms: i128) { + for pair in readings.windows(2) { + let gap = pair[1].reading.sampled_at_ms - pair[0].reading.sampled_at_ms; + assert!( + pair[1].reading.sequence > pair[0].reading.sequence && gap <= 3 * interval_ms, + "readings {gap} ms apart: {:?}", + readings + .iter() + .map(|update| update.reading.sampled_at_ms) + .collect::>() + ); + } +} + +async fn collect_updates( + subscription: &mut HostMetricsSubscription, + count: usize, +) -> Vec> { + let mut updates: Vec> = Vec::new(); + while updates.len() < count { + let after = updates + .last() + .map_or(i128::MIN, |update| update.reading.sampled_at_ms); + updates.push(next_update(subscription, after).await); + } + updates +} + +// Paused clock, fast fake. + +#[tokio::test(start_paused = true)] +async fn memory_available_is_total_minus_used() { + let source = Arc::new( + FakeHostMetricsSource::new(vec![]).script_cpu([Ok(CpuMemoryReading { + cpu_percent: 10.0, + memory_total_bytes: 32_000_000_000, + memory_used_bytes: 11_200_000_000, + })]), + ); + let collector = collector(source); + collector.start_background(); + + let reading = current(&collector).await; + + assert_eq!(reading.memory_total_bytes, 32_000_000_000); + assert_eq!(reading.memory_used_bytes, 11_200_000_000); + assert_eq!(reading.memory_available_bytes, 20_800_000_000); + collector.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn cpu_is_clamped_to_0_and_100() { + let source = Arc::new(FakeHostMetricsSource::new(vec![]).script_cpu([cpu(104.5), cpu(-0.2)])); + let collector = collector(source); + collector.start_background(); + + let first = current(&collector).await; + run_for(&collector, Duration::from_secs(60), Duration::from_secs(60)).await; + let second = current(&collector).await; + + assert_eq!(first.cpu_percent, 100.0); + assert_eq!(second.cpu_percent, 0.0); + collector.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn construction_reads_nothing_and_starts_no_thread() { + let source = Arc::new(FakeHostMetricsSource::new(vec![descriptor("/", "ext4")])); + let collector = collector(source.clone()); + + run_for( + &collector, + Duration::from_secs(600), + Duration::from_secs(60), + ) + .await; + + assert_eq!(source.cpu_reads(), 0); + assert_eq!(source.list_calls(), 0); + assert!(lock(&collector.shared.workers).is_none()); + assert!(lock(&collector.shared.tasks).is_empty()); +} + +#[tokio::test(start_paused = true)] +async fn background_reads_every_60_s_and_volumes_every_fifth_reading() { + let source = Arc::new(FakeHostMetricsSource::new(vec![descriptor("/", "ext4")])); + let collector = collector(source.clone()); + collector.start_background(); + quiesce(&collector).await; + + run_for( + &collector, + Duration::from_secs(600), + Duration::from_secs(10), + ) + .await; + + // Readings at 0, 60, ..., 600 s; volumes at the 1st, 6th and 11th. + let readings = source.cpu_reads(); + assert!((10..=11).contains(&readings), "{readings} reads"); + assert_eq!(source.list_calls(), readings.div_ceil(5)); + assert_eq!(source.space_reads("/"), source.list_calls()); + collector.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn streams_switch_to_the_live_rate_and_share_one_read() { + let source = Arc::new(FakeHostMetricsSource::new(vec![descriptor("/", "ext4")])); + let collector = collector(source.clone()); + collector.start_background(); + quiesce(&collector).await; + + run_for(&collector, Duration::from_secs(120), Duration::from_secs(1)).await; + let background = source.cpu_reads(); + let first = collector.subscribe(); + run_for( + &collector, + Duration::from_secs(10), + Duration::from_millis(100), + ) + .await; + let one_stream = source.cpu_reads() - background; + let second = collector.subscribe(); + assert_eq!(collector.live_subscribers(), 2); + run_for( + &collector, + Duration::from_secs(10), + Duration::from_millis(100), + ) + .await; + let two_streams = source.cpu_reads() - background - one_stream; + drop(first); + drop(second); + assert_eq!(collector.live_subscribers(), 0); + let before_idle = source.cpu_reads(); + run_for(&collector, Duration::from_secs(120), Duration::from_secs(1)).await; + let after = source.cpu_reads() - before_idle; + + assert!( + (2..=3).contains(&background), + "{background} background reads" + ); + assert!( + (9..=11).contains(&one_stream), + "{one_stream} reads with one stream" + ); + assert!( + (9..=11).contains(&two_streams), + "{two_streams} reads with two streams" + ); + assert!((2..=3).contains(&after), "{after} reads after both closed"); + // One read primes the live rate; every other read is a reading, and every fifth reading + // lists the volumes. + let readings = source.cpu_reads() - 1; + assert_eq!(source.list_calls(), readings.div_ceil(5)); + collector.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn the_first_live_reading_covers_one_second() { + let source = + Arc::new(FakeHostMetricsSource::new(vec![]).script_cpu([cpu(10.0), cpu(50.0), cpu(90.0)])); + let collector = collector(source.clone()); + collector.start_background(); + run_for(&collector, Duration::from_secs(30), Duration::from_secs(1)).await; + assert_eq!(source.cpu_reads(), 1); + let background = current(&collector).await; + + let mut subscription = collector.subscribe(); + run_for( + &collector, + Duration::from_millis(100), + Duration::from_millis(100), + ) + .await; + assert_eq!( + source.cpu_reads(), + 2, + "the live rate refreshes CPU once first" + ); + let subscribed_at = collector.shared.now_ms(); + run_for( + &collector, + Duration::from_millis(1_000), + Duration::from_millis(100), + ) + .await; + let update = subscription + .receiver() + .borrow_and_update() + .clone() + .expect("a live reading"); + + assert_eq!(update.reading.cpu_percent, 90.0); + let covered = update.reading.sampled_at_ms - subscribed_at; + assert!((900..=1_000).contains(&covered), "{covered} ms"); + assert!(update.reading.sampled_at_ms > background.sampled_at_ms); + // 10 percent for a minute, 90 for a second; the priming read counts for nothing. + let expected = (10.0 * 60.0 + 90.0) / 61.0; + assert!( + (update.cpu.average - expected).abs() < 1e-9, + "{:?}", + update.cpu + ); + collector.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn a_failed_cpu_read_keeps_the_last_reading_and_warns_once() { + let log = CapturedLog::default(); + let _logging = capture(&log); + let source = Arc::new(FakeHostMetricsSource::new(vec![]).script_cpu([ + cpu(30.0), + Err("host statistics unavailable".to_owned()), + Err("host statistics unavailable".to_owned()), + Err("host statistics unavailable".to_owned()), + ])); + let collector = collector(source.clone()); + collector.start_background(); + let first = current(&collector).await; + + run_for( + &collector, + Duration::from_secs(180), + Duration::from_secs(60), + ) + .await; + + assert_eq!(source.cpu_reads(), 4); + assert_eq!(current(&collector).await, first); + assert_eq!( + log.text().matches("host metrics sample failed").count(), + 1, + "{}", + log.text() + ); + collector.shutdown().await; +} + +#[test] +fn free_above_total_is_clamped() { + let reading = volume_reading( + descriptor("/", "ext4"), + VolumeSpace { + total_bytes: 100 * GB, + free_bytes: 800 * GB, + }, + ); + + assert_eq!(reading.total_bytes, Some(100 * GB)); + assert_eq!(reading.free_bytes, Some(100 * GB)); + assert_eq!(reading.used_bytes(), Some(0)); + assert_eq!(reading.state, VolumeState::Ok); +} + +#[tokio::test] +async fn snapshot_waits_at_most_one_second_for_the_first_reading() { + let source = Arc::new(BlockingHostMetricsSource::new(FakeHostMetricsSource::new( + vec![], + ))); + let release = source.hold_cpu(); + let collector = collector(source.clone()); + collector.start_background(); + + let started = std::time::Instant::now(); + let snapshot = collector.snapshot().await; + let waited = started.elapsed(); + + assert!(snapshot.current.is_none()); + assert!( + (Duration::from_millis(900)..Duration::from_millis(1_500)).contains(&waited), + "{waited:?}" + ); + assert_eq!(snapshot.facts.cpu_count, 12); + drop(release); + collector.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn the_first_subscriber_starts_an_idle_collector() { + let source = Arc::new(FakeHostMetricsSource::new(vec![])); + let collector = collector(source.clone()); + run_for( + &collector, + Duration::from_secs(120), + Duration::from_secs(10), + ) + .await; + assert_eq!(source.cpu_reads(), 0); + + let mut subscription = collector.subscribe(); + run_for( + &collector, + Duration::from_secs(2), + Duration::from_millis(100), + ) + .await; + + assert!(source.cpu_reads() >= 2); + assert!(subscription.receiver().borrow_and_update().is_some()); + assert_eq!(collector.live_subscribers(), 1); + collector.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn the_chart_revision_rises_when_a_minute_bucket_closes() { + let source = Arc::new(FakeHostMetricsSource::new(vec![])); + let collector = collector(source); + collector.start_background(); + let first = collector.snapshot().await.chart; + assert!(first.uptime.is_empty()); + + run_for(&collector, Duration::from_secs(60), Duration::from_secs(60)).await; + let later = collector.snapshot().await.chart; + + assert!(later.revision > first.revision); + assert_eq!(later.uptime.len(), 1); + collector.shutdown().await; +} + +/// #292 review: a wall clock moved back an hour neither freezes the chart nor folds new +/// readings into an older bucket; readings keep their wall time as a label. +#[tokio::test(start_paused = true)] +async fn a_wall_clock_moved_back_keeps_the_chart_moving() { + let source = Arc::new(FakeHostMetricsSource::new(vec![])); + let collector = collector(source); + collector.start_background(); + run_for( + &collector, + Duration::from_secs(180), + Duration::from_secs(60), + ) + .await; + let before = collector.snapshot().await; + + collector + .shared + .wall_offset_ms + .store(-3_600_000, Ordering::Release); + run_for( + &collector, + Duration::from_secs(180), + Duration::from_secs(60), + ) + .await; + let after = collector.snapshot().await; + + assert!( + after.chart.uptime.len() >= before.chart.uptime.len() + 3, + "{} bars before, {} after", + before.chart.uptime.len(), + after.chart.uptime.len() + ); + let newest = after.recent.last().expect("recent points"); + let previous = before.recent.last().expect("recent points"); + assert!( + newest.sampled_at_ms > previous.sampled_at_ms, + "{:?}", + after.recent + ); + let (before, after) = ( + before.current.expect("a reading"), + after.current.expect("a reading"), + ); + assert!(after.sequence > before.sequence); + assert!( + after.sampled_at_ms < before.sampled_at_ms, + "the label is the wall time" + ); + collector.shutdown().await; +} + +/// #292 review: a listing that hangs is reported apart from any volume, and its recovery too. +#[tokio::test(flavor = "current_thread")] +async fn a_hung_listing_is_reported_and_recovers() { + let log = CapturedLog::default(); + let _logging = capture(&log); + let source = blocking(vec![descriptor("/", "ext4")]); + let list = source.hold_list(1); + let rates = HostMetricsRates { + volumes_every: 1_000, + ..HostMetricsRates::default() + }; + let collector = HostMetricsCollector::new(source.clone(), rates, identity()) + .with_volume_cadence(Duration::from_millis(200)); + let mut subscription = collector.subscribe(); + + wait_until("the hung listing is logged", || { + log.text().contains("host volume listing did not answer") + }) + .await; + let mut last = i128::MIN; + let reading = tokio::time::timeout(Duration::from_secs(15), async { + loop { + let update = next_update(&mut subscription, last).await; + last = update.reading.sampled_at_ms; + if update.reading.volume_listing_not_responding { + return update.reading.clone(); + } + } + }) + .await + .expect("a reading says the listing is not responding"); + assert!(!reading.volumes_paused, "volume reads go on"); + assert_eq!( + volume(&reading, "/").map(|volume| volume.state), + Some(VolumeState::Ok), + "a hung listing marks no volume" + ); + list.send(vec![descriptor("/", "ext4")]) + .expect("the listing is held"); + wait_until("the listing's recovery is logged", || { + log.text().contains("host volume listing answered again") + }) + .await; + tokio::time::timeout(Duration::from_secs(15), async { + loop { + let update = next_update(&mut subscription, last).await; + last = update.reading.sampled_at_ms; + if !update.reading.volume_listing_not_responding { + return; + } + } + }) + .await + .expect("a reading says the listing answers again"); + collector.shutdown().await; +} + +#[tokio::test(start_paused = true)] +async fn after_shutdown_a_subscription_gets_no_reading() { + let source = Arc::new(FakeHostMetricsSource::new(vec![])); + let collector = collector(source.clone()); + collector.start_background(); + let _ = current(&collector).await; + + collector.shutdown().await; + let reads = source.cpu_reads(); + let mut subscription = collector.subscribe(); + run_for(&collector, Duration::from_secs(10), Duration::from_secs(1)).await; + + assert_eq!(source.cpu_reads(), reads); + assert!(subscription.receiver().changed().await.is_err()); +} + +// Real clock, held threads. + +fn blocking(volumes: Vec) -> Arc { + Arc::new(BlockingHostMetricsSource::new(FakeHostMetricsSource::new( + volumes, + ))) +} + +async fn wait_until(what: &str, mut condition: impl FnMut() -> bool) { + let deadline = std::time::Instant::now() + Duration::from_secs(15); + while !condition() { + assert!( + std::time::Instant::now() < deadline, + "timed out waiting: {what}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cpu_and_memory_keep_their_pace_while_disks_hang() { + let source = blocking(vec![ + descriptor("/", "ext4"), + descriptor("/mnt/slow", "ext4"), + ]); + let slow = source.hold_space("/mnt/slow", 0); + let list = source.hold_list(1); + let collector = collector(source.clone()); + let mut subscription = collector.subscribe(); + + let updates = collect_updates(&mut subscription, 8).await; + + assert_live(&updates, 1_000); + let last_four = &updates[4..]; + for update in last_four { + assert_eq!( + volume(&update.reading, "/").map(|volume| volume.state), + Some(VolumeState::Ok) + ); + let slow_volume = volume(&update.reading, "/mnt/slow").expect("/mnt/slow is listed"); + assert_eq!(slow_volume.state, VolumeState::NotResponding); + assert_eq!(slow_volume.total_bytes, None); + } + assert_eq!(source.inner.list_calls(), 2, "the second list is held"); + assert_eq!(source.inner.space_reads("/mnt/slow"), 1); + drop((slow, list)); + collector.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_volume_that_answers_late_recovers() { + let source = blocking(vec![ + descriptor("/", "ext4"), + descriptor("/mnt/slow", "ext4"), + ]); + let slow = source.hold_space("/mnt/slow", 0); + let collector = collector(source.clone()); + let mut subscription = collector.subscribe(); + let mut last = 0; + loop { + let update = next_update(&mut subscription, last).await; + last = update.reading.sampled_at_ms; + if volume(&update.reading, "/mnt/slow") + .is_some_and(|volume| volume.state == VolumeState::NotResponding) + { + break; + } + } + + slow.send(VolumeSpace { + total_bytes: 100 * GB, + free_bytes: 30 * GB, + }) + .expect("the held read is waiting"); + let recovered = loop { + let update = next_update(&mut subscription, last).await; + last = update.reading.sampled_at_ms; + let slow_volume = volume(&update.reading, "/mnt/slow").cloned(); + if slow_volume + .as_ref() + .is_some_and(|volume| volume.state == VolumeState::Ok) + { + break slow_volume.expect("checked"); + } + }; + + assert_eq!(recovered.total_bytes, Some(100 * GB)); + assert_eq!(recovered.free_bytes, Some(30 * GB)); + wait_until("the next volume tick reads /mnt/slow again", || { + source.inner.space_reads("/mnt/slow") == 2 + }) + .await; + collector.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn every_worker_held_pauses_disk_reads() { + let mounts = ["/a", "/b", "/c", "/d"]; + let source = blocking( + mounts + .iter() + .map(|mount| descriptor(mount, "ext4")) + .collect(), + ); + let holds = mounts + .iter() + .map(|mount| source.hold_space(mount, 0)) + .collect::>(); + let collector = collector(source.clone()); + let mut subscription = collector.subscribe(); + + let updates = collect_updates(&mut subscription, 10).await; + + assert_live(&updates, 1_000); + // Held from about one second in; the limit passes two seconds later. + for update in &updates[4..] { + assert!(update.reading.volumes_paused, "{:?}", update.reading); + } + assert!(!updates[0].reading.volumes_paused); + assert_eq!( + source.inner.list_calls() + source.inner.all_space_reads(), + 5, + "one list and four held reads, and no fifth disk call" + ); + drop(holds); + collector.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_volume_replaced_at_the_same_mount_point_gets_fresh_state() { + let inner = FakeHostMetricsSource::with_list(Box::new(|call| match call { + 1 => vec![descriptor("/", "ext4")], + _ => vec![descriptor("/", "ext4"), descriptor("/mnt/usb", "exfat")], + })) + .space("/mnt/usb", 64 * GB, 10 * GB); + let source = Arc::new(BlockingHostMetricsSource::new(inner)); + let first_generation = source.hold_space("/mnt/usb", 0); + // Holds the generation 2 volume's next read, so no later read hides a late result. + let later_read = source.hold_space("/mnt/usb", 2); + let rates = HostMetricsRates { + volumes_every: 1_000, + ..HostMetricsRates::default() + }; + let collector = HostMetricsCollector::new(source.clone(), rates, identity()) + .with_volume_cadence(Duration::from_millis(300)); + let mut subscription = collector.subscribe(); + + wait_until("a later listing shows /mnt/usb again and reads it", || { + source.inner.list_calls() >= 3 && source.inner.space_reads("/mnt/usb") >= 2 + }) + .await; + // The generation 1 read is still held while the generation 2 read was admitted. + first_generation + .send(VolumeSpace { + total_bytes: 32 * GB, + free_bytes: 30 * GB, + }) + .expect("the first generation read is still held"); + let mut last = 0; + let mut shown = None; + for _ in 0..3 { + let update = next_update(&mut subscription, last).await; + last = update.reading.sampled_at_ms; + shown = volume(&update.reading, "/mnt/usb").cloned(); + } + + let shown = shown.expect("/mnt/usb is listed"); + assert_eq!(shown.total_bytes, Some(64 * GB)); + assert_eq!(shown.free_bytes, Some(10 * GB)); + drop(later_read); + collector.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mount_churn_keeps_state_bounded() { + let inner = FakeHostMetricsSource::with_list(Box::new(|call| { + vec![ + descriptor("/", "ext4"), + descriptor(&format!("/mnt/churn-{call}"), "ext4"), + ] + })); + let source = Arc::new(BlockingHostMetricsSource::new(inner)); + let rates = HostMetricsRates { + volumes_every: 1_000, + ..HostMetricsRates::default() + }; + let collector = HostMetricsCollector::new(source.clone(), rates, identity()) + .with_volume_cadence(Duration::from_millis(50)); + let mut subscription = collector.subscribe(); + + let mut updates: Vec> = Vec::new(); + while source.inner.list_calls() < 200 { + let after = updates + .last() + .map_or(i128::MIN, |update| update.reading.sampled_at_ms); + updates.push(next_update(&mut subscription, after).await); + } + quiesce(&collector).await; + + assert_live(&updates, 1_000); + let tracked = lock(&collector.shared.disk_view).tracked; + assert!(tracked <= 2, "{tracked} volumes tracked"); + let workers = lock(&collector.shared.workers) + .clone() + .expect("the workers run"); + let held = workers.held_and_queued(); + // The latest listing's volumes and one list job; no read is held in this test. + assert!(held <= 3, "{held} jobs queued or running"); + collector.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn shutdown_returns_while_a_disk_call_is_held() { + let source = blocking(vec![descriptor("/", "ext4")]); + let held = source.hold_space("/", 0); + let collector = collector(source.clone()); + collector.start_background(); + wait_until("the space read started", || { + source.inner.space_reads("/") == 1 + }) + .await; + + tokio::time::timeout(Duration::from_millis(500), collector.shutdown()) + .await + .expect("shutdown does not wait for the held read"); + drop(held); +} + +#[tokio::test(flavor = "current_thread")] +async fn a_hung_volume_log_names_no_mount_point() { + let log = CapturedLog::default(); + let _logging = capture(&log); + let source = blocking(vec![ + descriptor("/", "ext4"), + descriptor("/media/test-user/backup", "exfat"), + ]); + let held = source.hold_space("/media/test-user/backup", 0); + let collector = collector(source.clone()); + collector.start_background(); + + wait_until("the hung volume is logged", || { + log.text().contains("host volume did not answer") + }) + .await; + + let text = log.text(); + let line = text + .lines() + .find(|line| line.contains("host volume did not answer")) + .expect("logged"); + assert!(line.contains("file_system=exfat"), "{line}"); + assert!(line.contains("position=1"), "{line}"); + assert!(!text.contains("test-user"), "{text}"); + assert!(!text.contains("backup"), "{text}"); + drop(held); + collector.shutdown().await; +} + +#[derive(Clone, Default)] +struct CapturedLog(Arc>>); + +impl CapturedLog { + fn text(&self) -> String { + String::from_utf8_lossy(&self.0.lock().unwrap()).into_owned() + } +} + +impl std::io::Write for CapturedLog { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +/// Captures this thread's log; the tests that use it run their tasks on this thread. +fn capture(log: &CapturedLog) -> tracing::subscriber::DefaultGuard { + let log = log.clone(); + tracing::subscriber::set_default( + tracing_subscriber::fmt() + .with_ansi(false) + .with_writer(move || log.clone()) + .finish(), + ) +} + +/// Panics on the first listing and on the first space read, then answers. +#[derive(Debug, Default)] +struct PanickingOnceSource { + lists: AtomicUsize, + reads: AtomicUsize, +} + +impl HostMetricsSource for PanickingOnceSource { + fn host_facts(&self) -> HostFacts { + FakeHostMetricsSource::new(vec![]).host_facts() + } + + fn read_cpu_memory(&self) -> Result { + cpu(10.0).map_err(SamplingError::Failed) + } + + fn list_volumes(&self) -> Vec { + assert!( + self.lists.fetch_add(1, Ordering::AcqRel) > 0, + "listing failed at /media/test-user/backup" + ); + vec![descriptor("/", "ext4")] + } + + fn read_volume_space(&self, _mount_point: &Path) -> Result { + assert!( + self.reads.fetch_add(1, Ordering::AcqRel) > 0, + "read failed at /media/test-user/backup" + ); + Ok(VolumeSpace { + total_bytes: 100 * GB, + free_bytes: 40 * GB, + }) + } +} + +/// Waits on the real clock until the workers hold a result, then takes them as debug text. +async fn next_results(workers: &disk_workers::DiskWorkers) -> String { + tokio::time::timeout(Duration::from_secs(10), async { + loop { + let results = workers.take_results(); + if !results.is_empty() { + return format!("{results:?}"); + } + tokio::time::sleep(Duration::from_millis(5)).await; + } + }) + .await + .expect("a disk result arrives") +} + +/// #292 review P1: a disk call that panics frees its key, reports a failure that names no +/// path, and leaves the only worker able to run the next job. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_panicking_disk_call_fails_its_job_and_keeps_the_worker() { + let workers = disk_workers::DiskWorkers::start( + Arc::new(PanickingOnceSource::default()), + 1, + Duration::from_secs(60), + ); + let admit = |mount_point: Option<&str>| { + workers.admit(DiskJob { + key: DiskJobKey { + mount_point: mount_point.map(PathBuf::from), + generation: 0, + }, + weight: Duration::from_secs(1), + }) + }; + + assert_eq!(admit(None), Admission::Admitted); + let failed_list = next_results(&workers).await; + assert!(failed_list.contains("Listed(Err("), "{failed_list}"); + assert_eq!(admit(Some("/")), Admission::Admitted); + let failed_read = next_results(&workers).await; + assert!(failed_read.contains("Space(Err("), "{failed_read}"); + assert_eq!(admit(None), Admission::Admitted, "the listing key is free"); + let listed = next_results(&workers).await; + assert!( + listed.contains("Listed(Ok("), + "the worker runs on: {listed}" + ); + assert!(!failed_list.contains("test-user"), "{failed_list}"); + assert!(!failed_read.contains("test-user"), "{failed_read}"); + workers.stop(); +} + +/// R81: a network volume is hidden with its reason before any space read. +#[tokio::test(start_paused = true)] +async fn a_network_volume_is_hidden_with_its_reason_and_never_read() { + let source = Arc::new( + FakeHostMetricsSource::new(vec![ + descriptor("/", "ext4"), + descriptor("/mnt/share", "nfs4"), + ]) + .space("/", 100 * GB, 40 * GB) + .space("/mnt/share", 100 * GB, 10 * GB), + ); + let collector = collector(source.clone()); + collector.start_background(); + run_for(&collector, Duration::from_secs(5), Duration::from_secs(1)).await; + + let snapshot = collector.snapshot().await; + let hidden = &snapshot.chart.hidden_volumes; + assert_eq!(hidden.len(), 1, "{hidden:?}"); + assert_eq!(hidden[0].descriptor.mount_point, Path::new("/mnt/share")); + assert_eq!(hidden[0].reason, HiddenReason::Network); + assert_eq!(source.space_reads("/mnt/share"), 0); + assert_eq!(source.space_reads("/"), 1); + let wire = wire::encode_snapshot(&snapshot); + assert_eq!( + wire["hiddenVolumes"], + serde_json::json!([{ "mountPoint": "/mnt/share", "fileSystem": "nfs4", "reason": "network" }]) + ); + collector.shutdown().await; +} + +/// R81: an empty volume and a second mount of `/`'s storage are read, then hidden. +#[tokio::test(start_paused = true)] +async fn an_empty_or_shared_volume_is_hidden_after_its_read() { + let source = Arc::new( + FakeHostMetricsSource::new(vec![ + descriptor("/", "ext4"), + descriptor("/mnt/empty", "tmpfs"), + descriptor("/srv/same", "ext4"), + ]) + .space("/", 100 * GB, 40 * GB) + .space("/mnt/empty", 0, 0) + .space("/srv/same", 100 * GB, 40 * GB), + ); + let collector = collector(source.clone()); + collector.start_background(); + // The second background reading, at 60 s, is the first to carry the volumes read at 0 s. + run_for(&collector, Duration::from_secs(65), Duration::from_secs(5)).await; + + let snapshot = collector.snapshot().await; + let reasons = snapshot + .chart + .hidden_volumes + .iter() + .map(|hidden| (hidden.descriptor.mount_point.clone(), hidden.reason.clone())) + .collect::>(); + assert_eq!( + reasons, + [ + (PathBuf::from("/mnt/empty"), HiddenReason::Empty), + ( + PathBuf::from("/srv/same"), + HiddenReason::SameStorage { + as_mount: PathBuf::from("/") + } + ), + ] + ); + assert_eq!(source.space_reads("/mnt/empty"), 1); + assert_eq!(source.space_reads("/srv/same"), 1); + assert_eq!( + snapshot.current.as_ref().expect("a reading").volumes.len(), + 1 + ); + let wire = wire::encode_snapshot(&snapshot); + assert_eq!(wire["hiddenVolumes"][1]["reason"], "sameStorage"); + assert_eq!(wire["hiddenVolumes"][1]["sameStorageAs"], "/"); + collector.shutdown().await; +} + +/// R83: each volume's used percent has the five statistics since start. +#[tokio::test(start_paused = true)] +async fn volume_statistics_follow_each_mount_point() { + let source = Arc::new( + FakeHostMetricsSource::new(vec![descriptor("/", "ext4")]) + .script_cpu([cpu(10.0), cpu(10.0), cpu(10.0), cpu(10.0)]) + .space("/", 100 * GB, 60 * GB), + ); + let rates = HostMetricsRates { + volumes_every: 1, + ..HostMetricsRates::default() + }; + let collector = HostMetricsCollector::new(source.clone(), rates, identity()); + collector.start_background(); + quiesce(&collector).await; + // Three background readings with volumes, at 0, 60 and 120 s: 40, 50 and 60 percent used. + for free in [50, 40] { + source + .space + .lock() + .unwrap() + .get_mut(Path::new("/")) + .expect("/") + .free_bytes = free * GB; + run_for(&collector, Duration::from_secs(60), Duration::from_secs(5)).await; + } + + let snapshot = collector.snapshot().await; + assert_eq!(source.space_reads("/"), 3); + let [(mount_point, statistic)] = snapshot.volume_statistics.as_slice() else { + panic!("one volume: {:?}", snapshot.volume_statistics); + }; + assert_eq!(mount_point, Path::new("/")); + assert_eq!(statistic.current, 60.0); + assert_eq!(statistic.minimum, 40.0); + assert_eq!(statistic.maximum, 60.0); + assert!((statistic.average - 50.0).abs() < 1e-9, "{statistic:?}"); + let wire = wire::encode_snapshot(&snapshot); + assert_eq!(wire["volumeStatistics"][0]["mountPoint"], "/"); + assert_eq!(wire["volumeStatistics"][0]["statistic"]["current"], 60.0); + collector.shutdown().await; +} + +/// Real clock with short rates: background 400 ms, live 100 ms, volumes every reading. +fn quick_rates() -> HostMetricsRates { + HostMetricsRates { + background: Duration::from_millis(400), + live: Duration::from_millis(100), + volumes_every: 1, + ..HostMetricsRates::default() + } +} + +fn volume_weight(collector: &HostMetricsCollector) -> Duration { + *lock(&collector.shared.volume_weight) +} + +/// Releases `/`'s held second read at 80 percent used, after a first read at 40 percent, and +/// returns the average once that read is recorded. +async fn release_second_read_at_80_percent( + collector: &HostMetricsCollector, + second: &mpsc::Sender, +) -> f64 { + second + .send(VolumeSpace { + total_bytes: 100 * GB, + free_bytes: 20 * GB, + }) + .expect("the second read is held"); + let deadline = std::time::Instant::now() + Duration::from_secs(15); + loop { + if let Some((_, statistic)) = collector.snapshot().await.volume_statistics.first() + && statistic.current == 80.0 + { + return statistic.average; + } + assert!( + std::time::Instant::now() < deadline, + "the second read was not recorded" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// R83: a read admitted at the background rate keeps the background weight when it answers after +/// the live rate starts. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_background_read_answering_while_live_keeps_the_background_weight() { + let source = Arc::new(BlockingHostMetricsSource::new( + FakeHostMetricsSource::new(vec![descriptor("/", "ext4")]).space("/", 100 * GB, 60 * GB), + )); + let second = source.hold_space("/", 1); + let third = source.hold_space("/", 2); + let collector = HostMetricsCollector::new(source.clone(), quick_rates(), identity()); + collector.start_background(); + wait_until("the second read of / is held", || { + source.inner.space_reads("/") == 2 + }) + .await; + let subscription = collector.subscribe(); + wait_until("the live weight applies", || { + volume_weight(&collector) == Duration::from_millis(100) + }) + .await; + + let average = release_second_read_at_80_percent(&collector, &second).await; + + // Both reads weigh one background interval. + assert!((average - 60.0).abs() < 1e-9, "average {average}"); + drop((subscription, second, third)); + collector.shutdown().await; +} + +/// R83: a read admitted at the live rate keeps the live weight when it answers after the +/// dashboard closes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_live_read_answering_after_the_dashboard_closes_keeps_the_live_weight() { + let source = Arc::new(BlockingHostMetricsSource::new( + FakeHostMetricsSource::new(vec![descriptor("/", "ext4")]).space("/", 100 * GB, 60 * GB), + )); + let second = source.hold_space("/", 1); + let third = source.hold_space("/", 2); + let collector = HostMetricsCollector::new(source.clone(), quick_rates(), identity()); + let subscription = collector.subscribe(); + wait_until("the second read of / is held", || { + source.inner.space_reads("/") == 2 + }) + .await; + drop(subscription); + wait_until("the background weight applies", || { + volume_weight(&collector) == Duration::from_millis(400) + }) + .await; + + let average = release_second_read_at_80_percent(&collector, &second).await; + + // Both reads weigh one live interval. + assert!((average - 60.0).abs() < 1e-9, "average {average}"); + drop((second, third)); + collector.shutdown().await; +} diff --git a/apps/server/src/diagnostics/host_metrics/volumes.rs b/apps/server/src/diagnostics/host_metrics/volumes.rs new file mode 100644 index 0000000000..65fff9641c --- /dev/null +++ b/apps/server/src/diagnostics/host_metrics/volumes.rs @@ -0,0 +1,768 @@ +//! The volume filter (D25): which listed volumes are read, and which readings are shown. + +use std::path::{Path, PathBuf}; + +use super::{HiddenReason, HiddenVolume, VolumeDescriptor, VolumeReading}; + +/// The file system recorded for a Windows remote drive letter: its real one is never asked, since +/// that would reach the share. +pub(crate) const REMOTE_DRIVE: &str = "remote"; + +/// Network file systems `sysinfo` does not already skip, or that a FUSE mount reports. +const NETWORK_FILE_SYSTEMS: [&str; 7] = [ + "nfs", + "nfs4", + "cifs", + "smbfs", + "autofs", + "fuse.sshfs", + REMOTE_DRIVE, +]; + +/// A network file system: any call on its mount point, an `lstat` included, can hang. +pub(crate) fn is_network(file_system: &str) -> bool { + NETWORK_FILE_SYSTEMS.contains(&file_system) +} + +/// Whether the mount point is a file, as Docker's `/etc/hosts` bind mount is, from +/// `symlink_metadata`. Never asks it of a network mount point: a hard mount can hang it (D25), +/// and such a volume is hidden as a network one whatever its mount point is. +pub(crate) fn is_file_mount( + file_system: &str, + mount_point: &Path, + symlink_metadata: impl FnOnce(&Path) -> std::io::Result, +) -> bool { + !is_network(file_system) + && symlink_metadata(mount_point).is_ok_and(|metadata| metadata.is_file()) +} + +/// The root (`X:\`) of each drive letter set in `mask` (`GetLogicalDrives`) that `is_remote` +/// (`GetDriveTypeW`) calls remote, as a mounted network file system for `add_unlisted_network`. +#[cfg(any(windows, test))] +pub(crate) fn remote_drives( + mask: u32, + mut is_remote: impl FnMut(&str) -> bool, +) -> Vec<(PathBuf, String)> { + (0..26_u8) + .filter(|bit| mask & (1 << bit) != 0) + .map(|bit| format!("{}:\\", char::from(b'A' + bit))) + .filter(|root| is_remote(root)) + .map(|root| (PathBuf::from(root), REMOTE_DRIVE.to_owned())) + .collect() +} + +/// Adds the mounted network file systems the listing left out (on macOS `sysinfo` skips every +/// volume that is not local, on Windows every mapped network drive), so the filter hides and +/// discloses them too. Only the file system type decides; nothing here touches a mount point. +#[cfg(any(target_os = "macos", windows, test))] +pub(crate) fn add_unlisted_network( + listed: &mut Vec, + mounted: Vec<(PathBuf, String)>, +) { + for (mount_point, file_system) in mounted { + // A stock Mac's automount map (the home trigger) is no volume anyone mounted. + if is_network(&file_system) + && file_system != "autofs" + && !listed + .iter() + .any(|volume| volume.mount_point == mount_point) + { + listed.push(VolumeDescriptor { + mount_point, + name: String::new(), + file_system, + removable: false, + read_only: false, + is_file: false, + }); + } + } +} + +/// Memory-backed storage the OS mounts for itself (`/dev`, `/dev/shm`, `/run/user/`). A +/// `tmpfs` someone mounted elsewhere is a real volume. +fn is_system_memory_mount(descriptor: &VolumeDescriptor) -> bool { + matches!( + descriptor.file_system.as_str(), + "tmpfs" | "devtmpfs" | "ramfs" + ) && ["/dev", "/run", "/sys"] + .iter() + .any(|root| descriptor.mount_point.starts_with(root)) +} + +/// The running AppImage's own image: a read-only FUSE mount holding this executable that is the +/// AppImage's by identity, from the listing alone. The runtime names the file system +/// `fuse.` and mounts it at `.mount_*`; either marks it, so an ordinary FUSE +/// volume holding the executable stays shown. `APPIMAGE` can be inherited from another image, but +/// then it names a file system this mount does not have. `APPDIR` is not used for the same reason. +/// Never `/`, which holds every executable: a read-only FUSE root is still the root volume. +fn is_running_image( + descriptor: &VolumeDescriptor, + running_executable: Option<&Path>, + appimage: Option<&Path>, +) -> bool { + let file_system = descriptor.file_system.as_str(); + let named_after_image = appimage + .and_then(Path::file_name) + .is_some_and(|name| file_system.strip_prefix("fuse.") == Some(&name.to_string_lossy())); + let runtime_mount = descriptor + .mount_point + .file_name() + .is_some_and(|name| name.to_string_lossy().starts_with(".mount_")); + descriptor.mount_point != Path::new("/") + && (file_system == "fuse" || file_system.starts_with("fuse.")) + && descriptor.read_only + && (named_after_image || runtime_mount) + && running_executable + .is_some_and(|executable| executable.starts_with(&descriptor.mount_point)) +} + +/// The AppImage file the runtime started this process from (`APPIMAGE`). +pub(crate) fn current_appimage() -> Option { + std::env::var_os("APPIMAGE").map(PathBuf::from) +} + +/// Drops, before any space read, the volumes that must never be read. +/// `running_executable` is this process's own executable, which an AppImage runs from its mount. +pub(crate) fn pre_filter( + listed: Vec, + running_executable: Option<&Path>, + appimage: Option<&Path>, +) -> (Vec, Vec) { + let mut kept = Vec::with_capacity(listed.len()); + let mut hidden = Vec::new(); + for descriptor in listed { + let reason = if is_network(&descriptor.file_system) { + Some(HiddenReason::Network) + } else if descriptor.file_system == "overlay" && descriptor.mount_point != Path::new("/") { + Some(HiddenReason::Overlay) + } else if descriptor.is_file { + Some(HiddenReason::FileMount) + } else if is_system_memory_mount(&descriptor) + || is_running_image(&descriptor, running_executable, appimage) + { + Some(HiddenReason::System) + } else { + None + }; + match reason { + Some(reason) => hidden.push(HiddenVolume { descriptor, reason }), + None => kept.push(descriptor), + } + } + (kept, hidden) +} + +/// Hides empty volumes and folds volumes that share storage (same file system, total and free +/// bytes) into the one at `/`, or else the shortest mount point. A volume without sizes is kept. +pub(crate) fn merge_shared(read: Vec) -> (Vec, Vec) { + let key = |reading: &VolumeReading| -> Option<(String, u64, u64)> { + Some(( + reading.descriptor.file_system.clone(), + reading.total_bytes?, + reading.free_bytes?, + )) + }; + let rank = |reading: &VolumeReading| { + let mount_point = &reading.descriptor.mount_point; + (mount_point != Path::new("/"), mount_point.as_os_str().len()) + }; + // The winner of each storage group, by index into `read`. + let winners = read + .iter() + .map(|reading| { + let own = key(reading)?; + read.iter() + .enumerate() + .filter(|(_, other)| key(other).as_ref() == Some(&own)) + .min_by_key(|(_, other)| rank(other)) + .map(|(index, _)| index) + }) + .collect::>(); + let winner_mounts: Vec> = winners + .iter() + .map(|winner| winner.map(|index| read[index].descriptor.mount_point.clone())) + .collect(); + + // A merged entry stands for every member, so it is read-only only when all of them are + // (macOS: `/` is the sealed system volume, its Data volume is writable). + let writable_groups = winners + .iter() + .zip(&read) + .filter(|(_, reading)| !reading.descriptor.read_only) + .filter_map(|(winner, _)| *winner) + .collect::>(); + let mut shown = Vec::with_capacity(read.len()); + let mut hidden = Vec::new(); + for (index, mut reading) in read.into_iter().enumerate() { + if reading.total_bytes == Some(0) { + hidden.push(HiddenVolume { + descriptor: reading.descriptor, + reason: HiddenReason::Empty, + }); + } else if winners[index].is_some_and(|winner| winner != index) + && let Some(as_mount) = winner_mounts[index].clone() + { + hidden.push(HiddenVolume { + descriptor: reading.descriptor, + reason: HiddenReason::SameStorage { as_mount }, + }); + } else { + if writable_groups.contains(&index) { + reading.descriptor.read_only = false; + } + shown.push(reading); + } + } + (shown, hidden) +} + +#[cfg(test)] +mod tests { + use std::path::{Path, PathBuf}; + + use super::*; + use crate::diagnostics::host_metrics::VolumeState; + + const GB: u64 = 1_000_000_000; + + fn descriptor(mount_point: &str, file_system: &str) -> VolumeDescriptor { + VolumeDescriptor { + mount_point: PathBuf::from(mount_point), + name: String::new(), + file_system: file_system.to_owned(), + removable: false, + read_only: false, + is_file: false, + } + } + + /// The listing skips its `lstat` for exactly the file systems the filter hides as network. + #[test] + fn network_file_systems_are_never_stat_by_the_listing() { + let file = tempfile::NamedTempFile::new().expect("a temporary file"); + for file_system in [ + "nfs", + "nfs4", + "cifs", + "smbfs", + "autofs", + "fuse.sshfs", + REMOTE_DRIVE, + ] { + assert!(is_network(file_system), "{file_system}"); + let is_file = is_file_mount(file_system, Path::new("/mnt/share"), |_| { + panic!("{file_system}: a network mount point was stat") + }); + assert!(!is_file, "{file_system}"); + let (_, hidden) = pre_filter(vec![descriptor("/mnt/share", file_system)], None, None); + assert_eq!(hidden[0].reason, HiddenReason::Network, "{file_system}"); + } + for file_system in ["ext4", "apfs", "tmpfs", "overlay", "ntfs"] { + assert!(!is_network(file_system), "{file_system}"); + let mut asked = None; + let is_file = is_file_mount(file_system, file.path(), |path| { + asked = Some(path.to_owned()); + std::fs::symlink_metadata(path) + }); + assert!(is_file, "{file_system}: the reader's answer is used"); + assert_eq!(asked.as_deref(), Some(file.path()), "{file_system}"); + } + } + + /// A network mount the listing left out is added once, to be hidden as a network volume. + /// An automount map is not: every stock Mac has one, and nobody mounted it (#292). + #[test] + fn mounted_network_volumes_missing_from_the_listing_are_added() { + let mut listed = vec![ + descriptor("/", "apfs"), + descriptor("/Volumes/listed-share", "smbfs"), + ]; + let mounted = [ + ("/", "apfs"), + ("/Volumes/listed-share", "smbfs"), + ("/Volumes/share", "nfs"), + ("/Volumes/map", "autofs"), + ("/Volumes/usb", "msdos"), + ] + .map(|(mount_point, file_system)| (PathBuf::from(mount_point), file_system.to_owned())); + + add_unlisted_network(&mut listed, mounted.to_vec()); + + let added = listed + .iter() + .map(|volume| (volume.mount_point.as_path(), volume.file_system.as_str())) + .collect::>(); + assert_eq!( + added, + [ + (Path::new("/"), "apfs"), + (Path::new("/Volumes/listed-share"), "smbfs"), + (Path::new("/Volumes/share"), "nfs"), + ] + ); + assert!(listed[2].name.is_empty() && !listed[2].is_file); + } + + /// Windows: a remote drive letter the listing left out is added and hidden as a network + /// volume, from the drive table alone; a listed or local letter is left as it is (#292). + #[test] + fn unlisted_remote_drive_letters_are_added_as_hidden_network_volumes() { + // C:, D:, Y: and Z: are present; Y: and Z: are remote, and Z: is already listed. + let mask = 1 << 2 | 1 << 3 | 1 << 24 | 1 << 25; + let mut asked = Vec::new(); + let drives = remote_drives(mask, |root| { + asked.push(root.to_owned()); + matches!(root, "Y:\\" | "Z:\\") + }); + assert_eq!(asked, ["C:\\", "D:\\", "Y:\\", "Z:\\"]); + assert_eq!( + drives, + [ + (PathBuf::from("Y:\\"), REMOTE_DRIVE.to_owned()), + (PathBuf::from("Z:\\"), REMOTE_DRIVE.to_owned()), + ] + ); + + let mut listed = vec![descriptor("C:\\", "NTFS"), descriptor("Z:\\", "NTFS")]; + add_unlisted_network(&mut listed, drives); + let (kept, hidden) = pre_filter(listed, None, None); + let kept = kept + .iter() + .map(|volume| volume.mount_point.as_path()) + .collect::>(); + assert_eq!(kept, [Path::new("C:\\"), Path::new("Z:\\")]); + assert_eq!(hidden.len(), 1, "{hidden:?}"); + assert_eq!(reason_of(&hidden, "Y:\\"), &HiddenReason::Network); + } + + fn reading(descriptor: VolumeDescriptor, total: u64, free: u64) -> VolumeReading { + VolumeReading { + descriptor, + total_bytes: Some(total), + free_bytes: Some(free), + state: VolumeState::Ok, + } + } + + /// Filters a list the way the collector does: `pre_filter` before reading, then + /// `merge_shared` over the readings. + fn filter( + listed: Vec<(VolumeDescriptor, u64, u64)>, + ) -> (Vec, Vec) { + let sizes = listed + .iter() + .map(|(descriptor, total, free)| (descriptor.mount_point.clone(), (*total, *free))) + .collect::>(); + let (kept, mut hidden) = + pre_filter(listed.into_iter().map(|(d, _, _)| d).collect(), None, None); + let read = kept + .into_iter() + .map(|descriptor| { + let (total, free) = sizes + .iter() + .find(|(mount_point, _)| *mount_point == descriptor.mount_point) + .map(|(_, sizes)| *sizes) + .expect("sizes"); + reading(descriptor, total, free) + }) + .collect(); + let (shown, merged) = merge_shared(read); + hidden.extend(merged); + (shown, hidden) + } + + fn mount_points(readings: &[VolumeReading]) -> Vec<&Path> { + readings + .iter() + .map(|reading| reading.descriptor.mount_point.as_path()) + .collect() + } + + fn reason_of<'a>(hidden: &'a [HiddenVolume], mount_point: &str) -> &'a HiddenReason { + &hidden + .iter() + .find(|volume| volume.descriptor.mount_point == Path::new(mount_point)) + .unwrap_or_else(|| panic!("{mount_point} is hidden")) + .reason + } + + #[test] + fn container_and_apfs_lists_show_each_volume_once() { + let mut hosts = descriptor("/etc/hosts", "ext4"); + hosts.is_file = true; + let mut usb = descriptor("/media/usb", "exfat"); + usb.removable = true; + let (container, hidden) = filter(vec![ + (descriptor("/", "overlay"), 100 * GB, 40 * GB), + (hosts, 100 * GB, 40 * GB), + ( + descriptor("/var/lib/docker/overlay2/x/merged", "overlay"), + 100 * GB, + 40 * GB, + ), + (descriptor("/snap/core/1", "squashfs"), 0, 0), + (usb, 64 * GB, 10 * GB), + ]); + assert_eq!( + mount_points(&container), + [Path::new("/"), Path::new("/media/usb")] + ); + assert!(container[1].descriptor.removable); + assert_eq!(reason_of(&hidden, "/etc/hosts"), &HiddenReason::FileMount); + assert_eq!( + reason_of(&hidden, "/var/lib/docker/overlay2/x/merged"), + &HiddenReason::Overlay + ); + assert_eq!(reason_of(&hidden, "/snap/core/1"), &HiddenReason::Empty); + + let (apfs, hidden) = filter(vec![ + ( + descriptor("/System/Volumes/Data", "apfs"), + 2_000 * GB, + 600 * GB, + ), + (descriptor("/", "apfs"), 2_000 * GB, 600 * GB), + ]); + assert_eq!(mount_points(&apfs), [Path::new("/")]); + assert_eq!( + reason_of(&hidden, "/System/Volumes/Data"), + &HiddenReason::SameStorage { + as_mount: PathBuf::from("/") + } + ); + } + + #[test] + fn two_volumes_that_only_look_alike_are_both_kept() { + let (shown, hidden) = filter(vec![ + (descriptor("/", "ext4"), 500 * GB, 200 * GB), + (descriptor("/data", "ext4"), 500 * GB, 199 * GB), + ]); + + assert_eq!(mount_points(&shown), [Path::new("/"), Path::new("/data")]); + assert!(hidden.is_empty()); + } + + #[test] + fn a_network_volume_is_hidden_before_it_is_read() { + for file_system in [ + "nfs", + "nfs4", + "cifs", + "smbfs", + "autofs", + "fuse.sshfs", + REMOTE_DRIVE, + ] { + let (kept, hidden) = pre_filter( + vec![ + descriptor("/", "ext4"), + descriptor("/mnt/share", file_system), + ], + None, + None, + ); + + assert_eq!(kept.len(), 1, "{file_system}"); + assert_eq!(kept[0].mount_point, Path::new("/")); + assert_eq!(reason_of(&hidden, "/mnt/share"), &HiddenReason::Network); + } + } + + #[test] + fn shared_storage_without_root_keeps_the_shortest_mount_point() { + let (shown, hidden) = filter(vec![ + (descriptor("/mnt/data/inner", "ext4"), 500 * GB, 200 * GB), + (descriptor("/mnt/data", "ext4"), 500 * GB, 200 * GB), + ]); + + assert_eq!(mount_points(&shown), [Path::new("/mnt/data")]); + assert_eq!( + reason_of(&hidden, "/mnt/data/inner"), + &HiddenReason::SameStorage { + as_mount: PathBuf::from("/mnt/data") + } + ); + } + + #[test] + fn a_volume_not_read_yet_is_kept() { + let unread = VolumeReading { + descriptor: descriptor("/mnt/slow", "ext4"), + total_bytes: None, + free_bytes: None, + state: VolumeState::NotResponding, + }; + let (shown, hidden) = merge_shared(vec![ + reading(descriptor("/", "ext4"), 500 * GB, 200 * GB), + unread, + ]); + + assert_eq!( + mount_points(&shown), + [Path::new("/"), Path::new("/mnt/slow")] + ); + assert!(hidden.is_empty()); + } + + #[test] + fn removable_and_read_only_markers_survive_the_filter() { + let mut usb = descriptor("/media/usb", "exfat"); + usb.removable = true; + let mut image = descriptor("/mnt/ro-test", "ext4"); + image.read_only = true; + + let (shown, _) = filter(vec![ + (descriptor("/", "ext4"), 500 * GB, 200 * GB), + (usb, 64 * GB, 10 * GB), + (image, GB, GB / 2), + ]); + + assert_eq!(shown.len(), 3); + assert!(shown[1].descriptor.removable && !shown[1].descriptor.read_only); + assert!(shown[2].descriptor.read_only && !shown[2].descriptor.removable); + } + + /// #292 review: the APFS container shown at `/` stands for the writable Data volume too, so + /// a merged entry is read-only only when every merged member is. + #[test] + fn a_merged_entry_is_read_only_only_when_every_member_is() { + let sealed = |mount_point: &str, read_only: bool| { + let mut volume = descriptor(mount_point, "apfs"); + volume.read_only = read_only; + (volume, 2_000 * GB, 600 * GB) + }; + + let (shown, _) = filter(vec![ + sealed("/System/Volumes/Data", false), + sealed("/", true), + ]); + assert_eq!(mount_points(&shown), [Path::new("/")]); + assert!(!shown[0].descriptor.read_only, "{shown:?}"); + + let (shown, hidden) = filter(vec![sealed("/Volumes/Image", true), sealed("/", true)]); + assert!(shown[0].descriptor.read_only, "{shown:?}"); + assert!( + hidden[0].descriptor.read_only, + "a hidden member keeps its own flag" + ); + } + + /// #292: with `linux-tmpfs` the listing has the OS's own memory-backed mounts; they are + /// hidden as system mounts by type and place alone. A tmpfs mounted elsewhere stays shown. + #[test] + fn memory_backed_system_mounts_are_hidden_before_they_are_read() { + let (kept, hidden) = pre_filter( + vec![ + descriptor("/", "ext4"), + descriptor("/dev", "tmpfs"), + descriptor("/dev/shm", "tmpfs"), + descriptor("/run/user/1000", "tmpfs"), + descriptor("/sys/firmware", "tmpfs"), + descriptor("/dev/hugepages", "ramfs"), + descriptor("/dev", "devtmpfs"), + descriptor("/tmp", "tmpfs"), + descriptor("/mnt/scratch", "tmpfs"), + descriptor("/devices", "tmpfs"), + descriptor("/dev/sdb1-mount", "ext4"), + ], + None, + None, + ); + + let kept = kept + .iter() + .map(|volume| volume.mount_point.as_path()) + .collect::>(); + assert_eq!( + kept, + [ + Path::new("/"), + Path::new("/tmp"), + Path::new("/mnt/scratch"), + Path::new("/devices"), + Path::new("/dev/sdb1-mount"), + ] + ); + assert_eq!(hidden.len(), 6, "{hidden:?}"); + assert!( + hidden + .iter() + .all(|volume| volume.reason == HiddenReason::System), + "{hidden:?}" + ); + } + + /// #292: the AppImage's own mount (read-only FUSE, 0 B free) holds the running executable, + /// which decides it without reading the volume. Another app's FUSE mount, a writable FUSE + /// mount holding the executable, and the mounts above the image stay shown. + #[test] + fn the_running_app_image_mount_is_hidden_before_it_is_read() { + let read_only = |mount_point: &str, file_system: &str| { + let mut volume = descriptor(mount_point, file_system); + volume.read_only = true; + volume + }; + let (kept, hidden) = pre_filter( + vec![ + read_only("/", "ext4"), + descriptor("/tmp", "ext4"), + read_only("/tmp/.mount_BiBCodX", "fuse.BiBCode-1.0.AppImage"), + read_only("/tmp/.mount_OtherY", "fuse.Other.AppImage"), + ], + Some(Path::new("/tmp/.mount_BiBCodX/usr/bin/bibcode-desktop")), + None, + ); + assert_eq!( + reason_of(&hidden, "/tmp/.mount_BiBCodX"), + &HiddenReason::System + ); + assert_eq!(hidden.len(), 1, "{hidden:?}"); + assert_eq!(kept.len(), 3); + + let (_, hidden) = pre_filter( + vec![descriptor("/home", "fuse.mergerfs")], + Some(Path::new("/home/test-user/bin/bibcode")), + None, + ); + assert!(hidden.is_empty(), "a writable FUSE home is a real volume"); + let (_, hidden) = pre_filter( + vec![read_only( + "/tmp/.mount_BiBCodX", + "fuse.BiBCode-1.0.AppImage", + )], + None, + None, + ); + assert!(hidden.is_empty(), "without the executable nothing matches"); + } + + /// #292 review: a read-only FUSE root holds the executable too, and is still the root volume. + #[test] + fn a_read_only_fuse_root_holding_the_executable_stays_shown() { + for file_system in ["fuse", "fuse.rootfs"] { + let mut root = descriptor("/", file_system); + root.read_only = true; + let (kept, hidden) = pre_filter( + vec![root], + Some(Path::new("/usr/bin/bibcode-desktop")), + None, + ); + assert!(hidden.is_empty(), "{file_system}: {hidden:?}"); + assert_eq!(kept.len(), 1, "{file_system}"); + } + } + + /// #292 review: only the AppImage's own mount is the running image. An ordinary read-only FUSE + /// volume holding the executable stays shown, and an `APPIMAGE` inherited from another image + /// does not hide a mount that is not that image's. + #[test] + fn only_the_app_image_identity_hides_a_fuse_mount() { + let read_only = |mount_point: &str, file_system: &str| { + let mut volume = descriptor(mount_point, file_system); + volume.read_only = true; + volume + }; + let hidden_mounts = |volume: VolumeDescriptor, executable: &str, appimage: Option<&str>| { + pre_filter( + vec![volume], + Some(Path::new(executable)), + appimage.map(Path::new), + ) + .1 + .len() + }; + + // An ordinary read-only FUSE volume holding the executable. + for appimage in [None, Some("/home/test-user/Apps/BiBCode-1.0.AppImage")] { + assert_eq!( + hidden_mounts( + read_only("/mnt/tools", "fuse.gocryptfs"), + "/mnt/tools/bin/bibcode", + appimage + ), + 0, + "{appimage:?}" + ); + } + // The real descriptor: named after the image file, mounted under `.mount_`. + let image = Some("/home/test-user/Apps/BiBCode-1.0.AppImage"); + let executable = "/tmp/.mount_BiBCodX/usr/bin/bibcode-desktop"; + assert_eq!( + hidden_mounts( + read_only("/tmp/.mount_BiBCodX", "fuse.BiBCode-1.0.AppImage"), + executable, + image + ), + 1 + ); + // The runtime's mount point alone identifies it, with no `APPIMAGE` at all. + assert_eq!( + hidden_mounts(read_only("/tmp/.mount_BiBCodX", "fuse"), executable, None), + 1 + ); + // The image's file name alone identifies it, wherever it is mounted. + assert_eq!( + hidden_mounts( + read_only("/opt/image", "fuse.BiBCode-1.0.AppImage"), + "/opt/image/bin/bibcode", + image + ), + 1 + ); + // An environment inherited from a foreign image names another file system and mount. + assert_eq!( + hidden_mounts( + read_only("/mnt/tools", "fuse.gocryptfs"), + "/mnt/tools/bin/bibcode", + Some("/home/test-user/Apps/Other.AppImage") + ), + 0 + ); + // The root is never the image. + assert_eq!( + hidden_mounts( + read_only("/", "fuse.BiBCode-1.0.AppImage"), + "/usr/bin/bibcode", + image + ), + 0 + ); + } + + #[test] + fn a_hidden_volume_carries_its_reason() { + let mut file = descriptor("/etc/hostname", "ext4"); + file.is_file = true; + let (shown, hidden) = filter(vec![ + (descriptor("/", "ext4"), 500 * GB, 200 * GB), + (descriptor("/home", "ext4"), 500 * GB, 200 * GB), + (descriptor("/mnt/share", "cifs"), 500 * GB, 1), + (descriptor("/run/overlay", "overlay"), 500 * GB, 1), + (file, 500 * GB, 200 * GB), + (descriptor("/mnt/empty", "ext4"), 0, 0), + ]); + + assert_eq!(mount_points(&shown), [Path::new("/")]); + let reasons = hidden + .iter() + .map(|volume| (volume.descriptor.mount_point.as_path(), &volume.reason)) + .collect::>(); + assert_eq!( + reasons, + [ + (Path::new("/mnt/share"), &HiddenReason::Network), + (Path::new("/run/overlay"), &HiddenReason::Overlay), + (Path::new("/etc/hostname"), &HiddenReason::FileMount), + ( + Path::new("/home"), + &HiddenReason::SameStorage { + as_mount: PathBuf::from("/") + } + ), + (Path::new("/mnt/empty"), &HiddenReason::Empty), + ] + ); + } +} diff --git a/apps/server/src/diagnostics/host_metrics/wire.rs b/apps/server/src/diagnostics/host_metrics/wire.rs new file mode 100644 index 0000000000..7ec499a768 --- /dev/null +++ b/apps/server/src/diagnostics/host_metrics/wire.rs @@ -0,0 +1,774 @@ +//! `server.subscribeHostMetrics` on the wire: serde mirrors of `ServerHostMetricsEvent` in +//! `packages/contracts/src/server.ts`, the two encoders, and the subscriber loop (R72, R85). + +use std::sync::Arc; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use time::{OffsetDateTime, format_description::well_known::Rfc3339}; +use tokio::sync::mpsc; +use tokio_util::sync::CancellationToken; + +use super::{ + ChartBucket, ChartPoint, HiddenReason, HiddenVolume, HostFacts, HostMetricsCollector, + HostMetricsSnapshot, HostMetricsUpdate, HostReading, MeasureStatistics, VolumeReading, + VolumeState, +}; +use crate::rpc::RpcStreamChunk; + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "_tag")] +pub(crate) enum HostMetricsEventWire { + Snapshot(SnapshotWire), + Sample(SampleWire), +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct HostFactsWire { + os: String, + arch: String, + cpu_count: u64, + memory_total_bytes: u64, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(crate) enum VolumeStateWire { + Ok, + NotResponding, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VolumeWire { + mount_point: String, + name: String, + file_system: String, + total_bytes: Option, + free_bytes: Option, + used_bytes: Option, + removable: bool, + read_only: bool, + state: VolumeStateWire, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct HostSampleWire { + sampled_at: String, + cpu_percent: f64, + memory_total_bytes: u64, + memory_used_bytes: u64, + memory_available_bytes: u64, + volumes: Vec, + volumes_paused: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + volume_listing_not_responding: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + sequence: Option, + /// The chart clock's time for this reading; older servers leave it out. + #[serde(default, skip_serializing_if = "Option::is_none")] + chart_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + age_ms: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct StatisticWire { + current: f64, + minimum: f64, + average: f64, + p95: f64, + maximum: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ChartPointWire { + sampled_at: String, + cpu_percent: f64, + memory_percent: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct ChartBucketWire { + started_at: String, + duration_ms: u64, + cpu_average: f64, + cpu_peak: f64, + memory_average: f64, + memory_peak: f64, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SnapshotWire { + host: HostFactsWire, + boot_id: Option, + started_at: String, + statistics_since: Option, + sample: Option, + cpu: Option, + memory: Option, + recent: Vec, + uptime: Vec, + /// Older servers leave it out. + #[serde(default)] + hidden_volumes: Vec, + /// Older servers leave it out. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + volume_statistics: Vec, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct VolumeStatisticWire { + mount_point: String, + statistic: StatisticWire, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct HiddenVolumeWire { + mount_point: String, + file_system: String, + reason: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + same_storage_as: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct SampleWire { + /// As on the snapshot; older servers leave it out. + #[serde(default, skip_serializing_if = "Option::is_none")] + statistics_since: Option, + sample: HostSampleWire, + cpu: StatisticWire, + memory: StatisticWire, + /// The latest chart when the subscriber has not been sent it yet; `null` otherwise. + uptime: Option>, + /// Sent with `uptime`, as part of the same chart state. + #[serde(default, skip_serializing_if = "Option::is_none")] + hidden_volumes: Option>, + /// Older servers leave it out. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + volume_statistics: Vec, +} + +fn volume_statistics( + statistics: &[(std::path::PathBuf, MeasureStatistics)], +) -> Vec { + statistics + .iter() + .map(|(mount_point, value)| VolumeStatisticWire { + mount_point: mount_point.to_string_lossy().into_owned(), + statistic: statistic(value), + }) + .collect() +} + +fn hidden_volume(hidden: &HiddenVolume) -> HiddenVolumeWire { + let (reason, same_storage_as) = match &hidden.reason { + HiddenReason::Network => ("network", None), + HiddenReason::Overlay => ("overlay", None), + HiddenReason::FileMount => ("fileMount", None), + HiddenReason::System => ("system", None), + HiddenReason::Empty => ("empty", None), + HiddenReason::SameStorage { as_mount } => { + ("sameStorage", Some(as_mount.to_string_lossy().into_owned())) + } + }; + HiddenVolumeWire { + mount_point: hidden.descriptor.mount_point.to_string_lossy().into_owned(), + file_system: hidden.descriptor.file_system.clone(), + reason: reason.to_owned(), + same_storage_as, + } +} + +fn format_ms(ms: i128) -> String { + OffsetDateTime::from_unix_timestamp_nanos(ms.saturating_mul(1_000_000)) + .unwrap_or(OffsetDateTime::UNIX_EPOCH) + .format(&Rfc3339) + .unwrap_or_default() +} + +fn facts(facts: &HostFacts) -> HostFactsWire { + HostFactsWire { + os: facts.os.to_owned(), + arch: facts.arch.to_owned(), + cpu_count: facts.cpu_count as u64, + memory_total_bytes: facts.memory_total_bytes, + } +} + +fn volume(volume: &VolumeReading) -> VolumeWire { + VolumeWire { + mount_point: volume.descriptor.mount_point.to_string_lossy().into_owned(), + name: volume.descriptor.name.clone(), + file_system: volume.descriptor.file_system.clone(), + total_bytes: volume.total_bytes, + free_bytes: volume.free_bytes, + used_bytes: volume.used_bytes(), + removable: volume.descriptor.removable, + read_only: volume.descriptor.read_only, + state: match volume.state { + VolumeState::Ok => VolumeStateWire::Ok, + VolumeState::NotResponding => VolumeStateWire::NotResponding, + }, + } +} + +fn sample(reading: &HostReading) -> HostSampleWire { + HostSampleWire { + sampled_at: format_ms(reading.sampled_at_ms), + cpu_percent: reading.cpu_percent, + memory_total_bytes: reading.memory_total_bytes, + memory_used_bytes: reading.memory_used_bytes, + memory_available_bytes: reading.memory_available_bytes, + volumes: reading.volumes.iter().map(volume).collect(), + volumes_paused: reading.volumes_paused, + volume_listing_not_responding: Some(reading.volume_listing_not_responding), + sequence: Some(reading.sequence), + chart_at: Some(format_ms(reading.chart_ms)), + age_ms: Some(u64::try_from(reading.taken_at.elapsed().as_millis()).unwrap_or(u64::MAX)), + } +} + +fn statistic(statistic: &MeasureStatistics) -> StatisticWire { + StatisticWire { + current: statistic.current, + minimum: statistic.minimum, + average: statistic.average, + p95: statistic.p95, + maximum: statistic.maximum, + } +} + +fn point(point: &ChartPoint) -> ChartPointWire { + ChartPointWire { + sampled_at: format_ms(point.sampled_at_ms), + cpu_percent: point.cpu_percent, + memory_percent: point.memory_percent, + } +} + +fn bucket(bucket: &ChartBucket) -> ChartBucketWire { + ChartBucketWire { + started_at: format_ms(bucket.started_at_ms), + duration_ms: bucket.duration_ms, + cpu_average: bucket.cpu_average, + cpu_peak: bucket.cpu_peak, + memory_average: bucket.memory_average, + memory_peak: bucket.memory_peak, + } +} + +fn to_value(event: &HostMetricsEventWire) -> Value { + serde_json::to_value(event).unwrap_or(Value::Null) +} + +pub(crate) fn encode_snapshot(snapshot: &HostMetricsSnapshot) -> Value { + to_value(&HostMetricsEventWire::Snapshot(SnapshotWire { + host: facts(&snapshot.facts), + boot_id: snapshot.identity.boot_id.clone(), + started_at: format_ms(snapshot.identity.started_at_ms), + statistics_since: snapshot.statistics_since_ms.map(format_ms), + sample: snapshot.current.as_ref().map(sample), + cpu: snapshot.cpu.as_ref().map(statistic), + memory: snapshot.memory.as_ref().map(statistic), + recent: snapshot.recent.iter().map(point).collect(), + uptime: snapshot.chart.uptime.iter().map(bucket).collect(), + hidden_volumes: snapshot + .chart + .hidden_volumes + .iter() + .map(hidden_volume) + .collect(), + volume_statistics: volume_statistics(&snapshot.volume_statistics), + })) +} + +pub(crate) fn encode_update(update: &HostMetricsUpdate, include_chart: bool) -> Value { + to_value(&HostMetricsEventWire::Sample(SampleWire { + statistics_since: update.statistics_since_ms.map(format_ms), + sample: sample(&update.reading), + cpu: statistic(&update.cpu), + memory: statistic(&update.memory), + uptime: include_chart.then(|| update.chart.uptime.iter().map(bucket).collect()), + hidden_volumes: include_chart.then(|| { + update + .chart + .hidden_volumes + .iter() + .map(hidden_volume) + .collect() + }), + volume_statistics: volume_statistics(&update.volume_statistics), + })) +} + +/// Sends a snapshot, then one sample per live reading, for as long as the subscriber stays. +/// Each item is built only once a slot in the subscriber's queue is free, from the latest +/// reading and chart at that moment, so a slow subscriber skips readings but never misses a +/// chart (R85). +pub(crate) async fn send_host_metrics( + collector: Arc, + sender: mpsc::Sender, + cancellation: CancellationToken, +) { + let mut subscription = collector.subscribe(); + let Ok(permit) = reserve(&sender, &cancellation).await else { + return; + }; + let snapshot = tokio::select! { + () = cancellation.cancelled() => return, + snapshot = collector.snapshot() => snapshot, + }; + // Whatever the snapshot already shows is not sent again. + subscription.receiver().borrow_and_update(); + let mut sent_revision = snapshot.chart.revision; + let mut sent_sequence = snapshot.current.as_ref().map(|reading| reading.sequence); + permit.send(Ok(vec![encode_snapshot(&snapshot)])); + loop { + let Ok(permit) = reserve(&sender, &cancellation).await else { + return; + }; + let update = loop { + let latest = subscription.receiver().borrow_and_update().clone(); + if let Some(update) = latest + && Some(update.reading.sequence) != sent_sequence + { + break update; + } + tokio::select! { + () = cancellation.cancelled() => return, + () = sender.closed() => return, + changed = subscription.receiver().changed() => { + if changed.is_err() { + return; + } + } + } + }; + let include_chart = update.chart.revision != sent_revision; + permit.send(Ok(vec![encode_update(&update, include_chart)])); + sent_revision = update.chart.revision; + sent_sequence = Some(update.reading.sequence); + } +} + +async fn reserve<'a>( + sender: &'a mpsc::Sender, + cancellation: &CancellationToken, +) -> Result, ()> { + tokio::select! { + () = cancellation.cancelled() => Err(()), + permit = sender.reserve() => permit.map_err(|_| ()), + } +} + +#[cfg(test)] +mod tests { + use std::{path::PathBuf, time::Duration}; + + use super::*; + use crate::diagnostics::host_metrics::{ + ChartState, HostMetricsIdentity, HostMetricsRates, VolumeDescriptor, + tests::{FakeHostMetricsSource, run_for}, + }; + + fn parse_ms(value: &Value) -> i128 { + let text = value.as_str().expect("a timestamp"); + OffsetDateTime::parse(text, &Rfc3339) + .expect("an RFC 3339 timestamp") + .unix_timestamp_nanos() + / 1_000_000 + } + + fn fixture(name: &str) -> Value { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../packages/contracts/fixtures/rpc-wire/stream-shapes") + .join(name); + let text = std::fs::read_to_string(path).expect("stream shape fixture"); + let fixture: Value = serde_json::from_str(&text).expect("fixture JSON"); + fixture["values"][0].clone() + } + + /// #292 review: every hidden reason's wire string is pinned, and the web sheet's labels and + /// the MCP reference name the same strings, so a rename cannot pass silently. + #[test] + fn every_hidden_reason_has_a_pinned_wire_string() { + let reasons = [ + HiddenReason::Network, + HiddenReason::Overlay, + HiddenReason::FileMount, + HiddenReason::System, + HiddenReason::Empty, + HiddenReason::SameStorage { + as_mount: PathBuf::from("/"), + }, + ]; + // No wildcard: a new variant fails to compile here until it is pinned too. + let expected = |reason: &HiddenReason| match reason { + HiddenReason::Network => "network", + HiddenReason::Overlay => "overlay", + HiddenReason::FileMount => "fileMount", + HiddenReason::System => "system", + HiddenReason::Empty => "empty", + HiddenReason::SameStorage { .. } => "sameStorage", + }; + let root = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../.."); + let read = |path: &str| std::fs::read_to_string(root.join(path)).expect(path); + let web = read("apps/web/src/components/hostDashboard/hostDashboard.logic.ts"); + let mcp = read("docs/reference/mcp.md"); + let mcp_line = mcp + .lines() + .find(|line| line.contains("`hiddenVolumeCount`")) + .expect("the MCP reference documents hiddenVolumeCount"); + for reason in reasons { + let wire = hidden_volume(&HiddenVolume { + descriptor: VolumeDescriptor { + mount_point: PathBuf::from("/mnt/x"), + name: String::new(), + file_system: "ext4".to_owned(), + removable: false, + read_only: false, + is_file: false, + }, + reason: reason.clone(), + }); + let string = expected(&reason); + assert_eq!(wire.reason, string, "{reason:?}"); + let labelled = if string == "sameStorage" { + web.contains("volume.reason === \"sameStorage\"") + } else { + web.contains(&format!(" {string}: \"")) + }; + assert!(labelled, "the web sheet labels `{string}`"); + assert!( + mcp_line.contains(&format!("`{string}`")), + "docs/reference/mcp.md names `{string}`" + ); + } + } + + #[test] + fn host_metrics_stream_fixtures_round_trip() { + for name in [ + "server__subscribeHostMetrics-00.json", + "server__subscribeHostMetrics-01.json", + ] { + let wire = fixture(name); + let event: HostMetricsEventWire = + serde_json::from_value(wire.clone()).expect("the Rust mirror decodes the TS shape"); + assert_eq!( + serde_json::to_value(&event).expect("re-encodes"), + wire, + "{name} must stay byte-identical to the TypeScript contract" + ); + } + } + + #[test] + fn encoded_snapshot_stays_under_128_kib() { + let reading = HostReading { + sampled_at_ms: 1_700_000_000_123, + chart_ms: 1_700_000_000_123, + sequence: 3_601, + taken_at: tokio::time::Instant::now(), + volume_listing_not_responding: false, + cpu_percent: 23.456_789, + memory_total_bytes: 34_359_738_368, + memory_used_bytes: 12_025_908_428, + memory_available_bytes: 22_333_829_940, + volumes: (0..20) + .map(|index| VolumeReading { + descriptor: VolumeDescriptor { + mount_point: PathBuf::from(format!("/mnt/volume-with-a-long-name-{index}")), + name: format!("Volume with a long name {index}"), + file_system: "ext4".to_owned(), + removable: index % 2 == 0, + read_only: false, + is_file: false, + }, + total_bytes: Some(2_147_483_648_000), + free_bytes: Some(671_088_640_000), + state: VolumeState::Ok, + }) + .collect(), + volumes_paused: false, + }; + let statistic = MeasureStatistics { + current: 23.456_789, + minimum: 1.234_567, + maximum: 97.654_321, + average: 11.111_111, + p95: 40.0, + }; + let snapshot = HostMetricsSnapshot { + identity: HostMetricsIdentity { + boot_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), + started_at_ms: 1_700_000_000_000, + }, + facts: HostFacts { + os: "linux", + arch: "x86_64", + cpu_count: 128, + memory_total_bytes: 34_359_738_368, + }, + statistics_since_ms: Some(1_700_000_000_000), + current: Some(reading), + cpu: Some(statistic), + memory: Some(statistic), + recent: (0..900) + .map(|index| ChartPoint { + sampled_at_ms: 1_700_000_000_123 + index * 1_000, + cpu_percent: 23.456_789, + memory_percent: 35.123_456, + }) + .collect(), + chart: Arc::new(ChartState { + revision: 1, + uptime: (0..240) + .map(|index| ChartBucket { + started_at_ms: 1_700_000_000_000 + i128::from(index) * 60_000, + duration_ms: 60_000, + cpu_average: 12.345_678, + cpu_peak: 98.765_432, + memory_average: 35.123_456, + memory_peak: 36.543_21, + }) + .collect(), + hidden_volumes: Vec::new(), + }), + volume_statistics: (0..20) + .map(|index| { + ( + PathBuf::from(format!("/mnt/volume-with-a-long-name-{index}")), + statistic, + ) + }) + .collect(), + }; + + let bytes = serde_json::to_vec(&encode_snapshot(&snapshot)).expect("encodes"); + + assert!(bytes.len() < 128 * 1_024, "{} bytes", bytes.len()); + } + + /// #292 review: a Sample written by an older server, without the order, age, listing and + /// statistics start fields, still decodes. + #[test] + fn a_sample_without_the_newer_fields_decodes() { + let mut wire = fixture("server__subscribeHostMetrics-01.json"); + let object = wire.as_object_mut().expect("a Sample"); + object + .remove("statisticsSince") + .expect("the fixture is the new shape"); + let sample = object["sample"].as_object_mut().expect("a sample"); + for field in ["sequence", "chartAt", "ageMs", "volumeListingNotResponding"] { + sample.remove(field).expect("the fixture is the new shape"); + } + + let event: HostMetricsEventWire = + serde_json::from_value(wire.clone()).expect("the old shape decodes"); + + assert_eq!(serde_json::to_value(&event).expect("re-encodes"), wire); + } + + /// R81: a Snapshot from a server older than the hidden volumes still decodes. + #[test] + fn a_snapshot_without_hidden_volumes_decodes() { + let mut wire = fixture("server__subscribeHostMetrics-00.json"); + let hidden = wire + .as_object_mut() + .expect("a Snapshot") + .remove("hiddenVolumes") + .expect("the fixture is the new shape"); + assert_eq!(hidden[1]["sameStorageAs"], "/"); + + let event: HostMetricsEventWire = + serde_json::from_value(wire).expect("the old shape decodes"); + let HostMetricsEventWire::Snapshot(snapshot) = event else { + panic!("a Snapshot"); + }; + assert!(snapshot.hidden_volumes.is_empty()); + } + + /// #292 review: every reading says its order and age, and every Sample says when the + /// statistics started, so a stream that began before the first reading learns it. + #[tokio::test(start_paused = true)] + async fn items_carry_order_age_and_the_statistics_start() { + let collector = Arc::new(HostMetricsCollector::new( + Arc::new(FakeHostMetricsSource::new(vec![])), + HostMetricsRates::default(), + HostMetricsIdentity { + boot_id: None, + started_at_ms: 1_700_000_000_000, + }, + )); + let (sender, mut receiver) = mpsc::channel(64); + let cancellation = CancellationToken::new(); + let task = tokio::spawn(send_host_metrics( + collector.clone(), + sender, + cancellation.clone(), + )); + + run_for( + &collector, + Duration::from_secs(4), + Duration::from_millis(500), + ) + .await; + let mut items = Vec::new(); + while let Ok(chunk) = receiver.try_recv() { + items.extend(chunk.expect("a value")); + } + let since = collector + .snapshot() + .await + .statistics_since_ms + .map(format_ms) + .expect("statistics started"); + + assert_eq!(items[0]["_tag"], "Snapshot"); + // A snapshot taken before the first live reading leaves the start to the Samples. + let mut sequence = match items[0]["sample"]["sequence"].as_u64() { + Some(sequence) => sequence, + None => { + assert!(items[0]["statisticsSince"].is_null(), "{}", items[0]); + 0 + } + }; + let later = encode_snapshot(&collector.snapshot().await); + assert!(later["sample"]["sequence"].is_u64(), "{later}"); + assert!(later["sample"]["ageMs"].is_u64(), "{later}"); + assert!(items.len() >= 3, "{items:?}"); + for item in &items[1..] { + assert_eq!(item["statisticsSince"], since.as_str()); + assert_eq!(item["sample"]["ageMs"], 0, "encoded as soon as it was read"); + assert_eq!(item["sample"]["volumeListingNotResponding"], false); + let next = item["sample"]["sequence"].as_u64().expect("a sequence"); + assert!(next > sequence, "{next} after {sequence}"); + sequence = next; + } + cancellation.cancel(); + task.await.expect("the subscriber loop ends"); + collector.shutdown().await; + } + + /// #292: a sample says its chart time, which the recent points use, apart from its wall time. + #[test] + fn a_sample_carries_its_chart_time() { + let mut reading = HostReading { + sampled_at_ms: 1_700_000_000_000, + chart_ms: 1_700_000_005_000, + sequence: 1, + taken_at: tokio::time::Instant::now(), + volume_listing_not_responding: false, + cpu_percent: 1.5, + memory_total_bytes: 2, + memory_used_bytes: 1, + memory_available_bytes: 1, + volumes: Vec::new(), + volumes_paused: false, + }; + let encoded = serde_json::to_value(sample(&reading)).expect("encodes"); + assert_eq!(parse_ms(&encoded["chartAt"]), 1_700_000_005_000); + assert_eq!(parse_ms(&encoded["sampledAt"]), 1_700_000_000_000); + reading.chart_ms = reading.sampled_at_ms; + let encoded = serde_json::to_value(sample(&reading)).expect("encodes"); + assert_eq!(encoded["chartAt"], encoded["sampledAt"]); + } + + /// R85: a subscriber that stops reading while a minute bucket closes still holds that + /// bucket once its queue drains, and no item is built while its queue is full. + #[tokio::test(start_paused = true)] + async fn a_slow_subscriber_holds_the_latest_chart_after_the_queue_drains() { + let collector = Arc::new(HostMetricsCollector::new( + Arc::new(FakeHostMetricsSource::new(vec![])), + HostMetricsRates::default(), + HostMetricsIdentity { + boot_id: None, + started_at_ms: 1_700_000_000_000, + }, + )); + let (sender, mut receiver) = mpsc::channel(64); + let cancellation = CancellationToken::new(); + let task = tokio::spawn(send_host_metrics( + collector.clone(), + sender, + cancellation.clone(), + )); + + // The queue fills about 64 s in; the minute that closes at about 121 s closes while it + // is full, and nothing is read until 130 s. + run_for(&collector, Duration::from_secs(130), Duration::from_secs(1)).await; + let resumed_at = collector.shared.now_ms(); + let mut items = Vec::new(); + while let Ok(chunk) = receiver.try_recv() { + items.extend(chunk.expect("a value")); + } + assert_eq!(items.len(), 64, "the queue was full"); + let queued = items[1..] + .iter() + .map(|item| parse_ms(&item["sample"]["sampledAt"])) + .collect::>(); + run_for(&collector, Duration::from_secs(3), Duration::from_secs(1)).await; + while let Ok(chunk) = receiver.try_recv() { + items.extend(chunk.expect("a value")); + } + + let mut chart = items[0]["uptime"].clone(); + assert_eq!(items[0]["_tag"], "Snapshot"); + for item in &items[1..] { + assert_eq!(item["_tag"], "Sample"); + if !item["uptime"].is_null() { + chart = item["uptime"].clone(); + } + } + let started = chart + .as_array() + .expect("uptime bars") + .iter() + .map(|bar| bar["startedAt"].as_str().expect("startedAt").to_owned()) + .collect::>(); + let first = collector + .snapshot() + .await + .statistics_since_ms + .expect("statistics started"); + assert!( + started.contains(&format_ms(first + 60_000)), + "the bucket closed while the queue was full is held: {started:?}" + ); + let after = &items[64..]; + assert!(!after.is_empty()); + for item in after { + let sampled_at = parse_ms(&item["sample"]["sampledAt"]); + assert!( + sampled_at >= resumed_at - 1_500, + "an item sent after the resume carries a reading from {sampled_at}, before {resumed_at}" + ); + } + // Nothing was built between the queue filling and the resume. + let full_at = queued.iter().copied().max().expect("queued samples"); + assert!( + full_at < first + 70_000, + "the last queued item was built at {full_at}" + ); + // The chart was sent once; later items that bring nothing new carry no chart. + assert!(after.len() >= 2); + assert!(after[after.len() - 1]["uptime"].is_null()); + + cancellation.cancel(); + task.await.expect("the subscriber loop ends"); + collector.shutdown().await; + } +} diff --git a/apps/server/src/diagnostics/mod.rs b/apps/server/src/diagnostics/mod.rs index f65c7d8b8e..e68f94d995 100644 --- a/apps/server/src/diagnostics/mod.rs +++ b/apps/server/src/diagnostics/mod.rs @@ -1,6 +1,7 @@ mod archive; mod attribution; pub(crate) mod history; +pub mod host_metrics; mod model; mod monitor; mod native; @@ -20,6 +21,10 @@ pub use history::{ AttributedProcessSample, BucketMetric, ProcessResourceBucket, ProcessResourceHistory, ProcessResourceSummary, SplitMetric, }; +pub use host_metrics::{ + HostMetricsCollector, HostMetricsIdentity, HostMetricsRates, HostMetricsSnapshot, + HostMetricsSource, HostMetricsUpdate, +}; pub use model::{ DescendantEntry, PROCESS_CLAIM_LABEL_MAX_SCALARS, PROCESS_COMMAND_MAX_SCALARS, ProcessDiagnosticsResult, ProcessIdentity, ProcessRow, ProcessTreeMetadata, diff --git a/apps/server/src/diagnostics/profiler/latency.rs b/apps/server/src/diagnostics/profiler/latency.rs index bf331a0bbc..b96a233817 100644 --- a/apps/server/src/diagnostics/profiler/latency.rs +++ b/apps/server/src/diagnostics/profiler/latency.rs @@ -1140,6 +1140,24 @@ mod tests { assert_eq!(finding.fingerprint_fields, ["test.hung", "server"]); } + #[test] + fn a_status_refresh_blocked_past_ten_seconds_is_still_a_hang() { + assert!( + !is_long_running("vcs.refreshStatus"), + "a status refresh must keep the 10 s hang budget (#345)" + ); + let now = Instant::now(); + let mut in_flight = HashMap::from([( + 1, + InFlight::new("vcs.refreshStatus", now - Duration::from_secs(11)), + )]); + let hung = overdue(&mut in_flight, now); + assert_eq!(hung.len(), 1); + let finding = hang_finding(&hung[0].0, hung[0].1); + assert_eq!(finding.kind, "rpc.hang"); + assert_eq!(finding.fingerprint_fields, ["vcs.refreshStatus", "server"]); + } + #[test] fn a_dropped_timer_leaves_the_in_flight_registry_and_off_registers_nothing() { let _profiling = PinnedProfiling::new(true); diff --git a/apps/server/src/diagnostics/profiler/worktree_audit.rs b/apps/server/src/diagnostics/profiler/worktree_audit.rs index 1718f703f4..7e16df43eb 100644 --- a/apps/server/src/diagnostics/profiler/worktree_audit.rs +++ b/apps/server/src/diagnostics/profiler/worktree_audit.rs @@ -1,5 +1,6 @@ -//! Worktree audit: removal outcomes, and a read-only scan every 10 minutes that compares the -//! worktree catalog with the filesystem, Git and the processes BiBCode owns. +//! Worktree audit: removal outcomes, and a scan every 10 minutes that compares the worktree +//! catalog with the filesystem, Git and the processes BiBCode owns. The scan changes neither +//! the filesystem nor Git, and its only write deletes a creation record whose folder is gone. use std::{ collections::{HashMap, HashSet}, @@ -26,7 +27,7 @@ use super::{ }; use crate::{ diagnostics::ProcessAttributionRegistry, - persistence::Repositories, + persistence::{Repositories, WorktreeCreationRecord}, worktree_catalog::{AdoptedWorktreeAvailability, WorktreeCatalogService}, }; @@ -326,9 +327,10 @@ async fn scan( } } -/// Reads one scan's facts, and which parts were read in full. Read-only: it never refreshes -/// the catalog. The registry and the processes come first and are always audited; the -/// process step and the comparison each give up at their own deadline. +/// Reads one scan's facts, and which parts were read in full. It never refreshes the catalog, +/// and its only write deletes a creation record whose folder is gone. The registry and the +/// processes come first and are always audited; the process step and the comparison each +/// give up at their own deadline. async fn gather( sources: &AuditSources, processes: &ProcessAttributionRegistry, @@ -402,6 +404,7 @@ async fn gather( base_dirs: comparison.base_dirs, git_registrations: comparison.registrations, managed_dirs: comparison.managed_dirs, + evidence: comparison.evidence, held_open, ..AuditFacts::default() }; @@ -429,6 +432,109 @@ struct Comparison { base_dirs: Vec, registrations: HashMap, managed_dirs: Vec, + /// The worktree evidence of each listed directory that has some. + evidence: HashMap, + /// The creation records whose folder is gone. + gone_records: Vec, +} + +/// What the database says about the projects' worktrees, in the spellings it stores. +#[derive(Default)] +struct Evidence { + /// Each creation record. + recorded: Vec, + /// The worktree paths of removals that were prepared and never completed. + incomplete: Vec, +} + +/// Why a folder in a managed worktree directory is a worktree's leftover. A folder with more +/// than one kind is reported with the first, in this order. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum WorktreeEvidence { + /// BiBCode created a worktree at this path. + CreationRecord, + /// A removal of this path was prepared and never completed. + IncompleteRemoval, + /// A `.git` file at its root points at a Git directory. + GitPointerFile, + /// A BiBCode removal marker is at its root. + RemovalMarker, +} + +impl WorktreeEvidence { + const ALL: [Self; 4] = [ + Self::CreationRecord, + Self::IncompleteRemoval, + Self::GitPointerFile, + Self::RemovalMarker, + ]; + + /// Whether `reason` is an evidence kind's `reason`. + pub(crate) fn is_reason(reason: &str) -> bool { + Self::ALL.iter().any(|evidence| evidence.reason() == reason) + } + + fn reason(self) -> &'static str { + match self { + Self::CreationRecord => "creation-record", + Self::IncompleteRemoval => "incomplete-removal", + Self::GitPointerFile => "git-pointer-file", + Self::RemovalMarker => "removal-marker", + } + } +} + +/// The markers a worktree removal writes at the worktree's root (`git/repository.rs`). +const REMOVAL_MARKERS: [&str; 2] = [".bibcode-removal-identity", ".bibcode-removal-tombstone"]; + +/// The first kind of evidence `dir` has: the database's first, then one `stat` or one small +/// read per marker at its root. Nothing below the root is read. +fn worktree_evidence( + dir: &Path, + recorded: &HashSet, + incomplete: &HashSet, +) -> Option { + if recorded.contains(dir) { + return Some(WorktreeEvidence::CreationRecord); + } + if incomplete.contains(dir) { + return Some(WorktreeEvidence::IncompleteRemoval); + } + if opens_as_git_pointer_file(&dir.join(".git")) { + return Some(WorktreeEvidence::GitPointerFile); + } + REMOVAL_MARKERS + .iter() + .any(|marker| std::fs::symlink_metadata(dir.join(marker)).is_ok()) + .then_some(WorktreeEvidence::RemovalMarker) +} + +/// A regular file, not a link, that starts with `gitdir:` as a worktree's `.git` file does. +/// The open neither follows a link nor waits on a FIFO, and the opened file is the one checked, +/// so a `.git` swapped after the folder was listed cannot block or redirect the audit. +fn opens_as_git_pointer_file(path: &Path) -> bool { + use std::io::Read; + + const PREFIX: &[u8] = b"gitdir:"; + let mut options = std::fs::OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + options.custom_flags(windows_sys::Win32::Storage::FileSystem::FILE_FLAG_OPEN_REPARSE_POINT); + } + let Ok(mut file) = options.open(path) else { + return false; + }; + let mut head = [0; PREFIX.len()]; + file.metadata().is_ok_and(|metadata| metadata.is_file()) + && file.read_exact(&mut head).is_ok() + && head == PREFIX } /// Reads, in order, the managed directories' listings, then each project's owners, then its @@ -476,6 +582,25 @@ async fn compare( // A thread can own a worktree in another project's managed directory (two checkouts of // one repository), so one unread owner list makes every comparison unsafe. let mut all_owners_known = true; + // Evidence is matched by worktree path, so it is read once for every project, whatever + // spelling of the project root it was written with. + let mut evidence = Evidence::default(); + match sources.repositories.list_worktree_creation_records().await { + Ok(records) => evidence.recorded = records, + Err(error) => { + tracing::warn!(%error, "worktree audit could not read the creation records"); + } + } + match sources + .repositories + .list_incomplete_worktree_removal_paths() + .await + { + Ok(paths) => evidence.incomplete = paths, + Err(error) => { + tracing::warn!(%error, "worktree audit could not read the removal receipts"); + } + } for (project, parent) in projects.into_iter().zip(parents) { match sources .repositories @@ -518,10 +643,34 @@ async fn compare( if !all_owners_known { complete.values_mut().for_each(|complete| *complete = false); } - blocking(permit, cancel, move |cancel| { - resolve(owned, complete, listings, registrations, cancel) + let mut comparison = blocking(permit, cancel, move |cancel| { + resolve(owned, complete, listings, registrations, evidence, cancel) }) - .await + .await?; + reap_gone_records( + &sources.repositories, + std::mem::take(&mut comparison.gone_records), + ) + .await; + Some(comparison) +} + +/// A record ends when its folder is gone (#344), so a later folder at that path has none. +/// Each record is deleted only if its folder is still gone, and by the id the audit read, so +/// a worktree created at that path since keeps its own record. +async fn reap_gone_records(repositories: &Repositories, gone: Vec) { + for record in gone { + match tokio::fs::symlink_metadata(&record.worktree_path).await { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + _ => continue, + } + if let Err(error) = repositories + .reap_worktree_creation_record(record.record_id) + .await + { + tracing::warn!(%error, "worktree audit could not delete a stale creation record"); + } + } } /// Each project's managed directory in its physical spelling, and what one level of each @@ -557,13 +706,15 @@ fn list_managed( } /// The filesystem half of the comparison: physical paths for the owners and registrations, -/// and the listed directories of complete managed directories that still exist. Returns early -/// once `cancel` fires. +/// the listed directories of complete managed directories that still exist with their +/// worktree evidence, and the creation records whose folder is gone. Returns early once +/// `cancel` fires. fn resolve( owned: Vec<(String, PathBuf)>, complete: HashMap, listings: HashMap>, registrations: Vec<(PathBuf, bool)>, + evidence: Evidence, cancel: &CancellationToken, ) -> Comparison { let managed_dirs: Vec = complete @@ -571,15 +722,40 @@ fn resolve( .filter_map(|(parent, complete)| complete.then_some(parent)) .collect(); let mut comparison = Comparison::default(); + let mut recorded = HashSet::new(); + for record in evidence + .recorded + .into_iter() + .take_while(|_| !cancel.is_cancelled()) + { + match std::fs::symlink_metadata(&record.worktree_path) { + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + comparison.gone_records.push(record); + } + _ => { + recorded.insert(physical(Path::new(&record.worktree_path))); + } + } + } + let incomplete: HashSet = evidence + .incomplete + .iter() + .take_while(|_| !cancel.is_cancelled()) + .map(|path| physical(Path::new(path))) + .collect(); for (parent, children) in listings { if managed_dirs.contains(&parent) { - comparison.base_dirs.extend( - children - .into_iter() - .take_while(|_| !cancel.is_cancelled()) - // Removed since it was listed: a removal finishing, not a leftover. - .filter(|child| std::fs::symlink_metadata(child).is_ok()), - ); + for child in children + .into_iter() + .take_while(|_| !cancel.is_cancelled()) + // Removed since it was listed: a removal finishing, not a leftover. + .filter(|child| std::fs::symlink_metadata(child).is_ok()) + { + if let Some(kind) = worktree_evidence(&child, &recorded, &incomplete) { + comparison.evidence.insert(child.clone(), kind); + } + comparison.base_dirs.push(child); + } } } for (path, prunable) in registrations @@ -761,6 +937,9 @@ pub struct AuditFacts { pub held_open: Vec<(PathBuf, u32)>, /// The managed worktree directories. pub managed_dirs: Vec, + /// The worktree evidence of each directory in `base_dirs` that has some. Only those can be + /// orphans. + pub evidence: HashMap, } /// The findings one scan's facts show. Orphans are keyed by hashed parent and relative @@ -806,8 +985,13 @@ pub fn audit(facts: &AuditFacts) -> Vec { let hidden = dir .file_name() .is_some_and(|name| name.to_string_lossy().starts_with('.')); + // A folder with no worktree evidence was never a worktree, as far as anything shows. + let Some(evidence) = facts.evidence.get(dir) else { + continue; + }; if !hidden && !owned.contains(dir.as_path()) && !facts.git_registrations.contains_key(dir) { let path = redact(dir); + let name = dir.file_name().unwrap_or_default().to_string_lossy(); let mut orphan = finding( "worktree.orphan-dir", Severity::Warn, @@ -817,7 +1001,11 @@ pub fn audit(facts: &AuditFacts) -> Vec { "{path} is in a managed worktree directory, but no catalog entry owns it \ and Git does not register it." ), - json!({ "path": path }), + json!({ + "path": path, + "name": redact_path(&name), + "reason": evidence.reason(), + }), ); // The scan lists direct children. Namespace their relative names by managed parent. orphan.fingerprint_fields.extend([ @@ -1003,15 +1191,33 @@ mod tests { (PathBuf::from("/base/repo/owned"), true), (PathBuf::from("/base/repo/registered"), true), ]), + evidence: HashMap::from([ + ( + PathBuf::from("/base/repo/owned"), + WorktreeEvidence::CreationRecord, + ), + ( + PathBuf::from("/base/repo/registered"), + WorktreeEvidence::GitPointerFile, + ), + ( + PathBuf::from("/base/repo/orphan"), + WorktreeEvidence::CreationRecord, + ), + ]), ..AuditFacts::default() }; let found = kinds(&facts); assert_eq!(found[0].0, "worktree.orphan-dir"); assert_eq!(found[0].1[0], "unowned-unregistered"); - // Another tool's hidden entry is not BiBCode's leftover (#105). + // Another tool's hidden entry is not BiBCode's leftover (#105), even with evidence. facts .base_dirs .push(PathBuf::from("/base/repo/.orca-worktree-trash")); + facts.evidence.insert( + PathBuf::from("/base/repo/.orca-worktree-trash"), + WorktreeEvidence::GitPointerFile, + ); assert_eq!(kinds(&facts), found); assert_eq!( found @@ -1022,6 +1228,379 @@ mod tests { ); } + /// A managed worktree directory in a disposable folder, in its physical spelling. + fn managed_dir() -> (tempfile::TempDir, PathBuf) { + let root = tempfile::tempdir().unwrap(); + let managed = std::fs::canonicalize(root.path()).unwrap().join("managed"); + std::fs::create_dir(&managed).unwrap(); + (root, managed) + } + + /// Resolves every child of `managed` as one complete managed directory, with the given + /// creation records and incomplete removal receipts, and audits the result. + fn audit_children( + managed: &Path, + recorded: Vec, + incomplete: Vec, + ) -> (AuditFacts, Vec) { + let children = std::fs::read_dir(managed) + .unwrap() + .map(|entry| entry.unwrap().path()) + .collect(); + let comparison = resolve( + Vec::new(), + HashMap::from([(managed.to_path_buf(), true)]), + HashMap::from([(managed.to_path_buf(), children)]), + Vec::new(), + Evidence { + recorded, + incomplete, + }, + &CancellationToken::new(), + ); + let facts = AuditFacts { + base_dirs: comparison.base_dirs, + evidence: comparison.evidence, + managed_dirs: comparison.managed_dirs, + ..AuditFacts::default() + }; + let findings = audit(&facts); + (facts, findings) + } + + fn orphan_reasons(findings: &[FindingInput]) -> Vec<(String, String)> { + findings + .iter() + .filter(|finding| finding.kind == "worktree.orphan-dir") + .map(|finding| { + ( + finding.evidence["name"].as_str().unwrap().to_owned(), + finding.evidence["reason"].as_str().unwrap().to_owned(), + ) + }) + .collect() + } + + fn record(path: &Path) -> WorktreeCreationRecord { + WorktreeCreationRecord { + record_id: 1, + project_cwd: "/repo".to_owned(), + worktree_path: path.to_string_lossy().into_owned(), + } + } + + async fn migrated_repositories() -> Repositories { + let database = Database::open_in_memory().await.unwrap(); + database + .call(|connection| { + run_migrations(connection, None)?; + Ok(()) + }) + .await + .unwrap(); + Repositories::new(database) + } + + /// The creation records `resolve` finds gone, read before anything below changes. + async fn gone_after_read( + repositories: &Repositories, + managed: &Path, + ) -> Vec { + let recorded = repositories.list_worktree_creation_records().await.unwrap(); + resolve( + Vec::new(), + HashMap::from([(managed.to_path_buf(), true)]), + HashMap::new(), + Vec::new(), + Evidence { + recorded, + incomplete: Vec::new(), + }, + &CancellationToken::new(), + ) + .gone_records + } + + async fn recorded_paths(repositories: &Repositories) -> Vec { + repositories + .list_worktree_creation_records() + .await + .unwrap() + .into_iter() + .map(|record| record.worktree_path) + .collect() + } + + #[tokio::test] + async fn the_reap_deletes_a_record_whose_folder_is_still_gone() { + let (_root, managed) = managed_dir(); + let repositories = migrated_repositories().await; + let path = managed.join("feature-a").to_string_lossy().into_owned(); + repositories + .record_worktree_creation("/repo", &path) + .await + .unwrap(); + let gone = gone_after_read(&repositories, &managed).await; + assert_eq!(gone.len(), 1, "the folder was never made"); + reap_gone_records(&repositories, gone).await; + assert!(recorded_paths(&repositories).await.is_empty()); + } + + #[tokio::test] + async fn the_reap_keeps_the_record_of_a_creation_after_the_audit_read() { + let (_root, managed) = managed_dir(); + let repositories = migrated_repositories().await; + let path = managed.join("feature-a").to_string_lossy().into_owned(); + repositories + .record_worktree_creation("/repo", &path) + .await + .unwrap(); + // The audit reads A and finds its folder gone. B is created at the same path and + // recorded before the delayed delete, with its folder not made yet. + let gone = gone_after_read(&repositories, &managed).await; + repositories + .record_worktree_creation("/repo", &path) + .await + .unwrap(); + reap_gone_records(&repositories, gone).await; + assert_eq!(recorded_paths(&repositories).await, [path], "B survives"); + } + + #[tokio::test] + async fn the_reap_keeps_a_record_whose_folder_came_back_after_the_audit_read() { + let (_root, managed) = managed_dir(); + let repositories = migrated_repositories().await; + let path = managed.join("feature-a"); + let written = path.to_string_lossy().into_owned(); + repositories + .record_worktree_creation("/repo", &written) + .await + .unwrap(); + let gone = gone_after_read(&repositories, &managed).await; + std::fs::create_dir(&path).unwrap(); + reap_gone_records(&repositories, gone).await; + assert_eq!(recorded_paths(&repositories).await, [written]); + } + + #[test] + fn a_folder_without_worktree_evidence_is_not_an_orphan() { + let (_root, managed) = managed_dir(); + let container = managed.join("container"); + std::fs::create_dir_all(container.join("project/src")).unwrap(); + std::fs::write(container.join("project/README.md"), "a project\n").unwrap(); + std::fs::write(container.join("project/src/main.rs"), "fn main() {}\n").unwrap(); + // The name does not matter: a plain folder named like a worktree has no evidence either. + std::fs::create_dir(managed.join("feature-a")).unwrap(); + // A clone's `.git` directory is not a worktree's pointer file. + std::fs::create_dir_all(managed.join("clone/.git")).unwrap(); + let (facts, findings) = audit_children(&managed, Vec::new(), Vec::new()); + assert_eq!(orphan_reasons(&findings), Vec::<(String, String)>::new()); + assert!( + facts + .base_dirs + .iter() + .all(|dir| dir.parent() == Some(managed.as_path())), + "the audit lists no path below a child: {:?}", + facts.base_dirs + ); + } + + #[test] + fn a_leftover_with_a_creation_record_is_an_orphan_without_git() { + let (root, managed) = managed_dir(); + std::fs::create_dir(managed.join("feature-a")).unwrap(); + // The record keeps the spelling it was written with, which may be a linked one. + let written = root.path().join("managed/feature-a"); + let (_, findings) = audit_children(&managed, vec![record(&written)], Vec::new()); + assert_eq!( + orphan_reasons(&findings), + [("feature-a".to_owned(), "creation-record".to_owned())] + ); + } + + #[test] + fn a_leftover_with_only_a_git_pointer_file_is_an_orphan() { + let (_root, managed) = managed_dir(); + std::fs::create_dir(managed.join("feature-a")).unwrap(); + std::fs::write( + managed.join("feature-a/.git"), + "gitdir: /repo/.git/worktrees/feature-a\n", + ) + .unwrap(); + std::fs::create_dir(managed.join("not-a-pointer")).unwrap(); + std::fs::write(managed.join("not-a-pointer/.git"), "something else\n").unwrap(); + let (_, findings) = audit_children(&managed, Vec::new(), Vec::new()); + assert_eq!( + orphan_reasons(&findings), + [("feature-a".to_owned(), "git-pointer-file".to_owned())] + ); + } + + /// A `.git` swapped for a link after the audit listed the folder is not followed. + #[cfg(unix)] + #[test] + fn a_git_link_is_not_followed_and_is_not_a_pointer_file() { + let (_root, managed) = managed_dir(); + let target = managed.join("pointer"); + std::fs::write(&target, "gitdir: /repo/.git/worktrees/feature-a\n").unwrap(); + assert!(opens_as_git_pointer_file(&target), "the file itself is one"); + std::fs::create_dir(managed.join("linked")).unwrap(); + let link = managed.join("linked/.git"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + assert!( + !opens_as_git_pointer_file(&link), + "the link is not followed" + ); + } + + /// A `.git` swapped for a FIFO after the audit listed the folder is opened without + /// blocking, and is not a pointer file. + #[cfg(unix)] + #[test] + fn a_git_fifo_is_opened_without_blocking_and_is_not_a_pointer_file() { + use std::{ffi::CString, os::unix::ffi::OsStrExt}; + + let (_root, managed) = managed_dir(); + std::fs::create_dir(managed.join("piped")).unwrap(); + let fifo = managed.join("piped/.git"); + let name = CString::new(fifo.as_os_str().as_bytes()).unwrap(); + // SAFETY: `name` is a valid NUL-terminated path that outlives the call. + assert_eq!(unsafe { libc::mkfifo(name.as_ptr(), 0o600) }, 0); + let (sender, receiver) = std::sync::mpsc::channel(); + let reader = { + let fifo = fifo.clone(); + std::thread::spawn(move || sender.send(opens_as_git_pointer_file(&fifo))) + }; + let opened = receiver.recv_timeout(Duration::from_secs(5)); + if opened.is_err() { + // Release the blocked open so the test can fail instead of hanging. + drop(std::fs::OpenOptions::new().write(true).open(&fifo)); + } + let _ = reader.join(); + assert_eq!(opened, Ok(false), "the FIFO is opened without blocking"); + } + + #[test] + fn a_leftover_with_a_removal_marker_is_an_orphan() { + let (_root, managed) = managed_dir(); + for (name, marker) in [ + ("identity", ".bibcode-removal-identity"), + ("tombstone", ".bibcode-removal-tombstone"), + ] { + std::fs::create_dir(managed.join(name)).unwrap(); + std::fs::write(managed.join(name).join(marker), "nonce\n").unwrap(); + } + let (_, mut findings) = audit_children(&managed, Vec::new(), Vec::new()); + findings.sort_by_key(|finding| finding.evidence["name"].to_string()); + assert_eq!( + orphan_reasons(&findings), + [ + ("identity".to_owned(), "removal-marker".to_owned()), + ("tombstone".to_owned(), "removal-marker".to_owned()), + ] + ); + } + + #[tokio::test] + async fn a_leftover_with_an_incomplete_removal_receipt_is_an_orphan() { + let (_root, managed) = managed_dir(); + let database = Database::open_in_memory().await.unwrap(); + database + .call(|connection| { + run_migrations(connection, None)?; + Ok(()) + }) + .await + .unwrap(); + let repositories = Repositories::new(database); + for (owner, name) in [ + ("thread-prepared", "prepared"), + ("thread-removed", "removed"), + ] { + std::fs::create_dir(managed.join(name)).unwrap(); + repositories + .prepare_worktree_removal_receipt(crate::persistence::WorktreeRemovalReceipt { + owner_thread_id: owner.to_owned(), + project_cwd: "/repo".to_owned(), + worktree_path: managed.join(name).to_string_lossy().into_owned(), + identity_nonce: format!("nonce-{name}"), + state: "prepared".to_owned(), + created_at: String::new(), + updated_at: String::new(), + }) + .await + .unwrap(); + } + repositories + .complete_worktree_removal_receipt( + "thread-removed".to_owned(), + "nonce-removed".to_owned(), + ) + .await + .unwrap(); + let incomplete = repositories + .list_incomplete_worktree_removal_paths() + .await + .unwrap(); + let (_, findings) = audit_children(&managed, Vec::new(), incomplete); + assert_eq!( + orphan_reasons(&findings), + [("prepared".to_owned(), "incomplete-removal".to_owned())], + "a receipt in state removed is not evidence" + ); + } + + #[test] + fn orphan_ids_stay_per_parent_and_name_and_evidence_names_the_folder() { + let parents = ["/outside-home/a", "/outside-home/b"]; + let facts = AuditFacts { + base_dirs: parents + .iter() + .map(|parent| Path::new(parent).join("feature-a")) + .collect(), + evidence: parents + .iter() + .map(|parent| { + ( + Path::new(parent).join("feature-a"), + WorktreeEvidence::CreationRecord, + ) + }) + .collect(), + managed_dirs: parents.iter().map(PathBuf::from).collect(), + ..AuditFacts::default() + }; + let findings = audit(&facts); + assert_eq!(findings.len(), 2); + for (finding, parent) in findings.iter().zip(parents) { + assert_eq!( + finding.fingerprint_fields, + [ + "unowned-unregistered".to_owned(), + fingerprint("worktree.parent", &[parent.to_owned()]), + fingerprint("worktree.directory", &["feature-a".to_owned()]), + ], + "the ID is computed as before" + ); + assert_eq!(finding.evidence["name"], "feature-a"); + } + assert_ne!( + findings[0].fingerprint_fields, + findings[1].fingerprint_fields + ); + let again: Vec<_> = audit(&facts) + .into_iter() + .map(|finding| finding.fingerprint_fields) + .collect(); + assert_eq!( + again, + findings + .iter() + .map(|finding| finding.fingerprint_fields.clone()) + .collect::>() + ); + } + #[test] fn an_unowned_registration_whose_directory_is_gone_is_a_mismatch() { let facts = AuditFacts { @@ -1259,6 +1838,7 @@ mod tests { updated_at: "2026-10-09T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .unwrap(); @@ -1299,7 +1879,14 @@ mod tests { let root = tempfile::tempdir().unwrap(); let (sources, _removal) = stalled_repository(root.path()).await; // A leftover directory the stalled registrations would have to be compared with. - std::fs::create_dir_all(root.path().join(".bibcode-worktrees/main/orphan")).unwrap(); + let orphan = root.path().join(".bibcode-worktrees/main/orphan"); + std::fs::create_dir_all(&orphan).unwrap(); + // Evidence, so only the deadline keeps it out. + std::fs::write( + orphan.join(".git"), + "gitdir: /elsewhere/.git/worktrees/orphan\n", + ) + .unwrap(); let limits = ScanLimits { budget: Duration::from_secs(10), git: Duration::from_millis(100), @@ -1478,6 +2065,10 @@ mod tests { availability: AdoptedWorktreeAvailability::MissingRegistered, }], base_dirs: vec![home.join("work/orphan-a"), home.join("work/orphan-b")], + evidence: HashMap::from([ + (home.join("work/orphan-a"), WorktreeEvidence::CreationRecord), + (home.join("work/orphan-b"), WorktreeEvidence::GitPointerFile), + ]), managed_dirs: vec![home.join("work")], ..AuditFacts::default() }; @@ -1489,6 +2080,10 @@ mod tests { ); let relocated = audit(&AuditFacts { base_dirs: vec![PathBuf::from("/outside-home/work/orphan-a")], + evidence: HashMap::from([( + PathBuf::from("/outside-home/work/orphan-a"), + WorktreeEvidence::CreationRecord, + )]), managed_dirs: vec![PathBuf::from("/outside-home/work")], ..AuditFacts::default() }); diff --git a/apps/server/src/git/broadcaster.rs b/apps/server/src/git/broadcaster.rs index 1f0858fb7f..667d7b00e9 100644 --- a/apps/server/src/git/broadcaster.rs +++ b/apps/server/src/git/broadcaster.rs @@ -16,7 +16,7 @@ use tokio_util::{sync::CancellationToken, task::TaskTracker}; use super::watcher::test_timeout; use super::{ - GitCommandError, GitRepository, GitWatchError, GitWatchRequest, GitWatchService, + ChangeRequest, GitCommandError, GitRepository, GitWatchError, GitWatchRequest, GitWatchService, GitWatcherHealth, VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusResult, VcsStatusStreamEvent, fetch_owner::RepositoryFetchOwner, @@ -90,6 +90,9 @@ struct RepositoryState { fetch_attachment_in_flight: bool, fetch_attachment_requested: bool, local: VcsStatusLocalResult, + /// The fence `local` was last published under. A stream drops a copy queued under an + /// older fence, so a read under a newer one publishes even an equal value again. + local_fence: Option, remote: Option>, remote_fence: Option, remote_ref_name: Option>, @@ -97,6 +100,9 @@ struct RepositoryState { /// The next remote read publishes even when unchanged: a pull request changed, which only /// a published remote makes the status streams look up again. republish_remote: bool, + /// Ends the work run for the status subscribers once the last one leaves, even while a + /// Git Manager signal subscriber keeps the entry. + status_lookups: CancellationToken, remote_refresh_requests: watch::Sender, git_manager_signature: Option, git_manager_signal: watch::Sender, @@ -504,11 +510,13 @@ impl StatusBroadcaster { fetch_attachment_in_flight: false, fetch_attachment_requested: false, local: local.clone(), + local_fence: Some(fence.clone()), remote: None, remote_fence: None, remote_ref_name: None, pending_local_reconcile: false, republish_remote: false, + status_lookups: CancellationToken::new(), remote_refresh_requests, git_manager_signature: None, git_manager_signal, @@ -520,9 +528,10 @@ impl StatusBroadcaster { tasks, } }); - if entry.local != *local { + if entry.local != *local || entry.local_fence.as_ref() != Some(&fence) { let ref_changed = entry.local.ref_name != local.ref_name; previous_local = Some(std::mem::replace(&mut entry.local, local.clone())); + entry.local_fence = Some(fence.clone()); reconcile_remote_after_local_publication(entry, ref_changed); publish( entry, @@ -810,9 +819,10 @@ impl StatusBroadcaster { .filter(|entry| entry.lifecycle_id == lifecycle_id) { accepted = true; - if entry.local != *local { + if entry.local != *local || entry.local_fence.as_ref() != Some(fence) { let ref_changed = entry.local.ref_name != local.ref_name; previous_local = Some(std::mem::replace(&mut entry.local, local.clone())); + entry.local_fence = Some(fence.clone()); reconcile_remote_after_local_publication(entry, ref_changed); publish(entry, event, fence); remove_repository = entry.subscribers.is_empty(); @@ -908,6 +918,70 @@ impl StatusBroadcaster { } } + /// The open pull request last found for `ref_name`, while it is the canonical worktree's + /// branch. It lives in the cached remote, so every snapshot and remote update carries it. + pub(crate) fn known_pull_request( + &self, + cwd: &Path, + ref_name: Option<&str>, + ) -> Option { + let state = self.lock_state(); + let entry = state.repositories.get(cwd)?; + if entry.local.ref_name.as_deref() != ref_name { + return None; + } + entry.remote.clone().flatten()?.pr + } + + /// Runs `task` for the repository's status subscribers, until the last one leaves or the + /// broadcaster shuts down. With no status subscriber, `task` is dropped at once. + pub(crate) fn spawn_for_subscribers( + &self, + cwd: &Path, + task: impl Future + Send + 'static, + ) { + let state = self.lock_state(); + if state.closed { + return; + } + if let Some(entry) = state.repositories.get(cwd).filter(|entry| { + entry + .subscribers + .values() + .any(|subscriber| matches!(subscriber, RepositorySubscriber::Status { .. })) + }) { + let stop = entry.poller_cancellation.clone(); + let unsubscribed = entry.status_lookups.clone(); + entry.tasks.spawn(async move { + tokio::select! { + () = stop.cancelled() => {} + () = unsubscribed.cancelled() => {} + () = task => {} + } + }); + } + } + + /// Remembers a finished lookup's answer for `ref_name`: an open request, or `None` for + /// none open. A failed lookup is not recorded, and neither is one for a former branch. + pub(crate) fn record_pull_request( + &self, + cwd: &Path, + ref_name: Option<&str>, + pull_request: Option, + ) { + let mut state = self.lock_state(); + if let Some(remote) = state + .repositories + .get_mut(cwd) + .filter(|entry| entry.local.ref_name.as_deref() == ref_name) + .and_then(|entry| entry.remote.as_mut()) + .and_then(Option::as_mut) + { + remote.pr = pull_request; + } + } + /// Registers the observer that runs after a finished mutation or a /// reported local change (`notify_local_change`). Status reads, including /// watcher-driven reads, also reach it when repository availability changes. @@ -1242,7 +1316,8 @@ impl StatusBroadcaster { entry.pending_local_reconcile = true; return Some((None, request_local_refresh)); } - let remote = observed.remote; + let mut remote = observed.remote; + keep_pull_request(&mut remote, entry); let changed = std::mem::take(&mut entry.republish_remote) || entry.remote.as_ref() != Some(&remote); entry.remote = Some(remote.clone()); @@ -1331,23 +1406,29 @@ impl StatusBroadcaster { self.inner .fetch_owner .update_worktree_ref(cwd, status.local.ref_name.clone()); - let remote = status.local.is_repo.then(|| status.remote.clone()); - let event = VcsStatusStreamEvent::Snapshot { - local: status.local.clone(), - remote: remote.clone(), - }; + let mut remote = status.local.is_repo.then(|| status.remote.clone()); let mut state = self.lock_state(); let mut remove_repository = false; let mut previous_local = None; let mut lifecycle_id = None; if let Some(entry) = state.repositories.get_mut(cwd) { lifecycle_id = Some(entry.lifecycle_id); - let changed = entry.local != status.local || entry.remote.as_ref() != Some(&remote); + if entry.local.ref_name == status.local.ref_name { + keep_pull_request(&mut remote, entry); + } + let event = VcsStatusStreamEvent::Snapshot { + local: status.local.clone(), + remote: remote.clone(), + }; + let changed = entry.local != status.local + || entry.local_fence.as_ref() != Some(fence) + || entry.remote.as_ref() != Some(&remote); previous_local = Some(std::mem::replace(&mut entry.local, status.local.clone())); entry.remote = Some(remote); entry.remote_fence = Some(fence.clone()); entry.remote_ref_name = Some(status.local.ref_name.clone()); if changed { + entry.local_fence = Some(fence.clone()); publish(entry, event, fence); } remove_repository = entry.subscribers.is_empty(); @@ -1836,6 +1917,13 @@ impl StatusBroadcaster { let mut state = self.lock_state(); let should_remove = if let Some(entry) = state.repositories.get_mut(cwd) { entry.subscribers.remove(&subscriber_id); + if !entry + .subscribers + .values() + .any(|subscriber| matches!(subscriber, RepositorySubscriber::Status { .. })) + { + std::mem::take(&mut entry.status_lookups).cancel(); + } entry.subscribers.is_empty() } else { false @@ -2264,6 +2352,16 @@ fn clear_remote_for_ref_change(entry: &mut RepositoryState) { .send_modify(|generation| *generation = generation.wrapping_add(1)); } +/// A remote read never finds the pull request, so a newer remote for the same branch keeps +/// the one the cache holds. +fn keep_pull_request(remote: &mut Option, entry: &RepositoryState) { + if let (Some(remote), Some(Some(cached))) = (remote.as_mut(), entry.remote.as_ref()) + && remote.pr.is_none() + { + remote.pr.clone_from(&cached.pr); + } +} + fn reconcile_remote_after_local_publication(entry: &mut RepositoryState, ref_changed: bool) { if ref_changed { clear_remote_for_ref_change(entry); @@ -6558,6 +6656,201 @@ mod tests { broadcaster.shutdown().await; } + #[tokio::test] + async fn an_equal_status_is_published_once_under_each_new_epoch() { + let root = tempfile::tempdir().expect("repository folder"); + let cwd = fs::canonicalize(root.path()).expect("canonical folder"); + let broadcaster = StatusBroadcaster::new( + Arc::new(GitRepository::default()), + Duration::from_secs(3_600), + 4, + ); + let mut events = install_epoch_repository(&broadcaster, &cwd); + let (local, remote) = { + let state = broadcaster.lock_state(); + let entry = state.repositories.get(&cwd).expect("repository entry"); + ( + entry.local.clone(), + entry.remote.clone().flatten().expect("cached remote"), + ) + }; + let advance_epoch = || async { + broadcaster.begin_mutation(&cwd).await.finish().await; + broadcaster + .acquire_read_fence(&cwd, &CancellationToken::new()) + .await + .expect("read fence") + }; + + let fence = broadcaster + .acquire_read_fence(&cwd, &CancellationToken::new()) + .await + .expect("read fence"); + broadcaster.publish_local(&cwd, 1, &local, &fence); + assert!( + events.try_recv().is_err(), + "an equal status under the same epoch" + ); + + let fence = advance_epoch().await; + broadcaster.publish_local(&cwd, 1, &local, &fence); + assert_eq!( + events + .try_recv() + .expect("an equal status under a new epoch") + .value, + VcsStatusStreamEvent::LocalUpdated { + local: local.clone() + } + ); + broadcaster.publish_local(&cwd, 1, &local, &fence); + assert!( + events.try_recv().is_err(), + "published twice under one epoch" + ); + + let fence = advance_epoch().await; + let status = VcsStatusResult { + local: local.clone(), + remote: remote.clone(), + }; + broadcaster.publish_status(&cwd, &status, &fence); + assert!( + matches!( + events + .try_recv() + .expect("an equal snapshot under a new epoch") + .value, + VcsStatusStreamEvent::Snapshot { .. } + ), + "the refresh path republishes too" + ); + broadcaster.publish_status(&cwd, &status, &fence); + assert!( + events.try_recv().is_err(), + "a snapshot twice under one epoch" + ); + broadcaster.shutdown().await; + } + + #[tokio::test] + async fn a_snapshot_under_a_new_epoch_keeps_the_known_pull_request() { + let root = tempfile::tempdir().expect("repository folder"); + let cwd = fs::canonicalize(root.path()).expect("canonical folder"); + let broadcaster = StatusBroadcaster::new( + Arc::new(GitRepository::default()), + Duration::from_secs(3_600), + 4, + ); + let mut events = install_epoch_repository(&broadcaster, &cwd); + let (local, remote) = { + let state = broadcaster.lock_state(); + let entry = state.repositories.get(&cwd).expect("repository entry"); + ( + entry.local.clone(), + entry.remote.clone().flatten().expect("cached remote"), + ) + }; + let pull_request = ChangeRequest { + number: 7, + title: "Feature".to_owned(), + url: "https://forge.example.test/pull/7".to_owned(), + base_ref: "main".to_owned(), + head_ref: "old".to_owned(), + state: "open".to_owned(), + }; + broadcaster.record_pull_request(&cwd, Some("old"), Some(pull_request.clone())); + broadcaster.begin_mutation(&cwd).await.finish().await; + let fence = broadcaster + .acquire_read_fence(&cwd, &CancellationToken::new()) + .await + .expect("read fence"); + + // A status read never finds the pull request. + broadcaster.publish_status(&cwd, &VcsStatusResult { local, remote }, &fence); + let VcsStatusStreamEvent::Snapshot { remote, .. } = events + .try_recv() + .expect("an equal snapshot under a new epoch") + .value + else { + panic!("a snapshot"); + }; + assert_eq!(remote.expect("remote").pr, Some(pull_request)); + broadcaster.shutdown().await; + } + + #[tokio::test] + async fn a_new_subscriber_republishes_an_equal_status_once_under_each_new_epoch() { + let _native_watcher_permit = super::super::acquire_native_watcher_test_permit().await; + let sandbox = TestSandbox::new("git-broadcaster-subscribe-epoch"); + let command = sandbox.executable_on_path("git"); + let (_, environment) = isolated_git_environment(&sandbox); + let cwd = sandbox.path("repository"); + fs::create_dir(&cwd).expect("repository directory"); + let output = Command::new(&command) + .args(["init", "-q", "-b", "main"]) + .current_dir(&cwd) + .env_clear() + .envs(environment.iter().cloned()) + .output() + .expect("fixture Git"); + assert!(output.status.success(), "{output:?}"); + let cwd = fs::canonicalize(cwd).expect("canonical repository"); + let broadcaster = StatusBroadcaster::new( + Arc::new(GitRepository::with_runner_for_test(Arc::new( + IsolatedGitRunner { + command, + environment, + }, + ))), + Duration::from_secs(3_600), + 4, + ); + // An entry without status workers, so only the subscribe path can publish. + let mut events = install_epoch_repository(&broadcaster, &cwd); + let local = broadcaster + .inner + .repository + .local_status(&cwd, &CancellationToken::new()) + .await + .expect("local status"); + broadcaster + .lock_state() + .repositories + .get_mut(&cwd) + .expect("repository entry") + .local = local.clone(); + let subscribe = || async { + broadcaster + .subscribe(cwd.clone(), CancellationToken::new()) + .await + .expect("subscription") + }; + + let _same_epoch = subscribe().await; + assert!( + events.try_recv().is_err(), + "an equal status under the same epoch" + ); + broadcaster.begin_mutation(&cwd).await.finish().await; + let _new_epoch = subscribe().await; + assert_eq!( + events + .try_recv() + .expect("an equal status under a new epoch") + .value, + VcsStatusStreamEvent::LocalUpdated { + local: local.clone() + } + ); + let _again = subscribe().await; + assert!( + events.try_recv().is_err(), + "published twice under one epoch" + ); + broadcaster.shutdown().await; + } + fn record_availability_nudges(broadcaster: &StatusBroadcaster) -> Arc>> { let calls = Arc::new(Mutex::new(Vec::new())); let observed_calls = Arc::clone(&calls); @@ -7312,10 +7605,19 @@ mod tests { ahead_of_default_count: Some(0), pr: None, })), + // As if `local` was published under the current epoch. + local_fence: futures_util::FutureExt::now_or_never( + broadcaster + .inner + .status_owner + .acquire_read_fence(cwd, &CancellationToken::new()), + ) + .and_then(Result::ok), remote_fence: None, remote_ref_name: None, pending_local_reconcile: false, republish_remote: false, + status_lookups: CancellationToken::new(), remote_refresh_requests, git_manager_signature: None, git_manager_signal, diff --git a/apps/server/src/git/manager/operations.rs b/apps/server/src/git/manager/operations.rs index 362e8cdfe0..6f71ce60e7 100644 --- a/apps/server/src/git/manager/operations.rs +++ b/apps/server/src/git/manager/operations.rs @@ -15,8 +15,8 @@ use thiserror::Error; use tokio_util::sync::CancellationToken; use crate::git::sign_in::{ - CredentialPrompt, Destination, SignInDirection, SignInRequired, has_gcm_refusal, - resolve_destination, + CredentialPrompt, Destination, ExecutionPlace, SignInDirection, SignInRequired, + has_gcm_refusal, resolve_destination, }; use crate::git::{ GitCommandError, GitManagerBlockedReason, GitManagerInProgressKind, GitManagerRefsSnapshot, @@ -1002,6 +1002,7 @@ pub async fn run_branch_or_sync_operation( catalog: WorktreeCatalogService, request: GitManagerOperationRequest, prompt: CredentialPrompt, + place: ExecutionPlace, cancellation: CancellationToken, ) -> Result { let operation = request.operation(); @@ -1021,7 +1022,7 @@ pub async fn run_branch_or_sync_operation( .try_with_project_mutation_lock_cancellation(&project_id, &cancellation, || async move { // vcs.* writes take this per-worktree guard but not the project lock, so it is // held from the snapshot onward: validation and execution see the same HEAD. - let mutation = tokio::select! { + let mut mutation = tokio::select! { biased; () = operation_cancellation.cancelled() => { return Err(cancelled_error(operation)); @@ -1055,11 +1056,15 @@ pub async fn run_branch_or_sync_operation( if operation_cancellation.is_cancelled() { return Err(cancelled_error(operation)); } + if releases_reads_during_transfer(&locked_request) { + mutation.local_writes_finished(); + } execute_branch_or_sync_operation( &locked_repository, &snapshot, &locked_request, prompt, + &place, &operation_cancellation, ) .await @@ -1270,6 +1275,19 @@ fn network_step( } } +/// Operations whose only Git effect is a network transfer: status reads resume before it +/// while the write lock stays held (#345). Pull and fetch keep reads waiting, because pull +/// rewrites files at its end. +fn releases_reads_during_transfer(request: &GitManagerOperationRequest) -> bool { + matches!( + request, + GitManagerOperationRequest::Push { .. } + | GitManagerOperationRequest::PublishBranch { .. } + | GitManagerOperationRequest::ForcePush { .. } + | GitManagerOperationRequest::RemoteBranchDelete { .. } + ) +} + fn expected_destination_mut( request: &mut GitManagerOperationRequest, ) -> Option<&mut Option> { @@ -1312,6 +1330,7 @@ async fn execute_branch_or_sync_operation( snapshot: &GitManagerRefsSnapshot, request: &GitManagerOperationRequest, prompt: CredentialPrompt, + place: &ExecutionPlace, cancellation: &CancellationToken, ) -> Result { let operation = request.operation(); @@ -1336,12 +1355,56 @@ async fn execute_branch_or_sync_operation( } } let result = execute_operation_steps(repository, snapshot, request, cancellation).await; - match (result, network, prompt) { + let result = match (result, network, prompt) { (Err(error), Some(step), CredentialPrompt::OfferSignIn) => { Err(offer_sign_in(repository, request.cwd(), error, step, cancellation).await) } (result, ..) => result, + }; + result.map_err(|mut error| { + if let Some(guidance) = remote_push_guidance(request, &error, place) { + error.message = guidance; + if !matches!(request, GitManagerOperationRequest::TagCreate { .. }) { + error.code = GitManagerFailureCode::Authentication.as_str().to_owned(); + } + } + error + }) +} + +/// #343: a push that failed on a remote server for a missing username says where to sign in, +/// whatever a credential helper printed before Git's line (Source Control decides the same way). +fn remote_push_guidance( + request: &GitManagerOperationRequest, + error: &GitManagerOperationError, + place: &ExecutionPlace, +) -> Option { + use GitManagerOperationRequest as Request; + let pushes = matches!( + request, + Request::Push { .. } + | Request::PublishBranch { .. } + | Request::ForcePush { .. } + | Request::TagPush { .. } + | Request::TagCreate { + push_remote: Some(_), + .. + } + ); + if !pushes || error.sign_in.is_some() { + return None; } + let guidance = place.push_guidance(&error.outputs.last()?.stderr)?; + Some(match request { + Request::TagCreate { + name, + push_remote: Some(remote), + .. + } => { + format!("Created tag {name} locally, but pushing it to {remote} failed.\n\n{guidance}") + } + _ => guidance, + }) } /// Turns a failure whose stderr carries GCM's refusal into `sign-in-required` naming the @@ -1934,25 +1997,30 @@ async fn execute_operation_steps( outputs.push(pushed); None } - Ok(pushed) => Some( - pushed + Ok(pushed) => { + let detail = pushed .stderr .lines() .map(str::trim) .find(|line| !line.is_empty()) .unwrap_or("Git could not reach the remote.") - .to_owned(), - ), + .to_owned(); + // Kept so the remote push guidance (#343) reads Git's whole text. + outputs.push(pushed); + Some(detail) + } Err(error) => Some(error.to_string()), }; if let Some(detail) = failure { - return Err(operation_error( + let mut error = operation_error( operation, "tag-push-failed", &format!( "Created tag {name} locally, but pushing it to {remote} failed: {detail}" ), - )); + ); + error.outputs = outputs; + return Err(error); } } outputs @@ -4192,6 +4260,7 @@ mod tests { &snapshot, &request, CredentialPrompt::Never, + &ExecutionPlace::default(), &CancellationToken::new(), ) .await @@ -6051,6 +6120,7 @@ mod tests { expected_destination: None, }, CredentialPrompt::Never, + &ExecutionPlace::default(), &token, ) .await @@ -6062,6 +6132,7 @@ mod tests { &snapshot, &request, CredentialPrompt::Never, + &ExecutionPlace::default(), &token, ) .await @@ -6077,3 +6148,311 @@ mod tests { ); } } + +/// Status reads during a held Git Manager network step (#345, R19, R21). +#[cfg(test)] +mod transfer_gate_tests { + use std::{path::PathBuf, process::Command, sync::Mutex}; + + use serde_json::json; + use tokio::sync::{Notify, Semaphore}; + use tokio_util::sync::CancellationToken; + + use super::*; + use crate::{ + git::{ + BoxGitProcessFuture, GitProcessRunner, ProcessError, ProcessOutput, ProcessRequest, + ProcessRunner, sign_in::CredentialPrompt, + }, + persistence::{Database, Repositories, run_migrations}, + worktree_catalog::{CatalogServiceOptions, WorkspaceAvailabilityRegistry}, + }; + + /// Shorter than any local status read takes to queue, well below the hang budget. + const ADMITTED_WITHIN: Duration = Duration::from_secs(3); + /// Long enough for a gate that would open to have opened. + const STILL_WAITING_AFTER: Duration = Duration::from_millis(300); + + /// Runs local Git for real and holds every fetch, pull and push until released, then + /// answers with `outcome` (success when empty). No network is contacted. + struct HeldNetworkRunner { + started: Notify, + release: Semaphore, + outcome: Mutex>, + } + + impl GitProcessRunner for HeldNetworkRunner { + fn run<'a>( + &'a self, + request: ProcessRequest, + cancellation: &'a CancellationToken, + ) -> BoxGitProcessFuture<'a> { + let network = request + .args + .iter() + .any(|arg| matches!(arg.to_str(), Some("fetch" | "pull" | "push"))); + if !network { + return Box::pin(ProcessRunner.run(request, cancellation)); + } + Box::pin(async move { + self.started.notify_one(); + tokio::select! { + biased; + () = cancellation.cancelled() => { + return Err(ProcessError::Cancelled { operation: request.operation }); + } + permit = self.release.acquire() => permit.expect("release stays open").forget(), + } + match self.outcome.lock().expect("outcome lock").take() { + Some(error) => Err(error), + None => Ok(ProcessOutput { + exit_code: 0, + stdout: String::new(), + stderr: String::new(), + stdout_truncated: false, + stderr_truncated: false, + }), + } + }) + } + } + + struct Harness { + _root: tempfile::TempDir, + cwd: PathBuf, + runner: Arc, + repository: Arc, + broadcaster: StatusBroadcaster, + catalog: WorktreeCatalogService, + } + + fn git(cwd: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(cwd) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .output() + .expect("git fixture starts"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + async fn harness() -> Harness { + let root = tempfile::tempdir().expect("repository root"); + let cwd = root.path().canonicalize().expect("canonical root"); + for args in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.name", "Test User"], + &["config", "user.email", "user@example.test"], + &["commit", "-q", "--allow-empty", "-m", "base"], + &[ + "remote", + "add", + "origin", + "https://gitlab.example.test/example-group/sample-project.git", + ], + &["update-ref", "refs/remotes/origin/main", "HEAD"], + &["update-ref", "refs/remotes/origin/topic", "HEAD"], + &["config", "branch.main.remote", "origin"], + &["config", "branch.main.merge", "refs/heads/main"], + ] { + git(&cwd, args); + } + let runner = Arc::new(HeldNetworkRunner { + started: Notify::new(), + release: Semaphore::new(0), + outcome: Mutex::new(None), + }); + let repository = Arc::new(GitRepository::with_runner_for_test(runner.clone())); + let database = Database::open_in_memory().await.expect("database"); + database + .call(|connection| { + run_migrations(connection, None)?; + Ok(()) + }) + .await + .expect("migrations"); + let catalog = WorktreeCatalogService::new_with_options_for_test( + Arc::new(Repositories::new(database)), + repository.clone(), + WorkspaceAvailabilityRegistry::new(), + CatalogServiceOptions { + poll_interval: Duration::from_secs(3_600), + ..CatalogServiceOptions::default() + }, + ); + let broadcaster = StatusBroadcaster::new(repository.clone(), Duration::from_secs(3_600), 8); + Harness { + _root: root, + cwd, + runner, + repository, + broadcaster, + catalog, + } + } + + type Running = + tokio::task::JoinHandle>; + + fn start( + harness: &Harness, + request: serde_json::Value, + cancellation: CancellationToken, + ) -> Running { + let mut request = request; + request["cwd"] = json!(harness.cwd); + request["projectId"] = json!("project-1"); + let request: GitManagerOperationRequest = + serde_json::from_value(request).expect("operation request"); + tokio::spawn(run_branch_or_sync_operation( + harness.repository.clone(), + harness.broadcaster.clone(), + harness.catalog.clone(), + request, + CredentialPrompt::Never, + ExecutionPlace::default(), + cancellation, + )) + } + + /// Waits until the operation reaches its network step, or fails with how it ended. + async fn wait_for_network_step(harness: &Harness, running: &mut Running, context: &str) { + tokio::select! { + () = harness.runner.started.notified() => {} + ended = &mut *running => panic!("{context}: ended before its network step: {ended:?}"), + () = tokio::time::sleep(Duration::from_secs(30)) => { + panic!("{context}: never reached its network step") + } + } + } + + async fn read_admitted(harness: &Harness, within: Duration) -> bool { + tokio::time::timeout( + within, + harness + .broadcaster + .acquire_read_fence(&harness.cwd, &CancellationToken::new()), + ) + .await + .is_ok_and(|fence| fence.is_ok()) + } + + fn push_family() -> Vec<(&'static str, serde_json::Value)> { + let push = |tag: &str| json!({"_tag": tag, "remote": "origin", "localBranch": "main", "remoteBranch": null}); + vec![ + ("push", push("push")), + ("publish-branch", push("publish-branch")), + ("force-push", push("force-push")), + ( + "remote-branch-delete", + json!({"_tag": "remote-branch-delete", "remote": "origin", "branch": "topic"}), + ), + ] + } + + #[tokio::test] + async fn manager_transfer_gate_push_family_delivers_status() { + for (name, request) in push_family() { + let harness = harness().await; + let mut running = start(&harness, request, CancellationToken::new()); + wait_for_network_step(&harness, &mut running, name).await; + + assert!( + read_admitted(&harness, ADMITTED_WITHIN).await, + "{name}: a status read waited for the held network step" + ); + + harness.runner.release.add_permits(1); + running.await.expect("operation task").expect(name); + } + } + + #[tokio::test] + async fn manager_transfer_gate_pull_and_fetch_keep_reads_waiting() { + for (name, request) in [ + ("pull", json!({"_tag": "pull", "remote": "origin"})), + ("fetch", json!({"_tag": "fetch", "remote": "origin"})), + ] { + let harness = harness().await; + let mut running = start(&harness, request, CancellationToken::new()); + wait_for_network_step(&harness, &mut running, name).await; + + assert!( + !read_admitted(&harness, STILL_WAITING_AFTER).await, + "{name}: a status read was admitted while it was held" + ); + + harness.runner.release.add_permits(1); + let _ = running.await.expect("operation task"); + assert!( + read_admitted(&harness, ADMITTED_WITHIN).await, + "{name}: no status read was admitted after it ended" + ); + } + } + + #[tokio::test] + async fn manager_transfer_gate_every_outcome_releases_readers() { + for outcome in [ + "success", + "refusal", + "timeout", + "cancellation", + "cancellation from a lost workspace", + "dropped caller", + ] { + let harness = harness().await; + let cancellation = CancellationToken::new(); + let mut running = start(&harness, push_family().remove(0).1, cancellation.clone()); + wait_for_network_step(&harness, &mut running, outcome).await; + *harness.runner.outcome.lock().expect("outcome lock") = match outcome { + "refusal" => Some(ProcessError::NonZeroExit { + operation: "GitManager.push".to_owned(), + exit_code: 128, + stdout_length: 0, + stderr_length: 60, + stdout: String::new().into_boxed_str(), + stderr: "fatal: Authentication failed for 'https://gitlab.example.test/'" + .into(), + }), + "timeout" => Some(ProcessError::Timeout { + operation: "GitManager.push".to_owned(), + timeout_ms: 600_000, + }), + _ => None, + }; + match outcome { + // The RPC layer cancels and awaits the operation when its workspace is lost. + "cancellation" | "cancellation from a lost workspace" => cancellation.cancel(), + "dropped caller" => running.abort(), + _ => harness.runner.release.add_permits(1), + } + let ended = running.await; + match outcome { + "success" => { + ended.expect("operation task").expect("push succeeds"); + } + "dropped caller" => assert!(ended.is_err(), "the operation was dropped"), + _ => { + let error = ended.expect("operation task").expect_err("the push fails"); + if outcome == "timeout" { + assert_eq!( + error.code, + GitManagerFailureCode::TimedOut.as_str(), + "a timeout is reported as a timeout, not an exit code: {error:?}" + ); + } + } + } + assert!( + read_admitted(&harness, ADMITTED_WITHIN).await, + "{outcome}: no status read was admitted after the push ended" + ); + } + } +} diff --git a/apps/server/src/git/manager/operations/sign_in_tests.rs b/apps/server/src/git/manager/operations/sign_in_tests.rs index febc075ad7..6fc58029e2 100644 --- a/apps/server/src/git/manager/operations/sign_in_tests.rs +++ b/apps/server/src/git/manager/operations/sign_in_tests.rs @@ -8,7 +8,8 @@ use tokio_util::sync::CancellationToken; use super::{GitManagerOperationRequest, execute_branch_or_sync_operation}; use crate::git::{ BoxGitProcessFuture, GitManagerRefEntry, GitManagerRefsSnapshot, GitProcessRunner, - GitRepository, ProcessOutput, ProcessRequest, sign_in::CredentialPrompt, + GitRepository, ProcessOutput, ProcessRequest, + sign_in::{CredentialPrompt, ExecutionPlace}, }; const GCM_STDERR: &str = concat!( @@ -160,6 +161,15 @@ async fn run( runner: &Arc, request: &GitManagerOperationRequest, prompt: CredentialPrompt, +) -> Result { + run_at(runner, request, prompt, &ExecutionPlace::default()).await +} + +async fn run_at( + runner: &Arc, + request: &GitManagerOperationRequest, + prompt: CredentialPrompt, + place: &ExecutionPlace, ) -> Result { let repository = GitRepository::with_runner_for_test(runner.clone()); execute_branch_or_sync_operation( @@ -167,6 +177,7 @@ async fn run( &snapshot(), request, prompt, + place, &CancellationToken::new(), ) .await @@ -330,3 +341,137 @@ async fn a_resumed_request_runs_only_against_the_approved_destination() { assert_eq!(changed.network_spawns(), 0, "{}", resume.operation()); } } + +#[tokio::test] +async fn local_gcm_refusal_still_offers_sign_in_after_the_guidance_change() { + let stderr = concat!( + "fatal: Cannot prompt because user interactivity has been disabled.\n", + "fatal: could not read Username for 'https:", + "//gitlab.example.test': terminal prompts disabled\n" + ); + let destination = concat!( + "https:", + "//gitlab.example.test/example-group/sample-project.git" + ); + let push = request( + json!({"_tag": "push", "remote": "origin", "localBranch": "main", "remoteBranch": null}), + ); + let local = ExecutionPlace { + remote: false, + server_name: None, + }; + let runner = RemoteRunner::new(destination, stderr); + let error = run_at(&runner, &push, CredentialPrompt::OfferSignIn, &local) + .await + .expect_err("GCM refused"); + assert_eq!(error.code, "sign-in-required"); + assert_eq!( + error.message, + "Git Credential Manager needs you to sign in before Git can reach this remote." + ); + let sign_in = error.sign_in.expect("signIn object"); + assert_eq!(sign_in.destination, destination); + assert_eq!(sign_in.host, "gitlab.example.test"); + assert_eq!(sign_in.direction.as_str(), "push"); + assert_eq!(runner.network_spawns(), 1, "no retry"); +} + +const MISSING_USERNAME: &str = concat!( + "fatal: could not read Username for 'https:", + "//gitlab.example.test': terminal prompts disabled" +); +const PUSH_GUIDANCE: &str = "Push ran on the remote server example-server. In a terminal on example-server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode."; + +fn remote_place() -> ExecutionPlace { + ExecutionPlace { + remote: true, + server_name: Some("example-server".to_owned()), + } +} + +fn plain_push() -> GitManagerOperationRequest { + request( + json!({"_tag": "push", "remote": "origin", "localBranch": "main", "remoteBranch": null}), + ) +} + +fn tag_create_and_push() -> GitManagerOperationRequest { + request( + json!({"_tag": "tag-create", "name": "v1", "sha": "a".repeat(40), "pushRemote": "origin"}), + ) +} + +#[tokio::test] +async fn a_remote_missing_username_after_a_timed_out_helper_gets_the_guidance() { + let stderr = format!("credential lookup timed out\n{MISSING_USERNAME}\n"); + let runner = RemoteRunner::new(DESTINATION, &stderr); + let error = run_at( + &runner, + &plain_push(), + CredentialPrompt::Never, + &remote_place(), + ) + .await + .expect_err("push refused"); + assert_eq!(error.code, "authentication"); + assert_eq!( + error.message, + format!("{PUSH_GUIDANCE}\n\n{}", stderr.trim()) + ); + assert!(error.sign_in.is_none()); +} + +#[tokio::test] +async fn a_local_cancelled_sign_in_before_the_missing_username_reads_as_before() { + let stderr = format!("credential lookup cancelled\n{MISSING_USERNAME}\n"); + let local = ExecutionPlace { + remote: false, + server_name: None, + }; + let runner = RemoteRunner::new(DESTINATION, &stderr); + let error = run_at(&runner, &plain_push(), CredentialPrompt::Never, &local) + .await + .expect_err("push refused"); + assert_eq!(error.code, "cancelled"); + assert_eq!(error.message, "The Git Manager operation was cancelled."); +} + +#[tokio::test] +async fn a_created_tag_whose_push_lacks_credentials_on_a_remote_server_gets_the_guidance() { + let stderr = format!("credential lookup timed out\n{MISSING_USERNAME}\n"); + let runner = RemoteRunner::new(DESTINATION, &stderr); + let error = run_at( + &runner, + &tag_create_and_push(), + CredentialPrompt::Never, + &remote_place(), + ) + .await + .expect_err("tag push refused"); + assert_eq!(error.code, "tag-push-failed"); + assert_eq!( + error.message, + format!( + "Created tag v1 locally, but pushing it to origin failed.\n\n{PUSH_GUIDANCE}\n\n{}", + stderr.trim() + ) + ); +} + +#[tokio::test] +async fn a_created_tag_whose_push_fails_locally_keeps_todays_message() { + let runner = RemoteRunner::new(DESTINATION, &format!("{MISSING_USERNAME}\n")); + let error = run_at( + &runner, + &tag_create_and_push(), + CredentialPrompt::Never, + &ExecutionPlace::default(), + ) + .await + .expect_err("tag push refused"); + assert_eq!(error.code, "tag-push-failed"); + assert_eq!( + error.message, + format!("Created tag v1 locally, but pushing it to origin failed: {MISSING_USERNAME}") + ); +} diff --git a/apps/server/src/git/process.rs b/apps/server/src/git/process.rs index aee36b1f7b..ca138c93dc 100644 --- a/apps/server/src/git/process.rs +++ b/apps/server/src/git/process.rs @@ -1,12 +1,20 @@ -use std::{ffi::OsString, path::PathBuf, process::Stdio, time::Duration}; +use std::{ + ffi::OsString, + path::{Path, PathBuf}, + process::Stdio, + sync::Arc, + time::Duration, +}; use thiserror::Error; use tokio::process::Command; use tokio_util::sync::CancellationToken; +use tokio_util::task::{TaskTracker, task_tracker::TaskTrackerToken}; +use crate::diagnostics::profiler::redact::redact_path; use crate::process::supervised::{ - SupervisedOverflow, SupervisedRunError, SupervisedRunOutput, SupervisedRunRequest, - SupervisedStreamOutput, run_supervised, + StopPolicy, SupervisedOverflow, SupervisedRunError, SupervisedRunOutput, SupervisedRunRequest, + SupervisedStreamOutput, TerminationReport, TerminationReports, run_supervised_reporting, }; use crate::process::{ChildCommand, EnvironmentInheritance, SpawnPlan}; @@ -249,7 +257,7 @@ impl ProcessRunner { request: ProcessRequest, cancellation: &CancellationToken, ) -> Result { - self.run_with_environment(request, cancellation, EnvironmentInheritance::Inherit) + self.run_with_environment(request, cancellation, EnvironmentInheritance::Inherit, None) .await } @@ -261,9 +269,14 @@ impl ProcessRunner { #[cfg(feature = "hermetic-test-guard")] let request = checked_guarded_request(request, EnvironmentInheritance::Inherit)?; let command_label = request.command.to_string_lossy().into_owned(); - let output = run_request(&request, EnvironmentInheritance::Inherit, cancellation) - .await - .map_err(|error| map_supervised_error(error, &request, command_label))?; + let output = run_request( + &request, + EnvironmentInheritance::Inherit, + cancellation, + None, + ) + .await + .map_err(|error| map_supervised_error(error, &request, command_label))?; let exit_code = output .status @@ -306,7 +319,7 @@ impl ProcessRunner { request: ProcessRequest, cancellation: &CancellationToken, ) -> Result { - self.run_with_environment(request, cancellation, EnvironmentInheritance::Cleared) + self.run_with_environment(request, cancellation, EnvironmentInheritance::Cleared, None) .await } @@ -316,7 +329,7 @@ impl ProcessRunner { request: ProcessRequest, cancellation: &CancellationToken, ) -> Result { - self.run_with_environment(request, cancellation, EnvironmentInheritance::Cleared) + self.run_with_environment(request, cancellation, EnvironmentInheritance::Cleared, None) .await } @@ -325,11 +338,12 @@ impl ProcessRunner { request: ProcessRequest, cancellation: &CancellationToken, inheritance: EnvironmentInheritance, + reports: Option, ) -> Result { #[cfg(feature = "hermetic-test-guard")] let request = checked_guarded_request(request, inheritance)?; let command_label = request.command.to_string_lossy().into_owned(); - let output = run_request(&request, inheritance, cancellation) + let output = run_request(&request, inheritance, cancellation, reports) .await .map_err(|error| map_supervised_error(error, &request, command_label))?; @@ -417,15 +431,17 @@ async fn run_request( request: &ProcessRequest, inheritance: EnvironmentInheritance, cancellation: &CancellationToken, + reports: Option, ) -> Result { SpawnPlan::new(&request.command, |program| { process_command(request, inheritance, program) }) .start( |command| { - Box::pin(run_supervised( + Box::pin(run_supervised_reporting( supervised_request(request, command), cancellation, + reports.clone(), )) }, |error| match error { @@ -436,6 +452,460 @@ async fn run_request( .await } +impl ProcessRunner { + /// [`Self::run`] for a Git command that may write the index. `registry` admits it, its + /// shutdown stops it, and a stop that had to be forced is followed by + /// [`IndexLockWatch::after_forced_stop`] before the write is released (R16, R17). A write + /// refused because shutdown closed admission fails as cancelled. + pub(crate) async fn run_write( + &self, + request: ProcessRequest, + registry: &GitWriteRegistry, + cancellation: &CancellationToken, + ) -> Result { + let Some(ticket) = registry.track() else { + return Err(ProcessError::Cancelled { + operation: request.operation, + }); + }; + // shortcut: one `rev-parse` per write; cache the Git directory per worktree if writes + // become a measured hot spot. + let watch = if runs_git(&request.command) && takes_index_lock(&request.args) { + self.resolve_git_dir(&request, cancellation) + .await + .map(|git_dir| IndexLockWatch::before_spawn(&git_dir)) + } else { + None + }; + let stop = ticket.cancellation.clone(); + let (sender, received) = tokio::sync::mpsc::unbounded_channel(); + let mut reports = TerminationReports::new(sender); + if let Some(watch) = &watch + && !watch.existed + { + let lock = watch.lock.clone(); + reports = reports.checking_before_force(move |pid| holds_open(pid, &lock)); + } + // Owns the release: a dropped run still settles it in the background. + let mut settlement = PendingSettlement(Some(WriteSettlement { + _ticket: ticket, + watch, + received, + operation: request.operation.clone(), + cwd: request.cwd.clone(), + })); + let output = tokio::select! { + output = self.run_with_environment( + request, + &stop, + EnvironmentInheritance::Inherit, + Some(reports), + ) => output, + () = async { + cancellation.cancelled().await; + stop.cancel(); + std::future::pending::<()>().await; + } => unreachable!("the forwarding branch never completes"), + }; + if let Some(settlement) = settlement.0.take() { + settlement.settle_now_or_later().await; + } + output + } + + /// The command's own Git directory, which for a linked worktree is + /// `/worktrees/`. `None` where there is none yet (`init`, `clone`). + pub(crate) async fn resolve_git_dir( + &self, + request: &ProcessRequest, + cancellation: &CancellationToken, + ) -> Option { + let mut probe = request.clone(); + probe.operation = format!("{}.gitDir", request.operation); + probe.args = ["rev-parse", "--absolute-git-dir"] + .into_iter() + .map(OsString::from) + .collect(); + probe.stdin = None; + probe.env.push(("GIT_OPTIONAL_LOCKS".into(), "0".into())); + probe.allow_non_zero_exit = false; + let output = self.run(probe, cancellation).await.ok()?; + let git_dir = output.stdout.trim_end_matches(['\r', '\n']); + (!git_dir.is_empty()).then(|| PathBuf::from(git_dir)) + } +} + +/// What a write still owes after its process ends: the lock cleanup, then its release. +struct WriteSettlement { + _ticket: GitWriteTicket, + watch: Option, + received: tokio::sync::mpsc::UnboundedReceiver, + operation: String, + cwd: PathBuf, +} + +impl WriteSettlement { + /// Awaited when the report is in; in the background while a reap is still running, so the + /// caller gets its outcome as before. Either way a spawned task owns the cleanup and the + /// ticket, so a caller dropped during the wait cannot release the write early. + async fn settle_now_or_later(mut self) { + match self.received.try_recv() { + Ok(report) => { + let _ = tokio::spawn(self.clean_up(report)).await; + } + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => { + tokio::spawn(self.settle()); + } + // No process started, so there is nothing to clean up. + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => {} + } + } + + async fn settle(mut self) { + if let Some(report) = self.received.recv().await { + self.clean_up(report).await; + } + } + + /// A stop Git honoured needs nothing: Git removed its own lock. + async fn clean_up(self, report: TerminationReport) { + let Some(watch) = self.watch else { + return; + }; + if !(report.requested && report.forced && report.exited) { + return; + } + // Linux and macOS ask Git to stop first, and Git removes its own lock then, so a forced + // stop leaves one only when Git could not act on the request. Its lock is ours only if + // the root still held it open just before the kill: a lock nobody of ours held is + // another Git process's, even one that stays unchanged during the wait. + if cfg!(any(target_os = "linux", target_os = "macos")) && report.before_force != Some(true) + { + return; + } + let worktree = redact_path(&self.cwd.to_string_lossy()); + let lock = watch.lock.clone(); + match watch.after_forced_stop().await { + LockCleanup::Removed => tracing::warn!( + operation = %self.operation, + %worktree, + "removed index.lock left by a Git process BiBCode had to force to stop" + ), + LockCleanup::RemoveFailed { kind, state } => { + self._ticket.lock_left(lock, state, worktree.clone()); + tracing::warn!( + operation = %self.operation, + %worktree, + error = %kind, + "could not remove index.lock left by a Git process BiBCode had to force to stop" + ); + } + LockCleanup::NotOurs | LockCleanup::Changed => {} + } + } +} + +/// Settles a write whose run future was dropped, on the current runtime. +struct PendingSettlement(Option); + +impl Drop for PendingSettlement { + fn drop(&mut self) { + if let Some(settlement) = self.0.take() + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + runtime.spawn(settlement.settle()); + } + } +} + +/// How long a lock left by a forced stop must stay unchanged before BiBCode removes it +/// (R16, D13). +pub(crate) const LOCK_UNCHANGED_WAIT: Duration = Duration::from_secs(2); + +/// What [`IndexLockWatch::after_forced_stop`] did with `index.lock`. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LockCleanup { + /// The lock existed before the process started, or there is none: not BiBCode's. + NotOurs, + /// The lock changed or went away during the wait: another Git process has it now. + Changed, + Removed, + /// The lock stayed unchanged but deleting it failed, so it is still there. `state` is the + /// one that passed the unchanged check. + RemoveFailed { + kind: std::io::ErrorKind, + state: LockState, + }, +} + +/// Whether a Git command's `index.lock` existed before the command started, so a lock its +/// forced stop leaves behind can be told apart from someone else's. +#[derive(Debug)] +pub(crate) struct IndexLockWatch { + lock: PathBuf, + existed: bool, +} + +impl IndexLockWatch { + /// `git_dir` is the command's own Git directory (see [`ProcessRunner::resolve_git_dir`]). + pub(crate) fn before_spawn(git_dir: &Path) -> Self { + let lock = git_dir.join("index.lock"); + Self { + existed: lock.symlink_metadata().is_ok(), + lock, + } + } + + /// Removes a lock that appeared during the command and stayed unchanged for + /// [`LOCK_UNCHANGED_WAIT`]. A lock that existed before the command is never removed. + /// + /// Time alone cannot tell whose lock this is. On Linux and macOS the settlement calls this + /// only when the killed Git process held the lock open just before the kill + /// ([`holds_open`]). Windows offers no such check here and every stop there is forced, so + /// a lock another Git process took while the killed command held none, and kept unchanged + /// for the wait, is still removed there. + pub(crate) async fn after_forced_stop(self) -> LockCleanup { + if self.existed { + return LockCleanup::NotOurs; + } + let Some(before) = lock_state(&self.lock) else { + return LockCleanup::NotOurs; + }; + tokio::time::sleep(LOCK_UNCHANGED_WAIT).await; + if lock_state(&self.lock) != Some(before) { + return LockCleanup::Changed; + } + match std::fs::remove_file(&self.lock) { + Ok(()) => LockCleanup::Removed, + // Gone between the check and the delete: another Git process took and released it. + Err(error) if error.kind() == std::io::ErrorKind::NotFound => LockCleanup::Changed, + Err(error) => LockCleanup::RemoveFailed { + kind: error.kind(), + state: before, + }, + } + } +} + +/// Whether process `pid` has `file` open. Git keeps `index.lock` open while it writes the +/// index, but closes it while a hook or the editor runs; there it removes the lock on +/// `SIGTERM`, so such a lock is never left by a forced stop. +#[cfg(target_os = "linux")] +fn holds_open(pid: u32, file: &Path) -> bool { + use std::os::unix::fs::MetadataExt; + let Ok(target) = file.symlink_metadata() else { + return false; + }; + let Ok(descriptors) = std::fs::read_dir(format!("/proc/{pid}/fd")) else { + return false; + }; + descriptors.flatten().any(|descriptor| { + std::fs::metadata(descriptor.path()) + .is_ok_and(|open| open.dev() == target.dev() && open.ino() == target.ino()) + }) +} + +/// See the Linux version. The root leads its own process group, which the search is limited to. +#[cfg(target_os = "macos")] +fn holds_open(pid: u32, file: &Path) -> bool { + use std::os::unix::ffi::OsStrExt; + unsafe extern "C" { + // libproc, part of libSystem: the processes that have `path` open. + fn proc_listpidspath( + kind: u32, + info: u32, + path: *const libc::c_char, + flags: u32, + buffer: *mut libc::c_void, + size: libc::c_int, + ) -> libc::c_int; + } + const PROC_PGRP_ONLY: u32 = 2; + let Ok(path) = std::ffi::CString::new(file.as_os_str().as_bytes()) else { + return false; + }; + // shortcut: a group of more than 64 processes may hide the root, which keeps the lock. + let mut pids = [0 as libc::pid_t; 64]; + // SAFETY: `path` is a NUL-terminated string and `pids` is writable for the size given. + let bytes = unsafe { + proc_listpidspath( + PROC_PGRP_ONLY, + pid, + path.as_ptr(), + 0, + pids.as_mut_ptr().cast(), + std::mem::size_of_val(&pids) as libc::c_int, + ) + }; + let found = usize::try_from(bytes).unwrap_or(0) / std::mem::size_of::(); + pids[..found.min(pids.len())] + .iter() + .any(|&found| u32::try_from(found) == Ok(pid)) +} + +/// No check on other platforms; the settlement does not ask for one there. +#[cfg(not(any(target_os = "linux", target_os = "macos")))] +fn holds_open(_pid: u32, _file: &Path) -> bool { + false +} + +/// A lock's length and modification time. +type LockState = (u64, Option); + +/// The lock's length and modification time, read without following a link. +fn lock_state(lock: &Path) -> Option { + let metadata = lock.symlink_metadata().ok()?; + Some((metadata.len(), metadata.modified().ok())) +} + +/// The Git commands that may write the index, one registry per server runtime, so shutdown +/// can stop each running write and wait until its lock cleanup is done (plan decisions P15 +/// and P20). +#[derive(Clone, Debug, Default)] +pub(crate) struct GitWriteRegistry { + state: Arc, +} + +#[derive(Debug, Default)] +struct GitWriteRegistryState { + closed: std::sync::Mutex, + writes: TaskTracker, + stop: CancellationToken, + /// Leftover locks of forced writes that could not be deleted, checked again on every stop. + locks_left: LeftLocks, +} + +type LeftLocks = Arc>>; + +/// A lock BiBCode owns but could not delete: its path, its state when the delete failed, and +/// the redacted worktree a log names. +#[derive(Debug)] +struct LeftLock { + lock: PathBuf, + state: LockState, + worktree: String, +} + +impl LeftLock { + /// Deletes the lock if it is still the one BiBCode left. `false` once it is gone or + /// changed (another Git process has it now, so it is not BiBCode's to delete). A lock + /// that cannot be inspected is kept and reported: only `NotFound` means gone. + fn still_left(&self) -> bool { + let metadata = match self.lock.symlink_metadata() { + Ok(metadata) => metadata, + Err(error) => return error.kind() != std::io::ErrorKind::NotFound, + }; + (metadata.len(), metadata.modified().ok()) == self.state + && std::fs::remove_file(&self.lock) + .is_err_and(|error| error.kind() != std::io::ErrorKind::NotFound) + } +} + +/// One admitted write. Dropping it, after the write's outcome and lock cleanup, releases it. +#[derive(Debug)] +pub(crate) struct GitWriteTicket { + _write: TaskTrackerToken, + cancellation: CancellationToken, + locks_left: LeftLocks, +} + +impl GitWriteTicket { + /// Records that this write's leftover `lock` in `worktree` (redacted) could not be removed. + /// `state` is the one that passed the unchanged check: the file is not read again, because + /// another Git process may have replaced it since. + fn lock_left(&self, lock: PathBuf, state: LockState, worktree: String) { + record_left_lock( + &self.locks_left, + LeftLock { + lock, + state, + worktree, + }, + ); + } +} + +fn record_left_lock(locks: &LeftLocks, left: LeftLock) { + locks + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .push(left); +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub(crate) enum QuiesceOutcome { + Clean, + /// This many writes had not finished their stop and cleanup within the limit. + Pending(usize), + /// Every write finished, but the leftover locks of these worktrees (redacted) are still + /// there after one more delete. + LocksLeft(Vec), +} + +impl GitWriteRegistry { + /// Admits one write, or `None` once shutdown closed admission. + pub(crate) fn track(&self) -> Option { + let closed = self + .state + .closed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + (!*closed).then(|| GitWriteTicket { + _write: self.state.writes.token(), + cancellation: self.state.stop.child_token(), + locks_left: self.state.locks_left.clone(), + }) + } + + pub(crate) fn close_admission(&self) { + *self + .state + .closed + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) = true; + self.state.writes.close(); + } + + /// Closes admission, cancels every admitted write and waits up to `limit` until each has + /// exited and finished its lock cleanup, then checks every leftover lock again. Safe to + /// call again: each call reports from the current files. + pub(crate) async fn stop_all_and_wait(&self, limit: Duration) -> QuiesceOutcome { + self.close_admission(); + self.state.stop.cancel(); + if tokio::time::timeout(limit, self.state.writes.wait()) + .await + .is_err() + { + return QuiesceOutcome::Pending(self.state.writes.len()); + } + let mut locks = self + .state + .locks_left + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + locks.retain(LeftLock::still_left); + if locks.is_empty() { + QuiesceOutcome::Clean + } else { + QuiesceOutcome::LocksLeft(locks.iter().map(|left| left.worktree.clone()).collect()) + } + } +} + +#[cfg(all(test, unix))] +impl GitWriteRegistry { + pub(crate) fn record_left_lock_for_test(&self, lock: &Path, worktree: &Path) { + record_left_lock( + &self.state.locks_left, + LeftLock { + lock: lock.to_path_buf(), + state: lock_state(lock).expect("the leftover lock exists"), + worktree: redact_path(&worktree.to_string_lossy()), + }, + ); + } +} + fn process_command( request: &ProcessRequest, inheritance: EnvironmentInheritance, @@ -471,9 +941,71 @@ fn supervised_request(request: &ProcessRequest, command: Command) -> SupervisedR OutputPolicy::Error => SupervisedOverflow::Error, OutputPolicy::Tail => SupervisedOverflow::Tail, }, + // Git removes its own `index.lock` when asked to stop (R15); other programs this + // runner starts keep the immediate stop. + stop_policy: if runs_git(&request.command) { + StopPolicy::AskThenForce + } else { + StopPolicy::Force + }, + } +} + +fn runs_git(command: &std::path::Path) -> bool { + command + .file_stem() + .is_some_and(|stem| stem.eq_ignore_ascii_case("git")) +} + +/// Whether the Git command takes `index.lock` itself, so a lock its forced stop leaves can be +/// its own. A command not listed never has a lock removed, because a lock that appears while +/// it runs belongs to someone else: a leftover lock is a nuisance, a deleted foreign lock can +/// break a person's commit. `index_lock_cleanup_lists_only_commands_that_take_the_lock` checks +/// every entry against the lock events of a real Git. +fn takes_index_lock(args: &[OsString]) -> bool { + let mut words = args.iter().map(|arg| arg.to_str().unwrap_or_default()); + let subcommand = loop { + match words.next() { + Some("-c" | "-C") => { + words.next(); + } + Some(option) if option.starts_with('-') => {} + Some(subcommand) => break subcommand, + None => return false, + } + }; + // Options end at `--`: what follows are paths, however they look. + let rest = words.take_while(|word| *word != "--").collect::>(); + let has = |flag: &str| rest.contains(&flag); + match subcommand { + "add" | "cherry-pick" | "commit" | "merge" | "rebase" | "restore" | "revert" | "rm" => true, + "reset" => !has("--soft"), + "checkout" | "switch" => checkout_takes_index_lock(&rest), + "apply" => has("--cached"), + "stash" => matches!(rest.first(), Some(&("push" | "apply" | "pop"))), + // `pull` holds no lock while it fetches, which is where it waits. + _ => false, } } +/// Creating a branch with no start point leaves the index alone. Any other checkout, including +/// a new branch at a start point, merges a tree into the index under the lock. +fn checkout_takes_index_lock(options: &[&str]) -> bool { + let (mut new_branch, mut start_point) = (false, false); + let mut words = options.iter(); + while let Some(word) = words.next() { + match *word { + "-b" | "-B" | "-c" | "-C" | "--orphan" => { + new_branch = true; + words.next(); + } + option if option.starts_with('-') => {} + _ => start_point = true, + } + } + !new_branch || start_point +} + /// Windows answers a missing working directory with `ERROR_DIRECTORY` ("not a directory"); it is /// the same missing folder that Unix reports as not found. fn missing_cwd_as_not_found(source: std::io::Error, cwd: &std::path::Path) -> std::io::Error { @@ -543,7 +1075,1055 @@ fn render_bytes(output: SupervisedStreamOutput, append_marker: bool) -> Vec #[cfg(test)] mod tests { use super::*; + use crate::process::supervised::GRACEFUL_STOP_WAIT; use crate::test_support::TestSandbox; + use crate::test_support::isolated_git_config::IsolatedGitConfig; + + fn git_environment(config: &IsolatedGitConfig) -> Vec<(OsString, OsString)> { + [ + ("GIT_CONFIG_GLOBAL", config.path().into_os_string()), + ("GIT_CONFIG_NOSYSTEM", "1".into()), + ("GIT_AUTHOR_NAME", "test-user".into()), + ("GIT_AUTHOR_EMAIL", "user@example.test".into()), + ("GIT_COMMITTER_NAME", "test-user".into()), + ("GIT_COMMITTER_EMAIL", "user@example.test".into()), + ] + .into_iter() + .map(|(key, value)| (key.into(), value)) + .collect() + } + + fn fixture_git(config: &IsolatedGitConfig, cwd: &Path, args: &[&str]) { + let mut command = std::process::Command::new("git"); + config.apply_to_command(&mut command); + let output = command + .args(args) + .current_dir(cwd) + .envs(git_environment(config)) + .output() + .expect("fixture Git starts"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + } + + fn git_request(config: &IsolatedGitConfig, cwd: &Path, args: &[&str]) -> ProcessRequest { + ProcessRequest { + operation: "fixture-write".to_owned(), + command: PathBuf::from("git"), + args: args.iter().map(OsString::from).collect(), + cwd: cwd.to_path_buf(), + env: git_environment(config), + stdin: None, + timeout: Duration::from_secs(30), + max_output_bytes: 64 * 1024, + output_policy: OutputPolicy::Truncate, + append_truncation_marker: false, + allow_non_zero_exit: false, + } + } + + /// A `git add` that holds `index.lock` and never finishes: the clean filter of the added + /// file marks that it started and then sleeps. + struct HeldWrite { + request: ProcessRequest, + started: PathBuf, + lock: PathBuf, + } + + fn held_write(sandbox: &TestSandbox, config: &IsolatedGitConfig) -> HeldWrite { + let repository = sandbox.path("repository"); + std::fs::create_dir_all(&repository).expect("repository folder"); + let started = sandbox.path("filter-started"); + fixture_git(config, &repository, &["init", "-q", "-b", "main"]); + fixture_git( + config, + &repository, + &[ + "config", + "filter.slow.clean", + &format!( + "touch '{}'; sleep 30; cat", + started.to_string_lossy().replace('\\', "/") + ), + ], + ); + std::fs::write(repository.join(".gitattributes"), "held.txt filter=slow\n") + .expect(".gitattributes"); + std::fs::write(repository.join("held.txt"), "held\n").expect("held.txt"); + HeldWrite { + request: git_request(config, &repository, &["add", "--", "held.txt"]), + started, + lock: repository.join(".git").join("index.lock"), + } + } + + async fn wait_until_exists(path: &Path) { + let deadline = tokio::time::Instant::now() + Duration::from_secs(20); + while !path.exists() { + assert!( + tokio::time::Instant::now() < deadline, + "{} never appeared", + path.display() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_removes_a_lock_our_forced_stop_left() { + let git_dir = tempfile::tempdir().expect("Git directory"); + let lock = git_dir.path().join("index.lock"); + let watch = IndexLockWatch::before_spawn(git_dir.path()); + std::fs::write(&lock, "").expect("the stopped write's lock"); + + assert_eq!(watch.after_forced_stop().await, LockCleanup::Removed); + assert!(!lock.exists()); + } + + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_keeps_a_lock_that_existed_before_spawn() { + let git_dir = tempfile::tempdir().expect("Git directory"); + let lock = git_dir.path().join("index.lock"); + std::fs::write(&lock, "").expect("someone else's lock"); + let watch = IndexLockWatch::before_spawn(git_dir.path()); + + assert_eq!(watch.after_forced_stop().await, LockCleanup::NotOurs); + assert!(lock.exists()); + } + + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_keeps_a_lock_that_changes_during_the_wait() { + let git_dir = tempfile::tempdir().expect("Git directory"); + let lock = git_dir.path().join("index.lock"); + let watch = IndexLockWatch::before_spawn(git_dir.path()); + std::fs::write(&lock, "").expect("the stopped write's lock"); + + let cleanup = tokio::spawn(watch.after_forced_stop()); + tokio::time::sleep(Duration::from_secs(1)).await; + std::fs::write(&lock, "another Git process took it").expect("a new lock"); + + assert_eq!(cleanup.await.expect("cleanup"), LockCleanup::Changed); + assert!(lock.exists()); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn index_lock_cleanup_shutdown_stops_a_running_write_and_leaves_no_lock() { + let sandbox = TestSandbox::new("index-lock-cleanup-shutdown"); + let config = IsolatedGitConfig::new(); + let held = held_write(&sandbox, &config); + let registry = GitWriteRegistry::default(); + let write = tokio::spawn({ + let (registry, request) = (registry.clone(), held.request.clone()); + async move { + ProcessRunner + .run_write(request, ®istry, &CancellationToken::new()) + .await + } + }); + wait_until_exists(&held.started).await; + assert!(held.lock.exists(), "the write holds index.lock"); + + let outcome = registry + .stop_all_and_wait(GRACEFUL_STOP_WAIT + LOCK_UNCHANGED_WAIT + Duration::from_secs(1)) + .await; + + assert_eq!(outcome, QuiesceOutcome::Clean); + assert!(!held.lock.exists(), "no index.lock remains"); + let result = tokio::time::timeout(Duration::from_secs(1), write) + .await + .expect("the write has returned") + .expect("write task"); + assert!( + matches!(result, Err(ProcessError::Cancelled { .. })), + "{result:?}" + ); + } + + /// A push never takes `index.lock`, so a lock that appears while it runs is someone else's, + /// such as a person's `git commit` waiting for its editor. A forced stop of the push, as + /// every stop is on Windows, must leave that lock alone. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn index_lock_cleanup_keeps_a_lock_taken_during_a_forced_push() { + let sandbox = TestSandbox::new("index-lock-cleanup-forced-push"); + let config = IsolatedGitConfig::new(); + let repository = sandbox.path("repository"); + let remote = sandbox.path("remote.git"); + let hooks = sandbox.path("hooks"); + let started = sandbox.path("hook-started"); + let bin = sandbox.path("bin"); + for folder in [&repository, &hooks, &bin] { + std::fs::create_dir_all(folder).expect("fixture folder"); + } + fixture_git( + &config, + sandbox.root(), + &["init", "-q", "--bare", &remote.to_string_lossy()], + ); + fixture_git(&config, &repository, &["init", "-q", "-b", "main"]); + fixture_git( + &config, + &repository, + &["commit", "-q", "--allow-empty", "-m", "initial"], + ); + fixture_git( + &config, + &repository, + &["config", "core.hooksPath", &hooks.to_string_lossy()], + ); + TestSandbox::write_executable( + &hooks.join("pre-push"), + &format!("#!/bin/sh\ntouch '{}'\nexec sleep 30\n", started.display()), + ); + let real_git = std::process::Command::new("sh") + .args(["-c", "command -v git"]) + .output() + .expect("locate Git"); + let real_git = String::from_utf8(real_git.stdout).expect("Git path"); + // The wrapper ignores the request to stop and outlives Git, so the stop is forced, as + // every stop is on Windows. + let git = bin.join("git"); + TestSandbox::write_executable( + &git, + &format!( + "#!/bin/sh\ntrap '' TERM\n'{}' \"$@\"\nstatus=$?\n[ \"$1\" = push ] && exec sleep 30\nexit $status\n", + real_git.trim() + ), + ); + let mut request = git_request( + &config, + &repository, + &["push", "-q", &remote.to_string_lossy(), "main"], + ); + request.command = git; + let cancellation = CancellationToken::new(); + let registry = GitWriteRegistry::default(); + let push = tokio::spawn({ + let (cancellation, registry) = (cancellation.clone(), registry.clone()); + async move { + ProcessRunner + .run_write(request, ®istry, &cancellation) + .await + } + }); + wait_until_exists(&started).await; + let lock = repository.join(".git").join("index.lock"); + std::fs::write(&lock, "").expect("a person's commit takes the lock"); + + cancellation.cancel(); + let result = tokio::time::timeout(Duration::from_secs(20), push) + .await + .expect("the push has returned") + .expect("push task"); + // Waits for the push's settlement, which may finish in the background. + let settled = registry + .stop_all_and_wait(GRACEFUL_STOP_WAIT + LOCK_UNCHANGED_WAIT + Duration::from_secs(5)) + .await; + + assert!( + matches!(result, Err(ProcessError::Cancelled { .. })), + "{result:?}" + ); + assert_eq!(settled, QuiesceOutcome::Clean); + assert!(lock.exists(), "the person's index.lock is kept"); + } + + /// A `git add` behind a wrapper that ignores the request to stop, so the stop is forced. It + /// marks `started` once running and, with `holds_lock`, first opens `index.lock` itself and + /// keeps it open, as a Git process killed while writing the index does. + #[cfg(unix)] + fn forced_add( + sandbox: &TestSandbox, + config: &IsolatedGitConfig, + holds_lock: bool, + ) -> HeldWrite { + let repository = sandbox.path("repository"); + let bin = sandbox.path("bin"); + let started = sandbox.path("add-started"); + for folder in [&repository, &bin] { + std::fs::create_dir_all(folder).expect("fixture folder"); + } + fixture_git(config, &repository, &["init", "-q", "-b", "main"]); + let lock = repository.join(".git").join("index.lock"); + let real_git = std::process::Command::new("sh") + .args(["-c", "command -v git"]) + .output() + .expect("locate Git"); + let real_git = String::from_utf8(real_git.stdout).expect("Git path"); + let open_lock = if holds_lock { + format!("exec 3>'{}'\n", lock.display()) + } else { + String::new() + }; + let git = bin.join("git"); + TestSandbox::write_executable( + &git, + &format!( + "#!/bin/sh\nif [ \"$1\" = add ]; then\ntrap '' TERM\n{open_lock}touch '{}'\nexec sleep 30\nfi\nexec '{}' \"$@\"\n", + started.display(), + real_git.trim() + ), + ); + let mut request = git_request(config, &repository, &["add", "--", "file.txt"]); + request.command = git; + HeldWrite { + request, + started, + lock, + } + } + + /// Cancels the write once it has started (after `meanwhile`), waits for its settlement and + /// returns the settled outcome. + #[cfg(unix)] + async fn cancel_forced_write(held: &HeldWrite, meanwhile: impl FnOnce()) -> QuiesceOutcome { + let cancellation = CancellationToken::new(); + let registry = GitWriteRegistry::default(); + let write = tokio::spawn({ + let (cancellation, registry, request) = + (cancellation.clone(), registry.clone(), held.request.clone()); + async move { + ProcessRunner + .run_write(request, ®istry, &cancellation) + .await + } + }); + wait_until_exists(&held.started).await; + meanwhile(); + cancellation.cancel(); + let result = tokio::time::timeout(Duration::from_secs(20), write) + .await + .expect("the write has returned") + .expect("write task"); + assert!( + matches!(result, Err(ProcessError::Cancelled { .. })), + "{result:?}" + ); + registry + .stop_all_and_wait(GRACEFUL_STOP_WAIT + LOCK_UNCHANGED_WAIT + Duration::from_secs(5)) + .await + } + + /// PR review of #341: a write that takes the lock can still be stopped while it holds none + /// (in a hook, at a prompt), and another Git process can take the lock meanwhile and keep + /// it unchanged for longer than the wait, such as a person's large `git add`. The forced + /// stop must leave that lock alone. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn index_lock_cleanup_keeps_a_lock_another_git_took_during_a_forced_write() { + let sandbox = TestSandbox::new("index-lock-cleanup-foreign-during-write"); + let config = IsolatedGitConfig::new(); + let held = forced_add(&sandbox, &config, false); + + let settled = cancel_forced_write(&held, || { + std::fs::write(&held.lock, "").expect("another Git process takes the lock"); + }) + .await; + + assert_eq!(settled, QuiesceOutcome::Clean); + assert!( + held.lock.exists(), + "the other Git process's index.lock is kept" + ); + } + + /// R16: a lock the forced write itself held is removed. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn index_lock_cleanup_removes_the_lock_a_forced_write_held() { + let sandbox = TestSandbox::new("index-lock-cleanup-own-forced-write"); + let config = IsolatedGitConfig::new(); + let held = forced_add(&sandbox, &config, true); + + let settled = cancel_forced_write(&held, || { + assert!(held.lock.exists(), "the write holds index.lock"); + }) + .await; + + assert_eq!(settled, QuiesceOutcome::Clean); + assert!( + !held.lock.exists(), + "the forced write's index.lock is removed" + ); + } + + /// Every command shape the server runs as a write, with whether a real Git took + /// `index.lock` for it. [`takes_index_lock`] may list only commands a real Git took the + /// lock for, and must list each `Locks` row, a shape it once misread. + #[test] + fn index_lock_cleanup_lists_only_commands_that_take_the_lock() { + use notify::Watcher; + enum Step { + Write(&'static str, &'static str), + Setup(&'static [&'static str]), + /// A setup that may fail, such as a merge that stops on a conflict. + Conflict(&'static [&'static str]), + Watch(&'static [&'static str]), + /// Takes the lock on every Git CI runs; listing it is checked too. + Locks(&'static [&'static str]), + } + use Step::{Conflict, Locks, Setup, Watch, Write}; + let sandbox = TestSandbox::new("index-lock-cleanup-allowlist"); + let config = IsolatedGitConfig::new(); + let work = sandbox.path("work"); + let remote = sandbox.path("remote.git"); + std::fs::create_dir_all(&work).expect("work folder"); + fixture_git( + &config, + sandbox.root(), + &["init", "-q", "--bare", &remote.to_string_lossy()], + ); + fixture_git(&config, &work, &["init", "-q", "-b", "main"]); + fixture_git( + &config, + &work, + &["remote", "add", "origin", &remote.to_string_lossy()], + ); + let steps = [ + Write("a.txt", "1\n"), + Setup(&["add", "a.txt"]), + Setup(&["commit", "-q", "-m", "one"]), + Setup(&["switch", "-q", "-c", "topic"]), + Write("b.txt", "b\n"), + Setup(&["add", "b.txt"]), + Setup(&["commit", "-q", "-m", "topic"]), + Setup(&["switch", "-q", "-c", "side", "main"]), + Write("s.txt", "s\n"), + Setup(&["add", "s.txt"]), + Setup(&["commit", "-q", "-m", "side"]), + Setup(&["switch", "-q", "main"]), + Setup(&["push", "-q", "origin", "main", "topic"]), + Write("a.txt", "2\n"), + Watch(&["add", "--", "a.txt"]), + Watch(&["--literal-pathspecs", "restore", "--staged", "--", "a.txt"]), + Watch(&[ + "--literal-pathspecs", + "restore", + "--worktree", + "--", + "a.txt", + ]), + Write("a.txt", "2\n"), + Setup(&["add", "a.txt"]), + Watch(&["commit", "--no-edit", "-q", "-m", "two"]), + Watch(&["reset", "--soft", "HEAD~1"]), + Watch(&["reset", "--", "a.txt"]), + Watch(&["stash", "push"]), + Watch(&["stash", "list", "--format=%H"]), + Watch(&["stash", "show", "--name-only", "stash@{0}"]), + Watch(&["stash", "apply", "--quiet", "stash@{0}"]), + Watch(&["--literal-pathspecs", "checkout", "HEAD", "--", "a.txt"]), + Watch(&["stash", "pop", "--quiet"]), + Setup(&["checkout", "HEAD", "--", "a.txt"]), + Write("a.txt", "3\n"), + Setup(&["stash", "push"]), + Watch(&["stash", "drop", "stash@{0}"]), + Watch(&["checkout", "-b", "fresh"]), + Watch(&["switch", "main"]), + Watch(&["switch", "-c", "fresh2"]), + Setup(&["switch", "-q", "main"]), + Watch(&["merge", "--no-edit", "topic"]), + Watch(&["revert", "--no-edit", "HEAD"]), + Watch(&["cherry-pick", "--empty=keep", "topic"]), + Watch(&["rm", "--cached", "-r", "--ignore-unmatch", "--", "b.txt"]), + Setup(&["reset", "-q", "--", "b.txt"]), + Write( + "../new.patch", + "diff --git a/new.txt b/new.txt\nnew file mode 100644\n--- /dev/null\n\ + +++ b/new.txt\n@@ -0,0 +1 @@\n+new\n", + ), + Watch(&["apply", "--cached", "--whitespace=nowarn", "../new.patch"]), + Setup(&["reset", "-q", "--", "new.txt"]), + Watch(&["apply", "--whitespace=nowarn", "../new.patch"]), + Watch(&["reset", "--mixed", "HEAD"]), + Watch(&["-c", "rebase.backend=merge", "rebase", "main", "side"]), + Setup(&["switch", "-q", "main"]), + Watch(&["push", "-q", "origin", "main"]), + Watch(&["fetch", "-q", "origin"]), + Watch(&["pull", "--ff-only", "origin", "main"]), + Watch(&["update-ref", "refs/heads/extra", "HEAD"]), + Watch(&["tag", "probe-tag"]), + Watch(&["config", "probe.key", "value"]), + // A new branch at a start point other than HEAD, and at HEAD itself. + Locks(&["switch", "-c", "from-topic", "topic", "--no-track"]), + Setup(&["switch", "-q", "main"]), + Locks(&["switch", "-c", "from-main", "main", "--no-track"]), + Setup(&["switch", "-q", "main"]), + Locks(&["switch", "-c", "tracked", "--track", "origin/topic"]), + Setup(&["switch", "-q", "main"]), + // Paths that look like options after `--`. + Write("--soft", "soft\n"), + Write("-b", "b\n"), + Setup(&["add", "--", "--soft", "-b"]), + Setup(&["commit", "-q", "-m", "dashes"]), + Write("--soft", "soft 2\n"), + Setup(&["add", "--", "--soft"]), + Locks(&["reset", "--", "--soft"]), + Write("-b", "b 2\n"), + Locks(&["checkout", "HEAD", "--", "-b"]), + Setup(&["checkout", "HEAD", "--", "--soft"]), + // Conflicts to continue, skip and abort. + Write("c.txt", "base\n"), + Setup(&["add", "c.txt"]), + Setup(&["commit", "-q", "-m", "base"]), + Setup(&["tag", "base"]), + Setup(&["switch", "-q", "-c", "theirs"]), + Write("c.txt", "theirs\n"), + Setup(&["commit", "-q", "-a", "-m", "theirs"]), + Setup(&["switch", "-q", "-c", "theirs2", "base"]), + Write("c.txt", "theirs 2\n"), + Setup(&["commit", "-q", "-a", "-m", "theirs 2"]), + Setup(&["switch", "-q", "-c", "replay", "base"]), + Write("d.txt", "d\n"), + Setup(&["add", "d.txt"]), + Setup(&["commit", "-q", "-m", "replay"]), + Setup(&["switch", "-q", "main"]), + Write("c.txt", "ours\n"), + Setup(&["commit", "-q", "-a", "-m", "ours"]), + Setup(&["tag", "ours"]), + Conflict(&["merge", "--no-edit", "theirs"]), + Locks(&["merge", "--abort"]), + Conflict(&["cherry-pick", "theirs"]), + Locks(&["cherry-pick", "--abort"]), + Conflict(&["cherry-pick", "theirs"]), + Write("c.txt", "picked\n"), + Setup(&["add", "c.txt"]), + Locks(&["-c", "core.editor=:", "cherry-pick", "--continue"]), + Conflict(&["revert", "--no-edit", "ours"]), + Locks(&["revert", "--abort"]), + Conflict(&["revert", "--no-edit", "ours"]), + Write("c.txt", "reverted\n"), + Setup(&["add", "c.txt"]), + Locks(&["-c", "core.editor=:", "revert", "--continue"]), + Setup(&["switch", "-q", "theirs"]), + Conflict(&["-c", "rebase.backend=merge", "rebase", "main"]), + Locks(&["-c", "rebase.backend=merge", "rebase", "--abort"]), + Conflict(&["-c", "rebase.backend=merge", "rebase", "main"]), + Locks(&["-c", "rebase.backend=merge", "rebase", "--skip"]), + Setup(&["switch", "-q", "theirs2"]), + Conflict(&["-c", "rebase.backend=merge", "rebase", "main"]), + Write("c.txt", "rebased\n"), + Setup(&["add", "c.txt"]), + Locks(&[ + "-c", + "core.editor=:", + "-c", + "rebase.backend=merge", + "rebase", + "--continue", + ]), + Setup(&["switch", "-q", "replay"]), + Locks(&[ + "-c", + "rebase.backend=merge", + "-c", + "sequence.editor=:", + "rebase", + "-i", + "main", + ]), + Setup(&["switch", "-q", "main"]), + ]; + let (events, received) = std::sync::mpsc::channel(); + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| { + if let Ok(event) = event { + for path in event.paths { + if let Some(name) = path.file_name().and_then(|name| name.to_str()) { + let _ = events.send(name.to_owned()); + } + } + } + }) + .expect("Git directory watcher"); + watcher + .watch(&work.join(".git"), notify::RecursiveMode::NonRecursive) + .expect("watch the Git directory"); + + let mut mismatches = Vec::new(); + for (row, step) in steps.iter().enumerate() { + let watched = match step { + Write(file, contents) => { + std::fs::write(work.join(file), contents).expect("fixture file"); + None + } + Setup(args) => { + fixture_git(&config, &work, args); + None + } + Conflict(args) => { + let mut command = std::process::Command::new("git"); + config.apply_to_command(&mut command); + command + .args(*args) + .current_dir(&work) + .envs(git_environment(&config)) + .output() + .expect("fixture Git starts"); + None + } + Watch(args) => { + fixture_git(&config, &work, args); + Some((args, false)) + } + Locks(args) => { + fixture_git(&config, &work, args); + Some((args, true)) + } + }; + // Events arrive in order, so the sentinel's event closes this step's events. + let sentinel = format!("row-{row}-done"); + let sentinel_path = work.join(".git").join(&sentinel); + std::fs::write(&sentinel_path, "").expect("sentinel file"); + let mut took_lock = false; + loop { + let name = received + .recv_timeout(Duration::from_secs(10)) + .expect("the watcher reports the sentinel file"); + took_lock |= name == "index.lock"; + if name == sentinel { + break; + } + } + std::fs::remove_file(&sentinel_path).expect("remove sentinel file"); + let Some((args, must_list)) = watched else { + continue; + }; + let listed = takes_index_lock(&args.iter().map(OsString::from).collect::>()); + // Only a listed command that took no lock is unsafe; leaving one out is a nuisance. + if listed && !took_lock { + mismatches.push(format!( + "git {args:?} is listed, but Git took no index.lock" + )); + } + if must_list && !(took_lock && listed) { + mismatches.push(format!( + "git {args:?} took index.lock {took_lock}, listed {listed}" + )); + } + } + + assert!(mismatches.is_empty(), "{mismatches:#?}"); + } + + #[tokio::test] + async fn index_lock_cleanup_resolves_the_lock_of_a_linked_worktree() { + let sandbox = TestSandbox::new("index-lock-cleanup-linked"); + let config = IsolatedGitConfig::new(); + let main = sandbox.path("main"); + let linked = sandbox.path("linked"); + std::fs::create_dir_all(&main).expect("main worktree"); + fixture_git(&config, &main, &["init", "-q", "-b", "main"]); + fixture_git( + &config, + &main, + &["commit", "-q", "--allow-empty", "-m", "initial"], + ); + fixture_git( + &config, + &main, + &[ + "worktree", + "add", + "-q", + "-b", + "linked", + &linked.to_string_lossy(), + ], + ); + + let git_dir = ProcessRunner + .resolve_git_dir( + &git_request(&config, &linked, &["add", "--", "file.txt"]), + &CancellationToken::new(), + ) + .await + .expect("the linked worktree's Git directory"); + + assert_eq!( + std::fs::canonicalize(&git_dir).expect("resolved Git directory"), + std::fs::canonicalize(main.join(".git").join("worktrees").join("linked")) + .expect("linked worktree Git directory") + ); + assert_eq!( + IndexLockWatch::before_spawn(&git_dir).lock, + git_dir.join("index.lock") + ); + } + + #[tokio::test] + async fn index_lock_cleanup_shutdown_with_nothing_running_returns_at_once() { + let started = std::time::Instant::now(); + + let outcome = GitWriteRegistry::default() + .stop_all_and_wait(Duration::from_secs(10)) + .await; + + assert_eq!(outcome, QuiesceOutcome::Clean); + assert!(started.elapsed() < Duration::from_secs(1)); + } + + #[tokio::test] + async fn index_lock_cleanup_two_runtimes_do_not_share_writes() { + let stopping = GitWriteRegistry::default(); + let running = GitWriteRegistry::default(); + let write = running.track().expect("the other runtime admits a write"); + + let outcome = stopping.stop_all_and_wait(Duration::from_secs(10)).await; + + assert_eq!(outcome, QuiesceOutcome::Clean); + assert!(!write.cancellation.is_cancelled()); + assert!(running.track().is_some()); + } + + #[tokio::test] + async fn index_lock_cleanup_refuses_a_write_after_admission_closed() { + let sandbox = TestSandbox::new("index-lock-cleanup-refused"); + let config = IsolatedGitConfig::new(); + let registry = GitWriteRegistry::default(); + registry.close_admission(); + + assert!(registry.track().is_none()); + let refused = ProcessRunner + .run_write( + git_request(&config, sandbox.root(), &["init", "-q"]), + ®istry, + &CancellationToken::new(), + ) + .await; + assert!( + matches!(refused, Err(ProcessError::Cancelled { .. })), + "{refused:?}" + ); + assert!( + !sandbox.path(".git").exists(), + "the refused write never ran" + ); + } + + /// The write's settlement removes the lock only after a forced stop, and releases the write + /// either way. On Linux and macOS a real Git write stops when asked, so only this test + /// reaches the forced branch there. + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_settles_only_a_forced_stop() { + for (report, lock_remains) in [ + ( + TerminationReport { + requested: true, + forced: true, + exited: true, + before_force: Some(true), + }, + false, + ), + ( + TerminationReport { + requested: true, + forced: false, + exited: true, + before_force: None, + }, + true, + ), + // A forced stop whose root did not hold the lock: another Git process's lock on + // Linux and macOS, where that can be checked. + ( + TerminationReport { + requested: true, + forced: true, + exited: true, + before_force: Some(false), + }, + cfg!(any(target_os = "linux", target_os = "macos")), + ), + ] { + let git_dir = tempfile::tempdir().expect("Git directory"); + let lock = git_dir.path().join("index.lock"); + let registry = GitWriteRegistry::default(); + let watch = IndexLockWatch::before_spawn(git_dir.path()); + std::fs::write(&lock, "").expect("the write's lock"); + let (reports, received) = tokio::sync::mpsc::unbounded_channel(); + reports.send(report).expect("the stop report"); + + WriteSettlement { + _ticket: registry.track().expect("an admitted write"), + watch: Some(watch), + received, + operation: "fixture-write".to_owned(), + cwd: git_dir.path().to_path_buf(), + } + .settle_now_or_later() + .await; + + assert_eq!(lock.exists(), lock_remains, "{report:?}"); + assert_eq!( + registry.stop_all_and_wait(Duration::from_secs(1)).await, + QuiesceOutcome::Clean, + "the settled write is released" + ); + } + } + + /// A caller dropped during the lock-unchanged wait must not release the write while its + /// lock remains: the cleanup finishes on its own and shutdown waits for it. + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_survives_a_caller_dropped_during_the_wait() { + let git_dir = tempfile::tempdir().expect("Git directory"); + let lock = git_dir.path().join("index.lock"); + let registry = GitWriteRegistry::default(); + let watch = IndexLockWatch::before_spawn(git_dir.path()); + std::fs::write(&lock, "").expect("the forced write's lock"); + let (reports, received) = tokio::sync::mpsc::unbounded_channel(); + reports + .send(TerminationReport { + requested: true, + forced: true, + exited: true, + before_force: Some(true), + }) + .expect("the stop report"); + let settlement = WriteSettlement { + _ticket: registry.track().expect("an admitted write"), + watch: Some(watch), + received, + operation: "fixture-write".to_owned(), + cwd: git_dir.path().to_path_buf(), + } + .settle_now_or_later(); + + let dropped = tokio::time::timeout(Duration::from_secs(1), settlement).await; + + assert!(dropped.is_err(), "the caller left during the wait"); + assert!(lock.exists(), "the lock is not yet two seconds old"); + assert_eq!( + registry.stop_all_and_wait(Duration::from_millis(500)).await, + QuiesceOutcome::Pending(1), + "the write is held while its cleanup runs" + ); + assert_eq!( + registry.stop_all_and_wait(Duration::from_secs(5)).await, + QuiesceOutcome::Clean + ); + assert!(!lock.exists(), "the cleanup still removed the lock"); + } + + /// Leaves an unchanged lock that a forced write could not delete, because its Git + /// directory is read-only. The directory stays read-only; `writable` undoes that. + #[cfg(unix)] + async fn leave_an_undeletable_lock( + registry: &GitWriteRegistry, + ) -> Option<(tempfile::TempDir, PathBuf)> { + use std::os::unix::fs::PermissionsExt; + // SAFETY: geteuid has no preconditions. + if unsafe { libc::geteuid() } == 0 { + eprintln!("skipped: root deletes from a read-only folder"); + return None; + } + let git_dir = tempfile::tempdir().expect("Git directory"); + let lock = git_dir.path().join("index.lock"); + let watch = IndexLockWatch::before_spawn(git_dir.path()); + std::fs::write(&lock, "").expect("the forced write's lock"); + std::fs::set_permissions(git_dir.path(), std::fs::Permissions::from_mode(0o555)) + .expect("read-only Git directory"); + let (reports, received) = tokio::sync::mpsc::unbounded_channel(); + reports + .send(TerminationReport { + requested: true, + forced: true, + exited: true, + before_force: Some(true), + }) + .expect("the stop report"); + WriteSettlement { + _ticket: registry.track().expect("an admitted write"), + watch: Some(watch), + received, + operation: "fixture-write".to_owned(), + cwd: git_dir.path().to_path_buf(), + } + .settle_now_or_later() + .await; + Some((git_dir, lock)) + } + + #[cfg(unix)] + fn writable(git_dir: &tempfile::TempDir) { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(git_dir.path(), std::fs::Permissions::from_mode(0o755)) + .expect("writable Git directory"); + } + + /// A lock that stayed unchanged but could not be deleted is not someone else's: every stop + /// reports it while it is still there. + #[cfg(unix)] + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_reports_a_lock_it_could_not_remove() { + let registry = GitWriteRegistry::default(); + let Some((git_dir, lock)) = leave_an_undeletable_lock(®istry).await else { + return; + }; + + let cleanup = IndexLockWatch { + lock: lock.clone(), + existed: false, + } + .after_forced_stop() + .await; + let first = registry.stop_all_and_wait(Duration::from_secs(1)).await; + let second = registry.stop_all_and_wait(Duration::from_secs(1)).await; + + writable(&git_dir); + assert!( + matches!( + cleanup, + LockCleanup::RemoveFailed { + kind: std::io::ErrorKind::PermissionDenied, + .. + } + ), + "{cleanup:?}" + ); + assert!(lock.exists(), "the lock could not be removed"); + let reported = + QuiesceOutcome::LocksLeft(vec![redact_path(&git_dir.path().to_string_lossy())]); + assert_eq!(first, reported); + assert_eq!(second, reported, "a later stop still reports the lock"); + } + + /// Codex review finding 3: a leftover lock someone else deleted is no longer reported. + #[cfg(unix)] + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_forgets_a_leftover_lock_someone_removed() { + let registry = GitWriteRegistry::default(); + let Some((git_dir, lock)) = leave_an_undeletable_lock(®istry).await else { + return; + }; + writable(&git_dir); + std::fs::remove_file(&lock).expect("someone deletes the lock"); + + assert_eq!( + registry.stop_all_and_wait(Duration::from_secs(1)).await, + QuiesceOutcome::Clean + ); + } + + /// Each stop tries once more to delete a leftover lock, so a lock left by a passing + /// permission problem goes away. + #[cfg(unix)] + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_retries_a_leftover_lock_on_the_next_stop() { + let registry = GitWriteRegistry::default(); + let Some((git_dir, lock)) = leave_an_undeletable_lock(®istry).await else { + return; + }; + writable(&git_dir); + + let outcome = registry.stop_all_and_wait(Duration::from_secs(1)).await; + + assert_eq!(outcome, QuiesceOutcome::Clean); + assert!(!lock.exists(), "the retry deleted the leftover lock"); + } + + /// A leftover lock that changed since it was recorded belongs to another Git process now, + /// so the retry forgets it and leaves it alone. + #[cfg(unix)] + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_keeps_a_leftover_lock_that_changed() { + let registry = GitWriteRegistry::default(); + let Some((git_dir, lock)) = leave_an_undeletable_lock(®istry).await else { + return; + }; + writable(&git_dir); + std::fs::write(&lock, "another Git process").expect("another Git takes the lock"); + + let outcome = registry.stop_all_and_wait(Duration::from_secs(1)).await; + + assert_eq!(outcome, QuiesceOutcome::Clean); + assert!(lock.exists(), "a lock that changed is never deleted"); + } + + /// Codex review: the record keeps the lock that passed the unchanged check, so a lock + /// another Git process put there before the record was made is never deleted later. + #[cfg(unix)] + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_records_the_lock_that_passed_the_check() { + use std::os::unix::fs::PermissionsExt; + // SAFETY: geteuid has no preconditions. + if unsafe { libc::geteuid() } == 0 { + eprintln!("skipped: root deletes from a read-only folder"); + return; + } + let registry = GitWriteRegistry::default(); + let git_dir = tempfile::tempdir().expect("Git directory"); + let lock = git_dir.path().join("index.lock"); + let watch = IndexLockWatch::before_spawn(git_dir.path()); + std::fs::write(&lock, "").expect("the forced write's lock"); + std::fs::set_permissions(git_dir.path(), std::fs::Permissions::from_mode(0o555)) + .expect("read-only Git directory"); + let cleanup = watch.after_forced_stop().await; + let LockCleanup::RemoveFailed { state, .. } = cleanup else { + panic!("the delete fails: {cleanup:?}"); + }; + std::fs::write(&lock, "another Git process").expect("another Git takes the lock"); + let ticket = registry.track().expect("an admitted write"); + + ticket.lock_left(lock.clone(), state, "worktree".to_owned()); + drop(ticket); + writable(&git_dir); + let outcome = registry.stop_all_and_wait(Duration::from_secs(1)).await; + + assert_eq!(outcome, QuiesceOutcome::Clean); + assert!( + lock.exists(), + "the other Git process's lock is never deleted" + ); + } + + /// Codex review: a leftover lock that cannot be inspected is not gone, so it stays + /// reported. + #[cfg(unix)] + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_keeps_reporting_a_lock_it_cannot_inspect() { + use std::os::unix::fs::PermissionsExt; + let registry = GitWriteRegistry::default(); + let Some((git_dir, lock)) = leave_an_undeletable_lock(®istry).await else { + return; + }; + std::fs::set_permissions(git_dir.path(), std::fs::Permissions::from_mode(0o000)) + .expect("unreadable Git directory"); + + let outcome = registry.stop_all_and_wait(Duration::from_secs(1)).await; + + writable(&git_dir); + assert_eq!( + outcome, + QuiesceOutcome::LocksLeft(vec![redact_path(&git_dir.path().to_string_lossy())]) + ); + assert!(lock.exists(), "the lock is still there"); + } + + #[tokio::test(start_paused = true)] + async fn index_lock_cleanup_reports_pending_when_a_reap_outlasts_the_limit() { + let registry = GitWriteRegistry::default(); + let write = registry.track().expect("an admitted write"); + + assert_eq!( + registry.stop_all_and_wait(Duration::from_secs(1)).await, + QuiesceOutcome::Pending(1) + ); + assert!( + write.cancellation.is_cancelled(), + "the write was told to stop" + ); + drop(write); + assert_eq!( + registry.stop_all_and_wait(Duration::from_secs(1)).await, + QuiesceOutcome::Clean + ); + } #[test] fn safe_spawn_failure_preserves_category_without_private_launch_data() { @@ -704,6 +2284,25 @@ mod tests { } } + #[test] + fn git_stop_only_git_gets_the_ask_first_policy() { + let sandbox = TestSandbox::new("git-stop-policy"); + let policy = |command: &str| { + let request = fixture_request(&sandbox, "fixture-policy", PathBuf::from(command)); + supervised_request(&request, Command::new(command)).stop_policy + }; + + assert_eq!(policy("git"), StopPolicy::AskThenForce); + assert_eq!(policy("/usr/bin/git"), StopPolicy::AskThenForce); + #[cfg(windows)] + assert_eq!( + policy("C:\\Program Files\\Git\\cmd\\git.exe"), + StopPolicy::AskThenForce + ); + assert_eq!(policy("trash"), StopPolicy::Force); + assert_eq!(policy("gitk"), StopPolicy::Force); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn runner_uses_command_local_environment_for_parallel_children() { async fn run(label: &str) -> ProcessOutput { diff --git a/apps/server/src/git/repository.rs b/apps/server/src/git/repository.rs index 1fdd43f4e2..5013ecf1c4 100644 --- a/apps/server/src/git/repository.rs +++ b/apps/server/src/git/repository.rs @@ -21,6 +21,7 @@ use crate::source_control::{self, ProviderHosts}; use super::clone_policy::ClonePlan; use super::manager::graph::MAX_DIFF_BUFFER_SIZE; use super::manager::in_progress::detect_in_progress_operation; +use super::process::{GitWriteRegistry, LOCK_UNCHANGED_WAIT, QuiesceOutcome}; use super::worktree::{WorktreePruneDryRunRecord, parse_worktree_prune_dry_run}; use super::{ ChangeRequest, CreateWorktreeInput, GitCommandDiagnostics, GitCommandError, @@ -33,6 +34,7 @@ use super::{ canonical_worktree_path_key, git_worktree_prune_impact_digest, host_path_platform, normalize_worktree_path_key, parse_numstat, parse_porcelain_v2_line, parse_worktree_porcelain, }; +use crate::process::supervised::GRACEFUL_STOP_WAIT; const DEFAULT_TIMEOUT: Duration = Duration::from_secs(30); const REPOSITORY_CLASSIFICATION_TIMEOUT: Duration = Duration::from_secs(2); @@ -126,6 +128,49 @@ pub(crate) trait GitProcessRunner: Send + Sync { ) -> BoxGitProcessFuture<'a> { self.run(request, cancellation) } + + /// [`Self::run`] for a command that may write the index (one without + /// `GIT_OPTIONAL_LOCKS=0`). The server's runner tracks it for shutdown and lock cleanup; + /// the default runs it as [`Self::run`]. + fn run_write<'a>( + &'a self, + request: ProcessRequest, + cancellation: &'a CancellationToken, + ) -> BoxGitProcessFuture<'a> { + self.run(request, cancellation) + } +} + +/// The server runtime's runner: [`ProcessRunner`], with each write admitted by the runtime's +/// [`GitWriteRegistry`] (#341, R16, R17). +struct RegisteredGitRunner { + writes: GitWriteRegistry, +} + +impl GitProcessRunner for RegisteredGitRunner { + fn run<'a>( + &'a self, + request: ProcessRequest, + cancellation: &'a CancellationToken, + ) -> BoxGitProcessFuture<'a> { + Box::pin(ProcessRunner.run(request, cancellation)) + } + + fn run_cleared<'a>( + &'a self, + request: ProcessRequest, + cancellation: &'a CancellationToken, + ) -> BoxGitProcessFuture<'a> { + Box::pin(ProcessRunner.run_cleared(request, cancellation)) + } + + fn run_write<'a>( + &'a self, + request: ProcessRequest, + cancellation: &'a CancellationToken, + ) -> BoxGitProcessFuture<'a> { + Box::pin(ProcessRunner.run_write(request, &self.writes, cancellation)) + } } /// A chosen Source Control push of the current branch (see @@ -204,6 +249,8 @@ pub struct GitRepository { /// flow) must share the server's observation through `with_worktree_settings` /// or `with_provider_hosts`. provider_hosts: Arc, + /// The Git writes [`Self::stop_writes`] stops; the server runtime's runner shares it. + writes: GitWriteRegistry, } pub(crate) struct StatusObservation { @@ -251,6 +298,7 @@ impl Default for GitRepository { command_config: &[], discovery_environment: |name| std::env::var_os(name), provider_hosts: Arc::default(), + writes: GitWriteRegistry::default(), } } } @@ -409,8 +457,11 @@ impl GitRepository { worktree_settings: Arc, provider_hosts: Arc, ) -> Self { + let writes = GitWriteRegistry::default(); Self { - runner: Arc::new(ProcessRunner), + runner: Arc::new(RegisteredGitRunner { + writes: writes.clone(), + }), worktree_settings, worktree_porcelain_z_supported: Arc::new(Mutex::new(None)), git_version: Arc::new(Mutex::new(None)), @@ -418,9 +469,36 @@ impl GitRepository { command_config: &[], discovery_environment: |name| std::env::var_os(name), provider_hosts, + writes, } } + /// Shutdown (R17): refuses further Git writes, stops the running ones and waits until each + /// has exited and any `index.lock` its forced stop left is handled. `Ok` carries the + /// redacted worktrees whose leftover lock is still there after one more delete. `Err` + /// carries how many writes were still stopping after the wait; calling again waits once + /// more. + pub(crate) async fn stop_writes(&self) -> Result, usize> { + let limit = GRACEFUL_STOP_WAIT + LOCK_UNCHANGED_WAIT + Duration::from_secs(1); + match self.writes.stop_all_and_wait(limit).await { + QuiesceOutcome::Clean => Ok(Vec::new()), + QuiesceOutcome::LocksLeft(locks) => Ok(locks), + QuiesceOutcome::Pending(pending) => Err(pending), + } + } + + /// Admits a stand-in write that stays pending until the returned ticket is dropped. + #[cfg(test)] + pub(crate) fn hold_write_for_test(&self) -> impl std::fmt::Debug + use<> { + self.writes.track().expect("Git writes are admitted") + } + + /// Records a leftover `index.lock` of `worktree` that a forced write could not delete. + #[cfg(all(test, unix))] + pub(crate) fn leave_lock_for_test(&self, lock: &Path, worktree: &Path) { + self.writes.record_left_lock_for_test(lock, worktree); + } + /// Shares the server's host observation, which provider probes fill elsewhere. #[must_use] pub fn with_provider_hosts(mut self, provider_hosts: Arc) -> Self { @@ -439,6 +517,7 @@ impl GitRepository { command_config: &[], discovery_environment: |name| std::env::var_os(name), provider_hosts: Arc::default(), + writes: GitWriteRegistry::default(), } } @@ -615,8 +694,10 @@ impl GitRepository { }; let output = if input.cleared { self.runner.run_cleared(request, cancellation).await - } else { + } else if takes_no_optional_locks(&request.env) { self.runner.run(request, cancellation).await + } else { + self.runner.run_write(request, cancellation).await }; output.map_err(|error| git_error(operation, cwd, argument_count, error)) } @@ -863,7 +944,7 @@ impl GitRepository { cancellation: &CancellationToken, ) -> Result, GitCommandError> { let status = self - .execute( + .execute_read( "GitManager.conflicts.status", cwd, &strings(&[ @@ -889,19 +970,25 @@ impl GitRepository { return Ok(Vec::new()); } let markers = self - .execute( + .execute_read( "GitManager.conflicts.markers", cwd, - &strings(&["diff", "--check"]), + &strings(&["-c", "diff.autoRefreshIndex=false", "diff", "--check"]), true, cancellation, ) .await?; let numstat = self - .execute( + .execute_read( "GitManager.conflicts.numstat", cwd, - &strings(&["diff", "--numstat", "-z"]), + &strings(&[ + "-c", + "diff.autoRefreshIndex=false", + "diff", + "--numstat", + "-z", + ]), true, cancellation, ) @@ -942,7 +1029,7 @@ impl GitRepository { cancellation: &CancellationToken, ) -> Result, GitCommandError> { let status = self - .execute( + .execute_read( "GitManager.resolveConflict.status", cwd, &strings(&[ @@ -1967,7 +2054,7 @@ impl GitRepository { )); } let output = self - .execute_with_options( + .execute_with_environment( "GitVcsDriver.inspectWorktreeRemoval.status", &fresh.path, &strings(&[ @@ -1984,6 +2071,7 @@ impl GitRepository { max_output_bytes: WORKTREE_REMOVAL_STATUS_OUTPUT_LIMIT, output_policy: OutputPolicy::Error, }, + git_read_environment(), cancellation, ) .await?; @@ -2406,7 +2494,7 @@ impl GitRepository { "--cached", "--numstat", ]); - let unstaged_args = strings(&["-c", "core.quotePath=false", "diff", "--numstat"]); + let unstaged_args = strings(UNSTAGED_NUMSTAT_ARGS); let (staged, unstaged) = tokio::try_join!( self.run_read( "GitVcsDriver.statusDetailsLocal.stagedNumstat", @@ -2448,7 +2536,7 @@ impl GitRepository { .run_read( "GitVcsDriver.statusDetailsLocal.unstagedNumstat", cwd, - &strings(&["-c", "core.quotePath=false", "diff", "--numstat"]), + &strings(UNSTAGED_NUMSTAT_ARGS), cancellation, ) .await?; @@ -5802,10 +5890,19 @@ impl GitRepository { } let deleted = self - .run( + .run_read( "GitManager.undoCommit.initial.deleted", cwd, - &strings(&["diff", "--name-only", "--diff-filter=D", "-z", "HEAD", "--"]), + &strings(&[ + "-c", + "diff.autoRefreshIndex=false", + "diff", + "--name-only", + "--diff-filter=D", + "-z", + "HEAD", + "--", + ]), cancellation, ) .await?; @@ -6079,9 +6176,15 @@ impl GitRepository { return Ok(staged.stdout); } let mut working_args = strings(&super::CANONICAL_DIFF_CONFIG); - working_args.extend(strings(&["diff", "--patch", "--stat"])); + working_args.extend(strings(&[ + "-c", + "diff.autoRefreshIndex=false", + "diff", + "--patch", + "--stat", + ])); let working = self - .run( + .run_read( "GitVcsDriver.commitContext.working", cwd, &working_args, @@ -7607,6 +7710,8 @@ pub(crate) fn clone_destination_leaf( } /// What reserving a clone destination found. +// Returned once per clone and matched at once; on Windows the file identity makes it large. +#[allow(clippy::large_enum_variant)] pub(crate) enum CloneReservation { /// The destination did not exist. This clone created it and owns its cleanup. Reserved(ReservedClone), @@ -8074,6 +8179,26 @@ pub(super) fn prompt_guard_environment() -> Vec<(OsString, OsString)> { .collect() } +/// `git diff` refreshes the index for files whose timestamp changed but whose content did +/// not, taking `index.lock` even under `GIT_OPTIONAL_LOCKS=0` (#341). The porcelain status +/// read already decides which files changed, so this read skips the refresh. +const UNSTAGED_NUMSTAT_ARGS: &[&str] = &[ + "-c", + "core.quotePath=false", + "-c", + "diff.autoRefreshIndex=false", + "diff", + "--numstat", +]; + +/// A background read ([`git_read_environment`]) never takes `index.lock`; every other command +/// may, so it runs as a write. +fn takes_no_optional_locks(environment: &[(OsString, OsString)]) -> bool { + environment + .iter() + .any(|(key, value)| key == "GIT_OPTIONAL_LOCKS" && value == "0") +} + pub(crate) fn git_read_environment() -> Vec<(OsString, OsString)> { let mut environment = git_environment(); environment.push(("GIT_OPTIONAL_LOCKS".into(), "0".into())); @@ -10101,8 +10226,14 @@ mod tests { "-z", "--untracked-files=all", ], - vec!["diff", "--check"], - vec!["diff", "--numstat", "-z"], + vec!["-c", "diff.autoRefreshIndex=false", "diff", "--check"], + vec![ + "-c", + "diff.autoRefreshIndex=false", + "diff", + "--numstat", + "-z", + ], vec!["check-attr", "--stdin", "-z", "merge"], ] .into_iter() @@ -10113,11 +10244,12 @@ mod tests { requests[3].stdin.as_deref(), Some(b"text.txt\0image.dat\0".as_slice()) ); - assert!(requests.iter().all(|request| { - !request + // The reads never take index.lock; the attribute check runs as before. + assert!(requests[..3].iter().all(|request| { + request .env .iter() - .any(|(key, _)| key == "GIT_OPTIONAL_LOCKS") + .any(|(key, value)| key == "GIT_OPTIONAL_LOCKS" && value == "0") })); } @@ -10327,6 +10459,60 @@ mod tests { ); } + /// Codex review finding 5: the deleted-file read of undoing the initial commit runs as a + /// read, without the index refresh. + #[tokio::test] + async fn background_status_undo_initial_commit_reads_deleted_files_without_a_lock() { + let runner = Arc::new(RecordingGitRunner { + outputs: HashMap::from([ + ( + "GitManager.undoCommit.head".into(), + process_output("2222222222222222222222222222222222222222\0\0subject\n"), + ), + ( + "GitManager.undoCommit.initial.deleted".into(), + process_output(""), + ), + ( + "GitManager.undoCommit.initial.deleteHead".into(), + process_output(""), + ), + ( + "GitManager.undoCommit.initial.unstage".into(), + process_output(""), + ), + ]), + requests: Mutex::new(Vec::new()), + }); + + GitRepository::with_runner_for_test(runner.clone()) + .undo_head_commit(Path::new("/repo"), &CancellationToken::new()) + .await + .expect("undo succeeds"); + + let deleted = runner + .requests() + .into_iter() + .find(|request| request.operation == "GitManager.undoCommit.initial.deleted") + .expect("the deleted-file read ran"); + assert!( + deleted + .env + .iter() + .any(|(key, value)| key == "GIT_OPTIONAL_LOCKS" && value == "0"), + "{:?}", + deleted.env + ); + assert!( + deleted + .args + .windows(2) + .any(|pair| pair[0] == "-c" && pair[1] == "diff.autoRefreshIndex=false"), + "{:?}", + deleted.args + ); + } + #[tokio::test] async fn git_manager_undo_uses_a_mixed_reset_to_the_first_parent() { let runner = Arc::new(RecordingGitRunner { @@ -14032,3 +14218,665 @@ mod literal_pathspec_tests { ); } } + +/// Background status reads must not take `index.lock` (#341, R14). Real Git, an isolated +/// global configuration, and both a main and a linked worktree. +#[cfg(test)] +mod index_lock_free_status_tests { + use std::{ + collections::HashMap, + fs, + path::{Path, PathBuf}, + process::Command, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, + time::{Duration, Instant, SystemTime}, + }; + + use notify::Watcher; + use tokio_util::sync::CancellationToken; + + use super::{ + BoxGitProcessFuture, GitProcessRunner, GitRepository, ProcessRequest, VcsStagingArea, + VcsStatusLocalResult, + }; + use crate::test_support::{TestSandbox, isolated_git_config::IsolatedGitConfig}; + + /// Runs the real Git runner under the fixture's global configuration only. + struct IsolatedGitRunner { + global_config: PathBuf, + } + + impl GitProcessRunner for IsolatedGitRunner { + fn run<'a>( + &'a self, + mut request: ProcessRequest, + cancellation: &'a CancellationToken, + ) -> BoxGitProcessFuture<'a> { + request.env.push(( + "GIT_CONFIG_GLOBAL".into(), + self.global_config.clone().into(), + )); + request.env.push(("GIT_CONFIG_NOSYSTEM".into(), "1".into())); + Box::pin(async move { super::ProcessRunner.run(request, cancellation).await }) + } + } + + struct StatDirtyWorktree { + _sandbox: TestSandbox, + config: IsolatedGitConfig, + worktree: PathBuf, + git_dir: PathBuf, + } + + impl StatDirtyWorktree { + fn git(&self, args: &[&str]) -> std::process::Output { + git_output(&self.config, &self.worktree, args) + } + + fn checked_git(&self, args: &[&str]) -> String { + checked_git(&self.config, &self.worktree, args) + } + + fn repository(&self) -> GitRepository { + GitRepository::with_runner_for_test(Arc::new(IsolatedGitRunner { + global_config: self.config.path(), + })) + } + + /// Gives `stable.txt` a new whole-second modification time without changing its bytes, + /// so its index entry no longer matches the file's stat data even where Git compares + /// seconds only, and asserts that Git sees the entry as stat-dirty. + fn make_stable_stat_dirty(&self) { + self.touch_stable(); + // `diff-files` compares stat data without refreshing or writing the index. + assert_eq!( + self.checked_git(&["diff-files", "--name-only", "--", "stable.txt"]), + "stable.txt", + "stable.txt must be stat-dirty" + ); + } + + /// The modification-time change alone, for loops where a concurrent `git restore` + /// may refresh the entry between the change and a check. + fn touch_stable(&self) { + static NEXT_SECOND: AtomicU64 = AtomicU64::new(0); + let second = NEXT_SECOND.fetch_add(1, Ordering::Relaxed); + let file = fs::OpenOptions::new() + .write(true) + .open(self.worktree.join("stable.txt")) + .expect("open stable.txt"); + file.set_modified(SystemTime::UNIX_EPOCH + Duration::from_secs(1_500_000_000 + second)) + .expect("set stable.txt modification time"); + } + + fn index(&self) -> PathBuf { + self.git_dir.join("index") + } + } + + fn git_output(config: &IsolatedGitConfig, cwd: &Path, args: &[&str]) -> std::process::Output { + let mut command = Command::new("git"); + config.apply_to_command(&mut command); + command + .args(args) + .current_dir(cwd) + .env("GIT_AUTHOR_NAME", "test-user") + .env("GIT_AUTHOR_EMAIL", "user@example.test") + .env("GIT_COMMITTER_NAME", "test-user") + .env("GIT_COMMITTER_EMAIL", "user@example.test") + .output() + .expect("fixture Git starts") + } + + fn checked_git(config: &IsolatedGitConfig, cwd: &Path, args: &[&str]) -> String { + let output = git_output(config, cwd, args); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() + } + + /// A repository whose `stable.txt` changed only its timestamp and whose `edited.txt` has + /// one appended line. With `linked`, the same state is in a linked worktree, whose index + /// and `index.lock` live under `/worktrees/` (the #341 layout). + fn stat_dirty_worktree(linked: bool) -> StatDirtyWorktree { + let sandbox = TestSandbox::new("index-lock-free-status"); + let config = IsolatedGitConfig::new(); + let main = sandbox.path("main"); + fs::create_dir_all(&main).expect("main worktree folder"); + checked_git(&config, &main, &["init", "-q", "-b", "main"]); + checked_git(&config, &main, &["config", "core.autocrlf", "false"]); + fs::write(main.join("stable.txt"), "stable\n").expect("stable.txt"); + fs::write(main.join("edited.txt"), "one\ntwo\n").expect("edited.txt"); + checked_git(&config, &main, &["add", "stable.txt", "edited.txt"]); + checked_git(&config, &main, &["commit", "-q", "-m", "initial"]); + let worktree = if linked { + let linked = sandbox.path("linked"); + checked_git( + &config, + &main, + &[ + "worktree", + "add", + "-q", + "-b", + "linked", + &linked.to_string_lossy(), + ], + ); + linked + } else { + main + }; + let git_dir = PathBuf::from(checked_git( + &config, + &worktree, + &["rev-parse", "--absolute-git-dir"], + )); + if linked { + assert!( + git_dir + .parent() + .is_some_and(|parent| parent.ends_with("worktrees")), + "a linked worktree keeps its index under worktrees/: {}", + git_dir.display() + ); + } + let fixture = StatDirtyWorktree { + _sandbox: sandbox, + config, + worktree, + git_dir, + }; + let mut edited = fs::OpenOptions::new() + .append(true) + .open(fixture.worktree.join("edited.txt")) + .expect("open edited.txt"); + std::io::Write::write_all(&mut edited, b"three\n").expect("append to edited.txt"); + fixture.make_stable_stat_dirty(); + fixture + } + + fn numstat(output: &str) -> HashMap { + output + .lines() + .filter_map(|line| { + let mut fields = line.split('\t'); + let insertions = fields.next()?.parse().ok()?; + let deletions = fields.next()?.parse().ok()?; + Some((fields.next()?.to_owned(), (insertions, deletions))) + }) + .collect() + } + + fn reported( + status: &VcsStatusLocalResult, + area: VcsStagingArea, + ) -> HashMap { + status + .working_tree + .files + .iter() + .filter(|file| file.area == Some(area)) + .map(|file| (file.path.clone(), (file.insertions, file.deletions))) + .collect() + } + + /// The index's bytes and modification time, and every `index.lock` event in its Git + /// directory from the start of the watch. + struct IndexWatch { + index: PathBuf, + git_dir: PathBuf, + bytes: Vec, + modified: SystemTime, + locks_seen: Arc>>, + sentinel_seen: CancellationToken, + _watcher: notify::RecommendedWatcher, + } + + impl IndexWatch { + fn start(fixture: &StatDirtyWorktree) -> Self { + let locks_seen = Arc::new(Mutex::new(Vec::new())); + let sentinel_seen = CancellationToken::new(); + let (recorded, sentinel) = (locks_seen.clone(), sentinel_seen.clone()); + let mut watcher = + notify::recommended_watcher(move |event: notify::Result| { + if let Ok(event) = event { + for path in event.paths { + match path.file_name().and_then(|name| name.to_str()) { + Some("index.lock") => { + recorded.lock().expect("lock events").push(event.kind); + } + Some("reads-done") => sentinel.cancel(), + _ => {} + } + } + } + }) + .expect("Git directory watcher"); + watcher + .watch(&fixture.git_dir, notify::RecursiveMode::NonRecursive) + .expect("watch the Git directory"); + Self { + index: fixture.index(), + git_dir: fixture.git_dir.clone(), + bytes: fs::read(fixture.index()).expect("index bytes"), + modified: fs::metadata(fixture.index()) + .and_then(|metadata| metadata.modified()) + .expect("index modification time"), + locks_seen, + sentinel_seen, + _watcher: watcher, + } + } + + async fn assert_untouched(self, reads: &str) { + // Events arrive in order, so once the watcher reports this file it has reported + // every lock created before it. + let sentinel_path = self.git_dir.join("reads-done"); + fs::write(&sentinel_path, "").expect("sentinel file"); + tokio::time::timeout(Duration::from_secs(10), self.sentinel_seen.cancelled()) + .await + .expect("the watcher reports the sentinel file"); + fs::remove_file(&sentinel_path).expect("remove sentinel file"); + + let bytes_unchanged = + fs::read(&self.index).expect("index bytes after reads") == self.bytes; + let modified_unchanged = fs::metadata(&self.index) + .and_then(|metadata| metadata.modified()) + .expect("index modification time after reads") + == self.modified; + let locks_seen = self.locks_seen.lock().expect("lock events").clone(); + let created = locks_seen + .iter() + .filter(|kind| matches!(kind, notify::EventKind::Create(_))) + .count(); + assert!( + bytes_unchanged && modified_unchanged && locks_seen.is_empty(), + "{reads} touched the index: bytes unchanged {bytes_unchanged}, modification \ + time unchanged {modified_unchanged}, {} index.lock events of which {created} \ + creations", + locks_seen.len() + ); + } + } + + async fn polling_alone_leaves_the_index_untouched(linked: bool) { + let fixture = stat_dirty_worktree(linked); + let repository = fixture.repository(); + let watch = IndexWatch::start(&fixture); + + for round in 0..50 { + if round > 0 { + fixture.make_stable_stat_dirty(); + } + let status = repository + .local_status(&fixture.worktree, &CancellationToken::new()) + .await + .expect("status read succeeds"); + assert!(status.has_working_tree_changes); + } + + watch.assert_untouched("polling").await; + } + + #[tokio::test] + async fn background_status_polling_alone_leaves_the_index_untouched() { + polling_alone_leaves_the_index_untouched(false).await; + } + + #[tokio::test] + async fn background_status_polling_alone_leaves_the_index_untouched_in_a_linked_worktree() { + polling_alone_leaves_the_index_untouched(true).await; + } + + type Rounds = Arc>>; + + fn began_during(rounds: &[(Instant, Instant)], others: &[(Instant, Instant)]) -> usize { + rounds + .iter() + .filter(|(start, _)| { + others + .iter() + .any(|(other_start, other_end)| other_start <= start && start <= other_end) + }) + .count() + } + + async fn does_not_break_a_concurrent_git_add(linked: bool) { + const ROUNDS: usize = 50; + const OVERLAPS: usize = 20; + let fixture = Arc::new(stat_dirty_worktree(linked)); + let repository = fixture.repository(); + let status_rounds: Rounds = Arc::default(); + let add_rounds: Rounds = Arc::default(); + let add_failures = Arc::new(Mutex::new(Vec::new())); + let stop = CancellationToken::new(); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + + let adder = { + let (fixture, add_rounds, add_failures, stop, barrier) = ( + fixture.clone(), + add_rounds.clone(), + add_failures.clone(), + stop.clone(), + barrier.clone(), + ); + tokio::spawn(async move { + barrier.wait().await; + while !stop.is_cancelled() { + let fixture = fixture.clone(); + let started = Instant::now(); + let outputs = tokio::task::spawn_blocking(move || { + [ + fixture.git(&["add", "edited.txt"]), + fixture.git(&["restore", "--staged", "edited.txt"]), + ] + }) + .await + .expect("git add round"); + add_rounds + .lock() + .expect("add rounds") + .push((started, Instant::now())); + for output in outputs { + if !output.status.success() { + add_failures + .lock() + .expect("add failures") + .push(String::from_utf8_lossy(&output.stderr).into_owned()); + } + } + } + }) + }; + let poller = { + let (fixture, status_rounds, stop, barrier) = ( + fixture.clone(), + status_rounds.clone(), + stop.clone(), + barrier.clone(), + ); + tokio::spawn(async move { + barrier.wait().await; + while !stop.is_cancelled() { + // A refresh would make the entry clean again; keep it stat-dirty. + let dirty = fixture.clone(); + tokio::task::spawn_blocking(move || dirty.touch_stable()) + .await + .expect("stat-dirty round"); + let started = Instant::now(); + repository + .local_status(&fixture.worktree, &CancellationToken::new()) + .await + .expect("status read succeeds"); + status_rounds + .lock() + .expect("status rounds") + .push((started, Instant::now())); + } + }) + }; + + let deadline = Instant::now() + Duration::from_secs(120); + let (statuses, adds) = loop { + tokio::time::sleep(Duration::from_millis(50)).await; + let statuses = status_rounds.lock().expect("status rounds").clone(); + let adds = add_rounds.lock().expect("add rounds").clone(); + let enough = statuses.len() >= ROUNDS + && adds.len() >= ROUNDS + && began_during(&statuses, &adds) >= OVERLAPS + && began_during(&adds, &statuses) >= OVERLAPS; + if enough || Instant::now() >= deadline { + break (statuses, adds); + } + }; + stop.cancel(); + adder.await.expect("adder task"); + poller.await.expect("poller task"); + + let failures = add_failures.lock().expect("add failures").clone(); + assert!( + failures.is_empty(), + "a git add failed during polling: {failures:?}" + ); + assert!( + statuses.len() >= ROUNDS && adds.len() >= ROUNDS, + "{} status rounds and {} add rounds", + statuses.len(), + adds.len() + ); + assert!( + began_during(&statuses, &adds) >= OVERLAPS + && began_during(&adds, &statuses) >= OVERLAPS, + "the loops did not overlap enough" + ); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn background_status_does_not_break_a_concurrent_git_add() { + does_not_break_a_concurrent_git_add(false).await; + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn background_status_does_not_break_a_concurrent_git_add_in_a_linked_worktree() { + does_not_break_a_concurrent_git_add(true).await; + } + + async fn assert_statistics_match_plain_git(fixture: &StatDirtyWorktree) { + // Plain Git computes the baseline before the timestamp-only change is made again, + // so the stat-dirty file cannot influence it. + let staged = numstat(&fixture.checked_git(&["diff", "--cached", "--numstat"])); + let unstaged = numstat(&fixture.checked_git(&["diff", "--numstat"])); + assert!(!unstaged.is_empty(), "the fixture has an unstaged edit"); + fixture.make_stable_stat_dirty(); + + let status = fixture + .repository() + .local_status(&fixture.worktree, &CancellationToken::new()) + .await + .expect("status read succeeds"); + + assert_eq!(reported(&status, VcsStagingArea::Staged), staged); + assert_eq!(reported(&status, VcsStagingArea::Unstaged), unstaged); + assert!( + status + .working_tree + .files + .iter() + .all(|file| file.path != "stable.txt"), + "a timestamp-only change is not a change: {:?}", + status.working_tree.files + ); + } + + async fn keeps_unstaged_statistics_correct(linked: bool) { + let fixture = stat_dirty_worktree(linked); + // Only unstaged changes: the lone unstaged statistics read. + assert_statistics_match_plain_git(&fixture).await; + // Staged and unstaged changes: the joined statistics read. + fixture.checked_git(&["add", "edited.txt"]); + fs::write(fixture.worktree.join("edited.txt"), "one\nthree\nfour\n").expect("edit again"); + assert_statistics_match_plain_git(&fixture).await; + } + + #[tokio::test] + async fn background_status_keeps_unstaged_statistics_correct() { + keeps_unstaged_statistics_correct(false).await; + } + + #[tokio::test] + async fn background_status_keeps_unstaged_statistics_correct_in_a_linked_worktree() { + keeps_unstaged_statistics_correct(true).await; + } + + /// Codex review finding 5: the conflict reads (`status`, `diff --check` and + /// `diff --numstat`) left the index alone only when nothing was stat-dirty. + #[tokio::test] + async fn background_status_conflict_reads_leave_the_index_untouched() { + let fixture = stat_dirty_worktree(false); + fs::write(fixture.worktree.join("c.txt"), "base\n").expect("c.txt"); + fixture.checked_git(&["add", "c.txt"]); + fixture.checked_git(&["commit", "-q", "-m", "base"]); + fixture.checked_git(&["switch", "-q", "-c", "other"]); + fs::write(fixture.worktree.join("c.txt"), "other\n").expect("c.txt on other"); + fixture.checked_git(&["add", "c.txt"]); + fixture.checked_git(&["commit", "-q", "-m", "other"]); + fixture.checked_git(&["switch", "-q", "main"]); + fs::write(fixture.worktree.join("c.txt"), "main\n").expect("c.txt on main"); + fixture.checked_git(&["add", "c.txt"]); + fixture.checked_git(&["commit", "-q", "-m", "main"]); + assert!( + !fixture + .git(&["merge", "--no-edit", "other"]) + .status + .success(), + "the merge stops on a conflict" + ); + fixture.make_stable_stat_dirty(); + let repository = fixture.repository(); + let cancellation = CancellationToken::new(); + let watch = IndexWatch::start(&fixture); + + let states = repository + .git_manager_conflict_states(&fixture.worktree, &cancellation) + .await + .expect("conflict states"); + let ours_deleted = repository + .git_manager_conflict_side_deleted( + &fixture.worktree, + "c.txt", + crate::git::manager::conflicts::ConflictSide::Ours, + &cancellation, + ) + .await + .expect("conflict side"); + + watch.assert_untouched("the conflict reads").await; + assert_eq!( + states + .iter() + .map(|state| (state.path.as_str(), state.marker_count)) + .collect::>(), + [("c.txt", 3)] + ); + assert_eq!( + states[0].kind, + crate::git::manager::conflicts::ConflictKind::Text + ); + assert_eq!(ours_deleted, Some(false)); + } + + /// Codex review finding 5: the status read of a worktree removal check. + #[tokio::test] + async fn background_status_worktree_removal_check_leaves_the_index_untouched() { + let fixture = stat_dirty_worktree(true); + let anchor = fixture + .git_dir + .ancestors() + .nth(3) + .expect("the main worktree holds the common Git directory") + .to_path_buf(); + let repository = fixture.repository(); + let cancellation = CancellationToken::new(); + let inventory = repository + .worktree_inventory(&anchor, &cancellation) + .await + .expect("worktree inventory"); + let linked = fs::canonicalize(&fixture.worktree).expect("linked worktree"); + let record = inventory + .records + .iter() + .find(|record| fs::canonicalize(&record.path).ok().as_ref() == Some(&linked)) + .cloned() + .expect("linked worktree record"); + let watch = IndexWatch::start(&fixture); + + let inspection = repository + .inspect_worktree_removal(&anchor, &record, &cancellation) + .await + .expect("removal inspection"); + + watch.assert_untouched("the removal check").await; + assert_eq!( + inspection.tracked_change_count, 1, + "edited.txt is changed and stable.txt is not" + ); + assert_eq!(inspection.untracked_file_count, 0); + } + + /// Codex review finding 5: the staged patch a commit message is written from. + #[tokio::test] + async fn background_status_staged_commit_context_leaves_the_index_untouched() { + let fixture = stat_dirty_worktree(false); + fixture.checked_git(&["add", "edited.txt"]); + fixture.make_stable_stat_dirty(); + let watch = IndexWatch::start(&fixture); + + let context = fixture + .repository() + .commit_context(&fixture.worktree, &CancellationToken::new()) + .await + .expect("commit context"); + + watch + .assert_untouched("the staged commit context read") + .await; + assert!( + context.contains("edited.txt") && context.contains("+three"), + "{context}" + ); + assert!( + !context.contains("stable.txt"), + "a timestamp-only change is not a change: {context}" + ); + } + + /// Codex review finding 5: the working-tree patch a commit message is written from. + #[tokio::test] + async fn background_status_commit_context_leaves_the_index_untouched() { + let fixture = stat_dirty_worktree(false); + let watch = IndexWatch::start(&fixture); + + let context = fixture + .repository() + .commit_context(&fixture.worktree, &CancellationToken::new()) + .await + .expect("commit context"); + + watch.assert_untouched("the commit context read").await; + assert!( + context.contains("edited.txt") && context.contains("+three"), + "{context}" + ); + assert!( + !context.contains("stable.txt"), + "a timestamp-only change is not a change: {context}" + ); + } + + #[tokio::test] + async fn stash_statistics_do_not_depend_on_the_working_tree() { + let fixture = stat_dirty_worktree(false); + fixture.checked_git(&["stash", "push", "-q"]); + assert_eq!(fixture.checked_git(&["status", "--porcelain"]), ""); + fixture.make_stable_stat_dirty(); + + let files = fixture + .repository() + .git_manager_stash_file_list(&fixture.worktree, "stash@{0}", &CancellationToken::new()) + .await + .expect("stash file list"); + + assert!( + files.stdout.contains("edited.txt"), + "the stash lists its file: {:?}", + files.stdout + ); + } +} diff --git a/apps/server/src/git/sign_in.rs b/apps/server/src/git/sign_in.rs index 2a3081ccaf..65ee4c4cc6 100644 --- a/apps/server/src/git/sign_in.rs +++ b/apps/server/src/git/sign_in.rs @@ -97,6 +97,56 @@ pub fn has_gcm_refusal(stderr: &str) -> bool { }) } +/// True when Git stopped for a missing username with prompts disabled (#343). +#[must_use] +pub fn has_missing_username_refusal(detail: &str) -> bool { + let detail = detail.to_ascii_lowercase(); + detail.contains("could not read username") && detail.contains("terminal prompts disabled") +} + +/// Where this server's Git runs, as a failed push names it (#343): `remote` for every server +/// the desktop app did not start inside itself. +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct ExecutionPlace { + pub remote: bool, + pub server_name: Option, +} + +#[must_use] +pub fn execution_place(config: &crate::ServerConfig) -> ExecutionPlace { + let label = config.environment_label.trim(); + ExecutionPlace { + remote: !config.interactive_git_sign_in, + server_name: (!label.is_empty() && label != crate::config::LOCAL_ENVIRONMENT_LABEL) + .then(|| label.to_owned()), + } +} + +impl ExecutionPlace { + /// The guidance for a push that failed on a remote server for a missing username, or + /// `None` when Git ran here or failed for another reason. Git Manager and Source Control + /// both decide with this, so on a remote server the refusal outranks a helper's earlier + /// "timed out" or "cancelled" line, while a local desktop keeps its classification. + #[must_use] + pub fn push_guidance(&self, git_detail: &str) -> Option { + (self.remote && has_missing_username_refusal(git_detail)) + .then(|| remote_push_guidance(self.server_name.as_deref(), git_detail)) + } +} + +/// Where to sign in after a remote missing-credentials push, a blank line, then Git's text. +#[must_use] +pub fn remote_push_guidance(server_name: Option<&str>, git_detail: &str) -> String { + let (server, there) = server_name.map_or_else( + || ("".to_owned(), "the remote server".to_owned()), + |name| (format!(" {name}"), name.to_owned()), + ); + format!( + "Push ran on the remote server{server}. In a terminal on {there}, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode.\n\n{}", + git_detail.trim() + ) +} + /// The pinned destination and its host for an `https://` URL, without user information. /// A query, fragment or `=` (which `-c http..*` cannot carry) gets no sign-in. #[must_use] @@ -521,6 +571,66 @@ mod tests { )); } + const MISSING_USERNAME: &str = "fatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled"; + + #[test] + fn detects_the_missing_username_refusal_only_with_prompts_disabled() { + assert!(has_missing_username_refusal(MISSING_USERNAME)); + assert!(has_missing_username_refusal( + &MISSING_USERNAME.to_ascii_uppercase() + )); + assert!(!has_missing_username_refusal( + "fatal: could not read Username for 'https://gitlab.example.test': No such device" + )); + assert!(!has_missing_username_refusal( + "fatal: Authentication failed for 'https://gitlab.example.test/'" + )); + } + + #[test] + fn names_a_known_server_and_keeps_gits_line_after_a_blank_line() { + assert_eq!( + remote_push_guidance(Some("example-server"), &format!("{MISSING_USERNAME}\n")), + format!( + "Push ran on the remote server example-server. In a terminal on example-server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode.\n\n{MISSING_USERNAME}" + ) + ); + assert_eq!( + remote_push_guidance(None, MISSING_USERNAME), + format!( + "Push ran on the remote server. In a terminal on the remote server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode.\n\n{MISSING_USERNAME}" + ) + ); + } + + #[test] + fn the_place_is_remote_unless_the_desktop_started_the_server() { + let mut config = crate::ServerConfig::new(std::env::temp_dir()); + config.environment_label = " example-server ".to_owned(); + assert_eq!( + execution_place(&config), + ExecutionPlace { + remote: true, + server_name: Some("example-server".to_owned()), + } + ); + for label in ["Local", " ", ""] { + config.environment_label = label.to_owned(); + assert_eq!(execution_place(&config).server_name, None, "{label:?}"); + } + config.interactive_git_sign_in = true; + let local = execution_place(&config); + assert!(!local.remote); + assert_eq!(local.push_guidance(MISSING_USERNAME), None); + config.interactive_git_sign_in = false; + let remote = execution_place(&config); + assert!(remote.push_guidance(MISSING_USERNAME).is_some()); + assert_eq!( + remote.push_guidance("fatal: Authentication failed for 'https://gitlab.example.test/'"), + None + ); + } + #[test] fn pins_only_https_destinations_without_user_information() { assert_eq!( diff --git a/apps/server/src/git/status_owner.rs b/apps/server/src/git/status_owner.rs index f86311fe1e..45a63da9c4 100644 --- a/apps/server/src/git/status_owner.rs +++ b/apps/server/src/git/status_owner.rs @@ -251,6 +251,8 @@ struct WorktreeStatusState { epoch: u64, mutation_lock: Arc>, mutation_active: bool, + /// True from lock acquisition to settle, also after reads resumed. + mutation_lock_held: bool, pending_mutations: usize, read_gate_closed: bool, read_gate: watch::Sender, @@ -271,6 +273,7 @@ impl WorktreeStatusState { epoch, mutation_lock: Arc::new(AsyncMutex::new(())), mutation_active: false, + mutation_lock_held: false, pending_mutations: 0, read_gate_closed: false, read_gate, @@ -360,6 +363,7 @@ pub struct StatusMutationGuard { owner: StatusReadOwner, canonical_cwd: PathBuf, mutation_guard: Option>, + reads_resumed: bool, } impl StatusReadOwner { @@ -512,7 +516,11 @@ impl StatusReadOwner { .pending_mutations .checked_add(1) .expect("pending status mutation count exhausted"); - worktree.read_gate_closed = true; + // Behind a lock whose local writes are finished, the gate closes + // only on acquisition, so background writes cannot stall reads. + if !worktree.mutation_lock_held || worktree.mutation_active { + worktree.read_gate_closed = true; + } ( Arc::clone(&worktree.mutation_lock), PendingMutation { @@ -534,6 +542,8 @@ impl StatusReadOwner { .checked_sub(1) .expect("pending status mutation count remains positive"); worktree.mutation_active = true; + worktree.mutation_lock_held = true; + worktree.read_gate_closed = true; worktree.epoch = worktree.epoch.wrapping_add(1); retire_reads(worktree); } @@ -542,6 +552,7 @@ impl StatusReadOwner { owner: self.clone(), canonical_cwd, mutation_guard: Some(mutation_guard), + reads_resumed: false, } } @@ -699,21 +710,26 @@ impl StatusReadOwner { let Some(worktree) = state.worktrees.get_mut(canonical_cwd) else { return; }; - if worktree.pending_mutations != 0 || worktree.mutation_active { + if worktree.pending_mutations != 0 + || worktree.mutation_active + || worktree.mutation_lock_held + { return; } if worktree_can_retire(worktree) { state.worktrees.remove(canonical_cwd); return; } - if !worktree.read_gate_closed { - return; - } + // The gate is already open when the mutation resumed reads early, but + // the trailing refresh is still owed. if std::mem::take(&mut worktree.trailing_refresh_pending) { worktree .local_refresh_requests .send_modify(|generation| *generation = generation.wrapping_add(1)); } + if !worktree.read_gate_closed { + return; + } worktree.read_gate_closed = false; worktree .read_gate @@ -1133,6 +1149,33 @@ impl StatusMutationGuard { self.settle(); } + /// Reopens status reads while the write lock stays held, for a mutation + /// that only waits on the network from here on. The epoch advances so a + /// read from before this point cannot publish, and `settle` advances it + /// again and publishes the trailing refresh. + pub fn local_writes_finished(&mut self) { + if self.mutation_guard.is_none() || std::mem::replace(&mut self.reads_resumed, true) { + return; + } + { + let mut state = self.owner.lock_state(); + if let Some(worktree) = state.worktrees.get_mut(&self.canonical_cwd) { + worktree.epoch = worktree.epoch.wrapping_add(1); + retire_reads(worktree); + worktree.mutation_active = false; + worktree.read_gate_closed = false; + worktree + .read_gate + .send_modify(|generation| *generation = generation.wrapping_add(1)); + worktree + .local_refresh_requests + .send_modify(|generation| *generation = generation.wrapping_add(1)); + } + } + #[cfg(test)] + self.owner.inner.lease_changed.notify_waiters(); + } + fn settle(&mut self) { let Some(mutation_guard) = self.mutation_guard.take() else { return; @@ -1143,6 +1186,7 @@ impl StatusMutationGuard { worktree.epoch = worktree.epoch.wrapping_add(1); retire_reads(worktree); worktree.mutation_active = false; + worktree.mutation_lock_held = false; worktree.trailing_refresh_pending = true; } } @@ -1204,6 +1248,7 @@ fn worktree_can_retire(worktree: &WorktreeStatusState) -> bool { worktree.retire_when_idle && worktree.pending_mutations == 0 && !worktree.mutation_active + && !worktree.mutation_lock_held && worktree.physical_reads_active == 0 && worktree.in_flight.is_empty() } @@ -2165,6 +2210,207 @@ mod tests { assert!(physical_cancellation.is_cancelled()); } + #[tokio::test] + async fn reads_resume_after_local_writes_finish_while_the_lock_is_held() { + let owner = StatusReadOwner::new(); + let key = status_key(StatusOutputKind::Local); + let mut mutation = owner.begin_mutation(key.canonical_cwd.clone()).await; + assert!( + !local_read_is_admitted_and_publishes(&owner, &key).await, + "a read was admitted before the local writes finished" + ); + + mutation.local_writes_finished(); + + assert!( + local_read_is_admitted_and_publishes(&owner, &key).await, + "a read was not published while the lock was held after the local writes" + ); + mutation.finish().await; + } + + #[tokio::test] + async fn a_second_mutation_waits_for_the_whole_first_mutation() { + let owner = StatusReadOwner::new(); + let cwd = PathBuf::from("/repo"); + let mut first = owner.begin_mutation(cwd.clone()).await; + first.local_writes_finished(); + + let mut second = Box::pin(owner.begin_mutation(cwd)); + assert!(matches!(futures_util::poll!(&mut second), Poll::Pending)); + tokio::task::yield_now().await; + assert!( + matches!(futures_util::poll!(&mut second), Poll::Pending), + "the second mutation started while the first still held the lock" + ); + + first.finish().await; + second.await.finish().await; + } + + #[tokio::test] + async fn a_worktree_with_a_held_lock_is_not_retired() { + let owner = StatusReadOwner::new(); + let cwd = PathBuf::from("/repo"); + let mut first = owner.begin_mutation(cwd.clone()).await; + first.local_writes_finished(); + + owner.cancel_reads(&cwd); + assert_eq!( + owner.worktree_state_count_for_test(), + 1, + "the worktree entry was retired while its write lock was held" + ); + let mut second = Box::pin(owner.begin_mutation(cwd)); + assert!( + matches!(futures_util::poll!(&mut second), Poll::Pending), + "a new mutation did not wait on the held lock" + ); + + first.finish().await; + second.await.finish().await; + } + + #[tokio::test] + async fn a_read_started_before_the_mutation_is_not_published_after_reads_resume() { + let owner = StatusReadOwner::new(); + let key = status_key(StatusOutputKind::Local); + let cwd = key.canonical_cwd.clone(); + let old_fence = owner + .acquire_read_fence(&cwd, &CancellationToken::new()) + .await + .expect("idle worktree admits a fence"); + let loads = Arc::new(AtomicUsize::new(0)); + let release = Arc::new(Semaphore::new(0)); + let old_read = spawn_blocked_local_read( + owner.clone(), + key.clone(), + Arc::clone(&loads), + Arc::clone(&release), + ); + wait_for_loads(&loads, 1).await; + let mut refreshes = owner.subscribe_local_refresh(&cwd); + + let mut mutation = owner.begin_mutation(cwd).await; + mutation.local_writes_finished(); + + assert!( + old_read.await.expect("old read task").is_err(), + "a read admitted before the mutation was published" + ); + assert!( + owner.publish_if_fence_current(&old_fence, || ()).is_err(), + "a fence taken before the mutation stayed current" + ); + refreshes.borrow_and_update(); + mutation.finish().await; + assert!( + refreshes.has_changed().expect("refresh owner remains open"), + "no fresh status was requested after the mutation" + ); + assert!(local_read_is_admitted_and_publishes(&owner, &key).await); + } + + #[tokio::test] + async fn a_mutation_queued_behind_a_released_lock_does_not_close_the_gate_until_it_starts() { + let owner = StatusReadOwner::new(); + let key = status_key(StatusOutputKind::Local); + let cwd = key.canonical_cwd.clone(); + let mut first = owner.begin_mutation(cwd.clone()).await; + first.local_writes_finished(); + + let mut second = Box::pin(owner.begin_mutation(cwd.clone())); + assert!(matches!(futures_util::poll!(&mut second), Poll::Pending)); + assert!( + local_read_is_admitted_and_publishes(&owner, &key).await, + "a queued mutation closed the gate before it acquired the lock" + ); + + first.finish().await; + let fence = owner + .acquire_read_fence(&cwd, &CancellationToken::new()) + .await + .expect("the gate stays open until the queued mutation acquires the lock"); + let second = second.await; + assert!( + !local_read_is_admitted_and_publishes(&owner, &key).await, + "the gate stayed open after the queued mutation acquired the lock" + ); + assert!( + owner.publish_if_fence_current(&fence, || ()).is_err(), + "the epoch did not advance when the queued mutation started" + ); + second.finish().await; + } + + #[tokio::test] + async fn a_mutation_queued_before_local_writes_finish_no_longer_blocks_reads_afterwards() { + let owner = StatusReadOwner::new(); + let key = status_key(StatusOutputKind::Local); + let cwd = key.canonical_cwd.clone(); + let mut first = owner.begin_mutation(cwd.clone()).await; + let mut second = Box::pin(owner.begin_mutation(cwd)); + assert!(matches!(futures_util::poll!(&mut second), Poll::Pending)); + assert!(!local_read_is_admitted_and_publishes(&owner, &key).await); + + first.local_writes_finished(); + + assert!( + local_read_is_admitted_and_publishes(&owner, &key).await, + "a mutation queued earlier kept reads waiting after the local writes" + ); + first.finish().await; + second.await.finish().await; + } + + #[tokio::test] + async fn settling_after_reads_resumed_still_publishes_the_trailing_refresh() { + let owner = StatusReadOwner::new(); + let key = status_key(StatusOutputKind::Local); + let cwd = key.canonical_cwd.clone(); + let mut refreshes = owner.subscribe_local_refresh(&cwd); + let mut mutation = owner.begin_mutation(cwd).await; + + mutation.local_writes_finished(); + mutation.local_writes_finished(); + + assert_eq!( + *refreshes.borrow_and_update(), + 1, + "reads resuming must queue exactly one refresh" + ); + assert!(local_read_is_admitted_and_publishes(&owner, &key).await); + mutation.finish().await; + assert!( + refreshes.has_changed().expect("refresh owner remains open"), + "settling with the gate open did not publish the trailing refresh" + ); + assert_eq!(*refreshes.borrow_and_update(), 2); + assert!(local_read_is_admitted_and_publishes(&owner, &key).await); + } + + async fn local_read_is_admitted_and_publishes( + owner: &StatusReadOwner, + key: &StatusReadKey, + ) -> bool { + let mut load = Some(|_: CancellationToken| async { + Ok(StatusReadValue::Local( + VcsStatusLocalResult::non_repository(), + )) + }); + let ReadAdmission::Lease(lease) = owner.try_acquire(key, &mut load) else { + return false; + }; + let Ok((StatusReadValue::Local(value), key, epoch)) = + lease.wait(&CancellationToken::new()).await + else { + return false; + }; + owner + .publish_if_current(StatusReadResult { value, key, epoch }, |_| ()) + .is_ok() + } + #[tokio::test] async fn final_lifecycle_cancellation_releases_idle_worktree_state() { let owner = StatusReadOwner::new(); diff --git a/apps/server/src/git/watcher.rs b/apps/server/src/git/watcher.rs index 005b601640..e7fe5b2908 100644 --- a/apps/server/src/git/watcher.rs +++ b/apps/server/src/git/watcher.rs @@ -2618,6 +2618,20 @@ mod tests { .registrations .lock() .expect("native readiness registrations lock"); + // Windows also reports a sentinel write as `Modify(Any)` of its directory to + // the watch on that directory's parent (the common directory's watch sees + // `refs`). That directory event is the fence's own, not a fixture change. + if cfg!(windows) + && event.kind == EventKind::Modify(ModifyKind::Any) + && event.paths.len() == 1 + { + let key = normalize_worktree_path_key(&event.paths[0], host_path_platform()); + if registrations.iter().any(|registration| { + normalize_worktree_path_key(®istration.root, host_path_platform()) == key + }) { + return Some(Vec::new()); + } + } event .paths .iter() diff --git a/apps/server/src/http.rs b/apps/server/src/http.rs index a7af348be5..0fbd4c098b 100644 --- a/apps/server/src/http.rs +++ b/apps/server/src/http.rs @@ -1155,6 +1155,7 @@ fn if_none_match_matches(header: &str, etag: &str) -> bool { /// The UI's policy. Browser mode frames preview gateways on the host the page was /// loaded from, one port per target, so `frame-src` allows that host on any port. +/// `blob:` is there because Chromium loads a PDF `` of a `blob:` URL in a frame. /// An IPv6 host gets none: the gateway lets no IPv6 page frame it. fn content_security_policy(headers: &HeaderMap) -> String { let gateways = headers @@ -1169,7 +1170,7 @@ fn content_security_policy(headers: &HeaderMap) -> String { }) .map(|host| format!(" http://{host}:*")) .unwrap_or_default(); - format!("{CONTENT_SECURITY_POLICY_VALUE}; frame-src 'self'{gateways}") + format!("{CONTENT_SECURITY_POLICY_VALUE}; frame-src 'self' blob:{gateways}") } async fn stream_file( @@ -1304,6 +1305,8 @@ mod tests { let csp = super::content_security_policy(&HeaderMap::new()); assert_eq!(directive(&csp, "media-src"), ["'self'", "blob:"]); assert_eq!(directive(&csp, "object-src"), ["blob:"]); + // Chromium loads a PDF `` in a frame, so `frame-src` needs `blob:` too. + assert_eq!(directive(&csp, "frame-src"), ["'self'", "blob:"]); let images = directive(&csp, "img-src"); assert!(images.contains(&"blob:".to_owned()), "{images:?}"); for wide in ["http:", "https:", "*"] { @@ -1318,7 +1321,7 @@ mod tests { headers.insert(HOST, HeaderValue::from_str(host).unwrap()); super::content_security_policy(&headers) }; - assert!(csp("box.lan:3773").ends_with("; frame-src 'self' http://box.lan:*")); + assert!(csp("box.lan:3773").ends_with("; frame-src 'self' blob: http://box.lan:*")); // The gateway frames no IPv6 host, and a Host that isn't an authority adds nothing. for host in [ "[::1]:3773", @@ -1326,9 +1329,11 @@ mod tests { "a b", "box.lan;x:3773", ] { - assert!(csp(host).ends_with("; frame-src 'self'"), "{host}"); + assert!(csp(host).ends_with("; frame-src 'self' blob:"), "{host}"); } - assert!(super::content_security_policy(&HeaderMap::new()).ends_with("; frame-src 'self'")); + assert!( + super::content_security_policy(&HeaderMap::new()).ends_with("; frame-src 'self' blob:") + ); } use super::*; diff --git a/apps/server/src/lifecycle.rs b/apps/server/src/lifecycle.rs index 8603b78bb3..745607e3ff 100644 --- a/apps/server/src/lifecycle.rs +++ b/apps/server/src/lifecycle.rs @@ -69,6 +69,7 @@ fn connect_environment_descriptor(config: &ServerConfig) -> serde_json::Value { "remoteUpdateProgress": true, "terminalOrderedInput": true, "terminalSizeOwnership": true, + "terminalUserInputClaim": true, "terminalImagePaste": true, "pullRequestCreateOptions": true, "vcsCloneReattach": true, @@ -84,6 +85,8 @@ fn connect_environment_descriptor(config: &ServerConfig) -> serde_json::Value { "webhookBearerToken": true, "webhookStatusKinds": true, "sideChats": true, + "codeReviews": true, + "hostMetrics": true, "conversationOnlyRewind": true, "threadDeleteOnlyIfUnused": true, }, @@ -1343,6 +1346,7 @@ mod tests { assert_eq!(descriptor["capabilities"]["remoteUpdateProgress"], true); assert_eq!(descriptor["capabilities"]["terminalOrderedInput"], true); assert_eq!(descriptor["capabilities"]["terminalSizeOwnership"], true); + assert_eq!(descriptor["capabilities"]["terminalUserInputClaim"], true); assert_eq!(descriptor["capabilities"]["previewTunnels"], true); assert_eq!(descriptor["capabilities"]["terminalImagePaste"], true); assert_eq!(descriptor["capabilities"]["pullRequestCreateOptions"], true); @@ -1352,6 +1356,8 @@ mod tests { assert_eq!(descriptor["capabilities"]["webhookBearerToken"], true); assert_eq!(descriptor["capabilities"]["webhookStatusKinds"], true); assert_eq!(descriptor["capabilities"]["sideChats"], true); + assert_eq!(descriptor["capabilities"]["codeReviews"], true); + assert_eq!(descriptor["capabilities"]["hostMetrics"], true); assert_eq!(descriptor["capabilities"]["threadDeleteOnlyIfUnused"], true); assert_eq!(descriptor["capabilities"]["attachmentStaging"], true); assert_eq!(descriptor["capabilities"]["profiler"], true); diff --git a/apps/server/src/mcp/tools/diagnostics.rs b/apps/server/src/mcp/tools/diagnostics.rs index 868eb24061..9473f334d4 100644 --- a/apps/server/src/mcp/tools/diagnostics.rs +++ b/apps/server/src/mcp/tools/diagnostics.rs @@ -18,6 +18,7 @@ use crate::{ diagnostics::profiler::{ mcp::{self as profiler, Producer, Refusal}, redact::redact_message, + worktree_audit::WorktreeEvidence, }, mcp::{ control::{ @@ -81,6 +82,9 @@ pub(crate) const TOOLS: &[ToolSpec] = &[ ToolSpec::client("trace_diagnostics", READ, &["server.getTraceDiagnostics"]) .reads() .in_set(DIAGNOSTICS), + ToolSpec::client("host_metrics", READ, &["server.getHostMetrics"]) + .reads() + .in_set(DIAGNOSTICS), ]; pub(crate) const FAMILY: Family = Family { @@ -110,6 +114,7 @@ fn call<'a>( "refresh_providers" => call.refresh_providers().await, "process_diagnostics" => call.process_diagnostics().await, "trace_diagnostics" => call.trace_diagnostics().await, + "host_metrics" => call.host_metrics().await, _ => Err(typed_tool_failure("McpUnknownToolError", "Unknown tool.")), } }) @@ -152,11 +157,85 @@ fn text(name: &str) -> Option<(&'static str, Value)> { &[], ), ), + "host_metrics" => ( + "The host this server runs on: its system, architecture, core count and memory; the current CPU, memory and volume sizes; and CPU, memory and per-volume used percent since the server started (current, minimum, average, p95, maximum). A volume's mount point is given only when it is / or a drive root such as C:\\, and null otherwise; volume names and the volumes the dashboard hides are never listed, only counted.", + none(), + ), _ => return None, }) } impl ToolCall<'_> { + async fn host_metrics(&self) -> ToolResult { + self.only(&[])?; + let snapshot = self.invoke("server.getHostMetrics", json!({})).await?; + let mut withheld = 0_u64; + let mut root_or_null = |mount_point: &Value| { + if mount_point.as_str().is_some_and(is_file_system_root) { + mount_point.clone() + } else { + withheld += 1; + Value::Null + } + }; + let sample = &snapshot["sample"]; + let sample = if sample.is_object() { + let mut picked = pick(sample, HOST_SAMPLE_FIELDS); + let volumes = sample["volumes"] + .as_array() + .into_iter() + .flatten() + .map(|volume| { + let mut picked = pick(volume, HOST_VOLUME_FIELDS); + picked["mountPoint"] = root_or_null(&volume["mountPoint"]); + picked + }) + .collect::>(); + picked["volumes"] = Value::Array(volumes); + picked + } else { + Value::Null + }; + // Statistics name the same volumes; a withheld mount point is counted once, above. + let volume_statistics = snapshot["volumeStatistics"] + .as_array() + .into_iter() + .flatten() + .map(|entry| { + let mount_point = &entry["mountPoint"]; + json!({ + "mountPoint": if mount_point.as_str().is_some_and(is_file_system_root) { + mount_point.clone() + } else { + Value::Null + }, + "statistic": pick(&entry["statistic"], STATISTIC_FIELDS), + }) + }) + .collect::>(); + let statistic = |value: &Value| { + if value.is_object() { + pick(value, STATISTIC_FIELDS) + } else { + Value::Null + } + }; + Ok(tool_result( + json!({ + "host": pick(&snapshot["host"], &["os", "arch", "cpuCount", "memoryTotalBytes"]), + "startedAt": snapshot["startedAt"], + "statisticsSince": snapshot["statisticsSince"], + "sample": sample, + "cpu": statistic(&snapshot["cpu"]), + "memory": statistic(&snapshot["memory"]), + "volumeStatistics": volume_statistics, + "mountPointsWithheld": withheld, + "hiddenVolumeCount": snapshot["hiddenVolumes"].as_array().map_or(0, Vec::len), + }), + &[], + )) + } + /// Runs a stdio profiler handler over the server's own profiler directory, off the async /// workers, then projects its content. async fn profiler(&self, produce: Producer, project: fn(&mut Value)) -> ToolResult { @@ -746,6 +825,33 @@ fn project_crash(content: &mut Value) { } /// The `fields` of `value` that it has; nothing else. +const HOST_SAMPLE_FIELDS: &[&str] = &[ + "sampledAt", + "cpuPercent", + "memoryTotalBytes", + "memoryUsedBytes", + "memoryAvailableBytes", + "volumesPaused", +]; +/// Never `name`, and `mountPoint` only through [`is_file_system_root`] (D26). +const HOST_VOLUME_FIELDS: &[&str] = &[ + "fileSystem", + "totalBytes", + "freeBytes", + "usedBytes", + "removable", + "readOnly", + "state", +]; +const STATISTIC_FIELDS: &[&str] = &["current", "minimum", "average", "p95", "maximum"]; + +/// `/` or a drive root such as `C:\`: the only mount points that cannot name a user (D26). +fn is_file_system_root(mount_point: &str) -> bool { + let bytes = mount_point.as_bytes(); + mount_point == "/" + || (bytes.len() == 3 && bytes[0].is_ascii_alphabetic() && &bytes[1..] == b":\\") +} + fn pick(value: &Value, fields: &[&str]) -> Value { let mut picked = Map::new(); for field in fields { @@ -923,9 +1029,8 @@ fn evidence_fields(kind: &str) -> Vec<&'static str> { "cancelled", "capacity", ]], - "worktree.missing" | "worktree.orphan-dir" | "worktree.registration-mismatch" => { - &[&["path"]] - } + "worktree.missing" | "worktree.registration-mismatch" => &[&["path"]], + "worktree.orphan-dir" => &[&["path", "name", "reason"]], "worktree.stuck-removal" => &[&["path", "minutes"]], "worktree.held-open" => &[&["path", "pid"]], "git.stacked-action-failed" => &[&[ @@ -976,8 +1081,18 @@ pub(crate) fn project_finding(finding: &Value) -> Value { } } clean_client_fields(&mut evidence); + // An orphan's reason is one of the audit's evidence kinds, never free text. + if kind == "worktree.orphan-dir" + && !evidence + .get("reason") + .and_then(Value::as_str) + .is_some_and(WorktreeEvidence::is_reason) + && let Some(fields) = evidence.as_object_mut() + { + fields.remove("reason"); + } // Keep home-relative paths; retain a safe relative path or file name outside home. - for field in ["worktree", "path"] { + for field in ["worktree", "path", "name"] { if let Some(path) = evidence.get(field).and_then(Value::as_str) { let inside_home = path == "~" || path.starts_with("~/") || path.starts_with("~\\"); if !inside_home { @@ -1085,7 +1200,7 @@ mod tests { use crate::diagnostics::profiler::{ findings::FindingsBook, store::{ProfilerStore, StoreConfig}, - worktree_audit::{AuditFacts, audit}, + worktree_audit::{AuditFacts, WorktreeEvidence, audit}, }; let state = tempfile::tempdir().unwrap(); @@ -1097,6 +1212,10 @@ mod tests { "/srv/worktrees/orphan-b".into(), ], managed_dirs: vec!["/srv/worktrees".into()], + evidence: ["/srv/worktrees/orphan-a", "/srv/worktrees/orphan-b"] + .into_iter() + .map(|dir| (dir.into(), WorktreeEvidence::CreationRecord)) + .collect(), ..AuditFacts::default() }; let now = time::OffsetDateTime::now_utc(); @@ -1112,6 +1231,8 @@ mod tests { assert_ne!(first[0]["id"], first[1]["id"]); assert_eq!(first[0]["evidence"]["path"], "orphan-a"); assert_eq!(first[1]["evidence"]["path"], "orphan-b"); + assert_eq!(first[0]["evidence"]["name"], "orphan-a"); + assert_eq!(first[0]["evidence"]["reason"], "creation-record"); facts.base_dirs.remove(0); let next = audit(&facts); @@ -1150,6 +1271,38 @@ mod tests { assert!(project_finding(&finding)["evidence"].get("path").is_none()); } + #[test] + fn an_orphan_keeps_its_folder_name_and_a_known_reason_only() { + let orphan = |name: Value, reason: Value| { + project_finding(&json!({ + "kind": "worktree.orphan-dir", + "evidence": { "path": "/srv/worktrees/a", "name": name, "reason": reason } + }))["evidence"] + .clone() + }; + for reason in [ + "creation-record", + "incomplete-removal", + "git-pointer-file", + "removal-marker", + ] { + assert_eq!(orphan(json!("feature-a"), json!(reason))["reason"], reason); + } + let kept = orphan(json!("/srv/worktrees/feature-a"), json!("creation-record")); + assert_eq!(kept["name"], "feature-a", "a path keeps its file name only"); + let dropped = orphan(json!({ "private": "data" }), json!("/srv/private/reason")); + assert!(dropped.get("name").is_none(), "{dropped}"); + assert!(dropped.get("reason").is_none(), "{dropped}"); + assert!(orphan(json!("a"), json!(7)).get("reason").is_none()); + + // The other worktree kinds keep only their path. + let missing = project_finding(&json!({ + "kind": "worktree.missing", + "evidence": { "path": "/srv/worktrees/a", "name": "a", "reason": "creation-record" } + })); + assert_eq!(missing["evidence"], json!({ "path": "a" })); + } + /// An OS crash record names modules by their paths on this host: a finding keeps their /// file names only, in `module`, `faultModule` and each frame (#139, review round 3). #[test] diff --git a/apps/server/src/orchestration/engine.rs b/apps/server/src/orchestration/engine.rs index e3e9064634..e66d4945e5 100644 --- a/apps/server/src/orchestration/engine.rs +++ b/apps/server/src/orchestration/engine.rs @@ -436,6 +436,12 @@ pub enum OrchestrationCommand { skip_serializing_if = "Option::is_none" )] worktree_discovery: Option, + #[serde( + rename = "codeReviewPrompt", + default, + skip_serializing_if = "Option::is_none" + )] + code_review_prompt: Option, }, #[serde(rename = "project.repository-identity.set")] ProjectRepositoryIdentitySet { @@ -524,6 +530,14 @@ pub enum OrchestrationCommand { skip_serializing_if = "std::ops::Not::not" )] side_chat: bool, + /// A panel started as a code review (#293); only `worktree.createPanel` sets it, and only + /// on a panel. Omitted when false so a plain command keeps its digest. + #[serde( + rename = "codeReview", + default, + skip_serializing_if = "std::ops::Not::not" + )] + code_review: bool, /// Why an outside agent created the thread (untrusted text, at most 200 characters); /// shown with the agent's label, never logged. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -1292,6 +1306,17 @@ struct CommandModel { project_roots_canonicalized: bool, } +/// Longest review prompt a project or a user may store, in UTF-16 units, as in the contract (#293). +pub(crate) const CODE_REVIEW_PROMPT_MAX_LENGTH: usize = 7_680; + +/// Whether a review prompt holds a C0 control, DEL or a C1 control other than tab, line feed +/// and carriage return: the web's rule for a prompt it launches in a terminal (#293). +pub(crate) fn code_review_prompt_has_control_character(prompt: &str) -> bool { + prompt + .chars() + .any(|character| character.is_control() && !matches!(character, '\t' | '\n' | '\r')) +} + #[derive(Clone, Debug)] struct ProjectState { workspace_root: String, @@ -4489,7 +4514,7 @@ async fn plan_command( created_at, command_id, metadata.clone(), - json!({"projectId":project_id,"title":title,"workspaceRoot":workspace_root,"defaultModelSelection":project_selection,"scripts":[],"worktreeDiscovery":default_worktree_discovery(),"createdAt":created_at,"updatedAt":created_at}), + json!({"projectId":project_id,"title":title,"workspaceRoot":workspace_root,"defaultModelSelection":project_selection,"scripts":[],"worktreeDiscovery":default_worktree_discovery(),"codeReviewPrompt":"","createdAt":created_at,"updatedAt":created_at}), ), make_event( "thread.created", @@ -4510,8 +4535,28 @@ async fn plan_command( default_model_selection, scripts, worktree_discovery, + code_review_prompt, } => { require_project(model, command, project_id)?; + if let Some(prompt) = code_review_prompt + && prompt.encode_utf16().count() > CODE_REVIEW_PROMPT_MAX_LENGTH + { + return invariant( + command, + format!( + "The project review prompt is longer than {CODE_REVIEW_PROMPT_MAX_LENGTH} characters." + ), + ); + } + if let Some(prompt) = code_review_prompt + && code_review_prompt_has_control_character(prompt) + { + return invariant( + command, + "The project review prompt holds a control character other than tab or a line break." + .to_owned(), + ); + } if let Some(workspace_root) = workspace_root && let Some(conflicting_project_id) = model.projects.iter().find_map(|(candidate_id, project)| { @@ -4548,6 +4593,11 @@ async fn plan_command( "worktreeDiscovery", worktree_discovery.as_ref().map(|value| json!(value)), ); + insert_optional( + &mut payload, + "codeReviewPrompt", + code_review_prompt.as_ref().map(|value| json!(value)), + ); Ok(vec![make_event( "project.meta-updated", "project", @@ -4860,6 +4910,7 @@ async fn plan_command( kind, host_thread_id, side_chat, + code_review, purpose, model_selection, runtime_mode, @@ -4875,6 +4926,12 @@ async fn plan_command( "A side chat must be a chat panel with a host thread.".to_owned(), ); } + if *code_review && (kind.as_deref() != Some("panel") || host_thread_id.is_none()) { + return invariant( + command, + "A code review must be a chat panel with a host thread.".to_owned(), + ); + } if purpose .as_deref() .is_some_and(|purpose| purpose.chars().count() > MAX_THREAD_PURPOSE_CHARS) @@ -4928,6 +4985,7 @@ async fn plan_command( ); insert_optional(&mut payload, "purpose", purpose.as_ref().map(|v| json!(v))); insert_optional(&mut payload, "sideChat", side_chat.then(|| json!(true))); + insert_optional(&mut payload, "codeReview", code_review.then(|| json!(true))); Ok(vec![make_event( "thread.created", "thread", @@ -7473,11 +7531,11 @@ fn apply_projects_projector_tx( match event.event.event_type.as_str() { "project.created" => { transaction.execute( - "INSERT INTO projection_projects (project_id, title, workspace_root, default_model_selection_json, scripts_json, worktree_discovery_json, created_at, updated_at, deleted_at) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL) \ + "INSERT INTO projection_projects (project_id, title, workspace_root, default_model_selection_json, scripts_json, worktree_discovery_json, code_review_prompt, created_at, updated_at, deleted_at) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL) \ ON CONFLICT (project_id) DO UPDATE SET \ title = excluded.title, workspace_root = excluded.workspace_root, \ - default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, worktree_discovery_json = excluded.worktree_discovery_json, \ + default_model_selection_json = excluded.default_model_selection_json, scripts_json = excluded.scripts_json, worktree_discovery_json = excluded.worktree_discovery_json, code_review_prompt = excluded.code_review_prompt, \ created_at = excluded.created_at, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at", params![ required_str(payload, "projectId")?, @@ -7497,6 +7555,7 @@ fn apply_projects_projector_tx( .cloned() .unwrap_or_else(default_worktree_discovery), )?, + optional_string(payload.get("codeReviewPrompt")).unwrap_or_default(), required_str(payload, "createdAt")?, required_str(payload, "updatedAt")?, ], @@ -7504,7 +7563,7 @@ fn apply_projects_projector_tx( } "project.meta-updated" => { transaction.execute( - "UPDATE projection_projects SET title = COALESCE(?, title), workspace_root = COALESCE(?, workspace_root), default_model_selection_json = CASE WHEN ? THEN ? ELSE default_model_selection_json END, scripts_json = COALESCE(?, scripts_json), worktree_discovery_json = CASE WHEN ? THEN ? ELSE worktree_discovery_json END, repository_identity_json = CASE WHEN ? THEN ? ELSE repository_identity_json END, updated_at = ? WHERE project_id = ?", + "UPDATE projection_projects SET title = COALESCE(?, title), workspace_root = COALESCE(?, workspace_root), default_model_selection_json = CASE WHEN ? THEN ? ELSE default_model_selection_json END, scripts_json = COALESCE(?, scripts_json), worktree_discovery_json = CASE WHEN ? THEN ? ELSE worktree_discovery_json END, code_review_prompt = COALESCE(?, code_review_prompt), repository_identity_json = CASE WHEN ? THEN ? ELSE repository_identity_json END, updated_at = ? WHERE project_id = ?", params![ optional_string(payload.get("title")), optional_string(payload.get("workspaceRoot")), @@ -7513,6 +7572,7 @@ fn apply_projects_projector_tx( payload.get("scripts").map(json_string).transpose()?, payload.get("worktreeDiscovery").is_some(), payload.get("worktreeDiscovery").map(json_string).transpose()?, + optional_string(payload.get("codeReviewPrompt")), payload.get("repositoryIdentity").is_some(), optional_json_string(payload.get("repositoryIdentity").filter(|value| !value.is_null()))?, required_str(payload, "updatedAt")?, @@ -7535,10 +7595,10 @@ fn apply_threads_projector_tx( if event.event.event_type == "thread.created" { let payload = &event.event.payload; transaction.execute( - "INSERT INTO projection_threads (thread_id, project_id, title, kind, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, deleted_at, host_thread_id, requester_json, side_chat) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, 0, 0, 0, NULL, ?, ?, ?) \ + "INSERT INTO projection_threads (thread_id, project_id, title, kind, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, deleted_at, host_thread_id, requester_json, side_chat, code_review) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?, NULL, NULL, 0, 0, 0, NULL, ?, ?, ?, ?) \ ON CONFLICT (thread_id) DO UPDATE SET \ - project_id = excluded.project_id, title = excluded.title, kind = excluded.kind, host_thread_id = excluded.host_thread_id, requester_json = excluded.requester_json, side_chat = excluded.side_chat, \ + project_id = excluded.project_id, title = excluded.title, kind = excluded.kind, host_thread_id = excluded.host_thread_id, requester_json = excluded.requester_json, side_chat = excluded.side_chat, code_review = excluded.code_review, \ model_selection_json = excluded.model_selection_json, runtime_mode = excluded.runtime_mode, \ interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, \ created_at = excluded.created_at, updated_at = excluded.updated_at, archived_at = excluded.archived_at, \ @@ -7572,6 +7632,7 @@ fn apply_threads_projector_tx( )) .transpose()?, payload.get("sideChat").and_then(Value::as_bool) == Some(true), + payload.get("codeReview").and_then(Value::as_bool) == Some(true), ], )?; } else { @@ -9613,6 +9674,7 @@ mod tests { workspace_root: None, default_model_selection: OptionalNullable::Missing, scripts: None, + code_review_prompt: None, worktree_discovery: Some(json!({ "visibility": "shown", "initialPromptDismissedAt": null, @@ -9647,6 +9709,7 @@ mod tests { branch: Some("feature/detach".to_owned()), worktree_path: Some(path.to_owned()), created_at: "2026-08-09T00:00:01Z".to_owned(), + code_review: false, }) .await .expect("thread"); @@ -9912,6 +9975,7 @@ mod tests { branch: None, worktree_path: None, created_at: "2026-10-08T00:00:01Z".to_owned(), + code_review: false, }) .await .expect("thread"); @@ -10186,6 +10250,7 @@ mod tests { workspace_root: None, default_model_selection: OptionalNullable::Missing, scripts: None, + code_review_prompt: None, worktree_discovery: Some(json!({ "visibility": "shown", "initialPromptDismissedAt": "2026-08-09T00:00:00Z", @@ -10259,6 +10324,7 @@ mod tests { branch: Some("old".to_owned()), worktree_path: Some(worktree_path.to_owned()), created_at: "2026-08-09T00:00:01Z".to_owned(), + code_review: false, }) .await .expect("thread"); @@ -10351,6 +10417,7 @@ mod tests { workspace_root: None, default_model_selection: OptionalNullable::Missing, scripts: None, + code_review_prompt: None, worktree_discovery: Some(json!({ "visibility": "shown", "initialPromptDismissedAt": "2026-08-09T00:00:00Z", @@ -10796,6 +10863,7 @@ mod tests { workspace_root: None, default_model_selection: OptionalNullable::Missing, scripts: None, + code_review_prompt: None, worktree_discovery: Some(json!({ "visibility":"shown", "initialPromptDismissedAt":"2026-08-09T00:00:00Z", @@ -11126,6 +11194,7 @@ mod tests { branch: None, worktree_path: None, created_at: "2026-08-01T00:00:01Z".to_owned(), + code_review: false, }) .await .expect("panel"); @@ -11212,6 +11281,7 @@ mod tests { branch: None, worktree_path: None, created_at: "2026-08-01T00:00:01Z".to_owned(), + code_review: false, }; let refusal = |result: Result<_, OrchestrationError>| match result { Err(OrchestrationError::Invariant { detail, .. }) => detail, @@ -11301,6 +11371,7 @@ mod tests { branch: None, worktree_path: None, created_at: "2026-11-02T00:00:01Z".to_owned(), + code_review: false, } } @@ -11697,6 +11768,111 @@ mod tests { assert!(!side_chat_of(host_id).await); } + fn code_review_create( + command_id: &str, + thread_id: &str, + kind: Option<&str>, + host: Option<&str>, + ) -> OrchestrationCommand { + let mut command = side_chat_create(command_id, thread_id, kind, host); + if let OrchestrationCommand::ThreadCreate { + side_chat, + code_review, + title, + .. + } = &mut command + { + *side_chat = false; + *code_review = true; + *title = "Code review · Codex".to_owned(); + } + command + } + + #[tokio::test] + async fn code_review_thread_flag_is_refused_without_a_host() { + let (engine, host_id) = delivery_engine(TestHooks::default()).await; + for command in [ + code_review_create("no-kind", "no-kind", None, Some(&host_id)), + code_review_create("workspace", "workspace", Some("workspace"), Some(&host_id)), + code_review_create("no-host", "no-host", Some("panel"), None), + ] { + assert_eq!( + invariant_detail(engine.dispatch(command).await), + "A code review must be a chat panel with a host thread." + ); + } + } + + #[tokio::test] + async fn code_review_thread_marker_survives_replay() { + let (engine, host_id) = delivery_engine(TestHooks::default()).await; + engine + .dispatch(code_review_create( + "review", + "review-panel", + Some("panel"), + Some(&host_id), + )) + .await + .expect("review panel"); + engine + .dispatch(plain_panel_create("plain", "plain-panel", &host_id)) + .await + .expect("plain panel"); + let code_review_of = |thread_id: &str| { + let repositories = engine.repositories(); + let thread_id = thread_id.to_owned(); + async move { + repositories + .get_thread(thread_id) + .await + .expect("thread read") + .expect("thread exists") + .code_review + } + }; + assert!(code_review_of("review-panel").await); + assert!(!code_review_of("plain-panel").await); + assert!(!code_review_of(&host_id).await); + // A plain create records no key, like every thread.created before the flag existed. + let plain_payload_has_key = engine + .repositories() + .database() + .call(|connection| { + Ok(connection.query_row( + "SELECT COUNT(*) FROM orchestration_events WHERE event_type = 'thread.created' AND json_extract(payload_json, '$.codeReview') IS NOT NULL", + [], + |row| row.get::<_, i64>(0), + )?) + }) + .await + .expect("count marked events"); + assert_eq!( + plain_payload_has_key, 1, + "only the review panel records the key" + ); + engine + .repositories() + .database() + .call(|connection| { + connection.execute("UPDATE projection_threads SET code_review = 0", [])?; + connection.execute( + "DELETE FROM projection_state WHERE projector = 'projection.threads'", + [], + )?; + Ok(()) + }) + .await + .expect("reset thread projection"); + bootstrap_projectors(&engine.repositories(), &TestHooks::default()) + .await + .expect("replay threads"); + assert!(code_review_of("review-panel").await); + assert!(!code_review_of("plain-panel").await); + assert!(!code_review_of(&host_id).await); + } + #[tokio::test] async fn side_chat_checkpoint_revert_is_refused() { let (engine, host_id) = delivery_engine(TestHooks::default()).await; diff --git a/apps/server/src/persistence/migrations.rs b/apps/server/src/persistence/migrations.rs index 73d373e5ff..082c395ddc 100644 --- a/apps/server/src/persistence/migrations.rs +++ b/apps/server/src/persistence/migrations.rs @@ -65,6 +65,7 @@ const CORE_TABLES: &[(u32, &str)] = &[ (62, "mcp_oauth_authorizations"), (62, "mcp_oauth_refresh_tokens"), (70, "mcp_named_clients"), + (72, "worktree_creation_records"), ]; #[derive(Debug, Error)] @@ -671,6 +672,9 @@ pub const MIGRATIONS: &[Migration] = &[ Migration::new(69, "ProjectionThreadMessagesQueuedIndex", migration_069), Migration::new(70, "McpNamedClients", migration_070), Migration::new(71, "McpNamedClientTakeOver", migration_071), + Migration::new(72, "WorktreeCreationRecords", migration_072), + Migration::new(73, "ProjectCodeReviewPrompt", migration_073), + Migration::new(74, "ProjectionThreadCodeReview", migration_074), ]; impl Migration { @@ -3049,6 +3053,50 @@ fn migration_071(transaction: &Transaction<'_>) -> Result<()> { Ok(()) } +fn migration_073(transaction: &Transaction<'_>) -> Result<()> { + // A project's own review prompt (#293); "" falls through to the user's prompt. Guarded like + // 052: a trusted-ledger restore may not carry this rebuildable projection table. + if table_exists(transaction, "projection_projects")? + && !table_has_column(transaction, "projection_projects", "code_review_prompt")? + { + transaction.execute_batch( + "ALTER TABLE projection_projects ADD COLUMN code_review_prompt TEXT NOT NULL DEFAULT '';", + )?; + } + Ok(()) +} + +fn migration_074(transaction: &Transaction<'_>) -> Result<()> { + // A chat panel started as a code review (#293), kept like `side_chat`. Guarded like 067: a + // trusted-ledger restore may not carry this rebuildable projection table. + if table_exists(transaction, "projection_threads")? + && !table_has_column(transaction, "projection_threads", "code_review")? + { + transaction.execute_batch( + "ALTER TABLE projection_threads ADD COLUMN code_review INTEGER NOT NULL DEFAULT 0;", + )?; + } + Ok(()) +} + +fn migration_072(transaction: &Transaction<'_>) -> Result<()> { + // The worktrees BiBCode created (#344): the orphan audit warns only for a leftover with + // worktree evidence, and a record ends when its folder is gone. One record per path, and + // each creation gets a new `record_id`, never reused, so the audit's reap of a record it + // read cannot delete a later creation's. + // Guarded like the migrations before it: a ledger rolled back below 72 runs it again. + transaction.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS worktree_creation_records ( + record_id INTEGER PRIMARY KEY AUTOINCREMENT, + worktree_path TEXT NOT NULL UNIQUE, + project_cwd TEXT NOT NULL, + created_at TEXT NOT NULL + ); + "#, + ) +} + /// The endpoint `revision` and the delivery's `endpoint_revision`, added to migration 61 /// before it was released. A development store that ran the earlier 61 gets them here: the /// webhook dispatcher calls this once when it starts, and it changes nothing when they exist. @@ -3278,7 +3326,9 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71] + [ + 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74 + ] ); let (state, outcome) = connection.query_row( "SELECT state, outcome_json FROM mcp_tool_requests WHERE client_request_id = 'k'", @@ -3311,7 +3361,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [65, 66, 67, 68, 69, 70, 71] + [65, 66, 67, 68, 69, 70, 71, 72, 73, 74] ); let claimed: i64 = connection.query_row( "SELECT claimed_once FROM provider_turn_outbox WHERE command_id = 'retried'", @@ -3347,7 +3397,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71] + [61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74] ); connection.execute( "INSERT INTO webhook_endpoints (endpoint_id, name, url, enabled, event_types_json, created_at, updated_at) VALUES ('w', 'CI', 'https://example.com/hook', 1, '[]', '2026-10-31T00:00:00Z', '2026-10-31T00:00:00Z')", @@ -3389,7 +3439,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [66, 67, 68, 69, 70, 71] + [66, 67, 68, 69, 70, 71, 72, 73, 74] ); let bearer: Option = connection.query_row( "SELECT bearer_secret FROM webhook_endpoints WHERE endpoint_id = 'w'", @@ -3420,7 +3470,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [69, 70, 71] + [69, 70, 71, 72, 73, 74] ); let index_sql: String = connection.query_row( "SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_projection_thread_messages_queued'", @@ -3465,7 +3515,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [70, 71] + [70, 71, 72, 73, 74] ); let unnamed: (Option, Option) = connection.query_row( "SELECT (SELECT mcp_named_client_id FROM auth_pairing_links WHERE id = 'l'), (SELECT mcp_named_client_id FROM auth_sessions WHERE session_id = 's')", @@ -3514,7 +3564,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [71] + [71, 72, 73, 74] ); let taken_over: Option = connection.query_row( "SELECT taken_over_at FROM mcp_named_clients WHERE client_id = 'nc_1'", @@ -3528,6 +3578,124 @@ mod tests { Ok(()) } + #[test] + fn migration_73_adds_code_review_prompt_defaulting_empty() -> rusqlite::Result<()> { + let mut connection = rusqlite::Connection::open_in_memory()?; + run_migrations(&mut connection, Some(72))?; + connection.execute( + "INSERT INTO projection_projects (project_id, title, workspace_root, default_model_selection_json, scripts_json, worktree_discovery_json, created_at, updated_at, deleted_at) VALUES ('p', 'P', '/repo', NULL, '[]', '{}', '2026-11-11T00:00:00Z', '2026-11-11T00:00:00Z', NULL)", + [], + )?; + let applied = run_migrations(&mut connection, None)?; + assert_eq!( + applied + .iter() + .map(|migration| migration.id) + .collect::>(), + [73, 74] + ); + let prompt: String = connection.query_row( + "SELECT code_review_prompt FROM projection_projects WHERE project_id = 'p'", + [], + |row| row.get(0), + )?; + assert_eq!(prompt, ""); + // Running it again changes nothing. + let transaction = connection.transaction()?; + super::migration_073(&transaction)?; + transaction.commit()?; + Ok(()) + } + + #[test] + fn migration_74_adds_code_review_defaulting_false() -> rusqlite::Result<()> { + let mut connection = rusqlite::Connection::open_in_memory()?; + run_migrations(&mut connection, Some(73))?; + connection.execute( + "INSERT INTO projection_threads (thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, deleted_at) VALUES ('t', 'p', 'T', '{}', 'full-access', 'default', NULL, NULL, NULL, '2026-11-11T00:00:00Z', '2026-11-11T00:00:00Z', NULL, NULL)", + [], + )?; + let applied = run_migrations(&mut connection, None)?; + assert_eq!( + applied + .iter() + .map(|migration| migration.id) + .collect::>(), + [74] + ); + let code_review: i64 = connection.query_row( + "SELECT code_review FROM projection_threads WHERE thread_id = 't'", + [], + |row| row.get(0), + )?; + assert_eq!(code_review, 0); + // Running it again changes nothing. + let transaction = connection.transaction()?; + super::migration_074(&transaction)?; + transaction.commit()?; + Ok(()) + } + + #[test] + fn migration_72_adds_worktree_creation_records() -> rusqlite::Result<()> { + let mut connection = rusqlite::Connection::open_in_memory()?; + run_migrations(&mut connection, Some(71))?; + connection.execute( + "INSERT INTO worktree_removal_receipts (owner_thread_id, project_cwd, worktree_path, identity_nonce, state, created_at, updated_at) \ + VALUES ('thread-1', '/repo', '/worktrees/a', 'nonce', 'prepared', '2026-11-09T00:00:00Z', '2026-11-09T00:00:00Z')", + [], + )?; + assert!(!table_exists(&connection, "worktree_creation_records")?); + let applied = run_migrations(&mut connection, None)?; + assert_eq!( + applied + .iter() + .map(|migration| migration.id) + .collect::>(), + [72, 73, 74] + ); + let columns = connection + .prepare("PRAGMA table_info(worktree_creation_records)")? + .query_map([], |row| { + Ok(( + row.get::<_, String>(1)?, + row.get::<_, bool>(3)?, + row.get::<_, i64>(5)?, + )) + })? + .collect::>>()?; + assert_eq!( + columns, + [ + ("record_id".to_owned(), false, 1), + ("worktree_path".to_owned(), true, 0), + ("project_cwd".to_owned(), true, 0), + ("created_at".to_owned(), true, 0), + ], + "a record id, and three required columns" + ); + let receipts: i64 = connection.query_row( + "SELECT COUNT(*) FROM worktree_removal_receipts", + [], + |row| row.get(0), + )?; + assert_eq!(receipts, 1, "existing rows are kept"); + let insert = "INSERT INTO worktree_creation_records (project_cwd, worktree_path, created_at) VALUES (?, ?, '2026-11-09T00:00:00Z')"; + connection.execute(insert, ["/repo", "/worktrees/a"])?; + connection.execute(insert, ["/repo", "/worktrees/b"])?; + assert!( + connection + .execute(insert, ["/other", "/worktrees/a"]) + .is_err(), + "one record per worktree path, whatever the project" + ); + // A ledger rolled back below 72 runs it again over the existing table. + let transaction = connection.transaction()?; + super::migration_072(&transaction)?; + transaction.commit()?; + Ok(()) + } + #[test] fn migration_068_adds_a_null_status_filter_to_existing_endpoints() -> rusqlite::Result<()> { let mut connection = rusqlite::Connection::open_in_memory()?; @@ -3542,7 +3710,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [68, 69, 70, 71] + [68, 69, 70, 71, 72, 73, 74] ); let kinds: Option = connection.query_row( "SELECT status_kinds_json FROM webhook_endpoints WHERE endpoint_id = 'w'", @@ -3570,7 +3738,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [67, 68, 69, 70, 71] + [67, 68, 69, 70, 71, 72, 73, 74] ); let side_chat: i64 = connection.query_row( "SELECT side_chat FROM projection_threads WHERE thread_id = 't'", @@ -3596,7 +3764,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [62, 63, 64, 65, 66, 67, 68, 69, 70, 71] + [62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74] ); for table in [ "mcp_oauth_clients", @@ -3651,7 +3819,7 @@ mod tests { .iter() .map(|migration| migration.id) .collect::>(), - [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71] + [60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74] ); for (table, column) in [ ("projection_thread_messages", "actor_json"), @@ -3697,7 +3865,7 @@ mod tests { .map(|migration| migration.id) .collect::>(), [ - 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71 + 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74 ] ); connection.execute( @@ -3740,7 +3908,7 @@ mod tests { let applied = run_migrations(&mut connection, None)?; - assert_eq!(applied.last().map(|migration| migration.id), Some(71)); + assert_eq!(applied.last().map(|migration| migration.id), Some(74)); for table in [ "projection_thread_messages", "projection_thread_activities", @@ -4472,7 +4640,7 @@ mod tests { .map(|migration| migration.id) .collect::>(); - assert_eq!(ids, (1..=71).collect::>()); + assert_eq!(ids, (1..=74).collect::>()); assert_eq!(MIGRATIONS[0].name, "OrchestrationEvents"); assert_eq!(MIGRATIONS[33].name, "ActivityProjection"); assert_eq!(MIGRATIONS[34].name, "ActivityJournalEventKeyNamespace"); @@ -4548,6 +4716,9 @@ mod tests { (69, "ProjectionThreadMessagesQueuedIndex"), (70, "McpNamedClients"), (71, "McpNamedClientTakeOver"), + (72, "WorktreeCreationRecords"), + (73, "ProjectCodeReviewPrompt"), + (74, "ProjectionThreadCodeReview"), ], ); assert_eq!( @@ -4698,7 +4869,7 @@ mod tests { assert_eq!(first[15].id, 16); let second = run_migrations(&mut connection, None)?; - assert_eq!(second.len(), 55); + assert_eq!(second.len(), 58); assert_eq!(second[0].id, 17); assert_eq!(second[35].id, 52); assert_eq!(second[36].id, 53); @@ -4721,7 +4892,7 @@ mod tests { [], |row| row.get::<_, u32>(0), )?; - assert_eq!(application_table_count, 40); + assert_eq!(application_table_count, 41); assert_delivery_schema(&connection)?; Ok(()) @@ -4818,7 +4989,7 @@ mod tests { .collect::>(), [ 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, - 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71 + 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74 ] ); let policy = connection.query_row( @@ -4858,7 +5029,7 @@ mod tests { .collect::>(), [ 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, - 62, 63, 64, 65, 66, 67, 68, 69, 70, 71 + 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74 ] ); let pin = connection.query_row( @@ -4889,7 +5060,7 @@ mod tests { .collect::>(), [ 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, - 63, 64, 65, 66, 67, 68, 69, 70, 71 + 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74 ] ); let pin = connection.query_row( @@ -5032,7 +5203,7 @@ mod tests { .collect::>(), [ 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, - 69, 70, 71 + 69, 70, 71, 72, 73, 74 ] ); assert_eq!( @@ -5062,7 +5233,7 @@ mod tests { )?; let applied = run_migrations(&mut connection, None)?; - assert_eq!(applied.len(), 38); + assert_eq!(applied.len(), 41); assert_eq!(applied[0].id, 34); assert_eq!(applied[1].id, 35); assert_eq!(applied[2].id, 36); diff --git a/apps/server/src/persistence/repositories.rs b/apps/server/src/persistence/repositories.rs index b5446e0103..2bd02652c5 100644 --- a/apps/server/src/persistence/repositories.rs +++ b/apps/server/src/persistence/repositories.rs @@ -307,6 +307,90 @@ impl Repositories { .await } + /// Records that BiBCode created a worktree at `worktree_path`. A creation at a path that + /// still has a record replaces it with a new `record_id`. + pub async fn record_worktree_creation( + &self, + project_cwd: &str, + worktree_path: &str, + ) -> Result<()> { + let (project_cwd, worktree_path) = (project_cwd.to_owned(), worktree_path.to_owned()); + self.database + .call(move |connection| { + connection.execute( + "INSERT OR REPLACE INTO worktree_creation_records (worktree_path, project_cwd, created_at) \ + VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))", + params![worktree_path, project_cwd], + )?; + Ok(()) + }) + .await + } + + /// Deletes the creation record of a removed worktree, written under any project spelling. + pub async fn delete_worktree_creation_record(&self, worktree_path: &str) -> Result<()> { + let worktree_path = worktree_path.to_owned(); + self.database + .call(move |connection| { + connection.execute( + "DELETE FROM worktree_creation_records WHERE worktree_path = ?", + [worktree_path], + )?; + Ok(()) + }) + .await + } + + /// Deletes exactly the creation record `record_id`, and never a later one at its path. + pub async fn reap_worktree_creation_record(&self, record_id: i64) -> Result<()> { + self.database + .call(move |connection| { + connection.execute( + "DELETE FROM worktree_creation_records WHERE record_id = ?", + [record_id], + )?; + Ok(()) + }) + .await + } + + pub async fn list_worktree_creation_records(&self) -> Result> { + self.database + .call(move |connection| { + connection + .prepare( + "SELECT record_id, project_cwd, worktree_path FROM worktree_creation_records \ + ORDER BY worktree_path", + )? + .query_map([], |row| { + Ok(WorktreeCreationRecord { + record_id: row.get(0)?, + project_cwd: row.get(1)?, + worktree_path: row.get(2)?, + }) + })? + .collect::>>() + .map_err(Into::into) + }) + .await + } + + /// The worktree paths of removals that were prepared and never completed, in every project. + pub async fn list_incomplete_worktree_removal_paths(&self) -> Result> { + self.database + .call(move |connection| { + connection + .prepare( + "SELECT DISTINCT worktree_path FROM worktree_removal_receipts \ + WHERE state = 'prepared' ORDER BY worktree_path", + )? + .query_map([], |row| row.get(0))? + .collect::>>() + .map_err(Into::into) + }) + .await + } + pub async fn upsert_checkpoint_diff_blob(&self, row: CheckpointDiffBlob) -> Result<()> { self.database.call(move |connection| { connection.execute( @@ -442,13 +526,13 @@ impl Repositories { pub async fn upsert_project(&self, row: ProjectionProject) -> Result<()> { self.database.call(move |connection| { connection.execute( - "INSERT INTO projection_projects (project_id, title, workspace_root, default_model_selection_json, scripts_json, worktree_discovery_json, created_at, updated_at, deleted_at, repository_identity_json) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + "INSERT INTO projection_projects (project_id, title, workspace_root, default_model_selection_json, scripts_json, worktree_discovery_json, created_at, updated_at, deleted_at, repository_identity_json, code_review_prompt) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT (project_id) DO UPDATE SET \ title=excluded.title, workspace_root=excluded.workspace_root, \ default_model_selection_json=excluded.default_model_selection_json, scripts_json=excluded.scripts_json, worktree_discovery_json=excluded.worktree_discovery_json, \ - created_at=excluded.created_at, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, repository_identity_json=excluded.repository_identity_json", - params![row.project_id, row.title, row.workspace_root, optional_json(&row.default_model_selection)?, encode_json(&row.scripts)?, encode_json(&row.worktree_discovery)?, row.created_at, row.updated_at, row.deleted_at, optional_json(&row.repository_identity)?], + created_at=excluded.created_at, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, repository_identity_json=excluded.repository_identity_json, code_review_prompt=excluded.code_review_prompt", + params![row.project_id, row.title, row.workspace_root, optional_json(&row.default_model_selection)?, encode_json(&row.scripts)?, encode_json(&row.worktree_discovery)?, row.created_at, row.updated_at, row.deleted_at, optional_json(&row.repository_identity)?, row.code_review_prompt], )?; Ok(()) }).await } @@ -872,11 +956,11 @@ impl Repositories { pub async fn upsert_thread(&self, row: ProjectionThread) -> Result<()> { self.database.call(move |connection| { connection.execute( - "INSERT INTO projection_threads (thread_id, project_id, title, kind, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, deleted_at, host_thread_id, side_chat) \ - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ + "INSERT INTO projection_threads (thread_id, project_id, title, kind, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, deleted_at, host_thread_id, side_chat, code_review) \ + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) \ ON CONFLICT (thread_id) DO UPDATE SET \ - project_id=excluded.project_id, title=excluded.title, kind=excluded.kind, model_selection_json=excluded.model_selection_json, runtime_mode=excluded.runtime_mode, interaction_mode=excluded.interaction_mode, branch=excluded.branch, worktree_path=excluded.worktree_path, latest_turn_id=excluded.latest_turn_id, created_at=excluded.created_at, updated_at=excluded.updated_at, archived_at=excluded.archived_at, latest_user_message_at=excluded.latest_user_message_at, pending_approval_count=excluded.pending_approval_count, pending_user_input_count=excluded.pending_user_input_count, has_actionable_proposed_plan=excluded.has_actionable_proposed_plan, deleted_at=excluded.deleted_at, host_thread_id=excluded.host_thread_id, side_chat=excluded.side_chat", - params![row.thread_id,row.project_id,row.title,row.kind,encode_json(&row.model_selection)?,row.runtime_mode,row.interaction_mode,row.branch,row.worktree_path,row.latest_turn_id,row.created_at,row.updated_at,row.archived_at,row.latest_user_message_at,row.pending_approval_count,row.pending_user_input_count,row.has_actionable_proposed_plan,row.deleted_at,row.host_thread_id,row.side_chat], + project_id=excluded.project_id, title=excluded.title, kind=excluded.kind, model_selection_json=excluded.model_selection_json, runtime_mode=excluded.runtime_mode, interaction_mode=excluded.interaction_mode, branch=excluded.branch, worktree_path=excluded.worktree_path, latest_turn_id=excluded.latest_turn_id, created_at=excluded.created_at, updated_at=excluded.updated_at, archived_at=excluded.archived_at, latest_user_message_at=excluded.latest_user_message_at, pending_approval_count=excluded.pending_approval_count, pending_user_input_count=excluded.pending_user_input_count, has_actionable_proposed_plan=excluded.has_actionable_proposed_plan, deleted_at=excluded.deleted_at, host_thread_id=excluded.host_thread_id, side_chat=excluded.side_chat, code_review=excluded.code_review", + params![row.thread_id,row.project_id,row.title,row.kind,encode_json(&row.model_selection)?,row.runtime_mode,row.interaction_mode,row.branch,row.worktree_path,row.latest_turn_id,row.created_at,row.updated_at,row.archived_at,row.latest_user_message_at,row.pending_approval_count,row.pending_user_input_count,row.has_actionable_proposed_plan,row.deleted_at,row.host_thread_id,row.side_chat,row.code_review], )?; Ok(()) }).await } @@ -3472,6 +3556,13 @@ pub(crate) fn finalize_command_receipt_on( Err(PersistenceError::CommandReceiptConflict(command_id)) } } +/// A worktree BiBCode created, as `worktree_creation_records` stores it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorktreeCreationRecord { + pub record_id: i64, + pub project_cwd: String, + pub worktree_path: String, +} #[derive(Clone, Debug, Serialize, Deserialize)] pub struct WorktreeRemovalReceipt { pub owner_thread_id: String, @@ -3515,6 +3606,9 @@ pub struct ProjectionProject { pub updated_at: Timestamp, pub deleted_at: Option, pub repository_identity: Option, + /// The project's review prompt (#293); `""` falls through to the user's and the built-in one. + #[serde(default)] + pub code_review_prompt: String, } #[derive(Clone, Debug, Eq, PartialEq)] @@ -3568,6 +3662,9 @@ pub struct ProjectionThread { /// carries the host's messages, and it captures no checkpoints. #[serde(default)] pub side_chat: bool, + /// A `panel` thread started as a code review (#293): it offers no Implement. + #[serde(default)] + pub code_review: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -4091,8 +4188,8 @@ pub struct AuthSession { pub mcp_named_client: Option, } -const PROJECT_SELECT: &str = "SELECT project_id, title, workspace_root, default_model_selection_json, scripts_json, worktree_discovery_json, (SELECT repository_key FROM project_worktree_repository_pins WHERE project_id = projection_projects.project_id), created_at, updated_at, deleted_at, repository_identity_json FROM projection_projects"; -const THREAD_SELECT: &str = "SELECT thread_id, project_id, title, kind, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, unresolved_delivery_state, unresolved_delivery_detail, deleted_at, host_thread_id, compaction_json, last_activity_at, requester_json, side_chat FROM projection_threads"; +const PROJECT_SELECT: &str = "SELECT project_id, title, workspace_root, default_model_selection_json, scripts_json, worktree_discovery_json, (SELECT repository_key FROM project_worktree_repository_pins WHERE project_id = projection_projects.project_id), created_at, updated_at, deleted_at, repository_identity_json, code_review_prompt FROM projection_projects"; +const THREAD_SELECT: &str = "SELECT thread_id, project_id, title, kind, model_selection_json, runtime_mode, interaction_mode, branch, worktree_path, latest_turn_id, created_at, updated_at, archived_at, latest_user_message_at, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, unresolved_delivery_state, unresolved_delivery_detail, deleted_at, host_thread_id, compaction_json, last_activity_at, requester_json, side_chat, code_review FROM projection_threads"; const PROJECT_LIST_ORDER: &str = " ORDER BY created_at ASC, project_id ASC"; const MESSAGE_SELECT: &str = "SELECT message_id, thread_id, turn_id, role, text, attachments_json, is_streaming, delivery_state, delivery_provider, delivery_detail, created_at, updated_at, delivery_mode, delivery_held, delivery_reason, delivery_provider_instance_id, resend_of_message_id, actor_json, source_thread_id FROM projection_thread_messages"; const MESSAGE_LIST_ORDER: &str = " ORDER BY CASE WHEN delivery_state = 'queued' THEN (SELECT receipt.result_sequence FROM provider_turn_outbox AS queued JOIN orchestration_command_receipts AS receipt ON receipt.command_id = queued.command_id WHERE queued.message_id = projection_thread_messages.message_id) END ASC, created_at ASC, message_id ASC"; @@ -5043,6 +5140,7 @@ fn decode_project(row: &Row<'_>) -> rusqlite::Result { updated_at: row.get(8)?, deleted_at: row.get(9)?, repository_identity: decode_optional_json(row.get(10)?, "repository_identity_json")?, + code_review_prompt: row.get(11)?, }) } fn decode_thread(row: &Row<'_>) -> rusqlite::Result { @@ -5072,6 +5170,7 @@ fn decode_thread(row: &Row<'_>) -> rusqlite::Result { last_activity_at: row.get(22)?, requester: decode_optional_json(row.get(23)?, "requester_json")?, side_chat: row.get(24)?, + code_review: row.get(25)?, }) } fn decode_message(row: &Row<'_>) -> rusqlite::Result { @@ -5575,6 +5674,155 @@ mod activity_order_tests { } } +#[cfg(test)] +mod worktree_creation_record_tests { + use super::*; + use crate::persistence::run_migrations; + + async fn migrated(database: Database) -> Repositories { + database + .call(|connection| { + run_migrations(connection, None)?; + Ok(()) + }) + .await + .unwrap(); + Repositories::new(database) + } + + async fn recorded_paths(repositories: &Repositories) -> Vec { + repositories + .list_worktree_creation_records() + .await + .unwrap() + .into_iter() + .map(|record| record.worktree_path) + .collect() + } + + #[tokio::test] + async fn worktree_creation_record_survives_reopen() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("state.sqlite"); + let database = Database::create_new(&path).await.unwrap(); + let repositories = migrated(database.clone()).await; + repositories + .record_worktree_creation("/repo", "/worktrees/feature-a") + .await + .unwrap(); + drop(repositories); + database.close().await; + + let reopened = migrated(Database::open_existing(&path).await.unwrap()).await; + let records = reopened.list_worktree_creation_records().await.unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].project_cwd, "/repo"); + assert_eq!(records[0].worktree_path, "/worktrees/feature-a"); + } + + #[tokio::test] + async fn worktree_creation_record_is_idempotent() { + let repositories = migrated(Database::open_in_memory().await.unwrap()).await; + for _ in 0..2 { + repositories + .record_worktree_creation("/repo", "/worktrees/feature-a") + .await + .unwrap(); + } + assert_eq!( + recorded_paths(&repositories).await, + ["/worktrees/feature-a"] + ); + } + + #[tokio::test] + async fn worktree_creation_record_of_a_recreation_replaces_the_earlier_one() { + let repositories = migrated(Database::open_in_memory().await.unwrap()).await; + repositories + .record_worktree_creation("/repo", "/worktrees/feature-a") + .await + .unwrap(); + let first = repositories.list_worktree_creation_records().await.unwrap(); + repositories + .record_worktree_creation("/repo", "/worktrees/feature-a") + .await + .unwrap(); + let second = repositories.list_worktree_creation_records().await.unwrap(); + assert_eq!(second.len(), 1, "one record per path: {second:?}"); + assert_ne!( + first[0].record_id, second[0].record_id, + "a new creation is a new record" + ); + + repositories + .reap_worktree_creation_record(first[0].record_id) + .await + .unwrap(); + assert_eq!( + recorded_paths(&repositories).await, + ["/worktrees/feature-a"], + "reaping the earlier record keeps the later one" + ); + repositories + .reap_worktree_creation_record(second[0].record_id) + .await + .unwrap(); + assert!(recorded_paths(&repositories).await.is_empty()); + } + + #[tokio::test] + async fn worktree_creation_record_delete_removes_only_that_path() { + let repositories = migrated(Database::open_in_memory().await.unwrap()).await; + for (project, path) in [ + ("/repo/", "/worktrees/feature-a"), + ("/repo", "/worktrees/feature-b"), + ] { + repositories + .record_worktree_creation(project, path) + .await + .unwrap(); + } + // The worktree path alone names the record, however the project root is spelled. + repositories + .delete_worktree_creation_record("/worktrees/feature-a") + .await + .unwrap(); + assert_eq!( + recorded_paths(&repositories).await, + ["/worktrees/feature-b"] + ); + } + + #[tokio::test] + async fn incomplete_removal_paths_do_not_depend_on_the_project_spelling() { + let repositories = migrated(Database::open_in_memory().await.unwrap()).await; + for (owner, project, path) in [ + ("thread-a", "/repo/", "/worktrees/feature-a"), + ("thread-b", "/repo", "/worktrees/feature-b"), + ] { + repositories + .prepare_worktree_removal_receipt(WorktreeRemovalReceipt { + owner_thread_id: owner.to_owned(), + project_cwd: project.to_owned(), + worktree_path: path.to_owned(), + identity_nonce: format!("nonce-{owner}"), + state: "prepared".to_owned(), + created_at: String::new(), + updated_at: String::new(), + }) + .await + .unwrap(); + } + assert_eq!( + repositories + .list_incomplete_worktree_removal_paths() + .await + .unwrap(), + ["/worktrees/feature-a", "/worktrees/feature-b"] + ); + } +} + #[cfg(test)] mod frozen_session_unfreeze_tests { use super::*; diff --git a/apps/server/src/process/executable.rs b/apps/server/src/process/executable.rs index b894d9f91a..1858918af5 100644 --- a/apps/server/src/process/executable.rs +++ b/apps/server/src/process/executable.rs @@ -161,6 +161,9 @@ pub(crate) fn wrap_windows_batch_command( }) } +/// The longest command line `cmd.exe` accepts, in UTF-16 units. +pub(crate) const WINDOWS_BATCH_COMMAND_LINE_MAX_CHARS: usize = 8_191; + fn make_windows_batch_command_line( script: &Path, arguments: &[OsString], @@ -181,9 +184,30 @@ fn make_windows_batch_command_line( append_windows_batch_argument(&mut command, &argument.to_string_lossy())?; } command.push('"'); + // Counted after quoting, backslash doubling and the `%` guard: what cmd.exe receives. + let length = command.encode_utf16().count(); + if length > WINDOWS_BATCH_COMMAND_LINE_MAX_CHARS { + return Err(format!( + "The command is too long for a Windows terminal ({} of {} characters). Shorten the review prompt or pick a chat reviewer.", + with_thousands_separators(length), + with_thousands_separators(WINDOWS_BATCH_COMMAND_LINE_MAX_CHARS) + )); + } Ok(command.into()) } +fn with_thousands_separators(value: usize) -> String { + let digits = value.to_string(); + let mut grouped = String::with_capacity(digits.len() + digits.len() / 3); + for (index, digit) in digits.chars().enumerate() { + if index > 0 && (digits.len() - index).is_multiple_of(3) { + grouped.push(','); + } + grouped.push(digit); + } + grouped +} + fn append_windows_batch_argument(command: &mut String, argument: &str) -> Result<(), String> { if argument .chars() @@ -214,8 +238,9 @@ fn append_windows_batch_argument(command: &mut String, argument: &str) -> Result command.push('"'); } else if character == '%' { // Prevent `%VAR%` expansion with the same zero-length `%cd%` - // substring used by Rust's hardened batch launcher. - command.push_str("%%cd:~,%"); + // substring used by Rust's hardened batch launcher: `%%cd:~,` before + // the `%` itself reads back as one `%`. + command.push_str("%%cd:~,"); } backslashes = 0; } @@ -395,6 +420,65 @@ impl SpawnPlan { } } +#[cfg(test)] +mod windows_batch_command_line_tests { + use std::{ffi::OsString, path::Path}; + + use super::make_windows_batch_command_line; + + const SCRIPT: &str = r"C:\tools\review.cmd"; + + fn encode(arguments: &[String]) -> Result { + let arguments = arguments.iter().map(OsString::from).collect::>(); + make_windows_batch_command_line(Path::new(SCRIPT), &arguments) + .map(|line| line.into_string().expect("UTF-8 command line")) + } + + #[test] + fn windows_batch_command_line_refuses_over_8191_chars() { + // About 4,240 characters raw; a quoted argument doubles its trailing backslashes. + let prompt = format!("Code review requested from BiBCode. {}", "\\".repeat(4_200)); + let arguments = ["--permission-mode", "plan", "--", &prompt].map(str::to_owned); + let encoded = format!( + r#"/e:ON /v:OFF /d /c ""{SCRIPT}" --permission-mode plan -- "Code review requested from BiBCode. {}"""#, + "\\".repeat(8_400) + ); + let length = encoded.encode_utf16().count(); + assert!(prompt.chars().count() < 8_191 && length > 8_191); + let thousands = length / 1_000; + let units = length % 1_000; + assert_eq!( + encode(&arguments), + Err(format!( + "The command is too long for a Windows terminal ({thousands},{units:03} of 8,191 characters). Shorten the review prompt or pick a chat reviewer." + )) + ); + } + + /// `%%cd:~,%` reads back as one `%` on the `cmd /c` line, so `%PATH%` is never expanded. + #[test] + fn windows_batch_percent_is_escaped_once() { + assert_eq!( + encode(&["100% %PATH%".to_owned()]), + Ok(format!( + r#"/e:ON /v:OFF /d /c ""{SCRIPT}" "100%%cd:~,% %%cd:~,%PATH%%cd:~,%"""# + )) + ); + } + + #[test] + fn windows_batch_command_line_accepts_exactly_8191_chars() { + let fixed = format!(r#"/e:ON /v:OFF /d /c ""{SCRIPT}" "#).len() + 1; + let fits = "a".repeat(8_191 - fixed); + assert_eq!( + encode(std::slice::from_ref(&fits)).map(|line| line.len()), + Ok(8_191) + ); + let over = format!("{fits}a"); + assert!(encode(&[over]).is_err()); + } +} + #[cfg(all(test, unix))] mod spawn_program_tests { use std::{fs, os::unix::fs::PermissionsExt, path::Path, process::Command}; diff --git a/apps/server/src/process/runner.rs b/apps/server/src/process/runner.rs index 1718f9c8fd..5abb6addf2 100644 --- a/apps/server/src/process/runner.rs +++ b/apps/server/src/process/runner.rs @@ -245,6 +245,7 @@ impl ProcessRunner { timeout: input.timeout, cleanup_timeout: Duration::from_secs(2), max_output_bytes: input.max_output_bytes, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: match input.output_mode { OutputMode::Error => SupervisedOverflow::Error, OutputMode::Truncate => SupervisedOverflow::Truncate, diff --git a/apps/server/src/process/supervised.rs b/apps/server/src/process/supervised.rs index 2fafba503f..0618e6fdb1 100644 --- a/apps/server/src/process/supervised.rs +++ b/apps/server/src/process/supervised.rs @@ -4,6 +4,7 @@ use std::{ io, path::Path, process::ExitStatus, + sync::Arc, time::{Duration, Instant}, }; @@ -19,6 +20,65 @@ use crate::diagnostics::profiler::exits::{self, Exited, Watch}; const PROCESS_CLEANUP_WAIT_TIMEOUT: Duration = Duration::from_secs(2); const PROCESS_OUTPUT_READ_BUFFER_BYTES: usize = 8 * 1024; +/// How long a process asked to stop gets before it is forced (R15, D13). +pub(crate) const GRACEFUL_STOP_WAIT: Duration = Duration::from_secs(2); + +/// How a run that must end early is stopped. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum StopPolicy { + /// Kill the ownership unit at once. + Force, + /// Send `SIGTERM` to the process group, wait up to [`GRACEFUL_STOP_WAIT`], then kill. + /// Git removes its own `index.lock` on `SIGTERM`. Windows offers no dependable request + /// to a process without a console (plan decision P4), so there it is [`Self::Force`]. + AskThenForce, +} + +/// How a run's process ended, sent once per spawned process after it has exited or the +/// cleanup gave up on it. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub(crate) struct TerminationReport { + /// The run ended early and the server stopped the process. + pub(crate) requested: bool, + /// The process was killed rather than stopping when asked. + pub(crate) forced: bool, + /// The process has exited. + pub(crate) exited: bool, + /// The [`TerminationReports::before_force`] answer, taken while the root still ran just + /// before it was forced to stop. `None` when nothing was checked. + pub(crate) before_force: Option, +} + +/// Where a run sends its [`TerminationReport`]. +#[derive(Clone)] +pub(crate) struct TerminationReports { + sender: tokio::sync::mpsc::UnboundedSender, + /// Asked about the root's process id just before a forced stop, while the root still runs. + before_force: Option bool + Send + Sync>>, +} + +impl TerminationReports { + pub(crate) fn new(sender: tokio::sync::mpsc::UnboundedSender) -> Self { + Self { + sender, + before_force: None, + } + } + + pub(crate) fn checking_before_force( + mut self, + check: impl Fn(u32) -> bool + Send + Sync + 'static, + ) -> Self { + self.before_force = Some(Arc::new(check)); + self + } +} + +fn send_report(reports: Option<&TerminationReports>, report: TerminationReport) { + if let Some(reports) = reports { + let _ = reports.sender.send(report); + } +} #[derive(Clone, Copy, Debug, Eq, PartialEq)] pub(crate) enum SupervisedOverflow { @@ -37,6 +97,7 @@ pub(crate) struct SupervisedRunRequest { pub(crate) cleanup_timeout: Duration, pub(crate) max_output_bytes: usize, pub(crate) overflow: SupervisedOverflow, + pub(crate) stop_policy: StopPolicy, } #[derive(Debug)] @@ -83,7 +144,18 @@ pub(crate) async fn run_supervised( request: SupervisedRunRequest, cancellation: &CancellationToken, ) -> Result { - run_supervised_with_observer(request, cancellation, None, |_| {}).await + run_supervised_with_observer(request, cancellation, None, None, |_| {}).await +} + +/// [`run_supervised`] that sends `reports` one [`TerminationReport`] for the process it spawns, +/// also when the run's future is dropped (then after the background stop has finished). A +/// run that spawns nothing sends nothing. +pub(crate) async fn run_supervised_reporting( + request: SupervisedRunRequest, + cancellation: &CancellationToken, + reports: Option, +) -> Result { + run_supervised_with_observer(request, cancellation, None, reports, |_| {}).await } pub(crate) async fn run_supervised_with_spawn_observer( @@ -94,7 +166,7 @@ pub(crate) async fn run_supervised_with_spawn_observer( where F: FnOnce(Option) + Send, { - run_supervised_with_observer(request, cancellation, None, observer).await + run_supervised_with_observer(request, cancellation, None, None, observer).await } #[cfg(test)] @@ -107,13 +179,14 @@ pub(crate) async fn run_supervised_with_spawn_observer_until( where F: FnOnce(Option) + Send, { - run_supervised_with_observer(request, cancellation, Some(deadline), observer).await + run_supervised_with_observer(request, cancellation, Some(deadline), None, observer).await } async fn run_supervised_with_observer( request: SupervisedRunRequest, cancellation: &CancellationToken, deadline: Option, + reports: Option, observer: F, ) -> Result where @@ -129,6 +202,7 @@ where cleanup_timeout, max_output_bytes, overflow, + stop_policy, } = request; let execution = SupervisedExecution { stdin, @@ -162,8 +236,14 @@ where // eventually produces so a late child never runs unsupervised. tokio::spawn(async move { if let Ok(Ok(child)) = spawn.await { - terminate_and_wait_owned(child, cleanup_timeout, "late supervised spawn") - .await; + terminate_and_wait_owned( + child, + cleanup_timeout, + "late supervised spawn", + false, + reports, + ) + .await; } }); return Err(SupervisedRunError::Timeout); @@ -174,7 +254,7 @@ where let child = spawn_retrying_busy_executable(&mut command, overall_deadline) .await .map_err(SupervisedRunError::Spawn)?; - let mut guard = SupervisedChildGuard::arm(child); + let mut guard = SupervisedChildGuard::arm_with(child, stop_policy, reports.clone()); let pid = guard.child().id(); observer(pid); @@ -185,6 +265,8 @@ where Some(overall_deadline), ) .await; + // Asked while the guard is armed, so a run dropped during the wait still stops the group. + let stopped_when_asked = outcome.is_err() && ask_to_stop(&mut guard, stop_policy).await; let child = guard.disarm(); if outcome.is_err() { // A failed run may already have reaped its root while a descendant @@ -192,7 +274,24 @@ where // descendant in the group keeps the group's id reserved, so the kill // reaches exactly the stragglers. Only a group with no members left // could have its id reused, which takes a PID wrap-around in between. - terminate_and_wait_owned(child, cleanup_timeout, "supervised process").await; + terminate_and_wait_owned( + child, + cleanup_timeout, + "supervised process", + stopped_when_asked, + reports, + ) + .await; + } else { + send_report( + reports.as_ref(), + TerminationReport { + requested: false, + forced: false, + exited: true, + before_force: None, + }, + ); } // A run that timed out, was cancelled or failed was stopped on request. if let (Some((program, spawned)), Some(pid), Ok(output)) = (&watched, pid, &outcome) { @@ -239,17 +338,40 @@ fn report_command_exit(program: &OsStr, pid: u32, status: &ExitStatus, started: /// reused. A run dropped after the root was reaped (while a descendant still /// holds the output pipes) is therefore not signalled. /// -/// `Drop` never waits, spawns, or blocks. The dropped child hands its root to -/// tokio's orphan reaper, and whichever process adopts the killed descendants -/// reaps them. Callers that must wait for cleanup to finish cancel the run's -/// token instead. +/// Under [`StopPolicy::Force`] without reports, `Drop` never waits, spawns, or +/// blocks. The dropped child hands its root to tokio's orphan reaper, and +/// whichever process adopts the killed descendants reaps them. Callers that +/// must wait for cleanup to finish cancel the run's token instead. +/// +/// Under [`StopPolicy::AskThenForce`], or when the run sends reports, `Drop` +/// sends `SIGTERM` at once and hands the wait, the forced stop and the report +/// to a background task on the current Tokio runtime. That task holds the +/// child in a `Force` guard, so it still kills the group if the runtime drops +/// it. Without a runtime, `Drop` kills at once as above. struct SupervisedChildGuard { child: Option>, + stop_policy: StopPolicy, + reports: Option, + /// When the stop already requested ends in a forced stop; set once `SIGTERM` was sent. + asked_until: Option, } impl SupervisedChildGuard { fn arm(child: Box) -> Self { - Self { child: Some(child) } + Self::arm_with(child, StopPolicy::Force, None) + } + + fn arm_with( + child: Box, + stop_policy: StopPolicy, + reports: Option, + ) -> Self { + Self { + child: Some(child), + stop_policy, + reports, + asked_until: None, + } } fn child(&self) -> &dyn ChildWrapper { @@ -266,6 +388,7 @@ impl SupervisedChildGuard { /// Hands the child to its next owner once the run has settled. fn disarm(mut self) -> Box { + self.reports = None; self.child .take() .expect("an armed supervised child guard owns its child") @@ -274,12 +397,101 @@ impl SupervisedChildGuard { impl Drop for SupervisedChildGuard { fn drop(&mut self) { - if let Some(child) = self.child.as_deref_mut() { - terminate_dropped_child(child); + let Some(mut child) = self.child.take() else { + return; + }; + let reports = self.reports.take(); + if child.id().is_none() { + // Reaped already: nothing to stop. + send_report( + reports.as_ref(), + TerminationReport { + requested: false, + forced: false, + exited: true, + before_force: None, + }, + ); + return; + } + let ask = self.stop_policy == StopPolicy::AskThenForce && cfg!(unix); + if (ask || reports.is_some()) + && let Ok(runtime) = tokio::runtime::Handle::try_current() + { + // A stop already requested keeps its deadline instead of asking again. + let asked_until = self.asked_until.or_else(|| { + (ask && signal_stop_request(&*child)) + .then(|| tokio::time::Instant::now() + GRACEFUL_STOP_WAIT) + }); + // Armed here, not in the task: a task dropped unpolled still kills the group. + runtime.spawn(stop_dropped_child( + SupervisedChildGuard::arm(child), + asked_until, + reports, + )); + return; + } + terminate_dropped_child(&mut *child); + } +} + +/// The background half of a dropped [`StopPolicy::AskThenForce`] or reporting run. +async fn stop_dropped_child( + mut guard: SupervisedChildGuard, + asked_until: Option, + reports: Option, +) { + let stopped_when_asked = match asked_until { + Some(deadline) => { + let left = deadline.saturating_duration_since(tokio::time::Instant::now()); + wait_for_exit(guard.child_mut(), left).await } + None => false, + }; + terminate_and_wait_owned( + guard.disarm(), + PROCESS_CLEANUP_WAIT_TIMEOUT, + "dropped supervised process", + stopped_when_asked, + reports, + ) + .await; +} + +/// Under [`StopPolicy::AskThenForce`] on Unix, asks the process group to stop and waits up to +/// [`GRACEFUL_STOP_WAIT`]. `true` when the root exited in time. +async fn ask_to_stop(guard: &mut SupervisedChildGuard, stop_policy: StopPolicy) -> bool { + if !(stop_policy == StopPolicy::AskThenForce + && cfg!(unix) + && signal_stop_request(guard.child())) + { + return false; + } + guard.asked_until = Some(tokio::time::Instant::now() + GRACEFUL_STOP_WAIT); + wait_for_exit(guard.child_mut(), GRACEFUL_STOP_WAIT).await +} + +/// Sends `SIGTERM` to the ownership unit. `false` when nothing could be asked. +fn signal_stop_request(child: &dyn ChildWrapper) -> bool { + #[cfg(unix)] + { + match child.signal(libc::SIGTERM) { + Ok(()) => true, + // The group is gone, so the root has exited: waiting reads its status at once. + Err(error) => kill_target_is_gone(&error), + } + } + #[cfg(not(unix))] + { + let _ = child; + false } } +async fn wait_for_exit(child: &mut dyn ChildWrapper, limit: Duration) -> bool { + matches!(tokio::time::timeout(limit, child.wait()).await, Ok(Ok(_))) +} + fn terminate_dropped_child(child: &mut dyn ChildWrapper) { if child.id().is_none() { return; @@ -487,16 +699,42 @@ async fn terminate_and_wait_with_timeout( report } +/// Kills the ownership unit and waits for the root, then sends `reports` one +/// [`TerminationReport`]. The kill also sweeps a group whose root already stopped when asked +/// (`stopped_when_asked`), for descendants that ignored the request. A root that outlives +/// the wait is reaped in the background, and the report follows that reap. async fn terminate_and_wait_owned( mut child: Box, wait_timeout: Duration, operation: &'static str, + stopped_when_asked: bool, + reports: Option, ) { + let before_force = if stopped_when_asked { + None + } else { + reports + .as_ref() + .and_then(|reports| reports.before_force.as_ref()) + .and_then(|check| child.id().map(|pid| check(pid))) + }; let mut report = ProcessCleanupReport::default(); request_termination(&mut *child, &mut report); + let stopped = move |exited| TerminationReport { + requested: true, + forced: !stopped_when_asked, + exited, + before_force, + }; match tokio::time::timeout(wait_timeout, child.wait()).await { - Ok(Ok(_)) => report.record_success(), - Ok(Err(error)) => report.record_failure(format!("wait: {error}")), + Ok(Ok(_)) => { + report.record_success(); + send_report(reports.as_ref(), stopped(true)); + } + Ok(Err(error)) => { + report.record_failure(format!("wait: {error}")); + send_report(reports.as_ref(), stopped(false)); + } Err(_) => { report.record_failure(format!( "wait timed out after {} ms", @@ -506,8 +744,14 @@ async fn terminate_and_wait_owned( tokio::spawn(async move { let mut final_report = ProcessCleanupReport::default(); match child.wait().await { - Ok(_) => final_report.record_success(), - Err(error) => final_report.record_failure(format!("wait: {error}")), + Ok(_) => { + final_report.record_success(); + send_report(reports.as_ref(), stopped(true)); + } + Err(error) => { + final_report.record_failure(format!("wait: {error}")); + send_report(reports.as_ref(), stopped(false)); + } } if final_report.failure_count > 0 { log_cleanup_failures(operation, &final_report); @@ -1002,6 +1246,7 @@ mod tests { timeout, cleanup_timeout: PROCESS_CLEANUP_WAIT_TIMEOUT, max_output_bytes: 1024, + stop_policy: StopPolicy::Force, overflow: SupervisedOverflow::Error, } } @@ -1068,6 +1313,7 @@ mod tests { timeout: Duration::from_secs(5), cleanup_timeout: PROCESS_CLEANUP_WAIT_TIMEOUT, max_output_bytes: 1024, + stop_policy: StopPolicy::Force, overflow: SupervisedOverflow::Error, }, &cancellation, @@ -1352,7 +1598,14 @@ mod tests { .await .expect("reap the root and its process group"); - terminate_and_wait_owned(child, PROCESS_CLEANUP_WAIT_TIMEOUT, "supervised process").await; + terminate_and_wait_owned( + child, + PROCESS_CLEANUP_WAIT_TIMEOUT, + "supervised process", + false, + None, + ) + .await; let text = || String::from_utf8(log.0.lock().expect("captured log").clone()).expect("UTF-8 log"); @@ -1423,6 +1676,8 @@ mod tests { child, Duration::from_millis(10), "delayed-reap test process", + false, + None, ) .await; @@ -1487,6 +1742,29 @@ mod tests { assert_eq!(kill_calls.load(Ordering::SeqCst), 0); } + /// The background stop of a dropped ask-first run may never be polled, when its runtime + /// ends first. The group must still be killed then, as a plain dropped run is. + #[cfg(unix)] + #[test] + fn git_stop_a_dropped_run_is_killed_even_if_its_background_stop_never_runs() { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("runtime"); + let (child, kill_calls, _wait_calls) = runtime.block_on(async { tracking_child(false) }); + + runtime.block_on(async { + drop(SupervisedChildGuard::arm_with( + child, + StopPolicy::AskThenForce, + None, + )); + }); + drop(runtime); + + assert_eq!(kill_calls.load(Ordering::SeqCst), 1); + } + #[test] fn a_guard_without_a_root_id_is_not_signalled() { let (child, kill_calls, wait_calls) = pending_child(None); @@ -1549,6 +1827,7 @@ mod tests { timeout: Duration::from_secs(30), cleanup_timeout: PROCESS_CLEANUP_WAIT_TIMEOUT, max_output_bytes: 1024, + stop_policy: StopPolicy::Force, overflow: SupervisedOverflow::Error, }; let cancellation = CancellationToken::new(); @@ -1691,6 +1970,7 @@ mod tests { timeout, cleanup_timeout: PROCESS_CLEANUP_WAIT_TIMEOUT, max_output_bytes: 1024, + stop_policy: StopPolicy::Force, overflow: SupervisedOverflow::Truncate, } } @@ -1741,6 +2021,7 @@ mod tests { timeout: Duration::from_secs(5), cleanup_timeout: PROCESS_CLEANUP_WAIT_TIMEOUT, max_output_bytes: 1024, + stop_policy: StopPolicy::Force, overflow: SupervisedOverflow::Error, }, &cancellation, @@ -1749,4 +2030,236 @@ mod tests { .expect_err("closed stdin should fail"); assert!(matches!(error, SupervisedRunError::Stdin(_))); } + + /// Runs `request` with its stop report and cancels it once `ready` exists. Returns the + /// outcome, the report and the time from the cancellation to the outcome. + async fn cancel_when_ready( + request: SupervisedRunRequest, + ready: &std::path::Path, + ) -> ( + Result, + TerminationReport, + Duration, + ) { + let cancellation = CancellationToken::new(); + let (report_sender, mut report) = tokio::sync::mpsc::unbounded_channel(); + let run = run_supervised_reporting( + request, + &cancellation, + Some(TerminationReports::new(report_sender)), + ); + tokio::pin!(run); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + tokio::select! { + () = async { + while !ready.exists() { + assert!(tokio::time::Instant::now() < deadline, "the child never started"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } => {} + result = &mut run => panic!("the child must still be running, got {result:?}"), + } + let cancelled = Instant::now(); + cancellation.cancel(); + let outcome = run.await; + let elapsed = cancelled.elapsed(); + let report = tokio::time::timeout(Duration::from_secs(10), report.recv()) + .await + .expect("the report arrives") + .expect("the report is sent"); + (outcome, report, elapsed) + } + + #[cfg(unix)] + fn stop_policy_request( + script: &str, + marker: &std::path::Path, + policy: StopPolicy, + ) -> SupervisedRunRequest { + let mut command = Command::new("sh"); + command + .args(["-c", script, "fixture"]) + .arg(marker) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + SupervisedRunRequest { + command, + stdin: None, + timeout: Duration::from_secs(30), + cleanup_timeout: PROCESS_CLEANUP_WAIT_TIMEOUT, + max_output_bytes: 1024, + overflow: SupervisedOverflow::Truncate, + stop_policy: policy, + } + } + + /// Stops when asked: the trap removes the marker, as Git removes its lock. Git cleans up on + /// `SIGPIPE` too, and so does this shell: a cancelled run has closed the output pipes, and + /// the shell's notice about its killed `sleep` would otherwise end it before the trap ran. + #[cfg(unix)] + const STOPS_WHEN_ASKED: &str = + "trap 'rm -f \"$1\"; exit 0' TERM PIPE; touch \"$1\"; while :; do sleep 0.05; done"; + /// Ignores the request: the group ignores TERM, and only a forced stop ends it. + #[cfg(unix)] + const IGNORES_THE_REQUEST: &str = "trap '' TERM; echo $$ > \"$1\"; exec sleep 30"; + + #[cfg(unix)] + #[tokio::test] + async fn git_stop_asks_first_and_the_child_exits_cleanly() { + let directory = tempfile::tempdir().expect("marker folder"); + let marker = directory.path().join("lock"); + + let (outcome, _, elapsed) = cancel_when_ready( + stop_policy_request(STOPS_WHEN_ASKED, &marker, StopPolicy::AskThenForce), + &marker, + ) + .await; + + assert!(matches!(outcome, Err(SupervisedRunError::Cancelled))); + assert!(!marker.exists(), "the child removed its marker when asked"); + assert!(elapsed < GRACEFUL_STOP_WAIT, "stopped in {elapsed:?}"); + } + + #[cfg(unix)] + #[tokio::test] + async fn git_stop_forces_after_two_seconds_when_ignored() { + let directory = tempfile::tempdir().expect("marker folder"); + let marker = directory.path().join("pid"); + + let (outcome, _, elapsed) = cancel_when_ready( + stop_policy_request(IGNORES_THE_REQUEST, &marker, StopPolicy::AskThenForce), + &marker, + ) + .await; + + assert!(matches!(outcome, Err(SupervisedRunError::Cancelled))); + assert!(elapsed >= GRACEFUL_STOP_WAIT, "forced after {elapsed:?}"); + let pid = std::fs::read_to_string(&marker) + .expect("child PID") + .trim() + .parse::() + .expect("numeric PID"); + // SAFETY: signal 0 only checks whether the reaped fixture PID still names a process. + let alive = unsafe { libc::kill(pid, 0) } == 0; + assert!(!alive, "the ignoring child is gone"); + } + + #[tokio::test] + async fn git_stop_default_policy_still_forces_at_once() { + let directory = tempfile::tempdir().expect("marker folder"); + let marker = directory.path().join("ready"); + #[cfg(unix)] + let request = stop_policy_request(STOPS_WHEN_ASKED, &marker, StopPolicy::Force); + #[cfg(windows)] + let request = { + let mut command = Command::new("cmd"); + command + .arg("/C") + .raw_arg(format!( + "type nul > \"{}\" & ping -n 30 127.0.0.1 > nul", + marker.display() + )) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + SupervisedRunRequest { + command, + stdin: None, + timeout: Duration::from_secs(30), + cleanup_timeout: PROCESS_CLEANUP_WAIT_TIMEOUT, + max_output_bytes: 1024, + overflow: SupervisedOverflow::Truncate, + stop_policy: StopPolicy::Force, + } + }; + + let (outcome, report, elapsed) = cancel_when_ready(request, &marker).await; + + assert!(matches!(outcome, Err(SupervisedRunError::Cancelled))); + assert!(elapsed < GRACEFUL_STOP_WAIT, "forced in {elapsed:?}"); + assert!(report.forced, "{report:?}"); + } + + /// A run dropped while it waits for an asked child keeps the first deadline: the forced + /// stop comes 2 seconds after the first request, not 2 seconds after the drop. + #[cfg(unix)] + #[tokio::test] + async fn git_stop_a_run_dropped_while_asking_keeps_the_first_deadline() { + let directory = tempfile::tempdir().expect("marker folder"); + let marker = directory.path().join("pid"); + let cancellation = CancellationToken::new(); + let (report_sender, mut report) = tokio::sync::mpsc::unbounded_channel(); + let mut run = Box::pin(run_supervised_reporting( + stop_policy_request(IGNORES_THE_REQUEST, &marker, StopPolicy::AskThenForce), + &cancellation, + Some(TerminationReports::new(report_sender)), + )); + let deadline = tokio::time::Instant::now() + Duration::from_secs(10); + tokio::select! { + () = async { + while !marker.exists() { + assert!(tokio::time::Instant::now() < deadline, "the child never started"); + tokio::time::sleep(Duration::from_millis(10)).await; + } + } => {} + result = &mut run => panic!("the child must still be running, got {result:?}"), + } + + let cancelled = Instant::now(); + cancellation.cancel(); + let waiting = tokio::time::timeout(Duration::from_millis(1500), &mut run).await; + drop(run); + let report = tokio::time::timeout(Duration::from_secs(10), report.recv()) + .await + .expect("the report arrives") + .expect("the report is sent"); + let elapsed = cancelled.elapsed(); + + assert!(waiting.is_err(), "the run was still asking when dropped"); + assert!(report.forced, "{report:?}"); + // 1 second of margin for the kill and the reap; a fresh wait after the drop takes 3.5 s. + assert!( + elapsed < GRACEFUL_STOP_WAIT + Duration::from_secs(1), + "forced {elapsed:?} after the first request" + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn git_stop_reports_whether_the_stop_was_forced() { + let directory = tempfile::tempdir().expect("marker folder"); + let asked = directory.path().join("asked"); + let ignored = directory.path().join("ignored"); + + let (_, stopped_when_asked, _) = cancel_when_ready( + stop_policy_request(STOPS_WHEN_ASKED, &asked, StopPolicy::AskThenForce), + &asked, + ) + .await; + let (_, forced, _) = cancel_when_ready( + stop_policy_request(IGNORES_THE_REQUEST, &ignored, StopPolicy::AskThenForce), + &ignored, + ) + .await; + + assert_eq!( + stopped_when_asked, + TerminationReport { + requested: true, + forced: false, + exited: true, + before_force: None + } + ); + assert_eq!( + forced, + TerminationReport { + requested: true, + forced: true, + exited: true, + before_force: None + } + ); + } } diff --git a/apps/server/src/production/agent_sessions_rpc.rs b/apps/server/src/production/agent_sessions_rpc.rs index dc272fe17d..0cbd224a9f 100644 --- a/apps/server/src/production/agent_sessions_rpc.rs +++ b/apps/server/src/production/agent_sessions_rpc.rs @@ -950,6 +950,7 @@ async fn import_session( branch: host.and_then(|host| host.branch.clone()), worktree_path: host.and_then(|host| host.worktree_path.clone()), created_at: created_at.clone(), + code_review: false, }; let ImportTarget::Panel(panel) = request.target else { dispatch( @@ -1389,6 +1390,7 @@ mod tests { branch: None, worktree_path: None, created_at: "2026-10-01T00:00:00Z".to_owned(), + code_review: false, }) .await .unwrap(); diff --git a/apps/server/src/production/control.rs b/apps/server/src/production/control.rs index 3ae1b0fe09..482b453e49 100644 --- a/apps/server/src/production/control.rs +++ b/apps/server/src/production/control.rs @@ -588,6 +588,18 @@ impl NativeServerControl { "settings patch must be an object", )); } + // Checked on the patch only, so a prompt stored before the rule still loads (#293). + if patch + .get("codeReviewPrompt") + .and_then(Value::as_str) + .is_some_and(crate::orchestration::engine::code_review_prompt_has_control_character) + { + return Err(settings_error( + &self.settings_path, + "normalize", + "codeReviewPrompt holds a control character other than tab or a line break", + )); + } if let Some(raw) = patch.get("worktreeBaseDirectory").and_then(Value::as_str) { let normalized = super::worktree_workspace::normalize_worktree_workspace(raw) .await @@ -722,6 +734,28 @@ impl NativeServerControl { *current = next_mcp_server; true }); + // An instance this build cannot run is known without a probe: publish it first, so + // no client sees it in settings with no snapshot, as if still being probed (#347). + let unsupported = { + let current = commit_control.providers.read().await; + provider_inventory::unsupported_instances(&next) + .into_iter() + .filter(|result| { + !current.iter().any(|row| { + row.get("instanceId") == result.snapshot.get("instanceId") + && row.get("availability") == Some(&json!("unavailable")) + }) + }) + .collect::>() + }; + if !unsupported.is_empty() { + let (providers, changed) = commit_control + .merge_provider_snapshots(unsupported, true) + .await; + if changed { + commit_control.publish_provider_snapshots(&providers); + } + } commit_control.publish(json!({ "version": 1, "type": "settingsUpdated", @@ -2196,7 +2230,26 @@ fn validate_settings_document(settings: &Value) -> Result<(), String> { validate_model_selection(selection, "textGenerationModelSelection")?; } if let Some(default_agent) = object.get("defaultAgent") { - validate_default_agent(default_agent)?; + validate_default_agent(default_agent, "defaultAgent")?; + } + if let Some(agent) = object.get("codeReviewAgent") { + validate_default_agent(agent, "codeReviewAgent")?; + } + if let Some(selection) = object.get("codeReviewModelSelection") + && !selection.is_null() + { + validate_model_selection(selection, "codeReviewModelSelection")?; + } + if let Some(prompt) = object.get("codeReviewPrompt") { + let max = crate::orchestration::engine::CODE_REVIEW_PROMPT_MAX_LENGTH; + if prompt + .as_str() + .is_none_or(|prompt| prompt.encode_utf16().count() > max) + { + return Err(format!( + "codeReviewPrompt must be a string of at most {max} characters" + )); + } } if let Some(providers) = object.get("providers") { validate_legacy_provider_settings(providers)?; @@ -2404,20 +2457,24 @@ fn validate_model_selection(selection: &Value, field: &str) -> Result<(), String Ok(()) } -fn validate_default_agent(value: &Value) -> Result<(), String> { +fn validate_default_agent(value: &Value, field: &str) -> Result<(), String> { let selection = value .as_object() - .ok_or_else(|| "defaultAgent must be an object".to_owned())?; + .ok_or_else(|| format!("{field} must be an object"))?; match selection.get("kind").and_then(Value::as_str) { Some("chat" | "terminal") => {} - _ => return Err("defaultAgent.kind must be either \"chat\" or \"terminal\"".to_owned()), + _ => { + return Err(format!( + "{field}.kind must be either \"chat\" or \"terminal\"" + )); + } } validate_slug( selection .get("instanceId") .and_then(Value::as_str) - .ok_or_else(|| "defaultAgent.instanceId is required".to_owned())?, - "defaultAgent.instanceId", + .ok_or_else(|| format!("{field}.instanceId is required"))?, + &format!("{field}.instanceId"), ) } @@ -2621,11 +2678,19 @@ fn apply_settings_defaults(settings: &mut Value) { "terminal": { "webglEnabled": true }, }), ); - settings - .as_object_mut() - .expect("settings object") + let object = settings.as_object_mut().expect("settings object"); + object .entry("defaultAgent") .or_insert_with(|| json!({ "kind": "chat", "instanceId": "codex" })); + object + .entry("codeReviewAgent") + .or_insert_with(|| json!({ "kind": "chat", "instanceId": "codex" })); + object + .entry("codeReviewModelSelection") + .or_insert(Value::Null); + object + .entry("codeReviewPrompt") + .or_insert_with(|| json!("")); } fn apply_settings_patch(target: &mut Value, patch: Value) { @@ -2638,6 +2703,8 @@ fn apply_settings_patch(target: &mut Value, patch: Value) { || key == "providerSessionDefaults" || key == "automaticGitFetchInterval" || key == "defaultAgent" + || key == "codeReviewAgent" + || key == "codeReviewModelSelection" { target.insert(key.clone(), value.clone()); continue; @@ -2833,9 +2900,13 @@ fn environment_descriptor(config: &ServerConfig, activity_protocol_registered: b "webhookBearerToken": true, "webhookStatusKinds": true, "sideChats": true, + "codeReviews": true, + "hostMetrics": true, "conversationOnlyRewind": true, "threadDeleteOnlyIfUnused": true, }); + // Outside the literal above, which is at `json!`'s recursion limit. + capabilities["terminalUserInputClaim"] = json!(true); // A flag is advertised only when every method it promises is registered. capabilities["pipelinesReads"] = json!(crate::production::pipelines_rpc::pipelines_reads_registered()); @@ -4414,6 +4485,65 @@ mod tests { assert_stale_manual_refresh_is_replaced_by_settings(true).await; } + /// #347: a chat on an instance whose driver this server does not ship must be explained as + /// unavailable, not left looking like an instance still being probed. Its snapshot is + /// published before the settings that add it, so no client ever sees it missing. + #[tokio::test] + async fn an_unsupported_driver_is_published_unavailable_before_the_settings_that_add_it() { + let temp = tempfile::tempdir().expect("state directory"); + let control = scheduler_control(&temp).await; + let mut events = control.config_events.subscribe(); + let quick_pause = control.install_next_quick_provider_probe_pause().await; + let update = { + let control = control.clone(); + tokio::spawn(async move { + control + .update_settings(json!({ + "patch": { + "providerInstances": { + "fork_agent": { "driver": "forkAgent", "enabled": true } + } + } + })) + .await + }) + }; + // The quick probe has not run yet. + quick_pause.wait_until_entered().await; + let published = std::iter::from_fn(|| events.try_recv().ok()).collect::>(); + let settings_at = published + .iter() + .position(|event| event["type"] == "settingsUpdated") + .expect("settings published"); + let unavailable = published[..settings_at] + .iter() + .filter(|event| event["type"] == "providerStatuses") + .filter_map(|event| event["payload"]["providers"].as_array()) + .flatten() + .find(|provider| provider["instanceId"] == "fork_agent") + .unwrap_or_else(|| panic!("no snapshot before the settings: {published:?}")); + assert_eq!(unavailable["availability"], "unavailable", "{unavailable}"); + assert_eq!(unavailable["enabled"], false, "{unavailable}"); + assert_eq!(unavailable["installed"], false, "{unavailable}"); + assert!( + unavailable["unavailableReason"] + .as_str() + .is_some_and(|reason| !reason.is_empty()), + "{unavailable}" + ); + quick_pause.release(); + update + .await + .expect("settings update joins") + .expect("settings update"); + let providers = control.providers.read().await.clone(); + let after = providers + .iter() + .find(|provider| provider["instanceId"] == "fork_agent") + .expect("still listed after the probe"); + assert_eq!(after["availability"], "unavailable"); + } + async fn assert_explicit_provider_refresh_publishes_unchanged_inventory(payload: Value) { let temp = tempfile::tempdir().expect("state directory"); let control = scheduler_control(&temp).await; @@ -5987,6 +6117,136 @@ mod tests { assert!(validate_settings_document(&settings).is_err()); } + #[test] + fn code_review_settings_default_when_missing() { + let mut settings = json!({}); + apply_settings_defaults(&mut settings); + assert_eq!( + settings["codeReviewAgent"], + json!({"kind":"chat","instanceId":"codex"}) + ); + assert_eq!(settings["codeReviewModelSelection"], Value::Null); + assert_eq!(settings["codeReviewPrompt"], ""); + assert!(validate_settings_document(&settings).is_ok()); + } + + #[tokio::test] + async fn code_review_settings_patch_replaces_model_whole() { + let temp = tempfile::tempdir().expect("control root"); + let config = ServerConfig::new(temp.path()); + hermetic_providers::write_hermetic_settings(&config.state_dir(), json!({})); + let control = NativeServerControl::new(config, json!({"policy":"test"})).await; + + control + .update_settings(json!({"patch":{ + "codeReviewAgent":{"kind":"terminal","instanceId":"claudeAgent"}, + "codeReviewModelSelection":{ + "instanceId":"claudeAgent","model":"opus", + "options":[{"id":"effort","value":"high"}] + }, + "codeReviewPrompt":"Look at tests." + }})) + .await + .expect("store review settings"); + let updated = control + .update_settings(json!({"patch":{ + "codeReviewModelSelection":{"instanceId":"codex","model":"gpt-5"} + }})) + .await + .expect("replace review model"); + assert_eq!( + updated["codeReviewModelSelection"], + json!({"instanceId":"codex","model":"gpt-5"}) + ); + assert_eq!( + updated["codeReviewAgent"], + json!({"kind":"terminal","instanceId":"claudeAgent"}) + ); + assert_eq!(updated["codeReviewPrompt"], "Look at tests."); + + let cleared = control + .update_settings(json!({"patch":{"codeReviewModelSelection":null}})) + .await + .expect("clear review model"); + assert_eq!(cleared["codeReviewModelSelection"], Value::Null); + } + + #[tokio::test] + async fn code_review_settings_reject_long_prompt_and_bad_agent() { + let temp = tempfile::tempdir().expect("control root"); + let config = ServerConfig::new(temp.path()); + hermetic_providers::write_hermetic_settings(&config.state_dir(), json!({})); + let control = NativeServerControl::new(config, json!({"policy":"test"})).await; + control + .update_settings(json!({"patch":{"codeReviewPrompt":"Kept."}})) + .await + .expect("store review prompt"); + let before = control.settings.read().await.clone(); + + // 7,680 UTF-16 units is the limit; "é" is one unit, so this is 7,681. + let long = format!("{}é", "a".repeat(7_680)); + for patch in [ + json!({"codeReviewPrompt": long}), + json!({"codeReviewPrompt": 7}), + json!({"codeReviewAgent":{"kind":"other"}}), + json!({"codeReviewModelSelection":{"instanceId":"codex"}}), + ] { + assert!( + control + .update_settings(json!({ "patch": patch })) + .await + .is_err(), + "{patch} must be rejected" + ); + assert_eq!(*control.settings.read().await, before); + } + control + .update_settings(json!({"patch":{"codeReviewPrompt": "😀".repeat(3_840)}})) + .await + .expect("7,680 UTF-16 units fit"); + } + + /// #293: the review prompt holds no control character but tab, line feed and carriage + /// return (C0, DEL and C1, as the web checks); a stored one from before the rule loads. + #[tokio::test] + async fn code_review_prompt_rejects_control_characters_on_write_only() { + let temp = tempfile::tempdir().expect("control root"); + let config = ServerConfig::new(temp.path()); + hermetic_providers::write_hermetic_settings( + &config.state_dir(), + json!({"codeReviewPrompt": "Old\u{1b}text"}), + ); + let control = NativeServerControl::new(config, json!({"policy":"test"})).await; + assert_eq!( + control.settings.read().await["codeReviewPrompt"], + "Old\u{1b}text", + "a stored prompt from before the rule still loads" + ); + control + .update_settings(json!({"patch":{"enableProfiler": false}})) + .await + .expect("an old prompt does not block other updates"); + for control_character in [ + '\u{0}', '\u{8}', '\u{b}', '\u{1b}', '\u{7f}', '\u{85}', '\u{9f}', + ] { + let error = control + .update_settings(json!({ + "patch":{"codeReviewPrompt": format!("a{control_character}b")} + })) + .await + .expect_err("a control character is rejected"); + assert!( + error.to_string().contains("control character"), + "{control_character:?}: {error}" + ); + } + let updated = control + .update_settings(json!({"patch":{"codeReviewPrompt": "Kept\tline\r\nnext\u{a0}"}})) + .await + .expect("tab, line breaks and U+00A0 are allowed"); + assert_eq!(updated["codeReviewPrompt"], "Kept\tline\r\nnext\u{a0}"); + } + #[tokio::test] async fn default_agent_update_persists_as_a_whole_selection() { let temp = tempfile::tempdir().expect("control root"); @@ -7325,6 +7585,7 @@ mod tests { assert_eq!(descriptor["capabilities"]["remoteUpdateProgress"], true); assert_eq!(descriptor["capabilities"]["terminalOrderedInput"], true); assert_eq!(descriptor["capabilities"]["terminalSizeOwnership"], true); + assert_eq!(descriptor["capabilities"]["terminalUserInputClaim"], true); assert_eq!(descriptor["capabilities"]["previewTunnels"], true); assert_eq!(descriptor["capabilities"]["terminalImagePaste"], true); assert_eq!(descriptor["capabilities"]["pullRequestCreateOptions"], true); @@ -7334,6 +7595,8 @@ mod tests { assert_eq!(descriptor["capabilities"]["webhookBearerToken"], true); assert_eq!(descriptor["capabilities"]["webhookStatusKinds"], true); assert_eq!(descriptor["capabilities"]["sideChats"], true); + assert_eq!(descriptor["capabilities"]["codeReviews"], true); + assert_eq!(descriptor["capabilities"]["hostMetrics"], true); assert_eq!(descriptor["capabilities"]["threadDeleteOnlyIfUnused"], true); // `server.getConfig` carries this descriptor; a client downloads a remote // environment's logs only when it says so (#194). diff --git a/apps/server/src/production/git_manager_rpc.rs b/apps/server/src/production/git_manager_rpc.rs index f6ed39526b..f0ba6467f9 100644 --- a/apps/server/src/production/git_manager_rpc.rs +++ b/apps/server/src/production/git_manager_rpc.rs @@ -36,8 +36,8 @@ use crate::{ tags::{self, GitManagerTagError}, }, sign_in::{ - CredentialPrompt, Destination, SIGN_IN_TIMEOUT, SignInDirection, SignInOutcome, - SignInRefusal, SignInSlot, resolve_destination, + CredentialPrompt, Destination, ExecutionPlace, SIGN_IN_TIMEOUT, SignInDirection, + SignInOutcome, SignInRefusal, SignInSlot, resolve_destination, }, validate_pathspecs, }, @@ -107,6 +107,7 @@ impl GitManagerRpcServices { trash, pull_requests: PullRequestService::default(), sign_in: None, + place: ExecutionPlace::default(), } } } @@ -122,6 +123,8 @@ pub struct ConfiguredGitManagerRpcServices { pull_requests: PullRequestService, /// Set only on the desktop's in-process server (#325). sign_in: Option, + /// Where this server's Git runs, for a failed push's guidance (#343). + place: ExecutionPlace, } /// `git.signIn`'s server state: where its neutral repositories go, the single slot, the cap. @@ -168,6 +171,7 @@ impl Default for ConfiguredGitManagerRpcServices { trash: Arc::new(NativeFileTrash::default()), pull_requests: PullRequestService::default(), sign_in: None, + place: ExecutionPlace::default(), } } } @@ -185,6 +189,13 @@ impl ConfiguredGitManagerRpcServices { self } + /// Where this server's Git runs (#343), built once from the server configuration. + #[must_use] + pub fn with_execution_place(mut self, place: ExecutionPlace) -> Self { + self.place = place; + self + } + /// Turns on Git Credential Manager sign-in (#325). Only the desktop's in-process server /// calls this, from `ServerConfig::interactive_git_sign_in`; neutral sign-in repositories /// are created under `state_dir`. @@ -469,6 +480,7 @@ impl ConfiguredGitManagerRpcServices { catalog, input, prompt, + services.place.clone(), operation_cancellation.clone(), ); tokio::pin!(operation_future); @@ -2439,6 +2451,7 @@ mod tests { updated_at: "2026-08-31T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project projection"); @@ -2534,6 +2547,7 @@ mod tests { updated_at: "2026-08-31T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project projection"); diff --git a/apps/server/src/production/git_vcs.rs b/apps/server/src/production/git_vcs.rs index 646d0bd319..22593dcd22 100644 --- a/apps/server/src/production/git_vcs.rs +++ b/apps/server/src/production/git_vcs.rs @@ -20,15 +20,15 @@ use crate::{ git::{ ChangeRequest, CloneLeaf, CreateWorktreeInput, GitCommandError, GitProcessRunner, GitRepository, GitStatusSummaryService, OutputPolicy, ProcessRequest, ProcessRunner, - STATUS_SAFETY_INTERVAL, StatusBroadcaster, StatusReadFence, VcsStatusLocalResult, - VcsStatusRemoteResult, VcsStatusStreamEvent, clone_destination_leaf, + STATUS_SAFETY_INTERVAL, StatusBroadcaster, StatusMutationGuard, StatusReadFence, + VcsStatusLocalResult, VcsStatusRemoteResult, VcsStatusStreamEvent, clone_destination_leaf, clone_policy::{ ClonePlan, ClonePolicy, CloneRefusal, DESTINATION_REFUSED, DETACH_REFUSED, DETACHED, NOT_OWNED, OTHERS_CLONE, OUTSIDE_PROJECTS, }, sign_in::{ - CredentialPrompt, Destination, SignInDirection, SignInRequired, has_gcm_refusal, - resolve_destination, upstream_remote, + CredentialPrompt, Destination, ExecutionPlace, SignInDirection, SignInRequired, + has_gcm_refusal, resolve_destination, upstream_remote, }, validate_pathspecs, }, @@ -52,6 +52,9 @@ use super::git_manager_rpc::credential_prompt; use super::host_paths::resolve_host_directory; const STREAM_CAPACITY: usize = 8; +/// How long `vcs.refreshStatus` waits for the pull-request lookup, well under the 10 second +/// hang budget. A slower lookup runs on for the status subscribers, if there are any. +const REFRESH_PULL_REQUEST_LOOKUP_BOUND: Duration = Duration::from_secs(2); #[derive(Clone, Default)] pub(crate) struct WorktreeRemovalTaskTracker { @@ -269,6 +272,8 @@ pub struct GitVcsRpcServices { /// Set only on the desktop's in-process server: the desktop app's own session may be offered a /// Git Credential Manager sign-in (#325). interactive_git_sign_in: bool, + /// Where this server's Git runs, for a failed push's guidance (#343). + place: ExecutionPlace, #[cfg(test)] status_stream_enrichment_test_hook: Option>, } @@ -426,6 +431,7 @@ impl GitVcsRpcServices { clone_policy: ClonePolicy::default(), mcp_settings: None, interactive_git_sign_in: false, + place: ExecutionPlace::default(), #[cfg(test)] status_stream_enrichment_test_hook: None, } @@ -528,6 +534,13 @@ impl GitVcsRpcServices { self } + /// Where this server's Git runs (#343), built once from the server configuration. + #[must_use] + pub fn with_execution_place(mut self, place: ExecutionPlace) -> Self { + self.place = place; + self + } + #[must_use] pub fn with_availability_registry( mut self, @@ -887,15 +900,66 @@ impl GitVcsRpcServices { .map_err(serialize_error)?; let fence = publication.fence; let mut status = publication.value; + let cwd = tokio::fs::canonicalize(&input.cwd) + .await + .unwrap_or_else(|_| input.cwd.clone()); let status = finish_fenced_enrichment(&self.broadcaster, fence, async { - enrich_remote_pull_request( - &self.pull_requests, - &input.cwd, - &status.local, - &mut status.remote, - &cancellation, - ) - .await; + let ref_name = status.local.ref_name.clone(); + // Owned, so the lookup can outlive the refresh with its lease intact. + let mut lookup = Box::pin({ + let pull_requests = self.pull_requests.clone(); + let lookup_cwd = input.cwd.clone(); + let local = status.local.clone(); + async move { + let cancellation = CancellationToken::new(); + lookup_open_pull_request( + &pull_requests, + &lookup_cwd, + &local, + &cancellation, + ) + .await + } + }); + let found = tokio::select! { + () = cancellation.cancelled() => Ok(None), + found = tokio::time::timeout( + REFRESH_PULL_REQUEST_LOOKUP_BOUND, + &mut lookup, + ) => found, + }; + status.remote.pr = match found { + Ok(Some(pull_request)) => { + self.broadcaster.record_pull_request( + &cwd, + ref_name.as_deref(), + pull_request.clone(), + ); + pull_request + } + Ok(None) => self + .broadcaster + .known_pull_request(&cwd, ref_name.as_deref()), + Err(_) => { + // Only subscribers need the late answer, so the lookup keeps its + // lease while the repository has one and then delivers to them. + let broadcaster = self.broadcaster.clone(); + let late_cwd = cwd.clone(); + let late_ref = ref_name.clone(); + self.broadcaster.spawn_for_subscribers(&cwd, async move { + if let Some(pull_request) = lookup.await { + broadcaster.record_pull_request( + &late_cwd, + late_ref.as_deref(), + pull_request, + ); + broadcaster.notify_pull_request_change(&late_cwd).await; + } + }); + self.broadcaster + .known_pull_request(&cwd, ref_name.as_deref()) + } + }; status }) .await @@ -1422,9 +1486,37 @@ impl GitVcsRpcServices { return; } }; - let mut enrichment: Option<(CancellationToken, tokio::task::JoinHandle<()>)> = None; - // The remote and branch the latest enrichment was started for. - let mut enriching: Option<(VcsStatusRemoteResult, Option)> = None; + let cwd = tokio::fs::canonicalize(&input.cwd) + .await + .unwrap_or_else(|_| input.cwd.clone()); + let start_lookup = |local: VcsStatusLocalResult| { + let cancellation = operation_cancellation.child_token(); + let task_token = cancellation.clone(); + let task_pull_requests = pull_requests.clone(); + let task_cwd = input.cwd.clone(); + let ref_name = local.ref_name.clone(); + #[cfg(test)] + let task_test_hook = enrichment_test_hook.clone(); + let task = tokio::spawn(async move { + #[cfg(test)] + if let Some(hook) = &task_test_hook { + hook.block(&task_token).await; + } + if task_token.is_cancelled() { + return None; + } + lookup_open_pull_request(&task_pull_requests, &task_cwd, &local, &task_token) + .await + }); + StatusStreamEnrichment { + cancellation, + task, + ref_name, + look_again: None, + } + }; + let mut enrichment: Option = None; + let mut shown = ShownStatus::default(); loop { tokio::select! { biased; @@ -1436,6 +1528,22 @@ impl GitVcsRpcServices { break; } _ = cancellation.cancelled() => break, + found = async { + (&mut enrichment.as_mut().expect("lookup in flight").task).await + }, if enrichment.is_some() => { + let finished = enrichment.take().expect("lookup in flight"); + if let Ok(Some(pull_request)) = found { + broadcaster.record_pull_request( + &cwd, + finished.ref_name.as_deref(), + pull_request, + ); + } + if !shown.reconcile(&sender, &broadcaster, &cwd).await { + break; + } + enrichment = finished.look_again.map(start_lookup); + } publication = subscription.recv_publication() => { let publication = match publication { Some(Ok(publication)) => publication, @@ -1445,73 +1553,43 @@ impl GitVcsRpcServices { } None => break, }; - let interrupted = enrichment - .as_ref() - .is_some_and(|(_, task)| !task.is_finished()); - stop_status_stream_enrichment(&mut enrichment).await; - // A local update on the same branch must not lose a lookup still in - // flight: its fence is stale now, so the lookup starts again. - let enrichment_remote = match &publication.value { - VcsStatusStreamEvent::Snapshot { remote, .. } - | VcsStatusStreamEvent::RemoteUpdated { remote } => remote.clone(), - VcsStatusStreamEvent::LocalUpdated { local } => enriching - .as_ref() - .filter(|(_, ref_name)| interrupted && *ref_name == local.ref_name) - .map(|(remote, _)| remote.clone()), - }; - if !send_status_stream_event( - &sender, - &broadcaster, - &publication.fence, - publication.value, + // A lookup for the same branch keeps running, so restarting it cannot + // starve a slow forge. Its answer is delivered under the newest fence the + // subscriber received. Any same-branch event may follow an invalidation + // that made that answer outdated, so one more lookup runs once it ends. + let mut looks_up = matches!( + &publication.value, + VcsStatusStreamEvent::Snapshot { remote: Some(_), .. } + | VcsStatusStreamEvent::RemoteUpdated { remote: Some(_) } ) - .await + .then(|| publication.local.clone()); + match enrichment.as_mut() { + Some(lookup) if lookup.ref_name == publication.local.ref_name => { + looks_up.take(); + lookup.look_again = Some(publication.local.clone()); + } + _ => stop_status_stream_enrichment(&mut enrichment).await, + } + let mut value = publication.value; + // A queued publication may predate a recorded lookup answer, so it shows + // the request `reconcile` would send next instead of flickering. + if let VcsStatusStreamEvent::Snapshot { remote: Some(remote), .. } + | VcsStatusStreamEvent::RemoteUpdated { + remote: Some(remote), + } = &mut value + { + remote.pr = broadcaster + .known_pull_request(&cwd, publication.local.ref_name.as_deref()); + } + if !shown + .send(&sender, &broadcaster, &publication.fence, value) + .await + || !shown.reconcile(&sender, &broadcaster, &cwd).await { break; } - if let Some(remote) = enrichment_remote { - let task_cancellation = operation_cancellation.child_token(); - let task_token = task_cancellation.clone(); - let task_sender = sender.clone(); - let task_broadcaster = broadcaster.clone(); - let task_pull_requests = pull_requests.clone(); - let task_cwd = input.cwd.clone(); - let task_local = publication.local; - let task_fence = publication.fence; - enriching = Some((remote.clone(), task_local.ref_name.clone())); - #[cfg(test)] - let task_test_hook = enrichment_test_hook.clone(); - let task = tokio::spawn(async move { - #[cfg(test)] - if let Some(hook) = &task_test_hook { - hook.block(&task_token).await; - } - if task_token.is_cancelled() { - return; - } - let mut enriched = remote.clone(); - enrich_remote_pull_request( - &task_pull_requests, - &task_cwd, - &task_local, - &mut enriched, - &task_token, - ) - .await; - if task_token.is_cancelled() || enriched == remote { - return; - } - let _ = send_status_stream_event( - &task_sender, - &task_broadcaster, - &task_fence, - VcsStatusStreamEvent::RemoteUpdated { - remote: Some(enriched), - }, - ) - .await; - }); - enrichment = Some((task_cancellation, task)); + if let Some(local) = looks_up { + enrichment = Some(start_lookup(local)); } } } @@ -1625,6 +1703,7 @@ impl GitVcsRpcServices { let availability = self.availability_registry.clone(); let trace_diagnostics = self.trace_diagnostics.clone(); let prompt = credential_prompt(self.interactive_git_sign_in, &context); + let place = self.place.clone(); tokio::spawn(async move { let started = tokio::time::Instant::now(); let payload = request.payload.clone(); @@ -1678,12 +1757,13 @@ impl GitVcsRpcServices { workspace_admission.as_ref(), operation_cancellation.clone(), async { - let mutation = broadcaster.begin_mutation(&input.cwd).await; + let mut mutation = broadcaster.begin_mutation(&input.cwd).await; let result = run_stacked_action( &repository, &pull_requests, &input, &operation_cancellation, + Some(&mut mutation), ) .await; mutation.finish().await; @@ -1733,7 +1813,11 @@ impl GitVcsRpcServices { Some(Err(note)) => { wire["detail"] = json!(format!("{detail} {note}")); } - None => {} + None => { + if let Some(guidance) = place.push_guidance(&detail) { + wire["detail"] = json!(guidance); + } + } } } result @@ -2881,19 +2965,30 @@ async fn run_stacked_action( pull_requests: &PullRequestService, input: &StackedActionInput, cancellation: &CancellationToken, + mutation: Option<&mut StatusMutationGuard>, ) -> Result { let mut phase = None; - run_stacked_action_steps(repository, pull_requests, input, cancellation, &mut phase) - .await - .map_err(|failure| StackedActionFailure { phase, ..failure }) + run_stacked_action_steps( + repository, + pull_requests, + input, + cancellation, + &mut phase, + mutation, + ) + .await + .map_err(|failure| StackedActionFailure { phase, ..failure }) } +/// `mutation`, when given, reopens status reads before the push and the pull-request +/// step, which wait on the network while the write lock stays held (#345). async fn run_stacked_action_steps( repository: &GitRepository, pull_requests: &PullRequestService, input: &StackedActionInput, cancellation: &CancellationToken, phase: &mut Option<&'static str>, + mut mutation: Option<&mut StatusMutationGuard>, ) -> Result { validate_stacked_action_input(input)?; let wants_commit = matches!( @@ -3117,6 +3212,9 @@ async fn run_stacked_action_steps( json!({ "status": "skipped_not_requested" }) }; let push = if wants_push { + if let Some(mutation) = &mut mutation { + mutation.local_writes_finished(); + } *phase = Some("push"); // One choice of push: a resumed push validates the very remote it then pushes to. let current_push = match selected_head { @@ -3200,6 +3298,9 @@ async fn run_stacked_action_steps( json!({ "status": "skipped_not_requested" }) }; let pull_request = if let Some(provider) = pull_request_provider { + if let Some(mutation) = &mut mutation { + mutation.local_writes_finished(); + } *phase = Some("pr"); let current_local = repository .local_status(&input.cwd, cancellation) @@ -3405,50 +3506,119 @@ async fn finish_fenced_enrichment( broadcaster.publish_if_fence_current(&fence, || value) } -async fn send_status_stream_event( - sender: &mpsc::Sender, - broadcaster: &StatusBroadcaster, - fence: &StatusReadFence, - event: VcsStatusStreamEvent, -) -> bool { - let chunk = serde_json::to_value(event) - .map(|event| vec![event]) - .map_err(|error| request_error("subscribeVcsStatus", &error.to_string())); - let permit = match sender.reserve().await { - Ok(permit) => permit, - Err(_) => return false, - }; - let _ = broadcaster.publish_if_fence_current(fence, || permit.send(chunk)); - true +/// The pull-request lookup a status stream has in flight, and the branch it is for. +struct StatusStreamEnrichment { + cancellation: CancellationToken, + task: tokio::task::JoinHandle>>, + ref_name: Option, + /// The newest status whose publication asked for a lookup while this one ran. + look_again: Option, } -async fn stop_status_stream_enrichment( - enrichment: &mut Option<(CancellationToken, tokio::task::JoinHandle<()>)>, -) { - if let Some((cancellation, task)) = enrichment.take() { - cancellation.cancel(); - task.abort(); - let _ = task.await; +async fn stop_status_stream_enrichment(enrichment: &mut Option) { + if let Some(enrichment) = enrichment.take() { + enrichment.cancellation.cancel(); + enrichment.task.abort(); + let _ = enrichment.task.await; + } +} + +/// What one status subscriber shows, folded from the events it received the way the +/// client's `applyGitStatusStreamEvent` folds them, and the fence of the newest one. +#[derive(Default)] +struct ShownStatus { + ref_name: Option>, + remote: Option, + fence: Option, +} + +impl ShownStatus { + /// Sends `event` while `fence` is current and drops it otherwise. Returns false once the + /// stream is closed. + async fn send( + &mut self, + sender: &mpsc::Sender, + broadcaster: &StatusBroadcaster, + fence: &StatusReadFence, + event: VcsStatusStreamEvent, + ) -> bool { + let chunk = serde_json::to_value(&event) + .map(|event| vec![event]) + .map_err(|error| request_error("subscribeVcsStatus", &error.to_string())); + let Ok(permit) = sender.reserve().await else { + return false; + }; + if broadcaster + .publish_if_fence_current(fence, || permit.send(chunk)) + .is_ok() + { + match event { + VcsStatusStreamEvent::Snapshot { local, remote } => { + self.ref_name = Some(local.ref_name); + self.remote = remote; + } + VcsStatusStreamEvent::LocalUpdated { local } => { + if self.ref_name.as_ref() != Some(&local.ref_name) { + self.remote = None; + } + self.ref_name = Some(local.ref_name); + } + VcsStatusStreamEvent::RemoteUpdated { remote } => self.remote = remote, + } + self.fence = Some(fence.clone()); + } + true + } + + /// Sends the pull request known for the shown branch when the subscriber shows another + /// one. A send under an old fence is dropped, so the next received event retries it. + async fn reconcile( + &mut self, + sender: &mpsc::Sender, + broadcaster: &StatusBroadcaster, + cwd: &std::path::Path, + ) -> bool { + let (Some(ref_name), Some(remote), Some(fence)) = + (&self.ref_name, &self.remote, self.fence.clone()) + else { + return true; + }; + let known = broadcaster.known_pull_request(cwd, ref_name.as_deref()); + if known == remote.pr { + return true; + } + let remote = VcsStatusRemoteResult { + pr: known, + ..remote.clone() + }; + self.send( + sender, + broadcaster, + &fence, + VcsStatusStreamEvent::RemoteUpdated { + remote: Some(remote), + }, + ) + .await } } -async fn enrich_remote_pull_request( +async fn lookup_open_pull_request( pull_requests: &PullRequestService, cwd: &std::path::Path, local: &VcsStatusLocalResult, - remote: &mut VcsStatusRemoteResult, cancellation: &CancellationToken, -) { +) -> Option> { let Some(reference) = local.ref_name.as_deref() else { - return; + return Some(None); }; let provider = local_provider_kind(local); if provider == ProviderKind::Unknown { - return; + return Some(None); } // Shared with the status stream and other refreshes for a few seconds; // see `PullRequestService::resolve_current_optional_shared`. - let Some(pull_request) = pull_requests + let pull_request = pull_requests .resolve_current_optional_shared( ResolvePullRequestInput { cwd: cwd.to_path_buf(), @@ -3458,20 +3628,19 @@ async fn enrich_remote_pull_request( cancellation, ) .await - .ok() - .flatten() - .filter(|pull_request| pull_request.state == ChangeRequestState::Open) - else { - return; - }; - remote.pr = Some(ChangeRequest { - number: pull_request.number, - title: pull_request.title, - url: pull_request.url, - base_ref: pull_request.base_branch, - head_ref: pull_request.head_branch, - state: "open".to_owned(), - }); + .ok()?; + Some( + pull_request + .filter(|pull_request| pull_request.state == ChangeRequestState::Open) + .map(|pull_request| ChangeRequest { + number: pull_request.number, + title: pull_request.title, + url: pull_request.url, + base_ref: pull_request.base_branch, + head_ref: pull_request.head_branch, + state: "open".to_owned(), + }), + ) } async fn local_branch_names( @@ -3906,6 +4075,9 @@ mod mutation_ownership_tests { blocked_operation: Option<&'static str>, mutation_started: Notify, release_mutation: Semaphore, + /// What the blocked operation returns once released, instead of success. + blocked_failure: Mutex>, + commits: std::sync::atomic::AtomicUsize, } struct SuccessfulGithubRunner; @@ -4020,6 +4192,8 @@ mod mutation_ownership_tests { blocked_operation: None, mutation_started: Notify::new(), release_mutation: Semaphore::new(0), + blocked_failure: Mutex::new(None), + commits: std::sync::atomic::AtomicUsize::new(0), } } @@ -4050,6 +4224,8 @@ mod mutation_ownership_tests { "GitVcsDriver.initRepo" | "GitVcsDriver.pushCurrentBranch" | "GitVcsDriver.pushCurrentBranchToRemote" + // Stands in for the commit becoming visible in status. + | "GitVcsDriver.commit" ); Box::pin(async move { if self.blocked_operation == Some(request.operation.as_str()) { @@ -4064,6 +4240,17 @@ mod mutation_ownership_tests { operation: request.operation, }); } + if let Some(failure) = self + .blocked_failure + .lock() + .expect("blocked failure lock") + .take() + { + return Err(failure); + } + } + if request.operation == "GitVcsDriver.commit" { + self.commits.fetch_add(1, Ordering::SeqCst); } if marks_working_tree_dirty { self.working_tree_dirty.store(true, Ordering::SeqCst); @@ -4090,6 +4277,10 @@ mod mutation_ownership_tests { (1, String::new()) } "GitVcsDriver.currentRef" => (0, "main\n".to_owned()), + "GitVcsDriver.prepareCommitContext.stagedSummary" => { + (0, "M\ttracked.txt\n".to_owned()) + } + "GitVcsDriver.commit.sha" => (0, "0123abc\n".to_owned()), "GitVcsDriver.defaultRef.candidate" => { let is_main = request .args @@ -4331,9 +4522,9 @@ mod mutation_ownership_tests { } #[tokio::test] - async fn stacked_push_runs_inside_the_shared_mutation_boundary() { + async fn stacked_push_keeps_the_write_lock_and_retires_stale_reads() { let root = tempfile::tempdir().expect("repository root"); - let runner = Arc::new(ControlledMutationRunner::new()); + let runner = Arc::new(ControlledMutationRunner::blocking(HELD_PUSH)); let repository = Arc::new(GitRepository::with_runner_for_test(runner.clone())); let services = GitVcsRpcServices::with_repository(repository, Arc::default()); let mut subscription = services @@ -4380,6 +4571,18 @@ mod mutation_ownership_tests { }, CancellationToken::new(), ); + runner.wait_for_mutation_start().await; + let mut second_write = services.stacked_action_stream_without_session( + stacked_request("3", root.path(), "commit"), + CancellationToken::new(), + ); + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!( + runner.commits.load(Ordering::SeqCst), + 0, + "a second write ran while the push held the write lock" + ); + runner.release_mutation.add_permits(1); let started = stream .recv() @@ -4395,6 +4598,11 @@ mod mutation_ownership_tests { assert_eq!(finished[0]["kind"], "action_finished"); assert_eq!(finished[0]["result"]["push"]["status"], "pushed"); assert!(stale.await.expect("stale read task").is_err()); + let second = action_end(&mut second_write) + .await + .expect("second write event"); + assert_eq!(second["kind"], "action_finished", "{second}"); + assert_eq!(runner.commits.load(Ordering::SeqCst), 1); let trailing = tokio::time::timeout(Duration::from_millis(200), async { loop { let event = subscription.recv().await?; @@ -4967,6 +5175,338 @@ mod mutation_ownership_tests { 1 ); } + + const HELD_PUSH: &str = "GitVcsDriver.pushCurrentBranch"; + /// Longer than any local status read and far below the 10 s hang budget. + const ANSWER_WHILE_HELD: Duration = Duration::from_secs(5); + + fn stacked_request(id: &str, cwd: &Path, action: &str) -> RpcRequest { + RpcRequest { + id: RequestId::try_from(id).expect("request id"), + tag: "git.runStackedAction".to_owned(), + payload: json!({ + "actionId": format!("action-{id}"), + "cwd": cwd, + "action": action, + "commitMessage": "test: held push", + }), + headers: Vec::new(), + trace_id: None, + span_id: None, + sampled: None, + } + } + + async fn refresh_within( + services: &GitVcsRpcServices, + cwd: &Path, + within: Duration, + context: &str, + ) -> RpcResult { + tokio::time::timeout( + within, + services.handle_unary_with_context( + request("vcs.refreshStatus", cwd), + RpcSessionContext::unauthenticated(), + CancellationToken::new(), + ), + ) + .await + .unwrap_or_else(|_| panic!("{context}: vcs.refreshStatus was not answered in time")) + } + + /// The action's terminal event, or the error that ended its stream. + async fn action_end(stream: &mut mpsc::Receiver) -> Result { + loop { + let values = stream + .recv() + .await + .expect("stacked stream ends with an event")?; + if let Some(event) = values.into_iter().find(|event| { + event["kind"] == "action_finished" || event["kind"] == "action_failed" + }) { + return Ok(event); + } + } + } + + fn held_push_services(runner: &Arc) -> GitVcsRpcServices { + let repository = Arc::new(GitRepository::with_runner_for_test(runner.clone())); + GitVcsRpcServices::with_repository(repository, Arc::default()) + } + + #[tokio::test] + async fn held_push_delivers_status_to_refresh_and_subscribers() { + let root = tempfile::tempdir().expect("repository root"); + let runner = Arc::new(ControlledMutationRunner::blocking(HELD_PUSH)); + let services = held_push_services(&runner); + let mut subscription = services + .broadcaster + .subscribe(root.path().to_path_buf(), CancellationToken::new()) + .await + .expect("initial status subscription"); + subscription.recv().await.expect("initial status snapshot"); + let mut stream = services.stacked_action_stream_without_session( + stacked_request("2", root.path(), "commit_push"), + CancellationToken::new(), + ); + runner.wait_for_mutation_start().await; + + let refreshed = refresh_within( + &services, + root.path(), + ANSWER_WHILE_HELD, + "held push, same worktree", + ) + .await + .expect("refresh succeeds during the held push"); + assert_eq!( + refreshed["hasWorkingTreeChanges"], true, + "the refresh reads the tree after the commit: {refreshed}" + ); + let published = tokio::time::timeout(ANSWER_WHILE_HELD, async { + loop { + if let VcsStatusStreamEvent::LocalUpdated { local } = + subscription.recv().await.expect("subscription stays open") + && local.has_working_tree_changes + { + return local; + } + } + }) + .await; + assert!( + published.is_ok(), + "a subscriber received no post-commit status during the held push" + ); + + runner.release_mutation.add_permits(1); + let ended = action_end(&mut stream).await.expect("action event"); + assert_eq!(ended["kind"], "action_finished", "{ended}"); + assert_eq!(ended["result"]["commit"]["status"], "created", "{ended}"); + assert_eq!(ended["result"]["push"]["status"], "pushed", "{ended}"); + } + + #[tokio::test] + async fn held_push_does_not_gate_another_worktree() { + let held = tempfile::tempdir().expect("held repository root"); + let other = tempfile::tempdir().expect("other repository root"); + let runner = Arc::new(ControlledMutationRunner::blocking(HELD_PUSH)); + let services = held_push_services(&runner); + let mut stream = services.stacked_action_stream_without_session( + stacked_request("2", held.path(), "commit_push"), + CancellationToken::new(), + ); + runner.wait_for_mutation_start().await; + + refresh_within( + &services, + other.path(), + ANSWER_WHILE_HELD, + "held push, other worktree", + ) + .await + .expect("the other worktree's refresh succeeds"); + + runner.release_mutation.add_permits(1); + action_end(&mut stream).await.expect("action event"); + } + + #[tokio::test] + async fn held_push_keeps_writes_serialized_and_the_commit_saved() { + let root = tempfile::tempdir().expect("repository root"); + let runner = Arc::new(ControlledMutationRunner::blocking(HELD_PUSH)); + let services = held_push_services(&runner); + let mut first = services.stacked_action_stream_without_session( + stacked_request("2", root.path(), "commit_push"), + CancellationToken::new(), + ); + runner.wait_for_mutation_start().await; + let mut second = services.stacked_action_stream_without_session( + stacked_request("3", root.path(), "commit"), + CancellationToken::new(), + ); + refresh_within( + &services, + root.path(), + ANSWER_WHILE_HELD, + "held push with a queued write", + ) + .await + .expect("refresh succeeds while a write is queued"); + tokio::time::sleep(Duration::from_millis(200)).await; + assert_eq!( + runner.commits.load(Ordering::SeqCst), + 1, + "the second write started before the held push ended" + ); + + *runner.blocked_failure.lock().expect("blocked failure lock") = + Some(ProcessError::NonZeroExit { + operation: HELD_PUSH.to_owned(), + exit_code: 1, + stdout_length: 0, + stderr_length: 13, + stdout: String::new().into_boxed_str(), + stderr: "push rejected".into(), + }); + runner.release_mutation.add_permits(1); + let ended = action_end(&mut first).await.expect("first action event"); + assert_eq!(ended["kind"], "action_failed", "{ended}"); + assert_eq!(ended["phase"], "push", "{ended}"); + let refreshed = refresh_within(&services, root.path(), ANSWER_WHILE_HELD, "after") + .await + .expect("refresh after the failed push"); + assert_eq!(refreshed["hasWorkingTreeChanges"], true, "{refreshed}"); + let second = action_end(&mut second).await.expect("second action event"); + assert_eq!(second["kind"], "action_finished", "{second}"); + assert_eq!(runner.commits.load(Ordering::SeqCst), 2); + } + + #[tokio::test] + async fn held_push_every_outcome_releases_readers() { + for outcome in [ + "success", + "refusal", + "timeout", + "cancellation", + "workspace loss", + ] { + let root = tempfile::tempdir().expect("repository root"); + let runner = Arc::new(ControlledMutationRunner::blocking(HELD_PUSH)); + let registry = WorkspaceAvailabilityRegistry::new(); + let services = held_push_services(&runner).with_availability_registry(registry.clone()); + let cancellation = CancellationToken::new(); + let mut stream = services.stacked_action_stream_without_session( + stacked_request("2", root.path(), "commit_push"), + cancellation.clone(), + ); + runner.wait_for_mutation_start().await; + *runner.blocked_failure.lock().expect("blocked failure lock") = match outcome { + "refusal" => Some(ProcessError::NonZeroExit { + operation: HELD_PUSH.to_owned(), + exit_code: 128, + stdout_length: 0, + stderr_length: 60, + stdout: String::new().into_boxed_str(), + stderr: "fatal: Authentication failed for 'https://gitlab.example.test/'" + .into(), + }), + "timeout" => Some(ProcessError::Timeout { + operation: HELD_PUSH.to_owned(), + timeout_ms: 600_000, + }), + _ => None, + }; + match outcome { + "cancellation" => cancellation.cancel(), + "workspace loss" => assert!( + registry + .mark_unavailable(crate::worktree_catalog::WorkspaceLossTransition { + thread_id: "thread-held-push".to_owned(), + repository_key: "repository-held-push".to_owned(), + generation: 1, + path: root.path().to_path_buf(), + availability: + crate::worktree_catalog::AdoptedWorktreeAvailability::MissingRegistered, + }) + .await + .expect("physical identity resolves") + ), + _ => {} + } + runner.release_mutation.add_permits(1); + let ended = action_end(&mut stream).await; + match outcome { + "success" => assert_eq!(ended.expect("event")["kind"], "action_finished"), + "workspace loss" => assert_eq!( + ended.expect_err("workspace loss ends the stream")["_tag"], + "WorkspaceUnavailableError" + ), + _ => { + let ended = ended.expect("event"); + assert_eq!(ended["kind"], "action_failed", "{outcome}: {ended}"); + if outcome == "timeout" { + assert_eq!(ended["message"], "Git command timed out.", "{ended}"); + } + } + } + + let refreshed = + refresh_within(&services, root.path(), ANSWER_WHILE_HELD, outcome).await; + if outcome == "workspace loss" { + let error = refreshed.expect_err("a lost workspace refuses the refresh"); + assert_eq!(error["_tag"], "WorkspaceUnavailableError", "{error}"); + } else { + refreshed.unwrap_or_else(|error| panic!("{outcome}: refresh failed: {error}")); + } + } + } + + #[cfg(unix)] + #[tokio::test] + async fn held_push_does_not_block_pull_request_lookups() { + use crate::pull_requests::{ + PullRequestsService, host::HostCommandRunner, model::VocabularyKind, + }; + + let sandbox = crate::test_support::TestSandbox::new("held-push-lookups"); + let cwd = sandbox.path("checkout"); + std::fs::create_dir(&cwd).expect("checkout directory"); + let repository_json = sandbox.path("github-repository.json"); + std::fs::write( + &repository_json, + include_str!("../../tests/fixtures/pull_requests/github_repository.json"), + ) + .expect("repository fixture"); + let gh = sandbox.executable_script( + "gh", + &format!( + "case \"$1 $2\" in\n '--version ') echo 'gh version 2.97.0' ;;\n 'auth status') echo '{{\"hosts\":{{\"github.com\":[{{\"state\":\"success\",\"active\":true,\"host\":\"github.com\",\"login\":\"test-user\"}}]}}}}' ;;\n 'api user') echo '{{\"login\":\"test-user\",\"name\":\"\"}}' ;;\n 'api repos/example/repository') cat '{}' ;;\n 'api repos/example/repository/labels?per_page=100') echo '[{{\"name\":\"bug\",\"color\":\"d73a4a\",\"description\":\"Bug fix\"}}]' ;;\n *) echo \"unexpected fixture command $*\" >&2; exit 64 ;;\nesac\n", + repository_json.display() + ), + "", + ); + let host_runner = HostCommandRunner::new(sandbox.path("state")).with_commands( + gh, + "missing-glab", + sandbox.executable_on_path("git"), + ); + let c = CancellationToken::new(); + for args in [ + &["init", "-q", "-b", "main"][..], + &[ + "remote", + "add", + "origin", + "https://github.com/example/repository.git", + ], + ] { + host_runner.git(&cwd, args, &c).await.expect("fixture git"); + } + let lookups = PullRequestsService::with_runner(host_runner); + let runner = Arc::new(ControlledMutationRunner::blocking(HELD_PUSH)); + let services = held_push_services(&runner); + let mut stream = services.stacked_action_stream_without_session( + stacked_request("2", &cwd, "commit_push"), + CancellationToken::new(), + ); + runner.wait_for_mutation_start().await; + + let (vocabulary, defaults) = tokio::time::timeout(ANSWER_WHILE_HELD, async { + tokio::join!( + lookups.vocabulary(&cwd, VocabularyKind::Labels, None, &c), + lookups.create_defaults(&cwd, &c), + ) + }) + .await + .expect("pull-request lookups answered while the push is held"); + assert_eq!(vocabulary.expect("vocabulary").entries.len(), 1); + defaults.expect("create defaults"); + + runner.release_mutation.add_permits(1); + action_end(&mut stream).await.expect("action event"); + } } #[cfg(all(test, windows))] @@ -5967,6 +6507,7 @@ esac &services.pull_requests, &invalid_action, &CancellationToken::new(), + None, ) .await .is_err() @@ -6094,13 +6635,128 @@ esac ); } - /// A reviewed pull request: the fixture repository tracks a local bare - /// remote for pushes while its fetch URL names GitHub so provider detection - /// resolves, and the `gh` stub records every argument it receives. + /// A create_pr action with no push: the provider holds the request creation open and + /// status for the worktree is still answered. #[cfg(unix)] #[tokio::test] - async fn create_pr_uses_the_reviewed_title_and_body_and_never_duplicates() { - let sandbox = crate::test_support::TestSandbox::new("git-vcs-reviewed-pr"); + async fn held_pull_request_step_without_a_push_delivers_status() { + let sandbox = crate::test_support::TestSandbox::new("git-vcs-held-pr-step"); + let repository = sandbox.root().join("repository"); + let bare_remote = sandbox.root().join("remote.git"); + for directory in [&repository, &bare_remote] { + tokio::fs::create_dir_all(directory) + .await + .expect("fixture directory"); + } + let bare_remote_text = bare_remote.to_string_lossy().into_owned(); + git(&sandbox, &bare_remote, &["init", "--bare", "-b", "main"]).await; + for args in [ + &["init", "-q", "-b", "main"][..], + &["config", "user.email", "fixture@example.test"], + &["config", "user.name", "Fixture"], + &["commit", "-q", "--allow-empty", "-m", "base"], + &["remote", "add", "origin", bare_remote_text.as_str()], + &["push", "-q", "-u", "origin", "main"], + &["switch", "-q", "-c", "feature/held"], + &["commit", "-q", "--allow-empty", "-m", "feature"], + &["push", "-q", "-u", "origin", "feature/held"], + &[ + "remote", + "set-url", + "origin", + "https://github.com/owner/name.git", + ], + &[ + "remote", + "set-url", + "--push", + "origin", + bare_remote_text.as_str(), + ], + ] { + git(&sandbox, &repository, args).await; + } + let creating = sandbox.root().join("pr-create-started"); + let release = sandbox.root().join("pr-create-release"); + let gh = sandbox.executable_script( + "gh", + &format!( + "case \"$1:$2\" in\n pr:list) printf '[]\\n' ;;\n pr:create) : > '{}'; while [ ! -f '{}' ]; do sleep 0.05; done; printf '%s\\n' 'https://github.com/owner/name/pull/7' ;;\n *) exit 64 ;;\nesac\n", + creating.display(), + release.display() + ), + "", + ); + let mut services = + GitVcsRpcServices::with_repository(Arc::new(GitRepository::default()), Arc::default()); + services.pull_requests = PullRequestService::with_provider_commands( + gh.to_string_lossy(), + "unused-glab", + "unused-az", + ); + let mut stream = services.stacked_action_stream_without_session( + rpc_request( + "git.runStackedAction", + json!({ + "actionId": "held-pr-step", + "cwd": repository, + "action": "create_pr", + "pullRequestTitle": "feature", + "pullRequestBaseBranch": "main", + }), + ), + CancellationToken::new(), + ); + tokio::time::timeout(Duration::from_secs(30), async { + while !creating.exists() { + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .await + .expect("the pull-request creation starts"); + + let refreshed = tokio::time::timeout( + Duration::from_secs(5), + services.handle_unary_with_context( + rpc_request("vcs.refreshStatus", json!({ "cwd": repository })), + RpcSessionContext::unauthenticated(), + CancellationToken::new(), + ), + ) + .await + .expect("vcs.refreshStatus answered while the pull-request step is held") + .expect("refresh succeeds"); + assert_eq!(refreshed["refName"], "feature/held", "{refreshed}"); + + std::fs::write(&release, "").expect("release the pull-request creation"); + let ended = loop { + let values = stream + .recv() + .await + .expect("stacked stream ends with an event") + .expect("stacked chunk"); + if let Some(event) = values + .into_iter() + .find(|event| event["kind"] != "action_started") + { + break event; + } + }; + assert_eq!(ended["kind"], "action_finished", "{ended}"); + assert_eq!(ended["result"]["pr"]["status"], "created", "{ended}"); + assert_eq!( + ended["result"]["push"]["status"], "skipped_not_requested", + "the pull-request step, not a push, released reads: {ended}" + ); + } + + /// A reviewed pull request: the fixture repository tracks a local bare + /// remote for pushes while its fetch URL names GitHub so provider detection + /// resolves, and the `gh` stub records every argument it receives. + #[cfg(unix)] + #[tokio::test] + async fn create_pr_uses_the_reviewed_title_and_body_and_never_duplicates() { + let sandbox = crate::test_support::TestSandbox::new("git-vcs-reviewed-pr"); let repository = sandbox.root().join("repository"); tokio::fs::create_dir_all(&repository) .await @@ -6220,6 +6876,7 @@ esac &pull_requests, &input, &CancellationToken::new(), + None, ) .await .expect_err("target required and validated"); @@ -6254,6 +6911,7 @@ esac &pull_requests, &github_merge_options, &CancellationToken::new(), + None, ) .await .expect_err("GitLab-only merge options are refused for GitHub"); @@ -6275,6 +6933,7 @@ esac &pull_requests, &reviewed, &CancellationToken::new(), + None, ) .await .expect("reviewed pull request creates"); @@ -6324,6 +6983,7 @@ esac &pull_requests, &reviewed, &CancellationToken::new(), + None, ) .await .expect("retry resolves the existing pull request"); @@ -6350,6 +7010,7 @@ esac &pull_requests, &with_options, &CancellationToken::new(), + None, ) .await .expect("existing request resolves"); @@ -6400,6 +7061,7 @@ esac &pull_requests, &explicit_current, &CancellationToken::new(), + None, ) .await .expect("explicit current-source publication"); @@ -6433,6 +7095,7 @@ esac &pull_requests, &explicit_current, &CancellationToken::new(), + None, ) .await .expect("an unmapped upstream stays configured"); @@ -6463,6 +7126,7 @@ esac &pull_requests, &misplaced, &CancellationToken::new(), + None, ) .await .expect_err("a target outside a pull request action is rejected"); @@ -6611,9 +7275,10 @@ esac "featureBranch": feature_branch, "commitMessage": "Do not commit", })) .unwrap(); - let error = run_stacked_action(&git_repository, &pull_requests, &input, &cancellation) - .await - .expect_err("invalid source rejected before mutations"); + let error = + run_stacked_action(&git_repository, &pull_requests, &input, &cancellation, None) + .await + .expect_err("invalid source rejected before mutations"); assert!( error.wire["detail"].as_str().unwrap().contains("branch"), "{error:?}" @@ -6638,9 +7303,10 @@ esac "pullRequestHeadBranch": "feature/source", "pullRequestBaseBranch": "main", })) .unwrap(); - let result = run_stacked_action(&git_repository, &pull_requests, &input, &cancellation) - .await - .expect("selected branch publishes with a dirty current checkout"); + let result = + run_stacked_action(&git_repository, &pull_requests, &input, &cancellation, None) + .await + .expect("selected branch publishes with a dirty current checkout"); let result = serde_json::to_value(result).unwrap(); assert_eq!(result["push"]["branch"], "feature/source"); assert_eq!(result["pr"]["headBranch"], "feature/source"); @@ -6691,7 +7357,7 @@ esac ) .await; tokio::fs::write(&calls, "").await.unwrap(); - run_stacked_action(&git_repository, &pull_requests, &input, &cancellation) + run_stacked_action(&git_repository, &pull_requests, &input, &cancellation, None) .await .expect_err("a diverged source is never force-pushed"); assert_eq!( @@ -6716,9 +7382,10 @@ esac tokio::fs::write(&existing_marker, "1").await.unwrap(); tokio::fs::write(&calls, "").await.unwrap(); - let retried = run_stacked_action(&git_repository, &pull_requests, &input, &cancellation) - .await - .expect("existing request resolves by selected head"); + let retried = + run_stacked_action(&git_repository, &pull_requests, &input, &cancellation, None) + .await + .expect("existing request resolves by selected head"); assert_eq!( serde_json::to_value(retried).unwrap()["pr"]["status"], "opened_existing" @@ -6747,7 +7414,7 @@ esac .await; tokio::fs::remove_file(&existing_marker).await.unwrap(); let remote_only = - run_stacked_action(&git_repository, &pull_requests, &input, &cancellation) + run_stacked_action(&git_repository, &pull_requests, &input, &cancellation, None) .await .expect("origin-only source needs no push or checkout"); let remote_only = serde_json::to_value(remote_only).unwrap(); @@ -7009,6 +7676,7 @@ esac &PullRequestService::with_provider_commands("unused-gh", "unused-glab", "unused-az"), &input, &CancellationToken::new(), + None, ) .await .expect_err("an unidentified host is refused"); @@ -7558,16 +8226,22 @@ mod open_pull_request_lookup_tests { use crate::RequestId; use crate::test_support::TestSandbox; - /// Records the `--head` branch of every `pr list`, then answers from - /// marker files: `fail` exits 1, `slow` sleeps its contents in seconds, - /// and `open` returns one open PR for the requested branch. + /// Answers every `pr list` from marker files read when it starts, then + /// records its `--head` branch: `fail` exits 1, `slow` sleeps its contents + /// in seconds, and `open` returns one open PR for the requested branch. + /// The markers are read before the record, so a test that sees a lookup + /// recorded can change a marker without the change reaching that lookup. const FAKE_GH: &str = r#"#!/bin/sh case "$1:$2" in pr:list) + fail=''; if [ -f 'STATE/fail' ]; then fail=1; fi + open=''; if [ -f 'STATE/open' ]; then open=1; fi + slow=''; if [ -f 'STATE/slow' ]; then slow="$(cat 'STATE/slow')"; fi printf '%s\n' "$4" >> 'CALLS' - if [ -f 'STATE/fail' ]; then echo 'forge unavailable' >&2; exit 1; fi - if [ -f 'STATE/slow' ]; then sleep "$(cat 'STATE/slow')"; fi - if [ -f 'STATE/open' ]; then + printf '%s\n' "$$" >> 'PIDS' + if [ -n "$fail" ]; then echo 'forge unavailable' >&2; exit 1; fi + if [ -n "$slow" ]; then sleep "$slow"; fi + if [ -n "$open" ]; then printf '[{"number":42,"title":"PR for %s","url":"https://github.com/acme/repo/pull/42","baseRefName":"main","headRefName":"%s","state":"OPEN"}]\n' "$4" "$4" else printf '[]\n' @@ -7582,6 +8256,8 @@ esac repository: PathBuf, state: PathBuf, calls: PathBuf, + pids: PathBuf, + registry: WorkspaceAvailabilityRegistry, services: GitVcsRpcServices, } @@ -7609,27 +8285,81 @@ esac ], ); let calls = sandbox.path("gh-calls"); + let pids = sandbox.path("gh-pids"); TestSandbox::write_executable( &bin.join("gh"), &FAKE_GH .replace("CALLS", &calls.to_string_lossy()) + .replace("PIDS", &pids.to_string_lossy()) .replace("STATE", &state.to_string_lossy()), ); let hosts = Arc::new(ProviderHosts::default()); + let registry = WorkspaceAvailabilityRegistry::new(); let services = GitVcsRpcServices::with_repository( Arc::new(GitRepository::default().with_provider_hosts(hosts.clone())), hosts, ) - .with_hosting_executable_dir_for_integration_test(bin); + .with_hosting_executable_dir_for_integration_test(bin) + .with_availability_registry(registry.clone()); Self { _sandbox: sandbox, repository, state, calls, + pids, + registry, services, } } + /// Whether any fake `gh` this fixture started is still running. + fn a_forge_cli_is_running(&self) -> bool { + std::fs::read_to_string(&self.pids) + .unwrap_or_default() + .lines() + .any(|pid| { + let stat = std::process::Command::new("ps") + .args(["-o", "stat=", "-p", pid]) + .output() + .expect("ps runs"); + let stat = String::from_utf8_lossy(&stat.stdout); + !stat.trim().is_empty() && !stat.trim().starts_with('Z') + }) + } + + async fn wait_until_no_forge_cli_runs(&self, why: &str) { + tokio::time::timeout(Duration::from_secs(5), async { + while self.a_forge_cli_is_running() { + tokio::time::sleep(Duration::from_millis(50)).await; + } + }) + .await + .unwrap_or_else(|_| panic!("{why}: a gh lookup is still running")); + } + + fn subscribe(&self, cancellation: &CancellationToken) -> Subscriber { + Subscriber { + stream: self + .services + .status_stream(subscribe_request(&self.repository), cancellation.clone()), + ref_name: Value::Null, + remote: Value::Null, + events: Vec::new(), + } + } + + /// Forgets the remembered lookups without telling any status stream. + fn forget_lookups(&self) { + self.services + .pull_requests + .invalidate_open_pull_request_lookups(&self.repository); + } + + /// What a finished `gh pr create` or merge from outside BiBCode reports. + fn report_pull_request_change(&self) { + self.services.pull_request_change_reporter()(&self.repository); + } + fn set(&self, marker: &str, contents: &str) { std::fs::write(self.state.join(marker), contents).expect("fixture marker"); } @@ -7672,6 +8402,76 @@ esac } } + /// The status a client shows, folded from the stream the way the client's + /// `applyGitStatusStreamEvent` folds it. + struct Subscriber { + stream: mpsc::Receiver, + ref_name: Value, + remote: Value, + /// Each event with the pull request shown after it. + events: Vec<(Value, Value)>, + } + + impl Subscriber { + fn pr(&self) -> Value { + self.remote.get("pr").cloned().unwrap_or(Value::Null) + } + + async fn next(&mut self) { + let chunk = self + .stream + .recv() + .await + .expect("status stream stays open") + .expect("status stream event"); + for event in chunk { + match event["_tag"].as_str() { + Some("snapshot") => { + self.ref_name = event["local"]["refName"].clone(); + self.remote = event["remote"].clone(); + } + Some("localUpdated") => { + if event["local"]["refName"] != self.ref_name { + self.remote = Value::Null; + } + self.ref_name = event["local"]["refName"].clone(); + } + Some("remoteUpdated") => self.remote = event["remote"].clone(), + tag => panic!("unexpected status event {tag:?}"), + } + self.events.push((event, self.pr())); + } + } + + async fn until(&mut self, why: &str, done: impl Fn(&Self) -> bool) { + tokio::time::timeout(Duration::from_secs(10), async { + while !done(self) { + self.next().await; + } + }) + .await + .unwrap_or_else(|_| panic!("{why}; events: {:?}", self.events)); + } + + /// Folds every event that arrives within `window`. + async fn drain(&mut self, window: Duration) { + let _ = tokio::time::timeout(window, async { + loop { + self.next().await; + } + }) + .await; + } + + /// The pull request shown after each event since event `from`. + fn shown_since(&self, from: usize) -> Vec { + self.events[from..] + .iter() + .map(|(_, pr)| pr["number"].clone()) + .collect() + } + } + fn git(cwd: &Path, args: &[&str]) { let output = std::process::Command::new("git") .args(["-c", "user.name=BiBCode Test"]) @@ -7715,7 +8515,7 @@ esac async fn concurrent_refreshes_share_one_forge_lookup() { let fixture = Fixture::new("pr-lookup-coalesce"); fixture.set("open", ""); - fixture.set("slow", "1"); + fixture.set("slow", "0.5"); let (first, second, third) = tokio::join!(fixture.refresh(), fixture.refresh(), fixture.refresh()); for status in [first, second, third] { @@ -8064,6 +8864,435 @@ esac assert_eq!(recovered["pr"]["number"], 42, "{recovered}"); assert_eq!(fixture.lookups(), ["main", "main"]); } + + /// Subscribes and waits until the subscriber shows the open pull request. + async fn subscriber_showing_the_pull_request( + fixture: &Fixture, + cancellation: &CancellationToken, + ) -> Subscriber { + fixture.set("open", ""); + let mut subscriber = fixture.subscribe(cancellation); + subscriber + .until("the subscriber shows the open pull request", |s| { + s.pr()["number"] == 42 + }) + .await; + subscriber + } + + #[tokio::test] + async fn pr_row_same_ref_keeps_the_pull_request_while_a_lookup_runs() { + let fixture = Fixture::new("pr-row-same-ref"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + let from = subscriber.events.len(); + + // The forge answers slowly now. A remote publication, a local update and a mutation + // that republishes the equal status under new epochs all arrive while it answers. + fixture.set("slow", "3"); + fixture.report_pull_request_change(); + wait_for_lookups(&fixture, "main", 2).await; + std::fs::write(fixture.repository.join("saved.txt"), "saved\n").expect("fixture file"); + fixture + .services + .broadcaster + .notify_local_change(&fixture.repository) + .await; + fixture + .services + .broadcaster + .begin_mutation(&fixture.repository) + .await + .finish() + .await; + subscriber.drain(Duration::from_millis(1500)).await; + + assert!( + subscriber.events.len() > from, + "the test drove no status event" + ); + assert!( + subscriber.shown_since(from).iter().all(|pr| *pr == 42), + "the pull request must stay shown while its lookup runs: {:?}", + subscriber.events + ); + cancellation.cancel(); + } + + #[tokio::test] + async fn pr_row_an_open_request_is_shown_when_the_lookup_ends() { + let fixture = Fixture::new("pr-row-open"); + let cancellation = CancellationToken::new(); + let mut subscriber = fixture.subscribe(&cancellation); + wait_for_lookups(&fixture, "main", 1).await; + subscriber.drain(Duration::from_millis(300)).await; + assert!(subscriber.pr().is_null(), "{:?}", subscriber.events); + + fixture.set("open", ""); + fixture.report_pull_request_change(); + subscriber + .until("the open pull request must be shown", |s| { + s.pr()["number"] == 42 + }) + .await; + cancellation.cancel(); + } + + #[tokio::test] + async fn pr_row_no_open_request_clears_it_when_the_lookup_ends() { + let fixture = Fixture::new("pr-row-none"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + let from = subscriber.events.len(); + + // The request was merged on the web. + fixture.clear("open"); + fixture.set("slow", "1"); + let reported = tokio::time::Instant::now(); + fixture.report_pull_request_change(); + subscriber + .until("the subscriber must clear the closed request", |s| { + s.pr().is_null() + }) + .await; + + assert!( + reported.elapsed() >= Duration::from_millis(900), + "only the lookup's own answer clears the request: {:?}", + &subscriber.events[from..] + ); + assert_eq!(fixture.lookups(), ["main", "main"]); + cancellation.cancel(); + } + + #[tokio::test] + async fn pr_row_a_failed_lookup_keeps_the_pull_request() { + let fixture = Fixture::new("pr-row-failed"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + let from = subscriber.events.len(); + + fixture.set("fail", ""); + fixture.report_pull_request_change(); + wait_for_lookups(&fixture, "main", 2).await; + subscriber.drain(Duration::from_millis(1000)).await; + + assert!( + subscriber.events.len() > from, + "the test drove no status event" + ); + assert!( + subscriber.shown_since(from).iter().all(|pr| *pr == 42), + "a failed lookup must keep the pull request: {:?}", + subscriber.events + ); + cancellation.cancel(); + } + + #[tokio::test] + async fn pr_row_a_ref_change_clears_the_pull_request_at_once() { + let fixture = Fixture::new("pr-row-ref-change"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + + fixture.set("slow", "6"); + git(&fixture.repository, &["switch", "-q", "-c", "feature"]); + fixture + .services + .broadcaster + .notify_local_change(&fixture.repository) + .await; + let started = tokio::time::Instant::now(); + subscriber + .until("the subscriber must see the new branch", |s| { + s.ref_name == "feature" + }) + .await; + assert!( + subscriber.pr().is_null(), + "the old branch's request must clear with the branch change: {:?}", + subscriber.events + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "the clear must not wait for the new branch's lookup" + ); + subscriber + .until("the new branch's request must be shown", |s| { + s.pr()["headRef"] == "feature" + }) + .await; + cancellation.cancel(); + } + + #[tokio::test] + async fn pr_row_a_result_across_an_epoch_change_is_delivered_once() { + let fixture = Fixture::new("pr-row-epoch"); + let cancellation = CancellationToken::new(); + let mut subscriber = fixture.subscribe(&cancellation); + wait_for_lookups(&fixture, "main", 1).await; + subscriber.drain(Duration::from_millis(300)).await; + assert!(subscriber.pr().is_null(), "{:?}", subscriber.events); + + // The lookup answers while a mutation holds the status gate, so its fence is old. + fixture.set("open", ""); + fixture.set("slow", "1"); + fixture.report_pull_request_change(); + wait_for_lookups(&fixture, "main", 2).await; + let mutation = fixture + .services + .broadcaster + .begin_mutation(&fixture.repository) + .await; + subscriber.drain(Duration::from_millis(2000)).await; + assert!( + subscriber.pr().is_null(), + "nothing is published while the gate is closed: {:?}", + subscriber.events + ); + let from = subscriber.events.len(); + mutation.finish().await; + subscriber + .until("the late result must be delivered", |s| { + s.pr()["number"] == 42 + }) + .await; + subscriber.drain(Duration::from_millis(1000)).await; + + let deliveries = subscriber.events[from..] + .iter() + .filter(|(event, _)| event["remote"]["pr"]["number"] == 42) + .count(); + assert_eq!(deliveries, 1, "delivered once: {:?}", subscriber.events); + cancellation.cancel(); + } + + #[tokio::test] + async fn a_refresh_at_its_bound_returns_the_last_known_pull_request() { + let fixture = Fixture::new("pr-refresh-bound"); + let cancellation = CancellationToken::new(); + let _subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + + fixture.set("slow", "6"); + fixture.forget_lookups(); + let status = tokio::time::timeout(Duration::from_secs(5), fixture.refresh()) + .await + .expect("the refresh answers at its bound"); + assert_eq!(status["pr"]["number"], 42, "{status}"); + cancellation.cancel(); + } + + #[tokio::test] + async fn a_lookup_past_the_refresh_bound_reaches_the_subscriber() { + let fixture = Fixture::new("pr-refresh-late"); + let cancellation = CancellationToken::new(); + let mut subscriber = fixture.subscribe(&cancellation); + wait_for_lookups(&fixture, "main", 1).await; + subscriber.drain(Duration::from_millis(300)).await; + + // Nothing tells the stream to look again; only the refresh does. + fixture.set("open", ""); + fixture.set("slow", "6"); + fixture.forget_lookups(); + let status = tokio::time::timeout(Duration::from_secs(5), fixture.refresh()) + .await + .expect("the refresh answers at its bound"); + assert!(status["pr"].is_null(), "{status}"); + subscriber + .until("the subscriber must receive the late lookup", |s| { + s.pr()["number"] == 42 + }) + .await; + cancellation.cancel(); + } + + #[tokio::test] + async fn refreshes_past_the_bound_do_not_restart_the_subscriber_lookup() { + let fixture = Fixture::new("pr-refresh-repeated"); + let cancellation = CancellationToken::new(); + let mut subscriber = fixture.subscribe(&cancellation); + wait_for_lookups(&fixture, "main", 1).await; + subscriber.drain(Duration::from_millis(300)).await; + + // Each refresh joins the lookup and stops waiting at its bound, again and again, + // while the lookup takes twice that bound. + fixture.set("open", ""); + fixture.set("slow", "4"); + fixture.report_pull_request_change(); + wait_for_lookups(&fixture, "main", 2).await; + let refreshes = async { + loop { + fixture.refresh().await; + tokio::time::sleep(Duration::from_millis(100)).await; + } + }; + tokio::select! { + () = subscriber.until("the lookup must finish while refreshes repeat", |s| { + s.pr()["number"] == 42 + }) => {} + () = refreshes => {} + } + assert_eq!( + fixture.lookups(), + ["main", "main"], + "the refreshes must not restart the subscriber's lookup" + ); + cancellation.cancel(); + } + + #[tokio::test] + async fn a_change_reported_during_a_lookup_is_looked_up_again() { + let fixture = Fixture::new("pr-lookup-again"); + let cancellation = CancellationToken::new(); + let mut subscriber = fixture.subscribe(&cancellation); + wait_for_lookups(&fixture, "main", 1).await; + subscriber.drain(Duration::from_millis(300)).await; + + // A lookup that started before the request was created answers that none is open. + fixture.set("slow", "1"); + fixture.report_pull_request_change(); + wait_for_lookups(&fixture, "main", 2).await; + fixture.set("open", ""); + fixture.report_pull_request_change(); + subscriber + .until("the reported request must be looked up again", |s| { + s.pr()["number"] == 42 + }) + .await; + assert_eq!(fixture.lookups(), ["main", "main", "main"]); + cancellation.cancel(); + } + + #[tokio::test] + async fn a_local_change_during_a_lookup_replaces_its_outdated_answer() { + let fixture = Fixture::new("pr-lookup-local-again"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + + // This lookup reads the open request, then answers slowly. + fixture.set("slow", "2"); + fixture.report_pull_request_change(); + wait_for_lookups(&fixture, "main", 2).await; + tokio::time::sleep(Duration::from_millis(300)).await; + // The request is closed, and a local change on the same branch reports it. + fixture.clear("open"); + std::fs::write(fixture.repository.join("saved.txt"), "saved\n").expect("fixture file"); + fixture + .services + .broadcaster + .notify_local_change(&fixture.repository) + .await; + subscriber + .until( + "the closed request must clear after the outdated answer", + |s| s.pr().is_null(), + ) + .await; + assert_eq!( + fixture.lookups(), + ["main", "main", "main"], + "one follow-up lookup runs after the outdated one" + ); + cancellation.cancel(); + } + + #[tokio::test] + async fn a_queued_publication_shows_the_known_pull_request_at_once() { + let fixture = Fixture::new("pr-queued-publication"); + let cancellation = CancellationToken::new(); + let mut subscriber = subscriber_showing_the_pull_request(&fixture, &cancellation).await; + subscriber.drain(Duration::from_millis(300)).await; + let from = subscriber.events.len(); + + // A remote publication read before the lookup answered carries no request. + let mut remote: VcsStatusRemoteResult = + serde_json::from_value(subscriber.remote.clone()).expect("shown remote"); + remote.pr = None; + fixture + .services + .broadcaster + .publish_status_event_for_test( + &fixture.repository, + VcsStatusStreamEvent::RemoteUpdated { + remote: Some(remote), + }, + ) + .await; + subscriber.drain(Duration::from_millis(1000)).await; + + assert_eq!( + subscriber.shown_since(from), + [json!(42)], + "one event that keeps the known request: {:?}", + &subscriber.events[from..] + ); + cancellation.cancel(); + } + + /// A subscriber, then a refresh that stops waiting for a 30 second lookup at its bound. + async fn a_refresh_past_its_bound( + fixture: &Fixture, + cancellation: &CancellationToken, + ) -> Subscriber { + fixture.set("slow", "30"); + let mut subscriber = fixture.subscribe(cancellation); + subscriber.next().await; + wait_for_lookups(fixture, "main", 1).await; + tokio::time::timeout(Duration::from_secs(5), fixture.refresh()) + .await + .expect("the refresh answers at its bound"); + assert!( + fixture.a_forge_cli_is_running(), + "the subscriber still looks" + ); + subscriber + } + + #[tokio::test] + async fn a_lookup_past_the_refresh_bound_stops_with_the_workspace() { + let fixture = Fixture::new("pr-refresh-loss"); + let cancellation = CancellationToken::new(); + // An open Git Manager keeps its signal subscription across a workspace loss. + let _git_manager = fixture + .services + .broadcaster + .subscribe_git_manager_signal(fixture.repository.clone(), cancellation.clone()) + .await + .expect("Git Manager signal subscription"); + let _subscriber = a_refresh_past_its_bound(&fixture, &cancellation).await; + + assert!( + fixture + .registry + .mark_unavailable(crate::worktree_catalog::WorkspaceLossTransition { + thread_id: "thread-lookup".to_owned(), + repository_key: "repository-lookup".to_owned(), + generation: 1, + path: fixture.repository.clone(), + availability: + crate::worktree_catalog::AdoptedWorktreeAvailability::MissingRegistered, + }) + .await + .expect("physical identity resolves") + ); + fixture + .wait_until_no_forge_cli_runs("after the workspace was lost") + .await; + cancellation.cancel(); + } + + #[tokio::test] + async fn a_lookup_past_the_refresh_bound_stops_at_quiesce() { + let fixture = Fixture::new("pr-refresh-quiesce"); + let cancellation = CancellationToken::new(); + let _subscriber = a_refresh_past_its_bound(&fixture, &cancellation).await; + + fixture.services.broadcaster.shutdown().await; + fixture + .wait_until_no_forge_cli_runs("after the status broadcaster shut down") + .await; + cancellation.cancel(); + } } #[cfg(test)] diff --git a/apps/server/src/production/mcp_tools_tests/clone.rs b/apps/server/src/production/mcp_tools_tests/clone.rs index 730187a983..3a1a59398a 100644 --- a/apps/server/src/production/mcp_tools_tests/clone.rs +++ b/apps/server/src/production/mcp_tools_tests/clone.rs @@ -394,6 +394,7 @@ async fn fixture(options: Options) -> Fixture { updated_at: CREATED_AT.to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project"); diff --git a/apps/server/src/production/mcp_tools_tests/diagnostics.rs b/apps/server/src/production/mcp_tools_tests/diagnostics.rs index 247f7da36c..04ed626383 100644 --- a/apps/server/src/production/mcp_tools_tests/diagnostics.rs +++ b/apps/server/src/production/mcp_tools_tests/diagnostics.rs @@ -36,7 +36,7 @@ const TOKEN: &str = "privateToken123"; const PANIC_MESSAGE: &str = "failed opening /mnt/private/customer.txt: MARKER-x2-ignore-previous-instructions"; -const TOOLS: [&str; 8] = [ +const TOOLS: [&str; 9] = [ "profiler_status", "profiler_findings", "profiler_events", @@ -45,6 +45,7 @@ const TOOLS: [&str; 8] = [ "refresh_providers", "process_diagnostics", "trace_diagnostics", + "host_metrics", ]; fn listed(harness: &Harness, agent: &crate::mcp::control::ClientPrincipal) -> Vec { @@ -121,7 +122,7 @@ async fn the_diagnostics_tools_are_listed_with_their_hints_and_hidden_with_their // A read-only connection keeps the reads and loses the refresh. let (reader, _) = harness.agent(McpAccess::ReadOnly).await; - assert_eq!(listed(&harness, &reader).len(), 7); + assert_eq!(listed(&harness, &reader).len(), 8); let refused = harness.call(&reader, "refresh_providers", json!({})).await; assert_eq!(error_tag(&refused), "McpAccessCeilingError"); @@ -830,3 +831,86 @@ async fn a_rejected_actions_text_never_reaches_an_agent() { harness.shutdown().await; child.complete(); } + +/// R82, D26: an agent reads the host's figures, but a mount point only when it is a file +/// system root; every other one, every volume name and every hidden volume's path is withheld. +#[tokio::test] +async fn host_metrics_tool_keeps_only_root_mount_points() { + let seen = Payloads::default(); + let volume = |mount_point: &str, name: &str| { + json!({ + "mountPoint": mount_point, "name": name, "fileSystem": "ext4", + "totalBytes": 100, "freeBytes": 40, "usedBytes": 60, + "removable": false, "readOnly": false, "state": "ok", + }) + }; + let statistic = + json!({ "current": 60.0, "minimum": 40.0, "average": 50.0, "p95": 59.0, "maximum": 60.0 }); + let harness = Harness::with_methods(|registry| { + fake( + registry, + "server.getHostMetrics", + json!({ + "_tag": "Snapshot", + "host": { "os": "linux", "arch": "x86_64", "cpuCount": 8, "memoryTotalBytes": 1000 }, + "bootId": null, + "startedAt": "2026-11-11T09:00:00.000Z", + "statisticsSince": "2026-11-11T09:00:00.000Z", + "sample": { + "sampledAt": "2026-11-11T10:00:00.000Z", "cpuPercent": 12.5, + "memoryTotalBytes": 1000, "memoryUsedBytes": 250, "memoryAvailableBytes": 750, + "volumes": [ + volume("/", "root-volume-label"), + volume("C:\\", "drive-volume-label"), + volume("/media/test-user/usb", "usb"), + ], + "volumesPaused": false, "sequence": 7, "ageMs": 3, + }, + "cpu": statistic, "memory": statistic, + "recent": [], "uptime": [], + "hiddenVolumes": [ + { "mountPoint": "/home/test-user/share", "fileSystem": "nfs4", "reason": "network" }, + ], + "volumeStatistics": [ + { "mountPoint": "/", "statistic": statistic }, + { "mountPoint": "/media/test-user/usb", "statistic": statistic }, + ], + }), + &seen, + ); + }) + .await; + let (agent, _) = harness.agent(McpAccess::ReadOnly).await; + + let result = harness.call(&agent, "host_metrics", json!({})).await; + + let content = structured(&result); + let text = content.to_string(); + for leak in ["test-user", "usb", "volume-label", "share"] { + assert!(!text.contains(leak), "{leak} in {text}"); + } + let mount_points: Vec = content["sample"]["volumes"] + .as_array() + .expect("volumes") + .iter() + .map(|volume| volume["mountPoint"].clone()) + .collect(); + assert_eq!(mount_points, [json!("/"), json!("C:\\"), Value::Null]); + assert_eq!(content["mountPointsWithheld"], 1); + assert_eq!(content["hiddenVolumeCount"], 1); + assert_eq!(content["sample"]["volumes"][0]["usedBytes"], 60); + assert!(content["sample"]["volumes"][0].get("name").is_none()); + assert_eq!(content["host"]["cpuCount"], 8); + assert_eq!(content["cpu"]["p95"], 59.0); + assert_eq!(content["sample"]["cpuPercent"], 12.5); + let volume_statistics = content["volumeStatistics"].as_array().expect("statistics"); + assert_eq!(volume_statistics[0]["mountPoint"], "/"); + assert_eq!(volume_statistics[1]["mountPoint"], Value::Null); + assert_eq!(volume_statistics[0]["statistic"]["average"], 50.0); + + let refused = harness + .call(&agent, "host_metrics", json!({ "anything": 1 })) + .await; + assert_eq!(error_tag(&refused), "McpInvalidArgumentError"); + harness.shutdown().await; +} diff --git a/apps/server/src/production/mcp_tools_tests/git.rs b/apps/server/src/production/mcp_tools_tests/git.rs index 983c0791d4..b89341f87e 100644 --- a/apps/server/src/production/mcp_tools_tests/git.rs +++ b/apps/server/src/production/mcp_tools_tests/git.rs @@ -142,6 +142,7 @@ pub(super) async fn fixture_with(register: impl FnOnce(&mut RpcRegistry)) -> Fix updated_at: CREATED_AT.to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project"); diff --git a/apps/server/src/production/operational_logs.rs b/apps/server/src/production/operational_logs.rs index 2ba9511148..f15b155069 100644 --- a/apps/server/src/production/operational_logs.rs +++ b/apps/server/src/production/operational_logs.rs @@ -258,6 +258,7 @@ impl TerminalOperationalLog { sequence, exit_code, exit_signal, + .. } => serde_json::json!({ "timestamp": timestamp, "eventType": "exit", @@ -272,6 +273,7 @@ impl TerminalOperationalLog { thread_id, terminal_id, sequence, + .. } => serde_json::json!({ "timestamp": timestamp, "eventType": "close", @@ -629,6 +631,7 @@ mod tests { .expect("terminal log starts"); let snapshot = TerminalSessionSnapshot { size: None, + generation: None, osc_color_responder_active: false, first_attachment_grant: false, thread_id: "thread-1".to_owned(), @@ -686,6 +689,7 @@ mod tests { .expect("terminal log starts"); let snapshot = TerminalSessionSnapshot { size: None, + generation: None, osc_color_responder_active: false, first_attachment_grant: false, thread_id: "thread-1".to_owned(), @@ -723,11 +727,13 @@ mod tests { sequence: 4, exit_code: Some(7), exit_signal: None, + generation: None, }, TerminalEvent::Closed { thread_id: "thread-1".to_owned(), terminal_id: "terminal-1".to_owned(), sequence: 5, + generation: None, }, TerminalEvent::Error { thread_id: "thread-1".to_owned(), @@ -748,7 +754,9 @@ mod tests { cols: 91, rows: 42, size_claim: Some("PRIVATE_CLAIM".to_owned()), + reply_claim: None, }, + only_for: None, }, ] { assert!(log.record(&event)); diff --git a/apps/server/src/production/orchestration_effects.rs b/apps/server/src/production/orchestration_effects.rs index 5190fa76b4..f60cd5dca4 100644 --- a/apps/server/src/production/orchestration_effects.rs +++ b/apps/server/src/production/orchestration_effects.rs @@ -472,6 +472,16 @@ impl ThreadTurnBootstrapEffects for ProductionBootstrapEffects { ) .await .map_err(|error| error.to_string())?; + // Only a worktree this call created has a rollback, and only it gets a record. The + // turn goes on without one: the worktree's `.git` pointer file is the audit's evidence. + if result.rollback.is_some() + && let Err(error) = self + .repositories + .record_worktree_creation(&input.project_cwd, &result.worktree.path) + .await + { + tracing::warn!(%error, "a bootstrap worktree has no creation record"); + } let path = PathBuf::from(&result.worktree.path); let worktree = BootstrapWorktree { repository_root: input.project_cwd, @@ -1510,7 +1520,7 @@ pub(crate) async fn ensure_first_turn_baseline( } /// How long cancelled baseline steps may take to stop their Git children and clean up. -const CANCELLED_BASELINE_GRACE: Duration = Duration::from_secs(10); +pub(crate) const CANCELLED_BASELINE_GRACE: Duration = Duration::from_secs(10); /// How long a capture waits to remove its temporary index; past this, the removal is left to /// finish on its own (a stalled network filesystem) and logged, so no capture waits on it. const TEMPORARY_INDEX_REMOVAL_LIMIT: Duration = Duration::from_secs(5); @@ -4936,6 +4946,7 @@ mod tests { updated_at: "2026-07-16T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("malformed project fixture"); @@ -4952,6 +4963,7 @@ mod tests { updated_at: "2026-07-16T00:00:01Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("empty project fixture"); @@ -4973,6 +4985,7 @@ mod tests { updated_at: "2026-07-16T00:00:02Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("setup project fixture"); @@ -5175,6 +5188,7 @@ mod tests { updated_at: "2026-08-01T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project"); diff --git a/apps/server/src/production/orchestration_rpc.rs b/apps/server/src/production/orchestration_rpc.rs index 2af48c81b3..60c1343140 100644 --- a/apps/server/src/production/orchestration_rpc.rs +++ b/apps/server/src/production/orchestration_rpc.rs @@ -2696,6 +2696,7 @@ fn project_shell(project: &crate::persistence::ProjectionProject) -> Value { "defaultModelSelection": project.default_model_selection, "scripts": project.scripts, "worktreeDiscovery": project.worktree_discovery, + "codeReviewPrompt": project.code_review_prompt, "repositoryIdentity": project.repository_identity, "createdAt": project.created_at, "updatedAt": project.updated_at, @@ -2967,6 +2968,9 @@ fn thread_shell_with_records( if thread.side_chat { shell["sideChat"] = json!(true); } + if thread.code_review { + shell["codeReview"] = json!(true); + } if let Some(requester) = &thread.requester { shell["requester"] = requester.clone(); } @@ -3307,6 +3311,7 @@ mod tests { deleted_at: None, host_thread_id: None, compaction: None, + code_review: false, }], messages: vec![ ProjectionThreadMessage { @@ -3561,6 +3566,22 @@ mod tests { ); } + #[test] + fn code_review_thread_shell_marks_only_a_review() { + let snapshot = preview_snapshot_fixture("running"); + let mut thread = snapshot.threads[0].clone(); + assert!( + thread_shell(&thread, &snapshot, None) + .get("codeReview") + .is_none() + ); + thread.code_review = true; + assert_eq!( + thread_shell(&thread, &snapshot, None)["codeReview"], + serde_json::json!(true) + ); + } + fn store_thread( thread_id: &str, project_id: &str, @@ -4221,6 +4242,7 @@ mod tests { updated_at: CREATED_AT.to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }; let mut deleted_project = project.clone(); deleted_project.project_id = "project-deleted".to_owned(); diff --git a/apps/server/src/production/provider_inventory.rs b/apps/server/src/production/provider_inventory.rs index 77a0aa65d1..ae2b31d83a 100644 --- a/apps/server/src/production/provider_inventory.rs +++ b/apps/server/src/production/provider_inventory.rs @@ -415,6 +415,10 @@ async fn probe_one( checks_enabled: bool, maintenance: &ProviderMaintenance, ) -> ProviderProbeResult { + // A driver this build does not ship has no binary to look up or version to advise on. + if !definition.available { + return unavailable_result(&definition); + } let target = maintenance_target_from_definition(&definition); let mut result = probe_one_snapshot(&definition, cwd, include_slow_capabilities).await; maintenance @@ -486,30 +490,44 @@ pub(crate) async fn probe_maintenance_target_version( about.and_then(|about| about.version).or(version) } +fn unavailable_result(definition: &ProviderDefinition) -> ProviderProbeResult { + ProviderProbeResult::new( + snapshot( + definition, + false, + None, + "disabled", + json!({ "status": "unknown" }), + provider_models_without_version(definition), + ProviderCapabilities::default(), + Some("Provider driver is unavailable in this build."), + super::control::now_iso(), + "unavailable", + ), + RichMetadataOutcome::NotRequested, + ) +} + +/// The configured instances whose driver this build does not ship, as their snapshots. They +/// need no probe, so a settings change publishes them before the settings themselves (#347). +pub(crate) fn unsupported_instances(settings: &Value) -> Vec { + definitions(settings) + .iter() + .filter(|definition| !definition.available) + .map(unavailable_result) + .collect() +} + async fn probe_one_snapshot( definition: &ProviderDefinition, cwd: &Path, include_slow_capabilities: bool, ) -> ProviderProbeResult { - let checked_at = super::control::now_iso(); - let default_models = provider_models_without_version(definition); if !definition.available { - return ProviderProbeResult::new( - snapshot( - definition, - false, - None, - "disabled", - json!({ "status": "unknown" }), - default_models, - ProviderCapabilities::default(), - Some("Provider driver is unavailable in this build."), - checked_at, - "unavailable", - ), - RichMetadataOutcome::NotRequested, - ); + return unavailable_result(definition); } + let checked_at = super::control::now_iso(); + let default_models = provider_models_without_version(definition); if !definition.enabled { return ProviderProbeResult::new( snapshot( diff --git a/apps/server/src/production/provider_runtime.rs b/apps/server/src/production/provider_runtime.rs index 6dc34d477b..893e28c213 100644 --- a/apps/server/src/production/provider_runtime.rs +++ b/apps/server/src/production/provider_runtime.rs @@ -18491,6 +18491,7 @@ async fn run_claude_probe( timeout: operation_timeout, cleanup_timeout: CLEANUP_RESERVE, max_output_bytes: CLAUDE_ACTIVITY_PROBE_OUTPUT_LIMIT, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Truncate, }; // The cache producer is detached from the deadline-bounded caller. Let the diff --git a/apps/server/src/production/runtime.rs b/apps/server/src/production/runtime.rs index 0818441c87..ec50a161bc 100644 --- a/apps/server/src/production/runtime.rs +++ b/apps/server/src/production/runtime.rs @@ -24,8 +24,10 @@ use crate::{ crypto::sha256_hex, diagnostic_bundle::{DiagnosticBundleIdentity, DiagnosticBundleService}, diagnostics::{ - DesktopUiProcessObserver, DiagnosticsMonitor, NativeProcessSampler, NativeResourceSampler, - ProcessAttributionRegistry, TraceDiagnosticsStore, bound_diagnostic_string, + DesktopUiProcessObserver, DiagnosticsMonitor, HostMetricsCollector, HostMetricsIdentity, + HostMetricsRates, NativeProcessSampler, NativeResourceSampler, ProcessAttributionRegistry, + TraceDiagnosticsStore, bound_diagnostic_string, + host_metrics::NativeHostMetricsSource, profiler::{ crash::MonitorDetector, latency::LatencyDetector, @@ -144,12 +146,17 @@ pub struct ProductionRuntime { worktree_runtime: WorktreeRuntime, worktree_removal_tasks: WorktreeRemovalTaskTracker, clone_operations: CloneRuntime, + /// The server's repository, whose Git writes shutdown stops (R17). + git_repository: Arc, status_broadcaster: crate::git::StatusBroadcaster, workspace: WorkspaceRpc, _resource_sampler: Arc, process_monitor: Arc>, + /// The host collector behind the Host dashboard (#292): always on from the start (D22). + host_metrics: Arc, profiler: Arc, - update_quiesced: tokio::sync::Mutex, + /// Whether an earlier quiesce stopped the services; later calls only settle the Git writes. + services_stopped: tokio::sync::Mutex, } pub fn finalize_rpc_registry( @@ -529,6 +536,8 @@ impl ProductionRuntime { catalog: worktree_catalog.clone(), repositories: repositories.clone(), }); + // Where Git runs, named by a failed push (#343). + let execution_place = crate::git::sign_in::execution_place(config); let pull_requests = PullRequestsRpcServices::with_dependencies( config.state_dir(), repositories.clone(), @@ -544,7 +553,8 @@ impl ProductionRuntime { .with_created_request_observer(Arc::new(pull_requests.service.clone())) .with_availability_registry(workspace_availability.clone()) .with_mcp_settings(control.mcp_server_signal()) - .with_interactive_git_sign_in(config.interactive_git_sign_in); + .with_interactive_git_sign_in(config.interactive_git_sign_in) + .with_execution_place(execution_place.clone()); let git_vcs = if let Some(dir) = &config.hosting_executable_dir_for_integration_test { git_vcs.with_hosting_executable_dir_for_integration_test(dir.clone()) } else { @@ -611,6 +621,14 @@ impl ProductionRuntime { resource_sampler.clone(), Duration::from_secs(2), )); + let host_metrics = Arc::new(HostMetricsCollector::new( + Arc::new(NativeHostMetricsSource::new()), + HostMetricsRates::default(), + HostMetricsIdentity { + boot_id: config.boot_id.map(|id| id.to_string()), + started_at_ms: OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000, + }, + )); let provider_usage = ProviderUsageService::new(production_fetchers(), Arc::new(OffsetDateTime::now_utc)); let relay = relay_client_service(config.state_dir()); @@ -637,7 +655,8 @@ impl ProductionRuntime { ) .with_workspace_admission(workspace_availability.clone(), repositories.clone()) .with_profiler(&profiler) - .with_environment_id(&config.environment_id); + .with_environment_id(&config.environment_id) + .with_host_metrics(host_metrics.clone()); terminal_services.watch_workspace_changes(orchestration.subscribe_events()); let worktree_runtime = WorktreeRuntime::start( orchestration.clone(), @@ -723,7 +742,8 @@ impl ProductionRuntime { repositories.clone(), workspace_availability.clone(), file_trash, - ); + ) + .with_execution_place(execution_place); let git_manager = if config.interactive_git_sign_in { git_manager.with_interactive_git_sign_in(&config.state_dir()) } else { @@ -794,6 +814,8 @@ impl ProductionRuntime { ); mcp_control.attach_terminals(mcp_terminals); + // Last before the runtime exists, so a failed start leaves no collector running. + host_metrics.start_background(); Ok(Self { mcp_control, webhooks, @@ -825,12 +847,14 @@ impl ProductionRuntime { worktree_runtime, worktree_removal_tasks, clone_operations, + git_repository, status_broadcaster, workspace, _resource_sampler: resource_sampler, process_monitor, + host_metrics, profiler, - update_quiesced: tokio::sync::Mutex::new(false), + services_stopped: tokio::sync::Mutex::new(false), }) } @@ -960,10 +984,19 @@ impl ProductionRuntime { }) } + /// `Err` while a Git write is still stopping; a leftover `index.lock` BiBCode could not + /// delete is logged and does not block the update. Each call decides from the current + /// writes and locks. pub async fn quiesce_for_update(&self) -> Result<(), String> { - let mut quiesced = self.update_quiesced.lock().await; - if *quiesced { - return Ok(()); + let mut services_stopped = self.services_stopped.lock().await; + if *services_stopped { + // The services stopped on an earlier call: only the Git writes and their leftover + // locks are checked again. + let writes = self.stop_git_writes().await; + self.end_profiler_session(&writes).await; + return writes + .map(drop) + .map_err(|pending| format!("{pending} Git writes had not stopped")); } // Each step is timed, so a slow stop names the step that took the time (#303). let mut steps = StopSteps::start("quiesce"); @@ -983,6 +1016,9 @@ impl ProductionRuntime { // Early, so diagnostics stop sampling before process ownership is frozen and cleaned up. self.process_monitor.shutdown().await; steps.mark("process-monitor"); + // Never waits for a disk read: the disk threads are not joined (D32). + self.host_metrics.shutdown().await; + steps.mark("host-metrics"); // Waits for a running tool update, which must not be killed mid-replacement, and // before the profiler's teardown. self.server_control.shutdown_tool_updates().await; @@ -1037,6 +1073,16 @@ impl ProductionRuntime { first_error = Some(error); } steps.mark("provider-runtime"); + // After every owner above that starts Git writes (worktree, clone, turn, checkpoint + // and provider work), and before terminal ownership cleanup and the profiler's + // teardown: later writes are refused, and each running write is stopped and its + // leftover `index.lock` removed (R17). On Windows, where the stop is forced, only this + // cleanup removes the lock. + let writes = self.stop_git_writes().await; + if let Err(pending) = writes { + first_error.get_or_insert_with(|| format!("{pending} Git writes had not stopped")); + } + steps.mark("git-writes"); self.terminal_services .shutdown_with_process_ownership(process_ownership) .await; @@ -1052,19 +1098,53 @@ impl ProductionRuntime { steps.mark("orchestration"); // Recorded while the profiler still runs; its own teardown below is logged only. steps.emit(); + *services_stopped = true; // Last, so the events and panics of every other service's shutdown are recorded, and a // crash anywhere in the shutdown still reads as a crash on the next start. - self.profiler.end_session().await; + self.end_profiler_session(&writes).await; steps.mark("profiler-end-session"); - *quiesced = true; match first_error { Some(error) => Err(error), None => Ok(()), } } + /// Stops every Git write, waiting twice, and logs each leftover lock still there. + async fn stop_git_writes(&self) -> Result, usize> { + let mut writes = self.git_repository.stop_writes().await; + if let Err(pending) = writes { + tracing::error!(pending, "Git writes were still stopping at shutdown"); + writes = self.git_repository.stop_writes().await; + } + match &writes { + Err(pending) => tracing::error!( + pending, + "Git writes were still stopping after a second wait" + ), + Ok(worktrees) => { + for worktree in worktrees { + tracing::error!(%worktree, "a leftover index.lock could not be removed"); + } + } + } + writes + } + + /// While a write is still stopping the Profiler keeps running, so a later call can still + /// record its end. A leftover lock ends the session as an incomplete shutdown. + async fn end_profiler_session(&self, writes: &Result, usize>) { + match writes { + Err(_) => {} + Ok(worktrees) if worktrees.is_empty() => self.profiler.end_session().await, + Ok(_) => self.profiler.shutdown().await, + } + } + pub async fn shutdown(&self) { let _ = self.quiesce_for_update().await; + // Releases the Profiler even when a Git write never stopped; its session then reads + // as an incomplete shutdown on the next start. + self.profiler.shutdown().await; } } @@ -2901,6 +2981,55 @@ mod tests { runtime.shutdown().await; } + #[tokio::test] + async fn host_metrics_start_with_the_runtime_and_stop_in_quiesce() { + let state = TempDir::new().expect("state"); + let config = ServerConfig::new(state.path()).with_bind("127.0.0.1", 0); + hermetic_providers::write_hermetic_settings(&config.state_dir(), json!({})); + let database = Database::open_in_memory().await.expect("database"); + database + .call(|connection| { + run_migrations(connection, None)?; + Ok(()) + }) + .await + .expect("migrations"); + let runtime = ProductionRuntime::start( + &config, + database, + AuthService::new(&config, vec![7_u8; 32]), + vec![9_u8; 32], + Arc::new(crate::diagnostics::NotApplicableUiProcessObserver), + ) + .await + .expect("runtime"); + + // Always on (D22, option c): a first background reading without any stream. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + while runtime.host_metrics.snapshot().await.current.is_none() { + assert!( + std::time::Instant::now() < deadline, + "the collector took no reading within 2 s of the runtime start" + ); + } + assert_eq!(runtime.host_metrics.live_subscribers(), 0); + + runtime.quiesce_for_update().await.expect("quiesced"); + + let mut subscription = runtime.host_metrics.subscribe(); + let changed = timeout( + std::time::Duration::from_secs(2), + subscription.receiver().changed(), + ) + .await + .expect("a stopped collector answers at once"); + assert!( + changed.is_err(), + "the collector still publishes after quiesce" + ); + assert!(subscription.receiver().borrow().is_none()); + } + #[tokio::test] async fn http_dispatch_cannot_bypass_durable_or_worktree_authority() { let state = TempDir::new().expect("state"); @@ -5431,4 +5560,233 @@ mod tests { quiesce_leaves_open_diff_apps_running(ProcessTreeCleanup::StandaloneServer).await; isolated.complete(); } + + /// R17: a Git write still running at shutdown is stopped, and its `index.lock` is gone when + /// quiescence returns. The write is a `git add` whose clean filter never finishes. + async fn start_quiesce_runtime(state: &TempDir) -> ProductionRuntime { + let config = ServerConfig::new(state.path()) + .with_bind("127.0.0.1", 0) + .with_unsafe_no_auth(); + hermetic_providers::write_hermetic_settings(&config.state_dir(), json!({})); + let database = Database::open_in_memory().await.expect("database"); + database + .call(|connection| { + run_migrations(connection, None)?; + Ok(()) + }) + .await + .expect("migrations"); + ProductionRuntime::start( + &config, + database, + AuthService::new(&config, vec![7_u8; 32]), + vec![9_u8; 32], + Arc::new(crate::diagnostics::NotApplicableUiProcessObserver), + ) + .await + .expect("runtime") + } + + /// A write whose stop outlasts both shutdown waits keeps quiescence incomplete: a later + /// call waits for it again and succeeds only once it has settled. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn index_lock_cleanup_quiesce_never_succeeds_while_a_write_is_pending() { + let state = TempDir::new().expect("state"); + let runtime = start_quiesce_runtime(&state).await; + let write = runtime.git_repository.hold_write_for_test(); + + let first = runtime.quiesce_for_update().await; + let second = runtime.quiesce_for_update().await; + + assert!( + first.is_err(), + "the first quiesce reports the pending write" + ); + assert!( + second.is_err(), + "a second quiesce must not succeed while the write is pending" + ); + drop(write); + runtime + .quiesce_for_update() + .await + .expect("quiesced once the write settled"); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn index_lock_cleanup_quiesce_stops_a_running_git_write() { + let state = TempDir::new().expect("state"); + let runtime = start_quiesce_runtime(&state).await; + + let git_config = crate::test_support::isolated_git_config::IsolatedGitConfig::new(); + let repository = state.path().join("repository"); + let started = state.path().join("filter-started"); + std::fs::create_dir(&repository).expect("repository"); + let git = |args: &[&str]| { + let mut command = std::process::Command::new("git"); + git_config.apply_to_command(&mut command); + let output = command + .args(args) + .current_dir(&repository) + .output() + .expect("git starts"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + }; + git(&["init", "-q", "-b", "main"]); + git(&[ + "config", + "filter.slow.clean", + &format!( + "touch '{}'; sleep 30; cat", + started.to_string_lossy().replace('\\', "/") + ), + ]); + std::fs::write(repository.join(".gitattributes"), "held.txt filter=slow\n") + .expect(".gitattributes"); + std::fs::write(repository.join("held.txt"), "held\n").expect("held.txt"); + let lock = repository.join(".git").join("index.lock"); + + let write = tokio::spawn({ + let (git_repository, repository) = (runtime.git_repository.clone(), repository.clone()); + async move { + git_repository + .stage_files( + &repository, + &["held.txt".to_owned()], + &CancellationToken::new(), + ) + .await + } + }); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); + while !started.exists() { + assert!(std::time::Instant::now() < deadline, "the write started"); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert!(lock.exists(), "the write holds index.lock"); + + runtime.quiesce_for_update().await.expect("quiesced"); + + assert!(!lock.exists(), "no index.lock remains after quiescence"); + assert_eq!(session_state(&state), "clean"); + let staged = timeout(std::time::Duration::from_secs(1), write) + .await + .expect("the write has returned") + .expect("write task"); + assert!(staged.is_err(), "the stopped write failed: {staged:?}"); + } + + /// The Profiler session's recorded state: `clean`, or `stopping` for an incomplete + /// shutdown. + fn session_state(state: &TempDir) -> String { + let session = crate::diagnostics::profiler::profiler_dir( + &ServerConfig::new(state.path()).state_dir(), + ) + .join("session.json"); + let record: Value = + serde_json::from_slice(&std::fs::read(session).expect("session record")) + .expect("session JSON"); + record["state"].as_str().expect("session state").to_owned() + } + + #[cfg(unix)] + #[derive(Clone, Default)] + struct CapturedLog(Arc>>); + + #[cfg(unix)] + impl std::io::Write for CapturedLog { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().expect("log lock").extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + + /// A leftover lock BiBCode could not delete does not block an update. Quiescence succeeds, + /// logs an error naming the worktree, and a later quiesce checks the lock again. + #[cfg(unix)] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn index_lock_cleanup_quiesce_succeeds_with_a_leftover_lock() { + use std::os::unix::fs::PermissionsExt; + // SAFETY: geteuid has no preconditions. + if unsafe { libc::geteuid() } == 0 { + eprintln!("skipped: root deletes from a read-only folder"); + return; + } + let state = TempDir::new().expect("state"); + let runtime = start_quiesce_runtime(&state).await; + let worktree = state.path().join("worktree"); + let git_dir = worktree.join(".git"); + std::fs::create_dir_all(&git_dir).expect("Git directory"); + let lock = git_dir.join("index.lock"); + std::fs::write(&lock, "").expect("the leftover lock"); + runtime.git_repository.leave_lock_for_test(&lock, &worktree); + std::fs::set_permissions(&git_dir, std::fs::Permissions::from_mode(0o555)) + .expect("read-only Git directory"); + let log = CapturedLog::default(); + let logging = tracing::subscriber::set_default( + tracing_subscriber::fmt() + .with_ansi(false) + .with_max_level(tracing::Level::ERROR) + .with_writer({ + let log = log.clone(); + move || log.clone() + }) + .finish(), + ); + + let first = runtime.quiesce_for_update().await; + let second = runtime.quiesce_for_update().await; + + drop(logging); + std::fs::set_permissions(&git_dir, std::fs::Permissions::from_mode(0o755)) + .expect("writable Git directory"); + assert_eq!(first, Ok(()), "a leftover lock does not block the update"); + assert_eq!(second, Ok(()), "a later quiesce still succeeds"); + assert!(lock.exists(), "the lock could not be removed"); + assert_eq!( + session_state(&state), + "stopping", + "the shutdown was incomplete" + ); + let log = String::from_utf8(log.0.lock().expect("log lock").clone()).expect("utf8"); + let named = crate::diagnostics::profiler::redact::redact_path(&worktree.to_string_lossy()); + assert_eq!( + log.matches(named.as_str()).count(), + 2, + "each quiesce names the worktree in an error:\n{log}" + ); + } + + /// A plain shutdown whose Git write never stops still releases the Profiler, so the next + /// server can record to the same state directory. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn index_lock_cleanup_shutdown_with_a_pending_write_releases_the_profiler() { + let state = TempDir::new().expect("state"); + let runtime = start_quiesce_runtime(&state).await; + let _write = runtime.git_repository.hold_write_for_test(); + + runtime.shutdown().await; + + assert_eq!( + session_state(&state), + "stopping", + "the shutdown was incomplete" + ); + let state_dir = ServerConfig::new(state.path()).state_dir(); + let status = tokio::task::spawn_blocking(move || { + ProfilerRuntime::start(&state_dir, tokio::sync::watch::channel(false).1, Vec::new()) + .status() + }) + .await + .expect("the next Profiler starts"); + assert_eq!(status["capabilities"]["storage"], "ok", "{status}"); + } } diff --git a/apps/server/src/production/server_terminal.rs b/apps/server/src/production/server_terminal.rs index 30ddcf322c..a9baa0b69e 100644 --- a/apps/server/src/production/server_terminal.rs +++ b/apps/server/src/production/server_terminal.rs @@ -17,11 +17,13 @@ use crate::{ cloud::{RelayClientInstallEvent, RelayClientService, RelayClientStatus}, diagnostics::{ AttributedProcess, AttributionConfidence, AttributionKind, AttributionScope, BucketMetric, - CurrentProcessDiagnostics, DiagnosticsMonitor, NativeProcessSampler, NativeResourceSampler, - ProcessAttributionRegistry, ProcessAttributionTotals, ProcessIdentity, - ProcessResourceHistory, ProcessResourceTotals, ProcessRow, ProcessSignal, - RuntimeProcessOwnership, SplitMetric, UiCoverage, UiCoverageStatus, - bound_diagnostic_string, process_tree_metadata, + CurrentProcessDiagnostics, DiagnosticsMonitor, HostMetricsCollector, HostMetricsIdentity, + HostMetricsRates, NativeProcessSampler, NativeResourceSampler, ProcessAttributionRegistry, + ProcessAttributionTotals, ProcessIdentity, ProcessResourceHistory, ProcessResourceTotals, + ProcessRow, ProcessSignal, RuntimeProcessOwnership, SplitMetric, UiCoverage, + UiCoverageStatus, bound_diagnostic_string, + host_metrics::{NativeHostMetricsSource, encode_snapshot, send_host_metrics}, + process_tree_metadata, profiler::{client::ingest_client_batch, runtime::ProfilerRuntime}, }, persistence::{OrchestrationEvent, Repositories}, @@ -34,8 +36,8 @@ use crate::{ rpc::{RpcRegistry, RpcResult, RpcStreamChunk}, terminal::{ AttachmentItem, InputReadmissionScope, TerminalAttachInput, TerminalError, TerminalEvent, - TerminalInputAdmission, TerminalLaunchCommand, TerminalManager, TerminalMetadataEvent, - TerminalOpenInput, TerminalSessionIdentity, + TerminalInputAdmission, TerminalInputOrigin, TerminalLaunchCommand, TerminalManager, + TerminalMetadataEvent, TerminalOpenInput, TerminalSessionIdentity, }, worktree_catalog::{ WorkspaceAdmissionCancellation, WorkspaceAdmissionLease, WorkspaceAvailabilityRegistry, @@ -108,6 +110,8 @@ pub struct ServerTerminalServices { profiler: Option>, /// Tags client profiler reports; empty when the host sets none. environment_id: Arc, + /// The host collector behind `server.subscribeHostMetrics`; idle until started or watched. + host_metrics: Arc, } impl ServerTerminalServices { @@ -168,6 +172,14 @@ impl ServerTerminalServices { workspace_watch: CancellationToken::new(), profiler: None, environment_id: Arc::from(""), + host_metrics: Arc::new(HostMetricsCollector::new( + Arc::new(NativeHostMetricsSource::new()), + HostMetricsRates::default(), + HostMetricsIdentity { + boot_id: None, + started_at_ms: OffsetDateTime::now_utc().unix_timestamp_nanos() / 1_000_000, + }, + )), } } @@ -177,6 +189,13 @@ impl ServerTerminalServices { self } + /// Serves this collector instead of the idle default. + #[must_use] + pub fn with_host_metrics(mut self, collector: Arc) -> Self { + self.host_metrics = collector; + self + } + #[must_use] pub(crate) fn with_environment_id(mut self, environment_id: &str) -> Self { self.environment_id = Arc::from(environment_id); @@ -617,6 +636,25 @@ async fn send_discovered_local_servers( } fn register_diagnostics_rpcs(registry: &mut RpcRegistry, services: &ServerTerminalServices) { + let collector = services.host_metrics.clone(); + registry.register_stream( + "server.subscribeHostMetrics", + move |_request, cancellation| { + let collector = collector.clone(); + spawn_stream(cancellation, move |sender, cancellation| { + send_host_metrics(collector, sender, cancellation) + }) + }, + ); + + // One Snapshot for the MCP `host_metrics` tool; it answers from memory and never switches + // the collector to the live rate. + let collector = services.host_metrics.clone(); + registry.register_unary("server.getHostMetrics", move |_request, _cancellation| { + let collector = collector.clone(); + async move { Ok(encode_snapshot(&collector.snapshot().await)) } + }); + let monitor = services.process_monitor.clone(); registry.register_unary( "server.getProcessDiagnostics", @@ -945,10 +983,11 @@ fn register_terminal_rpcs(registry: &mut RpcRegistry, services: &ServerTerminalS let availability = availability.clone(); async move { let input: TerminalWritePayload = decode_payload(&request.payload)?; + let origin = terminal_input_origin(request.payload.get("userInputClaim"))?; let _workspace_admission = acquire_terminal_thread(availability.as_ref(), &input.thread_id).await?; terminal - .write(&input.thread_id, &input.terminal_id, &input.data) + .write_from(&input.thread_id, &input.terminal_id, &input.data, origin) .await .map_err(terminal_error)?; Ok(Value::Null) @@ -962,17 +1001,23 @@ fn register_terminal_rpcs(registry: &mut RpcRegistry, services: &ServerTerminalS |terminal, payload| { Box::pin(async move { let input: TerminalResizePayload = decode_payload(&payload)?; - terminal - .resize( + let result = terminal + .resize_acknowledging( &input.thread_id, &input.terminal_id, input.cols, input.rows, validate_terminal_size_claim(payload.get("sizeClaim"))?, + input.structured_result, ) .await .map_err(terminal_error)?; - Ok(Value::Null) + // An older client decodes this result as void, which accepts only null (#348). + Ok(if input.structured_result { + serde_json::to_value(result).expect("terminal resize result serializes") + } else { + Value::Null + }) }) }, ); @@ -1221,9 +1266,10 @@ fn register_ordered_input_rpcs(registry: &mut RpcRegistry, services: &ServerTerm return Err(json!({"_tag":"TerminalInputError", "code":"capacity", "message":"Terminal input exceeded its frame limit. Reconnect input before typing again."})); } let input: TerminalInputFramePayload = decode_payload(&request.payload)?; + let origin = terminal_input_origin(request.payload.get("userInputClaim"))?; // In memory only: the session's lease already holds the workspace admission // `terminal.beginInput` took, so a keystroke never waits for the database. - let frame = terminal.prepare_input_write(context.connection_id(), &input.thread_id, &input.terminal_id, &input.input_id, input.sequence, &input.data).map_err(terminal_error)?; + let frame = terminal.prepare_input_write(context.connection_id(), &input.thread_id, &input.terminal_id, &input.input_id, input.sequence, &input.data).map_err(terminal_error)?.with_origin(origin); // Loss of the session's current admission seals it, which fails this frame // wherever it waits; report that loss rather than the sealed session. let seal = frame.seal(); @@ -1490,6 +1536,23 @@ fn validate_terminal_size_claim(value: Option<&Value>) -> Result, Ok(Some(claim.to_owned())) } +/// `userInputClaim`: absent from an older client, `null` for the terminal's own replies, else +/// the size claim of the window a user typed in (#348). +fn terminal_input_origin(value: Option<&Value>) -> Result, Value> { + match value { + None => Ok(TerminalInputOrigin::Unmarked), + Some(Value::Null) => Ok(TerminalInputOrigin::Automatic), + Some(Value::String(claim)) => { + let claim = trim_ecmascript(claim); + if claim.is_empty() || claim.encode_utf16().count() > TERMINAL_SIZE_CLAIM_MAX_LENGTH { + return Err(invalid_request("userInputClaim must be a size claim")); + } + Ok(TerminalInputOrigin::User(claim)) + } + Some(_) => Err(invalid_request("userInputClaim must be a string or null")), + } +} + fn validate_terminal_launch_command( command: Option, ) -> Result, Value> { @@ -1677,6 +1740,9 @@ struct TerminalInputFramePayload { input_id: String, sequence: u64, data: String, + /// Read from the payload by [`terminal_input_origin`], which tells null from absent. + #[serde(default, rename = "userInputClaim")] + _user_input_claim: serde::de::IgnoredAny, } #[derive(Deserialize)] @@ -1693,6 +1759,8 @@ struct TerminalResizePayload { terminal_id: String, cols: u16, rows: u16, + #[serde(default)] + structured_result: bool, } #[derive(Deserialize)] #[serde(rename_all = "camelCase")] diff --git a/apps/server/src/production/server_terminal_input_tests.rs b/apps/server/src/production/server_terminal_input_tests.rs index 72c6fa0167..73eb8bf1db 100644 --- a/apps/server/src/production/server_terminal_input_tests.rs +++ b/apps/server/src/production/server_terminal_input_tests.rs @@ -323,6 +323,7 @@ fn project_row(project_id: &str, workspace_root: &Path) -> ProjectionProject { updated_at: "2026-10-12T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), } } @@ -361,6 +362,7 @@ fn thread_row_in( deleted_at: None, host_thread_id: None, compaction: None, + code_review: false, } } @@ -470,6 +472,79 @@ async fn write_input_does_not_wait_for_a_busy_database_once_input_begins() { fixture.shutdown().await; } +/// An older client decodes `terminal.resize` as void, a null-only JSON codec: an object +/// answer would turn every successful resize into a failure there (#348). +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_resize_answers_its_outcome_only_to_a_caller_that_reads_it() { + let mut fixture = Fixture::start().await; + let resize = |cols: u16, structured: Option| { + let mut payload = + json!({"threadId": THREAD, "terminalId": TERMINAL, "cols": cols, "rows": 30}); + if let Some(structured) = structured { + payload["structuredResult"] = json!(structured); + } + payload + }; + + let older_client = success(fixture.request("terminal.resize", resize(100, None)).await); + assert_eq!( + older_client, + Value::Null, + "an older client gets the null it decodes" + ); + let declined = success( + fixture + .request("terminal.resize", resize(101, Some(false))) + .await, + ); + assert_eq!(declined, Value::Null); + + let newer_client = success( + fixture + .request("terminal.resize", resize(102, Some(true))) + .await, + ); + assert_eq!(newer_client["outcome"], "applied"); + assert_eq!(newer_client["size"]["cols"], 102); + assert!(newer_client["sequence"].is_u64()); + fixture.shutdown().await; +} + +/// A frame or write says who produced it: a window's size claim, or null for the terminal's +/// own replies (#348). Frames refuse unknown fields, so a client sends this only to servers +/// advertising `terminalUserInputClaim`. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn input_says_who_produced_it_and_a_malformed_origin_is_refused() { + let mut fixture = Fixture::start().await; + let input = fixture.begin_input(0).await; + let marked = |sequence: u64, claim: Value| { + let mut frame = Fixture::frame(&input, sequence, "a"); + frame["userInputClaim"] = claim; + frame + }; + success( + fixture + .request("terminal.writeInput", marked(0, json!("claim-a"))) + .await, + ); + success( + fixture + .request("terminal.writeInput", marked(1, Value::Null)) + .await, + ); + let write = |claim: Value| json!({"threadId": THREAD, "terminalId": TERMINAL, "data": "b", "userInputClaim": claim}); + success(fixture.request("terminal.write", write(Value::Null)).await); + assert_eq!(fixture.backend.writes(), ["a", "a", "b"]); + failure(fixture.request("terminal.write", write(json!(7))).await); + failure( + fixture + .request("terminal.writeInput", marked(2, json!(""))) + .await, + ); + assert_eq!(fixture.backend.writes(), ["a", "a", "b"]); + fixture.shutdown().await; +} + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn workspace_loss_cancels_pending_and_later_input_and_releases_the_admission() { let mut fixture = Fixture::start().await; diff --git a/apps/server/src/production/tool_maintenance.rs b/apps/server/src/production/tool_maintenance.rs index c37ad5c2a4..1c7a41627c 100644 --- a/apps/server/src/production/tool_maintenance.rs +++ b/apps/server/src/production/tool_maintenance.rs @@ -904,6 +904,7 @@ impl ToolMaintenance { timeout: UPDATE_TIMEOUT, cleanup_timeout: Duration::from_secs(2), max_output_bytes: OUTPUT_LIMIT, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Truncate, }, &self.inner.updater_cancellation, diff --git a/apps/server/src/production/turn_delivery_queue_tests.rs b/apps/server/src/production/turn_delivery_queue_tests.rs index 0a5ab5e69c..2a10ca089d 100644 --- a/apps/server/src/production/turn_delivery_queue_tests.rs +++ b/apps/server/src/production/turn_delivery_queue_tests.rs @@ -1657,6 +1657,12 @@ impl HeldRefZeroUpdate { /// Long enough for the capture's Git steps before `update-ref` under load, then given up. const LIVE_WRITER_LIMIT: Duration = Duration::from_secs(15); +/// How long a test waits for the turn once the update is held. The held pause ignores +/// cancellation, as a stalled spawn does, so the baseline gives up only after the limit and then +/// the whole grace for cancelled steps; the margin covers a loaded runner. +const LIVE_WRITER_WAIT: Duration = LIVE_WRITER_LIMIT + .saturating_add(crate::production::orchestration_effects::CANCELLED_BASELINE_GRACE) + .saturating_add(Duration::from_secs(30)); /// A ref 0 update still running at the limit is stopped before the turn is claimed: released /// afterwards, it never writes ref 0. @@ -1679,7 +1685,7 @@ async fn a_live_ref_zero_writer_is_stopped_before_the_claim() { held.wait_entered().await; quiet(&mut routes).await; assert_eq!( - timeout(LIVE_WRITER_LIMIT + Duration::from_secs(10), routes.recv()) + timeout(LIVE_WRITER_WAIT, routes.recv()) .await .expect("the turn goes after the limit") .as_deref(), @@ -1715,7 +1721,7 @@ async fn a_late_ref_zero_write_after_the_claim_publishes_the_pre_turn_checkout() held.wait_entered().await; let commit = held.commit(); assert_eq!( - timeout(LIVE_WRITER_LIMIT + Duration::from_secs(10), routes.recv()) + timeout(LIVE_WRITER_WAIT, routes.recv()) .await .expect("the turn goes after the limit") .as_deref(), diff --git a/apps/server/src/production/worktree_catalog_rpc.rs b/apps/server/src/production/worktree_catalog_rpc.rs index fa537b0153..0f76724572 100644 --- a/apps/server/src/production/worktree_catalog_rpc.rs +++ b/apps/server/src/production/worktree_catalog_rpc.rs @@ -1347,6 +1347,28 @@ async fn create_managed_worktree_locked( None, )) })?; + let repositories = services.orchestration.repositories(); + if let Err(error) = repositories + .record_worktree_creation(&project.workspace_root, &created.worktree.path) + .await + { + tracing::error!(%error, "managed worktree creation could not record the worktree"); + let rollback = repository + .rollback_managed_worktree_creation(Path::new(&project.workspace_root), &rollback) + .await; + let detail = match rollback { + Ok(()) => format!("The worktree creation could not be recorded: {error}"), + Err(rollback) => format!( + "The worktree creation could not be recorded: {error}; exact managed-worktree rollback also failed: {}", + rollback.detail + ), + }; + return Err(encode(adoption_error( + WorktreeAdoptionErrorReason::Internal, + detail, + None, + ))); + } let terminal_cancellation = CancellationToken::new(); let dispatch = services .orchestration @@ -1366,6 +1388,7 @@ async fn create_managed_worktree_locked( branch: Some(created.worktree.ref_name.clone()), worktree_path: Some(created.worktree.path.clone()), created_at: now_iso(), + code_review: false, }, CommandAdmission { payload_digest, @@ -1380,6 +1403,13 @@ async fn create_managed_worktree_locked( let rollback = repository .rollback_managed_worktree_creation(Path::new(&project.workspace_root), &rollback) .await; + if rollback.is_ok() + && let Err(error) = repositories + .delete_worktree_creation_record(&created.worktree.path) + .await + { + tracing::warn!(%error, "a rolled-back worktree kept its creation record"); + } let detail = match rollback { Ok(()) => error.to_string(), Err(rollback) => format!( @@ -1513,6 +1543,7 @@ async fn create_worktree_panel( kind: Some("panel".to_owned()), host_thread_id: Some(host.thread_id), side_chat: input.side_chat, + code_review: input.code_review, purpose: input.purpose, model_selection: input.thread_defaults.model_selection, runtime_mode: input.thread_defaults.runtime_mode, @@ -1805,6 +1836,8 @@ struct ResolvedRemovalPlan { struct ResolvedRemovalThread { path: PathBuf, known_thread_ids: Vec, + /// The worktree path as stored, the key of the worktree's creation record. + creation_record: String, } #[derive(Clone, Serialize)] @@ -2375,6 +2408,15 @@ async fn remove_worktree_owned( .map_err(encode)?; drop(guard); if matches!(git_outcome, "removed" | "cleaned") { + // The folder is gone. A failed delete is reaped by the next worktree audit. + if let Err(error) = services + .orchestration + .repositories() + .delete_worktree_creation_record(&resolved.creation_record) + .await + { + tracing::warn!(%error, "a removed worktree kept its creation record"); + } services .catalog .invalidate_repository_after_mutation(&input.project_id) @@ -2510,6 +2552,7 @@ async fn resolve_removal_thread( Ok(ResolvedRemovalThread { path, known_thread_ids, + creation_record: persisted_path.to_string_lossy().into_owned(), }) } @@ -3375,6 +3418,7 @@ async fn update_discovery_policy_locked( workspace_root: None, default_model_selection: OptionalNullable::Missing, scripts: None, + code_review_prompt: None, worktree_discovery: Some(policy_value.clone()), }; if legacy_receipt { @@ -3499,6 +3543,10 @@ struct WorktreeCreatePanelInput { /// digest of a receipt stored before side chats existed. #[serde(default, skip_serializing_if = "std::ops::Not::not")] side_chat: bool, + /// A code review panel (#293). Omitted when false, so a plain panel request keeps the + /// digest of a receipt stored before code reviews existed. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + code_review: bool, } #[derive(Deserialize, Serialize)] @@ -4193,6 +4241,7 @@ mod mutation_invalidation_tests { branch: Some("feature/remove".to_owned()), worktree_path: Some(self.linked.to_string_lossy().into_owned()), created_at: "2026-08-23T00:00:01Z".to_owned(), + code_review: false, }) .await .expect("thread created"); @@ -5344,6 +5393,251 @@ mod mutation_invalidation_tests { fixture.shutdown().await; } + fn managed_create_payload(command_id: &str, ref_name: &str) -> serde_json::Value { + json!({ + "commandId":command_id, + "projectId":"project-1", + "threadId":format!("{command_id}-thread"), + "title":"Managed", + "refName":ref_name, + "newRefName":null, + "baseRefName":null, + "threadDefaults":{ + "modelSelection":{"instanceId":"codex","model":"gpt-5"}, + "runtimeMode":"full-access", + "interactionMode":"default" + } + }) + } + + async fn create_managed( + fixture: &HandlerFixture, + command_id: &str, + ref_name: &str, + ) -> super::RpcResult { + create_managed_worktree( + &fixture.services, + request( + "worktree.createManaged", + managed_create_payload(command_id, ref_name), + ), + None, + CancellationToken::new(), + ) + .await + } + + async fn creation_records(fixture: &HandlerFixture) -> Vec { + fixture + .engine + .repositories() + .list_worktree_creation_records() + .await + .expect("creation records") + .into_iter() + .map(|record| record.worktree_path) + .collect() + } + + #[tokio::test] + async fn worktree_creation_record_follows_create_and_remove() { + let fixture = HandlerFixture::new(Arc::new(super::NoopWorktreeRemovalQuiescer)).await; + git(&fixture.main, &["branch", "feature/recorded"]); + let created = create_managed(&fixture, "create-recorded", "feature/recorded") + .await + .expect("managed creation succeeds"); + let path = created["path"].as_str().expect("created path").to_owned(); + assert_eq!( + creation_records(&fixture).await, + std::slice::from_ref(&path) + ); + + create_managed(&fixture, "create-missing", "feature/does-not-exist") + .await + .expect_err("a missing ref fails the creation"); + assert_eq!( + creation_records(&fixture).await, + std::slice::from_ref(&path), + "a failed creation writes no record" + ); + + fixture + .catalog + .refresh("project-1", CatalogRefreshTrigger::Explicit) + .await + .expect("catalog includes the created worktree"); + let plan = get_removal_plan( + &fixture.services, + request( + "worktree.getRemovalPlan", + json!({"projectId":"project-1","threadId":"create-recorded-thread"}), + ), + CancellationToken::new(), + ) + .await + .expect("removal plan"); + remove_worktree( + &fixture.services, + request( + "worktree.remove", + json!({ + "commandId":"remove-recorded", "projectId":"project-1", + "threadId":"create-recorded-thread", "mode":"delete-git-worktree", + "expectedGeneration":plan["generation"], "planToken":plan["planToken"], + "forceDirty":false, "confirmRepositoryWidePrune":false + }), + ), + CancellationToken::new(), + ) + .await + .expect("removal succeeds"); + assert!(!Path::new(&path).exists(), "the worktree folder is gone"); + assert!( + creation_records(&fixture).await.is_empty(), + "the record ends with the worktree" + ); + fixture.shutdown().await; + } + + #[tokio::test] + async fn a_bootstrap_worktree_record_ends_with_its_catalog_removal() { + let fixture = HandlerFixture::new(Arc::new(super::NoopWorktreeRemovalQuiescer)).await; + // As a turn's bootstrap creates a worktree: the record keeps the project root the + // client sent, here with a trailing separator, and the thread the created path. + let created = GitRepository::default() + .ensure_worktree( + crate::git::CreateWorktreeInput { + cwd: fixture.main.clone(), + ref_name: "main".to_owned(), + new_ref_name: Some("bibcode/bootstrap".to_owned()), + base_ref_name: None, + path: None, + }, + &CancellationToken::new(), + ) + .await + .expect("bootstrap worktree"); + let path = created.worktree.path.clone(); + fixture + .engine + .repositories() + .record_worktree_creation(&format!("{}/", fixture.main.to_string_lossy()), &path) + .await + .expect("creation record"); + fixture + .engine + .dispatch(OrchestrationCommand::ThreadCreate { + command_id: "bootstrap-create".to_owned(), + thread_id: "bootstrap-thread".to_owned(), + project_id: "project-1".to_owned(), + title: "Bootstrap".to_owned(), + kind: Some("workspace".to_owned()), + host_thread_id: None, + side_chat: false, + purpose: None, + model_selection: json!({"instanceId":"codex","model":"gpt-5"}), + runtime_mode: "full-access".to_owned(), + interaction_mode: "default".to_owned(), + branch: Some(created.worktree.ref_name.clone()), + worktree_path: Some(path.clone()), + created_at: "2026-08-23T00:00:01Z".to_owned(), + code_review: false, + }) + .await + .expect("thread created"); + fixture + .catalog + .refresh("project-1", CatalogRefreshTrigger::Explicit) + .await + .expect("catalog includes the bootstrap worktree"); + let plan = get_removal_plan( + &fixture.services, + request( + "worktree.getRemovalPlan", + json!({"projectId":"project-1","threadId":"bootstrap-thread"}), + ), + CancellationToken::new(), + ) + .await + .expect("removal plan"); + remove_worktree( + &fixture.services, + request( + "worktree.remove", + json!({ + "commandId":"remove-bootstrap", "projectId":"project-1", + "threadId":"bootstrap-thread", "mode":"delete-git-worktree", + "expectedGeneration":plan["generation"], "planToken":plan["planToken"], + "forceDirty":false, "confirmRepositoryWidePrune":false + }), + ), + CancellationToken::new(), + ) + .await + .expect("removal succeeds"); + assert!(!Path::new(&path).exists(), "the worktree folder is gone"); + assert!( + creation_records(&fixture).await.is_empty(), + "the record ends with the worktree, whatever the project spelling" + ); + fixture.shutdown().await; + } + + #[tokio::test] + async fn a_creation_whose_record_cannot_be_written_fails_and_leaves_no_worktree() { + let fixture = HandlerFixture::new(Arc::new(super::NoopWorktreeRemovalQuiescer)).await; + git(&fixture.main, &["branch", "feature/unrecorded"]); + fixture + .engine + .repositories() + .database() + .call(|connection| { + connection.execute_batch("DROP TABLE worktree_creation_records")?; + Ok(()) + }) + .await + .expect("records table removed"); + let managed = GitRepository::default() + .managed_worktree_parent(&fixture.main) + .await + .expect("managed worktree directory"); + + create_managed(&fixture, "create-unrecorded", "feature/unrecorded") + .await + .expect_err("an unwritable record fails the creation"); + + let leftovers: Vec<_> = std::fs::read_dir(&managed) + .map(|entries| { + entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .collect() + }) + .unwrap_or_default(); + assert!(leftovers.is_empty(), "no worktree folder: {leftovers:?}"); + let inventory = GitRepository::default() + .worktree_inventory(&fixture.main, &CancellationToken::new()) + .await + .expect("repository remains readable"); + assert_eq!( + inventory.records.len(), + 1, + "Git lists only the primary checkout: {:?}", + inventory.records + ); + assert!( + fixture + .engine + .repositories() + .get_thread("create-unrecorded-thread".to_owned()) + .await + .expect("thread read") + .is_none(), + "no workspace thread owns the path" + ); + fixture.shutdown().await; + } + #[tokio::test] async fn removal_invalidates_only_after_accepted_terminal_receipt() { let entered = Arc::new(Semaphore::new(0)); @@ -5381,6 +5675,7 @@ mod mutation_invalidation_tests { branch: Some("feature/remove".to_owned()), worktree_path: Some(linked.to_string_lossy().into_owned()), created_at: "2026-08-23T00:00:01Z".to_owned(), + code_review: false, }) .await .expect("thread created"); @@ -5547,6 +5842,52 @@ mod mutation_invalidation_tests { fixture.shutdown().await; } + #[tokio::test] + async fn code_review_thread_panel_is_marked_and_survives_replay() { + let fixture = HandlerFixture::new(Arc::new(super::NoopWorktreeRemovalQuiescer)).await; + create_host(&fixture).await; + let mut review = panel_request("review", None); + review["codeReview"] = json!(true); + let created = create_panel(&fixture, review.clone()) + .await + .expect("review panel created"); + assert_eq!(created["threadId"], "review-thread"); + let thread = fixture + .engine + .repositories() + .get_thread("review-thread".to_owned()) + .await + .expect("review panel read") + .expect("review panel exists"); + assert_eq!(thread.kind, "panel"); + assert!(thread.code_review); + assert!(!thread.side_chat); + assert_eq!(thread.host_thread_id.as_deref(), Some("host-thread")); + // The same command id replays the same thread; dropping the marker is another request. + assert_eq!( + create_panel(&fixture, review).await.expect("replay")["threadId"], + "review-thread" + ); + let conflict = create_panel(&fixture, panel_request("review", None)) + .await + .expect_err("a changed marker conflicts"); + assert_eq!(conflict["reason"], "command-conflict"); + fixture.shutdown().await; + } + + #[test] + fn code_review_thread_plain_panel_keeps_its_receipt_digest() { + let old_format = panel_request("old", None); + let mut explicit_false = panel_request("old", None); + explicit_false["codeReview"] = json!(false); + let decoded: super::WorktreeCreatePanelInput = + serde_json::from_value(explicit_false).expect("decode"); + assert_eq!( + crate::orchestration::canonical_command_digest(&decoded).expect("digest"), + crate::orchestration::canonical_command_digest(&old_format).expect("old digest"), + ); + } + #[tokio::test] async fn create_panel_replays_a_receipt_stored_before_side_chats() { // A receipt stored before side chats hashed the input without a `sideChat` key, so diff --git a/apps/server/src/production/worktree_runtime.rs b/apps/server/src/production/worktree_runtime.rs index d373745384..0f68bb3737 100644 --- a/apps/server/src/production/worktree_runtime.rs +++ b/apps/server/src/production/worktree_runtime.rs @@ -2827,6 +2827,7 @@ mod tests { updated_at: "2026-08-09T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project"); @@ -2864,6 +2865,7 @@ mod tests { deleted_at: None, host_thread_id: None, compaction: None, + code_review: false, }) .await .expect("thread"); diff --git a/apps/server/src/provider_terminal/claude.rs b/apps/server/src/provider_terminal/claude.rs index b5d2b29761..4bfad507cd 100644 --- a/apps/server/src/provider_terminal/claude.rs +++ b/apps/server/src/provider_terminal/claude.rs @@ -733,7 +733,14 @@ impl ClaudeTerminalObserverFactory { &self, input: ProviderTerminalObserverFactoryInput, ) -> Option { - if unsafe_interactive_settings_args(&input.launch.args) { + // A review ends its options with `--`: what follows is the prompt, never an option. + let end_of_options = input + .launch + .args + .iter() + .position(|arg| arg == "--") + .unwrap_or(input.launch.args.len()); + if unsafe_interactive_settings_args(&input.launch.args[..end_of_options]) { note_pass_through("unsafe_settings_args"); return None; } @@ -848,8 +855,13 @@ impl ClaudeTerminalObserverFactory { }), }; let mut args = input.launch.args; - args.push("--settings".to_owned()); - args.push(overlay_path.to_string_lossy().into_owned()); + args.splice( + end_of_options..end_of_options, + [ + "--settings".to_owned(), + overlay_path.to_string_lossy().into_owned(), + ], + ); Some(PreparedTerminalLaunch { executable: pinned_executable.to_string_lossy().into_owned(), args, @@ -2313,6 +2325,7 @@ async fn run_attributed_helper( timeout, cleanup_timeout: Duration::from_millis(500), max_output_bytes, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Error, }, &CancellationToken::new(), @@ -2773,6 +2786,7 @@ impl ClaudeCapabilityProbeRunner for SystemClaudeCapabilityProbeRunner { timeout: self.timeout, cleanup_timeout: Duration::from_secs(1), max_output_bytes: CLAUDE_PROBE_OUTPUT_LIMIT, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Truncate, }; #[cfg(test)] diff --git a/apps/server/src/provider_terminal/codex.rs b/apps/server/src/provider_terminal/codex.rs index 39b3a346f8..c95c8eb308 100644 --- a/apps/server/src/provider_terminal/codex.rs +++ b/apps/server/src/provider_terminal/codex.rs @@ -570,9 +570,18 @@ impl CodexTerminalObserverFactory { return Err(CodexPreparationSkip::new("unsupported_capabilities")); } stage.set("arguments"); - let args = remote_tui_args(&input.launch.args) - .ok_or_else(|| CodexPreparationSkip::new("rejected_args"))?; + // A review ends its options with `--`: the prompt after it stays last, unparsed. + let end_of_options = input + .launch + .args + .iter() + .position(|arg| arg == "--") + .unwrap_or(input.launch.args.len()); + let (options, positional) = input.launch.args.split_at(end_of_options); + let args = + remote_tui_args(options).ok_or_else(|| CodexPreparationSkip::new("rejected_args"))?; let expected_cwd = effective_tui_cwd(&args, &input.launch.cwd); + let positional = positional.to_vec(); let generation_key = input.launch.generation.id().simple().to_string(); stage.set("generation_dir"); let generation_dir = create_owned_generation_directory( @@ -623,6 +632,7 @@ impl CodexTerminalObserverFactory { let mut tui_args = args; tui_args.push("--remote".to_owned()); tui_args.push(endpoint.clone()); + tui_args.extend(positional); let observer = CodexPreparedTerminalObserver { inner: Arc::new(CodexObserverInner { resources, @@ -2674,6 +2684,7 @@ impl CodexCapabilityProbeRunner for SystemCodexCapabilityProbeRunner { timeout: self.timeout, cleanup_timeout: self.cleanup_timeout, max_output_bytes: CODEX_PROBE_OUTPUT_LIMIT, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Truncate, }; let cancellation = tokio_util::sync::CancellationToken::new(); diff --git a/apps/server/src/provider_terminal/opencode.rs b/apps/server/src/provider_terminal/opencode.rs index a465711503..b45343535c 100644 --- a/apps/server/src/provider_terminal/opencode.rs +++ b/apps/server/src/provider_terminal/opencode.rs @@ -2162,6 +2162,7 @@ impl OpenCodeCapabilityProbeRunner for SystemOpenCodeCapabilityProbeRunner { timeout: Duration::from_millis(900), cleanup_timeout: Duration::from_secs(2), max_output_bytes: OPENCODE_PROBE_OUTPUT_LIMIT, + stop_policy: crate::process::supervised::StopPolicy::Force, overflow: SupervisedOverflow::Truncate, }, &tokio_util::sync::CancellationToken::new(), diff --git a/apps/server/src/rpc/methods.rs b/apps/server/src/rpc/methods.rs index 01bb404aff..a4dab67d88 100644 --- a/apps/server/src/rpc/methods.rs +++ b/apps/server/src/rpc/methods.rs @@ -169,6 +169,7 @@ pub const ACTIVE_RPC_METHODS: &[RpcMethodSpec] = &[ read_unary("server.discoverSourceControl"), read_stream("server.downloadDiagnosticLogs"), read_unary("server.getConfig"), + read_unary("server.getHostMetrics"), read_unary("server.getProcessDiagnostics"), read_unary("server.getProcessResourceHistory"), read_unary("server.getProfilerStatus"), @@ -183,6 +184,7 @@ pub const ACTIVE_RPC_METHODS: &[RpcMethodSpec] = &[ mutation_unary("server.sendDiagnosticLogsRelayItem"), mutation_unary("server.signalProcess"), read_stream("server.subscribeDiagnosticLogsRelay"), + read_stream("server.subscribeHostMetrics"), mutation_unary("server.updateProvider"), mutation_unary("server.updateSettings"), mutation_unary("server.updateTool"), diff --git a/apps/server/src/terminal/history.rs b/apps/server/src/terminal/history.rs index 7d38a22c39..3eb1de1ccd 100644 --- a/apps/server/src/terminal/history.rs +++ b/apps/server/src/terminal/history.rs @@ -7,6 +7,32 @@ const DEQUE_SHRINK_OCCUPANCY_DENOMINATOR: usize = 4; /// ample replay headroom above the 512 KiB client transcript cap. const DEFAULT_BYTE_LIMIT: usize = 1_048_576; +/// The cut a released client applies to an attach snapshot: `DEFAULT_MAX_TERMINAL_BUFFER_BYTES` +/// in `packages/client-runtime/src/state/terminalTranscript.ts` (pinned by a test). A history +/// retaining more replays only its last this-many bytes in that client. +#[cfg(unix)] +const CLIENT_TRANSCRIPT_CUT_BYTES: usize = 512 * 1024; + +/// Why replaying a history may not rebuild the program's screen (#348). +#[cfg(unix)] +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum LossyReplay { + /// Output before the retained bytes was evicted or cleared. + DiscardedPrefix, + /// Everything is retained, but more than a released client keeps of a snapshot. + OverClientCut, +} + +#[cfg(unix)] +impl LossyReplay { + pub(crate) fn as_str(self) -> &'static str { + match self { + Self::DiscardedPrefix => "discarded prefix", + Self::OverClientCut => "over the client cut", + } + } +} + /// Bounded, chunked terminal scrollback. /// /// Each push scans only the new data for line boundaries. Eviction advances an @@ -173,6 +199,24 @@ impl TerminalHistory { self.total_bytes_written } + /// Whether output before the retained bytes is gone: evicted by a limit, or cleared. + #[cfg(unix)] + pub(crate) fn has_discarded_prefix(&self) -> bool { + self.total_bytes_written > self.total_bytes as u64 + } + + /// Whether a client replaying this history misses output, and why. O(1): counters only. + #[cfg(unix)] + pub(crate) fn lossy_replay(&self) -> Option { + if self.has_discarded_prefix() { + Some(LossyReplay::DiscardedPrefix) + } else if self.total_bytes > CLIENT_TRANSCRIPT_CUT_BYTES { + Some(LossyReplay::OverClientCut) + } else { + None + } + } + /// The offset of the oldest retained byte. fn oldest(&self) -> u64 { self.total_bytes_written - self.total_bytes as u64 @@ -380,6 +424,8 @@ fn byte_index_after_nth_newline(data: &str, count: usize) -> usize { #[cfg(test)] mod tests { + #[cfg(unix)] + use super::{CLIENT_TRANSCRIPT_CUT_BYTES, LossyReplay}; use super::{HistoryCursor, TerminalHistory}; fn legacy_push(history: &mut String, data: &str, line_limit: usize) { @@ -511,6 +557,59 @@ mod tests { assert_eq!(history.line_limit(), 0); } + #[cfg(unix)] + #[test] + fn a_complete_history_under_the_client_cut_replays_without_loss() { + let mut history = TerminalHistory::new(5_000); + assert_eq!(history.lossy_replay(), None); + history.push(&"x".repeat(CLIENT_TRANSCRIPT_CUT_BYTES)); + assert!(!history.has_discarded_prefix()); + assert_eq!(history.lossy_replay(), None); + } + + #[cfg(unix)] + #[test] + fn eviction_and_clearing_leave_a_discarded_prefix() { + let mut evicted = TerminalHistory::new(2); + evicted.push("one\ntwo\nthree\n"); + assert!(evicted.has_discarded_prefix()); + assert_eq!(evicted.lossy_replay(), Some(LossyReplay::DiscardedPrefix)); + + let mut by_bytes = TerminalHistory::with_byte_limit(5_000, 4); + by_bytes.push("abcdef"); + assert_eq!(by_bytes.lossy_replay(), Some(LossyReplay::DiscardedPrefix)); + + let mut cleared = TerminalHistory::new(5_000); + cleared.clear(); + assert_eq!(cleared.lossy_replay(), None, "nothing was ever written"); + cleared.push("prompt"); + cleared.clear(); + cleared.push("fresh"); + assert_eq!(cleared.lossy_replay(), Some(LossyReplay::DiscardedPrefix)); + } + + #[cfg(unix)] + #[test] + fn a_complete_history_over_the_client_cut_is_lossy_for_a_released_client() { + let mut history = TerminalHistory::new(5_000); + history.push(&"x".repeat(CLIENT_TRANSCRIPT_CUT_BYTES + 1)); + assert!(!history.has_discarded_prefix()); + assert_eq!(history.lossy_replay(), Some(LossyReplay::OverClientCut)); + } + + /// The server's cut must track the client's: a change to either breaks this. + #[cfg(unix)] + #[test] + fn the_client_cut_matches_the_client_transcript_default() { + let client = + include_str!("../../../../packages/client-runtime/src/state/terminalTranscript.ts"); + assert!( + client.contains("export const DEFAULT_MAX_TERMINAL_BUFFER_BYTES = 512 * 1024;"), + "the client transcript default changed; update CLIENT_TRANSCRIPT_CUT_BYTES" + ); + assert_eq!(CLIENT_TRANSCRIPT_CUT_BYTES, 512 * 1024); + } + #[test] fn clear_discards_output_without_changing_the_limit() { let mut history = TerminalHistory::new(2); diff --git a/apps/server/src/terminal/input.rs b/apps/server/src/terminal/input.rs index a2ef67d91d..bc6c580190 100644 --- a/apps/server/src/terminal/input.rs +++ b/apps/server/src/terminal/input.rs @@ -12,6 +12,7 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use super::manager::{TerminalError, TerminalSessionIdentity}; +use super::model::TerminalInputOrigin; const MAX_FRAMES: usize = 16; const MAX_FRAME_BYTES: usize = 16 * 1024; @@ -441,6 +442,7 @@ impl TerminalInputRegistry { input_id, sequence, data, + origin: TerminalInputOrigin::Unmarked, }) } } @@ -464,9 +466,16 @@ pub(crate) struct PreparedTerminalInput<'a> { input_id: &'a str, sequence: u64, data: &'a str, + origin: TerminalInputOrigin<'a>, } -impl PreparedTerminalInput<'_> { +impl<'a> PreparedTerminalInput<'a> { + /// Who produced this frame; unmarked by default, as an older client's. + pub(crate) fn with_origin(mut self, origin: TerminalInputOrigin<'a>) -> Self { + self.origin = origin; + self + } + /// Tells, once the write has failed, whether the session was sealed by the loss of its /// current admission. pub(crate) fn seal(&self) -> TerminalInputSeal { @@ -477,6 +486,7 @@ impl PreparedTerminalInput<'_> { let lease = self.frame.lease.clone(); let sequence = self.sequence; let data = self.data; + let origin = self.origin; lease.wait_turn(sequence).await?; // Loss of the current admission stops the write at once, without waiting for the // cleanup task; a replaced admission's loss does not. The frame is queued once: a @@ -490,7 +500,7 @@ impl PreparedTerminalInput<'_> { let enqueue = lease .identity - .enqueue_input(data, &lease.closed, &lease.connection_closed); + .enqueue_input(data, origin, &lease.closed, &lease.connection_closed); tokio::select! { biased; () = async { if let Some(lost) = &lost { lost.cancelled().await } else { std::future::pending().await } } => { diff --git a/apps/server/src/terminal/manager.rs b/apps/server/src/terminal/manager.rs index 54c9cb92f1..2b9d9aed6d 100644 --- a/apps/server/src/terminal/manager.rs +++ b/apps/server/src/terminal/manager.rs @@ -15,12 +15,17 @@ use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::sync::{Mutex, RwLock, broadcast}; use tokio_util::{sync::CancellationToken, task::TaskTracker}; -use super::model::{TerminalConsoleTheme, terminal_console_theme_from_env}; +#[cfg(unix)] +use super::history::LossyReplay; +use super::model::{ + TerminalConsoleTheme, TerminalGeneration, TerminalInputOrigin, TerminalResizeOutcome, + TerminalResizeResult, terminal_console_theme_from_env, +}; use super::{ - PortablePtyBackend, PtyBackend, PtyExit, PtyProcess, PtySpawnInput, TerminalAttachInput, - TerminalEvent, TerminalLaunchCommand, TerminalMetadataEvent, TerminalOpenInput, - TerminalRemovalReason, TerminalRestartInput, TerminalSessionSnapshot, TerminalSize, - TerminalStatus, TerminalSummary, + ExhaustedResize, PortablePtyBackend, PtyBackend, PtyExit, PtyProcess, PtySpawnInput, + TerminalAttachInput, TerminalEvent, TerminalLaunchCommand, TerminalMetadataEvent, + TerminalOpenInput, TerminalRemovalReason, TerminalRestartInput, TerminalSessionSnapshot, + TerminalSize, TerminalStatus, TerminalSummary, history::{HistoryCursor, HistoryWindow, TerminalHistory}, }; use super::{TerminalAgentStatus, TerminalAgentStatusState}; @@ -288,6 +293,24 @@ struct Session { cols: u16, rows: u16, size_owner: Option, + /// The sequence of the last published size event, reported by resize results. + size_sequence: u64, + /// The claim whose attachment left while it owned the size: the window last used. It gets + /// the size back when it returns, unless the window the size went to was used since. + departed_owner: Option, + /// The owner received the size when the previous owner left, and nobody typed since. + owner_unused_since_handoff: bool, + /// When a user last typed in each window, by size claim, on `user_input_clock`: the size + /// goes to the one used last when its owner leaves several viewers (#348). + user_input_order: HashMap, + user_input_clock: u64, + /// Set while the size is un-owned because its owner left several viewers and none was used + /// since: the hand-over waits for use, so an attach does not take the size, and only this + /// viewer answers the terminal's queries meanwhile (#348). + handover_replier: Option, + /// A size the PTY refused on every retry; the session holds the size it kept instead. + /// Asking for it again changes nothing until another size is asked for (#348). + refused_size: Option<(u16, u16)>, first_attachment_grant_used: bool, osc_color_responder_active: bool, process: Option>, @@ -356,10 +379,39 @@ impl SessionAgentStatus { /// agent's tool calls do not flood the metadata stream. const AGENT_STATUS_REFRESH_INTERVAL: Duration = Duration::from_secs(60); +/// The next size a PTY kept after refusing one on every retry; never for a backend that +/// does not report them. +async fn next_exhausted_resize( + receiver: &mut Option>>, +) -> ExhaustedResize { + loop { + let Some(current) = receiver.as_mut() else { + return std::future::pending().await; + }; + if current.changed().await.is_err() { + *receiver = None; + continue; + } + if let Some(report) = *current.borrow_and_update() { + return report; + } + } +} + +/// The windows whose typing a session orders at once; more are not tracked (#348). +const MAX_USER_INPUT_CLAIMS: usize = 64; + +/// The viewer that answers the terminal's queries while the size is un-owned: the least claim, +/// any one would do, and it stays the same while it is attached (#348). +fn handover_replier(claims: Option<&HashMap>) -> Option { + claims.and_then(|claims| claims.keys().min().cloned()) +} + /// What a replacement process keeps from the session it replaces. #[derive(Default)] struct SessionCarryOver { center_panel: bool, + size_owner: Option, } type SessionKey = (String, String); @@ -389,6 +441,7 @@ impl TerminalSessionIdentity { pub(super) async fn enqueue_input( &self, data: &str, + origin: TerminalInputOrigin<'_>, cancelled: &CancellationToken, connection_closed: &CancellationToken, ) -> Result { @@ -399,7 +452,7 @@ impl TerminalSessionIdentity { () = self.generation.cancellation.cancelled() => return Err(TerminalError::PublicationCancelled), guard = self.generation.publication.lock() => guard, }; - let session = self.session.lock().await; + let mut session = self.session.lock().await; if cancelled.is_cancelled() || connection_closed.is_cancelled() || self.generation.is_invalidated() @@ -419,6 +472,7 @@ impl TerminalSessionIdentity { terminal_id: self.key.1.clone(), }); } + session.note_input(origin); drop(session); let pending = self.generation.writer.enqueue(&self.process, data, true)?; self.generation @@ -1120,6 +1174,7 @@ struct ClosedSessionNotification { thread_id: String, terminal_id: String, sequence: u64, + generation: TerminalGeneration, } #[derive(Debug, Default)] @@ -1200,6 +1255,8 @@ impl Drop for UncommittedPtyProcess { #[derive(Debug)] struct SessionGeneration { + /// This process's place in the manager's generation order, kept after it exits. + id: TerminalGeneration, observation: TerminalObserverGeneration, observer: std::sync::Mutex>, closing: std::sync::atomic::AtomicBool, @@ -1223,8 +1280,13 @@ struct SessionGeneration { } impl SessionGeneration { - fn new(key: &SessionKey, observer_runtime: Option) -> Self { + fn new( + key: &SessionKey, + observer_runtime: Option, + id: TerminalGeneration, + ) -> Self { Self { + id, observation: TerminalObserverGeneration::new_with_runtime( key.0.clone(), key.1.clone(), @@ -1341,6 +1403,9 @@ impl SessionGeneration { struct SessionGenerationRegistry { observer_runtime: Option, current: std::sync::Mutex>>, + /// Names this manager instance: a restarted server starts its generations over (#348). + boot: String, + issued: std::sync::atomic::AtomicU64, } impl SessionGenerationRegistry { @@ -1348,6 +1413,35 @@ impl SessionGenerationRegistry { Self { observer_runtime, current: std::sync::Mutex::new(HashMap::new()), + boot: uuid::Uuid::new_v4().simple().to_string(), + issued: std::sync::atomic::AtomicU64::new(0), + } + } + + fn create(&self, key: &SessionKey) -> Arc { + let process = self + .issued + .fetch_add(1, std::sync::atomic::Ordering::AcqRel) + + 1; + Arc::new(SessionGeneration::new( + key, + self.observer_runtime.clone(), + TerminalGeneration { + boot: self.boot.clone(), + process, + }, + )) + } + + /// At or above every generation issued so far, for a close an attachment reports after + /// it fell behind: it must outrank whatever process that attachment last saw. + fn latest(&self) -> TerminalGeneration { + TerminalGeneration { + boot: self.boot.clone(), + process: self + .issued + .load(std::sync::atomic::Ordering::Acquire) + .max(1), } } @@ -1359,7 +1453,7 @@ impl SessionGenerationRegistry { { return generation; } - let generation = Arc::new(SessionGeneration::new(key, self.observer_runtime.clone())); + let generation = self.create(key); current.insert(key.clone(), Arc::downgrade(&generation)); generation } @@ -1379,7 +1473,7 @@ impl SessionGenerationRegistry { let mut current = self.current.lock().expect("terminal generations lock"); current.retain(|_, generation| generation.strong_count() > 0); let displaced = current.remove(key).and_then(|value| value.upgrade()); - let generation = Arc::new(SessionGeneration::new(key, self.observer_runtime.clone())); + let generation = self.create(key); current.insert(key.clone(), Arc::downgrade(&generation)); (displaced, generation) } @@ -1719,17 +1813,80 @@ impl Session { cols: self.cols, rows: self.rows, size_claim: self.size_owner.as_ref().map(|owner| owner.claim.clone()), + reply_claim: self + .handover_replier + .clone() + .filter(|_| self.size_owner.is_none()), } } fn resized(&mut self) -> TerminalEvent { let sequence = self.advance(); + self.size_sequence = sequence; TerminalEvent::Resized { thread_id: self.thread_id.clone(), terminal_id: self.terminal_id.clone(), sequence, size: self.size(), + only_for: None, + } + } + + /// Input counts as use of a window unless it is the terminal's own reply: use ends the + /// departed owner's claim to the size, and a user's input orders the windows (#348). + fn note_input(&mut self, origin: TerminalInputOrigin<'_>) { + if origin == TerminalInputOrigin::Automatic { + return; + } + self.departed_owner = None; + self.owner_unused_since_handoff = false; + if let TerminalInputOrigin::User(claim) = origin { + self.user_input_clock += 1; + // Departed claims are pruned at each release; past the bound, the window typed in + // longest ago is forgotten rather than this one. + if let Some(order) = self.user_input_order.get_mut(claim) { + *order = self.user_input_clock; + } else if self + .user_input_order + .insert(claim.to_owned(), self.user_input_clock) + .is_none() + && self.user_input_order.len() > MAX_USER_INPUT_CLAIMS + && let Some(oldest) = self + .user_input_order + .iter() + .min_by_key(|(_, typed)| **typed) + .map(|(claim, _)| claim.clone()) + { + self.user_input_order.remove(&oldest); + } + } + } + + /// What this session holds, as a resize request's answer. + fn resize_result(&self, outcome: TerminalResizeOutcome) -> TerminalResizeResult { + TerminalResizeResult { + outcome, + size: Some(self.size()), + generation: Some(self.generation.id.clone()), + sequence: Some(self.size_sequence), + } + } + + /// The process to ask for a repaint after an attach replayed a lossy history, and why: + /// best-effort repaint recovery for Claude terminals that BiBCode starts, not terminal-state + /// reconstruction. The Claude CLI repaints in full on `SIGCONT`; other programs are not + /// known to, so they get nothing (#348). + #[cfg(unix)] + fn resume_after_lossy_attach(&self) -> Option<(Arc, LossyReplay)> { + if self.status != TerminalStatus::Running { + return None; } + self.command + .as_ref() + .and_then(|command| command.activity.as_ref()) + .filter(|activity| activity.driver_kind == CLAUDE_DRIVER_KIND)?; + let reason = self.history.lossy_replay()?; + Some((self.process.clone()?, reason)) } fn snapshot(&self) -> TerminalSessionSnapshot { @@ -1748,6 +1905,7 @@ impl Session { updated_at: self.updated_at.clone(), sequence: self.sequence, size: Some(self.size()), + generation: Some(self.generation.id.clone()), osc_color_responder_active: self.osc_color_responder_active, // Only attach can issue the first-attachment grant. first_attachment_grant: false, @@ -1777,6 +1935,10 @@ impl Session { fn carry_over(&self) -> SessionCarryOver { SessionCarryOver { center_panel: self.center_panel, + size_owner: self.size_owner.as_ref().map(|owner| SizeOwner { + claim: owner.claim.clone(), + attachment: owner.attachment, + }), } } @@ -1844,6 +2006,8 @@ struct Inner { cancellation: CancellationToken, #[cfg(test)] restart_after_exact_cleanup_barrier: std::sync::Mutex>>, + #[cfg(test)] + restart_before_register_barrier: std::sync::Mutex>>, } #[derive(Clone)] @@ -1909,6 +2073,8 @@ impl TerminalManager { cancellation: CancellationToken::new(), #[cfg(test)] restart_after_exact_cleanup_barrier: std::sync::Mutex::new(None), + #[cfg(test)] + restart_before_register_barrier: std::sync::Mutex::new(None), }), } } @@ -2577,6 +2743,13 @@ impl TerminalManager { cols: input.cols, rows: input.rows, size_owner: None, + size_sequence: 1, + departed_owner: None, + owner_unused_since_handoff: false, + user_input_order: HashMap::new(), + user_input_clock: 0, + handover_replier: None, + refused_size: None, first_attachment_grant_used: false, osc_color_responder_active: process.osc_color_responder_active(), process: Some(process.clone()), @@ -2641,22 +2814,45 @@ impl TerminalManager { } return Ok(snapshot); } - // Only Restarted resets surviving attachments' sequence cursors. - // A non-restart reopen leaves the first-attachment grant available. - let first_attachment_grant_used = is_restart - && self + #[cfg(test)] + if is_restart { + let barrier = self .inner - .size_claim_attachments + .restart_before_register_barrier .lock() - .expect("terminal attachment claims") - .get(&key) - .is_some_and(|claims| !claims.is_empty()); - session.lock().await.first_attachment_grant_used = first_attachment_grant_used; + .expect("restart barrier lock") + .take(); + if let Some(barrier) = barrier { + barrier.started.notify_one(); + barrier.release.notified().await; + } + } + // Registered before the owner check: an owner whose stream drops after the check finds + // this session, and its release waits for this publication lock (#348). self.inner .sessions .write() .await - .insert(key, session.clone()); + .insert(key.clone(), session.clone()); + { + let mut session = session.lock().await; + let attachments = self + .inner + .size_claim_attachments + .lock() + .expect("terminal attachment claims"); + let claims = attachments.get(&key); + // Only Restarted resets surviving attachments' sequence cursors. + // A non-restart reopen leaves the first-attachment grant available. + session.first_attachment_grant_used = + is_restart && claims.is_some_and(|claims| !claims.is_empty()); + // The window that sized the replaced process sizes the restarted one while it is + // still attached; left un-owned, every surviving window would race to claim it. + session.size_owner = carry_over.size_owner.take().filter(|owner| { + is_restart + && claims.and_then(|claims| claims.get(&owner.claim)) == Some(&owner.attachment) + }); + } self.supervise(session.clone(), process, exit, generation.clone()); uncommitted_process.commit(); // The installed observer is now visible to the lifecycle snapshot used by disable. @@ -2830,6 +3026,10 @@ impl TerminalManager { let exit_inner = inner.clone(); let exit_session = session.clone(); let exit_generation = generation.clone(); + // This task also publishes the size a PTY kept after refusing one on every retry, so + // that ends with the process, its generation, or the manager (#348). + let mut exhausted_resizes = process.subscribe_resize_exhausted(); + let exit_process = process.clone(); tokio::spawn(async move { loop { let observed_exit = exit.borrow().clone(); @@ -2842,6 +3042,16 @@ impl TerminalManager { return; } } + report = next_exhausted_resize(&mut exhausted_resizes) => { + Self::publish_kept_size( + &exit_inner, + &exit_session, + &exit_generation, + &exit_process, + report, + ) + .await; + } } continue; }; @@ -3144,6 +3354,7 @@ impl TerminalManager { sequence, exit_code: exit.exit_code, exit_signal: exit.signal, + generation: Some(session.generation.id.clone()), }, session.summary(), PathBuf::from( @@ -3379,11 +3590,18 @@ impl TerminalManager { let first_attachment_grant = current.size_owner.is_none() && !current.first_attachment_grant_used; current.first_attachment_grant_used = true; - if current.size_owner.is_none() { + // A size left un-owned until a window is used is not taken by attaching: an + // idle window reloaded or shown again would take it. The window it was taken + // from gets it back before anyone uses another (#348). + let handover_pending = current.handover_replier.is_some() + && current.departed_owner.as_deref() != Some(claim.as_str()); + if current.size_owner.is_none() && !handover_pending { current.size_owner = Some(SizeOwner { claim: claim.clone(), attachment: id, }); + current.handover_replier = None; + current.departed_owner = None; let _ = self.inner.events.send(current.resized()); } else if let Some(owner) = current .size_owner @@ -3393,6 +3611,18 @@ impl TerminalManager { // A reconnect may reuse a renderer's claim. Its departed stream // must not clear ownership from this replacement attachment. owner.attachment = id; + } else if current.owner_unused_since_handoff + && current.departed_owner.as_deref() == Some(claim.as_str()) + { + // The window last used came back before anyone used the one the size + // went to: an unattended window does not keep the size (#348). + current.size_owner = Some(SizeOwner { + claim: claim.clone(), + attachment: id, + }); + current.departed_owner = None; + current.owner_unused_since_handoff = false; + let _ = self.inner.events.send(current.resized()); } let dropped = CancellationToken::new(); let inner = self.inner.clone(); @@ -3424,9 +3654,19 @@ impl TerminalManager { }); let mut initial = current.snapshot(); initial.first_attachment_grant = first_attachment_grant; + let generation = current.generation.clone(); + #[cfg(unix)] + let resume = current.resume_after_lossy_attach(); + // The repaint is published under these locks: release them before asking for it. + drop(current); + drop(_publication); + #[cfg(unix)] + if let Some((process, reason)) = resume { + resume_after_lossy_attach(&input.thread_id, &input.terminal_id, &*process, reason); + } Ok(TerminalAttachment { inner: Arc::downgrade(&self.inner), - generation: Some(current.generation.clone()), + generation: Some(generation), thread_id: input.thread_id, terminal_id: input.terminal_id, next_sequence: initial.sequence, @@ -3554,6 +3794,19 @@ impl TerminalManager { thread_id: &str, terminal_id: &str, data: &str, + ) -> Result<(), TerminalError> { + self.write_from(thread_id, terminal_id, data, TerminalInputOrigin::Unmarked) + .await + } + + /// [`Self::write`] from `origin`: only a user's input, or an older client's, counts as use + /// of a window for the size rule (#348). + pub async fn write_from( + &self, + thread_id: &str, + terminal_id: &str, + data: &str, + origin: TerminalInputOrigin<'_>, ) -> Result<(), TerminalError> { let not_running = || TerminalError::NotRunning { thread_id: thread_id.to_owned(), @@ -3564,7 +3817,8 @@ impl TerminalManager { let mut pending = { let _publication = generation.publication.lock().await; let (process, status) = { - let session = session.lock().await; + let mut session = session.lock().await; + session.note_input(origin); (session.process.clone(), session.status) }; // An exit invalidates the generation and then marks the session exited, both under @@ -3593,6 +3847,9 @@ impl TerminalManager { pending.wait().await } + /// Answers with what the session holds afterwards. A claim for the size and owner it + /// already holds publishes a `resized` to that claim's attachment alone, in order with + /// its output, so a renderer holding a stale size repairs it without a timer (#348). pub async fn resize( &self, thread_id: &str, @@ -3600,7 +3857,23 @@ impl TerminalManager { cols: u16, rows: u16, size_claim: Option, - ) -> Result<(), TerminalError> { + ) -> Result { + self.resize_acknowledging(thread_id, terminal_id, cols, rows, size_claim, true) + .await + } + + /// [`Self::resize`] for a caller that may predate the acknowledgement: without + /// `acknowledge`, a claim of the size and owner already held publishes nothing, as before + /// the structured answer (#348). + pub async fn resize_acknowledging( + &self, + thread_id: &str, + terminal_id: &str, + cols: u16, + rows: u16, + size_claim: Option, + acknowledge: bool, + ) -> Result { validate_dimensions(cols, rows)?; let Some(session) = self .inner @@ -3610,14 +3883,14 @@ impl TerminalManager { .get(&(thread_id.to_string(), terminal_id.to_string())) .cloned() else { - return Ok(()); + return Ok(TerminalResizeResult::session_missing()); }; let generation = session.lock().await.generation.clone(); let _publication = generation.publication.lock().await; + let mut session = session.lock().await; if generation.is_invalidated() { - return Ok(()); + return Ok(session.resize_result(TerminalResizeOutcome::GenerationInvalidated)); } - let mut session = session.lock().await; let size_owner = match size_claim { Some(claim) => { let attachments = self @@ -3630,7 +3903,7 @@ impl TerminalManager { .and_then(|claims| claims.get(&claim)) else { // A queued resize from a departed renderer must not resurrect its claim. - return Ok(()); + return Ok(session.resize_result(TerminalResizeOutcome::ClaimNotRegistered)); }; Some(SizeOwner { claim, @@ -3639,14 +3912,23 @@ impl TerminalManager { } None => None, }; - let Some(process) = session.process.as_ref() else { - return Ok(()); + let Some(process) = session.process.clone() else { + return Ok(session.resize_result(TerminalResizeOutcome::ProcessAbsent)); }; let size_changed = session.cols != cols || session.rows != rows; let owner_changed = session.size_owner.as_ref().map(|owner| &owner.claim) != size_owner.as_ref().map(|owner| &owner.claim); - if size_changed { + // The PTY refused this size on every retry and the session holds the size it kept: + // asking for it again sends and publishes nothing, so a renderer that claims its fit + // after each publication settles instead of cycling (#348). + if !owner_changed && session.refused_size == Some((cols, rows)) { + session.first_attachment_grant_used |= size_owner.is_some(); + return Ok(session.resize_result(TerminalResizeOutcome::Unchanged)); + } + // The PTY refused the size the session records: an equal request sends it again. + if size_changed || process.resize_needs_retry() { process.resize(cols, rows).map_err(TerminalError::Io)?; + session.refused_size = None; generation .activity_wake .store(true, std::sync::atomic::Ordering::Release); @@ -3654,13 +3936,63 @@ impl TerminalManager { // Applying a claim consumes the grant even if no dimensions changed. session.first_attachment_grant_used |= size_owner.is_some(); if !size_changed && !owner_changed { - return Ok(()); + if let Some(owner) = size_owner.filter(|_| acknowledge) { + let mut event = session.resized(); + if let TerminalEvent::Resized { only_for, .. } = &mut event { + *only_for = Some(owner.attachment); + } + let _ = self.inner.events.send(event); + } + return Ok(session.resize_result(TerminalResizeOutcome::Unchanged)); + } + if owner_changed { + session.departed_owner = None; + session.owner_unused_since_handoff = false; } session.cols = cols; session.rows = rows; + if size_owner.is_some() { + session.handover_replier = None; + } session.size_owner = size_owner; let _ = self.inner.events.send(session.resized()); - Ok(()) + Ok(session.resize_result(TerminalResizeOutcome::Applied)) + } + + /// The PTY refused the size the session records on every retry and kept the size in + /// `report`: publish that size with a new sequence, so every viewer converges on what the + /// PTY really has (#348). A report a newer request overtook is left to that request. + async fn publish_kept_size( + inner: &Inner, + session: &SharedSession, + generation: &Arc, + process: &Arc, + report: ExhaustedResize, + ) { + let _publication = tokio::select! { + () = inner.cancellation.cancelled() => return, + () = generation.cancellation.cancelled() => return, + guard = generation.publication.lock() => guard, + }; + let mut session = session.lock().await; + if generation.is_invalidated() + || !Arc::ptr_eq(&session.generation, generation) + || !session + .process + .as_ref() + .is_some_and(|current| Arc::ptr_eq(current, process)) + || report.requests != process.resize_requests_sent() + { + return; + } + let refused = (session.cols, session.rows); + if refused == (report.cols, report.rows) { + return; + } + session.refused_size = Some(refused); + session.cols = report.cols; + session.rows = report.rows; + let _ = inner.events.send(session.resized()); } async fn release_size_claim(inner: &Inner, key: &SessionKey, attachment_id: uuid::Uuid) { @@ -3682,10 +4014,73 @@ impl TerminalManager { return; } let mut session = session.lock().await; + let attachments = inner + .size_claim_attachments + .lock() + .expect("terminal attachment claims"); + let claims = attachments.get(key); + // Every release, not only the owner's: a window hidden and shown again returns under a + // new claim, and the ones it left must not fill the bounded typing record. + session + .user_input_order + .retain(|claim, _| claims.is_some_and(|claims| claims.contains_key(claim))); if session.size_owner.as_ref().map(|owner| owner.attachment) != Some(attachment_id) { + // A pending hand-over: a lone viewer left takes the size, and a replier that left + // is replaced so one viewer keeps answering the terminal's queries. + let Some(replier) = session.handover_replier.as_deref() else { + return; + }; + let remaining = claims.map_or(0, HashMap::len); + if remaining > 1 && claims.is_some_and(|claims| claims.contains_key(replier)) { + return; + } + let lone = claims + .filter(|claims| claims.len() == 1) + .and_then(|claims| claims.iter().next()) + .map(|(claim, id)| SizeOwner { + claim: claim.clone(), + attachment: *id, + }); + session.handover_replier = handover_replier(claims).filter(|_| remaining > 1); + drop(attachments); + if remaining == 0 { + return; + } + session.owner_unused_since_handoff = lone.is_some(); + session.size_owner = lone; + let _ = inner.events.send(session.resized()); return; } - session.size_owner = None; + // The window last used sizes the terminal (#348). A lone remaining viewer takes the + // size at once; among several, the one a user typed in last does, and with none typed + // in it stays un-owned until one claims it. The departed owner gets it back if it + // returns before anyone types. + let remaining = { + let order = &session.user_input_order; + claims + .and_then(|claims| { + if claims.len() == 1 { + return claims.iter().next(); + } + claims + .iter() + .filter_map(|entry| order.get(entry.0).map(|typed| (typed, entry))) + .max_by_key(|(typed, _)| **typed) + .map(|(_, entry)| entry) + }) + .map(|(claim, id)| SizeOwner { + claim: claim.clone(), + attachment: *id, + }) + }; + session.handover_replier = remaining + .is_none() + .then(|| handover_replier(claims)) + .flatten(); + drop(attachments); + session.departed_owner = session.size_owner.take().map(|owner| owner.claim); + session.owner_unused_since_handoff = remaining.is_some(); + session.size_owner = remaining; let _ = inner.events.send(session.resized()); } @@ -3977,7 +4372,7 @@ impl TerminalManager { let Some(session) = removed else { continue; }; - let (process, sequence) = { + let (process, sequence, generation) = { let mut session = session.lock().await; session.attribution_registration.take(); session.observer.take(); @@ -3986,7 +4381,7 @@ impl TerminalManager { session.pid = None; session.has_running_subprocess = false; session.child_command_label = None; - (process, session.advance()) + (process, session.advance(), session.generation.id.clone()) }; if let Some(process) = process { let exit = process.subscribe_exit(); @@ -4014,6 +4409,7 @@ impl TerminalManager { thread_id: key.0, terminal_id: key.1, sequence, + generation, }); } closed @@ -4029,6 +4425,7 @@ impl TerminalManager { thread_id: notification.thread_id.clone(), terminal_id: notification.terminal_id.clone(), sequence: notification.sequence, + generation: Some(notification.generation.clone()), }); let _ = self.inner.metadata.send(TerminalMetadataEvent::Remove { thread_id: notification.thread_id.clone(), @@ -4194,6 +4591,36 @@ impl TerminalManager { } } +/// The provider driver whose terminals get a repaint signal after a lossy attach (#348). +#[cfg(unix)] +const CLAUDE_DRIVER_KIND: &str = "claudeAgent"; + +/// Sends the one repaint signal of a lossy attach; logs no terminal content. +#[cfg(unix)] +fn resume_after_lossy_attach( + thread_id: &str, + terminal_id: &str, + process: &dyn PtyProcess, + reason: LossyReplay, +) { + match process.resume() { + Ok(sent) => tracing::debug!( + thread_id, + terminal_id, + reason = reason.as_str(), + sent, + "asked a Claude terminal to repaint after a lossy attach" + ), + Err(error) => tracing::warn!( + thread_id, + terminal_id, + reason = reason.as_str(), + %error, + "failed to ask a Claude terminal to repaint after a lossy attach" + ), + } +} + /// Reports a session's process that ended by itself to the profiler. Only crash signals /// count: in a terminal people end processes with `exit 1`, Ctrl-C or `kill`. BiBCode's own /// stops invalidate the generation first, or note the stop. @@ -4449,12 +4876,15 @@ impl TerminalAttachment { } /// The session closed and its event was among the lost ones: say so. + /// Its sequence is this attachment's own guess, so it carries the latest generation issued: + /// a client applies it by generation, never against another stream's sequence (#348). fn closed(&mut self) -> AttachmentItem { self.next_sequence = self.next_sequence.saturating_add(1); AttachmentItem::Event(TerminalEvent::Closed { thread_id: self.thread_id.clone(), terminal_id: self.terminal_id.clone(), sequence: self.next_sequence, + generation: self.inner.upgrade().map(|inner| inner.generations.latest()), }) } @@ -4482,6 +4912,15 @@ impl TerminalAttachment { } if sequence > self.next_sequence { self.next_sequence = sequence; + // Another claim's acknowledgement: passed over, though its sequence is spent. + if let TerminalEvent::Resized { + only_for: Some(target), + .. + } = &event + && self._size_claim.as_ref().map(|claim| claim.id) != Some(*target) + { + return None; + } return Some(event); } None @@ -4583,7 +5022,7 @@ async fn validate_cwd(cwd: &Path) -> Result<(), TerminalError> { } } -fn validate_dimensions(cols: u16, rows: u16) -> Result<(), TerminalError> { +pub(super) fn validate_dimensions(cols: u16, rows: u16) -> Result<(), TerminalError> { if !(1..=1_000).contains(&cols) || !(1..=500).contains(&rows) { return Err(TerminalError::Io(format!( "invalid terminal size {cols}x{rows}" @@ -5084,6 +5523,14 @@ mod tests { resize_error: std::sync::Mutex>, resizes: std::sync::Mutex>, output_on_resize: std::sync::Mutex>, + /// Reports that the PTY refused its last size, as the resize worker does. + resize_needs_retry: std::sync::atomic::AtomicBool, + /// Sizes asked of this PTY, and the resize worker's report of one it kept refusing. + resize_requests: std::sync::atomic::AtomicU64, + resize_exhausted: tokio::sync::watch::Sender>, + /// Every resume signal the manager asked of this PTY (#348). + #[cfg(unix)] + resumes: std::sync::atomic::AtomicUsize, } /// Holds PTY writes until opened. Opening it on drop keeps a failed test from hanging in @@ -5166,9 +5613,19 @@ mod tests { resize_error: std::sync::Mutex::new(None), resizes: std::sync::Mutex::new(Vec::new()), output_on_resize: std::sync::Mutex::new(None), + resize_needs_retry: std::sync::atomic::AtomicBool::new(false), + resize_requests: std::sync::atomic::AtomicU64::new(0), + resize_exhausted: tokio::sync::watch::channel(None).0, + #[cfg(unix)] + resumes: std::sync::atomic::AtomicUsize::new(0), } } + #[cfg(unix)] + fn resumes(&self) -> usize { + self.resumes.load(std::sync::atomic::Ordering::Acquire) + } + fn with_identity(pid: u32) -> Self { let mut process = Self::new(pid); process.process_identity = @@ -5267,6 +5724,8 @@ mod tests { } fn resize(&self, cols: u16, rows: u16) -> Result<(), String> { + self.resize_requests + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); if let Some(error) = self.resize_error.lock().unwrap().clone() { return Err(error); } @@ -5277,6 +5736,22 @@ mod tests { Ok(()) } + fn resize_needs_retry(&self) -> bool { + self.resize_needs_retry + .load(std::sync::atomic::Ordering::Acquire) + } + + fn subscribe_resize_exhausted( + &self, + ) -> Option>> { + Some(self.resize_exhausted.subscribe()) + } + + fn resize_requests_sent(&self) -> u64 { + self.resize_requests + .load(std::sync::atomic::Ordering::Acquire) + } + fn kill(&self) -> Result<(), String> { self.killed .store(true, std::sync::atomic::Ordering::Release); @@ -5295,6 +5770,13 @@ mod tests { result } + #[cfg(unix)] + fn resume(&self) -> Result { + self.resumes + .fetch_add(1, std::sync::atomic::Ordering::AcqRel); + Ok(true) + } + fn wait_for_process_tree_exit(&self, _timeout: Duration) -> Result, String> { if self .tree_exit_supported @@ -5480,87 +5962,349 @@ mod tests { } } - /// An attachment that falls further behind than the event channel holds resets its client - /// to the session's state instead of silently skipping output, which left lines missing - /// from the terminal after a burst. - #[tokio::test] - async fn a_lagging_attachment_resyncs_instead_of_skipping_output() { + /// A manager for the resume-signal tests (#348): `history_line_limit` lines retained. + #[cfg(unix)] + fn resume_manager( + history_line_limit: usize, + event_capacity: usize, + ) -> (tempfile::TempDir, Arc, TerminalManager) { let root = tempfile::tempdir().unwrap(); let backend = Arc::new(HistoryTestBackend::default()); let manager = TerminalManager::new( backend.clone(), TerminalManagerOptions { + history_line_limit, + event_capacity, subprocess_poll_interval: Duration::ZERO, - event_capacity: 16, ..TerminalManagerOptions::default() }, ); - manager - .open(TerminalOpenInput::new( - "lag", - "term", - root.path().to_path_buf(), - 80, - 24, - )) - .await - .unwrap(); - let mut attachment = manager - .attach(TerminalAttachInput::existing("lag", "term")) - .await - .unwrap(); - let pty = backend.latest(); - for line in 0..100 { - pty.emit(&format!("line {line}\n")); - // Each line is published before the next, while the attachment reads nothing. - for _ in 0..8 { - tokio::task::yield_now().await; - } - } - tokio::time::sleep(Duration::from_millis(100)).await; + (root, backend, manager) + } - let item = tokio::time::timeout(Duration::from_secs(2), attachment.recv()) + /// A terminal of `thread`, launched as a provider terminal of `driver`, or a shell. + #[cfg(unix)] + fn resume_terminal( + root: &tempfile::TempDir, + thread: &str, + driver: Option<&str>, + ) -> TerminalOpenInput { + let mut input = TerminalOpenInput::new(thread, "term", root.path().to_path_buf(), 80, 24); + input.command = driver.map(|driver| TerminalLaunchCommand { + executable: "provider-cli".to_owned(), + args: Vec::new(), + label: None, + activity: Some(crate::terminal::ProviderTerminalActivityLaunch { + driver_kind: driver.to_owned(), + provider_instance_id: driver.to_owned(), + }), + env: None, + }); + input + } + + /// Emits `data` and waits until the session's history has taken it in. + #[cfg(unix)] + async fn emit_into_history( + manager: &TerminalManager, + pty: &HistoryTestPty, + thread: &str, + data: &str, + ) { + let before = manager + .read_history(thread, "term", None, 0) .await .unwrap() - .unwrap(); - let AttachmentItem::Resync { - snapshot, - same_process: true, - } = item - else { - panic!("a lagging attachment gets the same process's state"); - }; - for line in 0..100 { - assert!( - snapshot.history.contains(&format!("line {line}\n")), - "line {line}" - ); - } - // Only what follows the snapshot is delivered after it. - pty.emit("after"); - let event = loop { - match tokio::time::timeout(Duration::from_secs(2), attachment.recv()) + .total; + pty.emit(data); + tokio::time::timeout(Duration::from_secs(2), async { + while manager + .read_history(thread, "term", None, 0) .await .unwrap() - .unwrap() + .total + < before + data.len() as u64 { - AttachmentItem::Event(TerminalEvent::Output { data, .. }) => break data, - AttachmentItem::Event(_) => continue, - AttachmentItem::Resync { .. } => panic!("one resync"), + tokio::task::yield_now().await; } - }; - assert_eq!(event, "after"); + }) + .await + .expect("output reached the history"); + } + + /// Attaches and drops the attachment, as a window that is shown and hidden again. + #[cfg(unix)] + async fn attach_once(manager: &TerminalManager, thread: &str) { + drop( + manager + .attach(TerminalAttachInput::existing(thread, "term")) + .await + .expect("attach"), + ); + } + + /// A complete history under the client's cut replays the whole screen: no signal. + #[cfg(unix)] + #[tokio::test] + async fn a_lossless_attach_of_a_claude_terminal_sends_no_resume() { + let (root, backend, manager) = resume_manager(5_000, 512); + manager + .open(resume_terminal(&root, "claude", Some("claudeAgent"))) + .await + .unwrap(); + let pty = backend.latest(); + emit_into_history(&manager, &pty, "claude", "frame\n").await; + attach_once(&manager, "claude").await; + attach_once(&manager, "claude").await; + assert_eq!(pty.resumes(), 0); manager.shutdown().await; } - /// Reads waiting when the publisher runs go out as one event: publishing them one by one - /// fell behind a program printing many short lines, and the PTY channel dropped output. + /// Each attach after eviction asks the session's own group to repaint once, and the close + /// that follows still kills the session. + #[cfg(unix)] #[tokio::test] - async fn reads_already_waiting_are_published_together() { - let (_root, backend, manager) = size_fixture(80, 24).await; - let mut attachment = manager.attach(size_attachment("reader")).await.unwrap(); + async fn each_lossy_attach_of_a_claude_terminal_sends_one_resume() { + let (root, backend, manager) = resume_manager(2, 512); + manager + .open(resume_terminal(&root, "claude", Some("claudeAgent"))) + .await + .unwrap(); let pty = backend.latest(); - // No await between them: all ten wait for the publisher. + emit_into_history(&manager, &pty, "claude", "one\ntwo\nthree\nfour\n").await; + attach_once(&manager, "claude").await; + assert_eq!(pty.resumes(), 1); + attach_once(&manager, "claude").await; + assert_eq!(pty.resumes(), 2); + + manager.close("claude", Some("term")).await.unwrap(); + assert!(pty.is_killed(), "close kills after a resume"); + manager.shutdown().await; + } + + /// Nothing evicted, but more retained than a released client keeps of the snapshot. + #[cfg(unix)] + #[tokio::test] + async fn an_attach_over_the_client_cut_sends_one_resume() { + let (root, backend, manager) = resume_manager(5_000, 512); + manager + .open(resume_terminal(&root, "claude", Some("claudeAgent"))) + .await + .unwrap(); + let pty = backend.latest(); + emit_into_history(&manager, &pty, "claude", &"x".repeat(512 * 1024 + 1)).await; + attach_once(&manager, "claude").await; + assert_eq!(pty.resumes(), 1); + manager.shutdown().await; + } + + /// A clear discards the program's last paint like eviction does. + #[cfg(unix)] + #[tokio::test] + async fn an_attach_after_clear_sends_one_resume() { + let (root, backend, manager) = resume_manager(5_000, 512); + manager + .open(resume_terminal(&root, "claude", Some("claudeAgent"))) + .await + .unwrap(); + let pty = backend.latest(); + emit_into_history(&manager, &pty, "claude", "frame\n").await; + manager.clear("claude", "term").await.unwrap(); + attach_once(&manager, "claude").await; + assert_eq!(pty.resumes(), 1); + manager.shutdown().await; + } + + /// Only Claude terminals BiBCode starts are signalled: not a shell, not another provider. + #[cfg(unix)] + #[tokio::test] + async fn lossy_attaches_of_shells_and_other_providers_send_no_resume() { + let (root, backend, manager) = resume_manager(2, 512); + for (thread, driver) in [ + ("shell", None), + ("codex", Some("codex")), + ("opencode", Some("opencode")), + ] { + manager + .open(resume_terminal(&root, thread, driver)) + .await + .unwrap(); + let pty = backend.latest(); + emit_into_history(&manager, &pty, thread, "one\ntwo\nthree\nfour\n").await; + attach_once(&manager, thread).await; + assert_eq!(pty.resumes(), 0, "{thread}"); + } + manager.shutdown().await; + } + + /// An exited session's process is not signalled. + #[cfg(unix)] + #[tokio::test] + async fn a_lossy_attach_of_an_exited_claude_terminal_sends_no_resume() { + let (root, backend, manager) = resume_manager(2, 512); + manager + .open(resume_terminal(&root, "claude", Some("claudeAgent"))) + .await + .unwrap(); + let pty = backend.latest(); + emit_into_history(&manager, &pty, "claude", "one\ntwo\nthree\nfour\n").await; + pty.exit(0); + tokio::time::timeout(Duration::from_secs(2), async { + while manager + .read_history("claude", "term", None, 0) + .await + .unwrap() + .status + == TerminalStatus::Running + { + tokio::task::yield_now().await; + } + }) + .await + .expect("exit published"); + attach_once(&manager, "claude").await; + assert_eq!(pty.resumes(), 0); + manager.shutdown().await; + } + + /// History reads and a lagging attachment's resync are not attaches: no signal. + #[cfg(unix)] + #[tokio::test] + async fn history_reads_and_lag_resyncs_send_no_resume() { + let (root, backend, manager) = resume_manager(2, 16); + manager + .open(resume_terminal(&root, "claude", Some("claudeAgent"))) + .await + .unwrap(); + let mut attachment = manager + .attach(TerminalAttachInput::existing("claude", "term")) + .await + .unwrap(); + let pty = backend.latest(); + for line in 0..100 { + pty.emit(&format!("line {line}\n")); + for _ in 0..8 { + tokio::task::yield_now().await; + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + let item = tokio::time::timeout(Duration::from_secs(2), attachment.recv()) + .await + .unwrap() + .unwrap(); + assert!( + matches!(item, AttachmentItem::Resync { .. }), + "a lag resync" + ); + manager + .read_history("claude", "term", None, 1_000) + .await + .unwrap(); + assert_eq!(pty.resumes(), 0); + manager.shutdown().await; + } + + /// A restart's new process is the only one a later lossy attach signals. + #[cfg(unix)] + #[tokio::test] + async fn a_restarted_claude_terminal_resumes_only_its_new_process() { + let (root, backend, manager) = resume_manager(2, 512); + let input = resume_terminal(&root, "claude", Some("claudeAgent")); + manager.open(input.clone()).await.unwrap(); + let old = backend.latest(); + emit_into_history(&manager, &old, "claude", "one\ntwo\nthree\nfour\n").await; + manager.restart(input).await.unwrap(); + let new = backend.latest(); + assert!(!Arc::ptr_eq(&old, &new)); + attach_once(&manager, "claude").await; + assert_eq!(new.resumes(), 0, "a fresh history replays without loss"); + emit_into_history(&manager, &new, "claude", "one\ntwo\nthree\nfour\n").await; + attach_once(&manager, "claude").await; + assert_eq!((old.resumes(), new.resumes()), (0, 1)); + manager.shutdown().await; + } + + /// An attachment that falls further behind than the event channel holds resets its client + /// to the session's state instead of silently skipping output, which left lines missing + /// from the terminal after a burst. + #[tokio::test] + async fn a_lagging_attachment_resyncs_instead_of_skipping_output() { + let root = tempfile::tempdir().unwrap(); + let backend = Arc::new(HistoryTestBackend::default()); + let manager = TerminalManager::new( + backend.clone(), + TerminalManagerOptions { + subprocess_poll_interval: Duration::ZERO, + event_capacity: 16, + ..TerminalManagerOptions::default() + }, + ); + manager + .open(TerminalOpenInput::new( + "lag", + "term", + root.path().to_path_buf(), + 80, + 24, + )) + .await + .unwrap(); + let mut attachment = manager + .attach(TerminalAttachInput::existing("lag", "term")) + .await + .unwrap(); + let pty = backend.latest(); + for line in 0..100 { + pty.emit(&format!("line {line}\n")); + // Each line is published before the next, while the attachment reads nothing. + for _ in 0..8 { + tokio::task::yield_now().await; + } + } + tokio::time::sleep(Duration::from_millis(100)).await; + + let item = tokio::time::timeout(Duration::from_secs(2), attachment.recv()) + .await + .unwrap() + .unwrap(); + let AttachmentItem::Resync { + snapshot, + same_process: true, + } = item + else { + panic!("a lagging attachment gets the same process's state"); + }; + for line in 0..100 { + assert!( + snapshot.history.contains(&format!("line {line}\n")), + "line {line}" + ); + } + // Only what follows the snapshot is delivered after it. + pty.emit("after"); + let event = loop { + match tokio::time::timeout(Duration::from_secs(2), attachment.recv()) + .await + .unwrap() + .unwrap() + { + AttachmentItem::Event(TerminalEvent::Output { data, .. }) => break data, + AttachmentItem::Event(_) => continue, + AttachmentItem::Resync { .. } => panic!("one resync"), + } + }; + assert_eq!(event, "after"); + manager.shutdown().await; + } + + /// Reads waiting when the publisher runs go out as one event: publishing them one by one + /// fell behind a program printing many short lines, and the PTY channel dropped output. + #[tokio::test] + async fn reads_already_waiting_are_published_together() { + let (_root, backend, manager) = size_fixture(80, 24).await; + let mut attachment = manager.attach(size_attachment("reader")).await.unwrap(); + let pty = backend.latest(); + // No await between them: all ten wait for the publisher. for read in 0..10 { pty.emit(&format!("[{read}]")); } @@ -5669,10 +6413,20 @@ mod tests { .await .unwrap() .unwrap(); - assert!(matches!( - item, - AttachmentItem::Event(TerminalEvent::Closed { .. }) - )); + // Its sequence is this attachment's guess, so it outranks every process by generation. + let AttachmentItem::Event(TerminalEvent::Closed { + generation: Some(generation), + .. + }) = item + else { + panic!("a close with a generation"); + }; + assert_eq!(generation, manager.inner.generations.latest()); + assert_eq!( + Some(&generation), + attachment.initial.generation.as_ref(), + "no other process was started" + ); manager.shutdown().await; } @@ -5796,7 +6550,8 @@ mod tests { Some(TerminalSize { cols: 151, rows: 50, - size_claim: Some("owner".to_owned()) + size_claim: Some("owner".to_owned()), + reply_claim: None, }) ); *backend.latest().output_on_resize.lock().unwrap() = Some("after resize".to_owned()); @@ -5812,14 +6567,387 @@ mod tests { assert!(matches!(&output, TerminalEvent::Output { data, .. } if data == "after resize")); assert!(output.sequence() > resized.sequence()); manager - .resize("sizing", "term", 91, 42, Some("owner".to_owned())) + .resize("sizing", "term", 91, 42, Some("owner".to_owned())) + .await + .unwrap(); + backend.latest().emit("after noop"); + // The no-op is acknowledged to its requester, in order, without resizing the PTY. + assert!( + matches!(next_attachment_event(&mut attachment).await, TerminalEvent::Resized { ref size, .. } if (size.cols, size.rows) == (91, 42)) + ); + assert!( + matches!(next_attachment_event(&mut attachment).await, TerminalEvent::Output { ref data, .. } if data == "after noop") + ); + assert_eq!(*backend.latest().resizes.lock().unwrap(), [(91, 42)]); + manager.shutdown().await; + } + + /// Every way a resize request ends is reported with what the server then holds (#348). + #[tokio::test] + async fn terminal_resize_reports_each_outcome_with_the_held_size() { + let (_root, backend, manager) = size_fixture(80, 24).await; + let mut owner = manager.attach(size_attachment("owner")).await.unwrap(); + let generation = owner.initial.generation.clone().expect("a generation"); + + let missing = manager + .resize("sizing", "missing", 80, 24, Some("owner".to_owned())) + .await + .unwrap(); + assert_eq!(missing, TerminalResizeResult::session_missing()); + + let unregistered = manager + .resize("sizing", "term", 91, 42, Some("departed".to_owned())) + .await + .unwrap(); + assert_eq!( + unregistered.outcome, + TerminalResizeOutcome::ClaimNotRegistered + ); + assert_eq!( + unregistered.size, + Some(TerminalSize { + cols: 80, + rows: 24, + size_claim: Some("owner".to_owned()), + reply_claim: None, + }) + ); + assert_eq!(unregistered.generation.as_ref(), Some(&generation)); + + let applied = manager + .resize("sizing", "term", 91, 42, Some("owner".to_owned())) + .await + .unwrap(); + let resized = next_attachment_event(&mut owner).await; + assert_eq!(applied.outcome, TerminalResizeOutcome::Applied); + assert_eq!(applied.sequence, Some(resized.sequence())); + assert_eq!( + applied.size.as_ref().map(|size| (size.cols, size.rows)), + Some((91, 42)) + ); + + let unchanged = manager + .resize("sizing", "term", 91, 42, Some("owner".to_owned())) + .await + .unwrap(); + let acknowledged = next_attachment_event(&mut owner).await; + assert_eq!(unchanged.outcome, TerminalResizeOutcome::Unchanged); + assert_eq!(unchanged.sequence, Some(acknowledged.sequence())); + assert!(acknowledged.sequence() > resized.sequence()); + + let session = manager.require_session("sizing", "term").await.unwrap(); + let process = session.lock().await.process.take(); + let absent = manager + .resize("sizing", "term", 70, 20, Some("owner".to_owned())) + .await + .unwrap(); + assert_eq!(absent.outcome, TerminalResizeOutcome::ProcessAbsent); + assert_eq!( + absent.size.as_ref().map(|size| (size.cols, size.rows)), + Some((91, 42)) + ); + session.lock().await.process = process; + + backend.latest().exit(0); + loop { + if matches!( + next_attachment_event(&mut owner).await, + TerminalEvent::Exited { .. } + ) { + break; + } + } + let exited = manager + .resize("sizing", "term", 70, 20, Some("owner".to_owned())) + .await + .unwrap(); + assert_eq!(exited.outcome, TerminalResizeOutcome::GenerationInvalidated); + assert_eq!( + exited.size.as_ref().map(|size| (size.cols, size.rows)), + Some((91, 42)) + ); + assert_eq!(exited.generation.as_ref(), Some(&generation)); + assert_eq!(*backend.latest().resizes.lock().unwrap(), [(91, 42)]); + manager.shutdown().await; + } + + /// A claim for the size it already holds is answered on the requester's stream alone, + /// behind the output published before it, so its renderer can repair a stale size without + /// a timer; other viewers get nothing and are not made to resync (#348). + #[tokio::test] + async fn an_unchanged_claim_is_acknowledged_to_the_requester_alone_in_order() { + let (_root, backend, manager) = size_fixture(80, 24).await; + let mut owner = manager.attach(size_attachment("owner")).await.unwrap(); + let mut mirror = manager.attach(size_attachment("mirror")).await.unwrap(); + let pty = backend.latest(); + pty.emit("before"); + for attachment in [&mut owner, &mut mirror] { + assert!( + matches!(next_attachment_event(attachment).await, TerminalEvent::Output { ref data, .. } if data == "before") + ); + } + let result = manager + .resize("sizing", "term", 80, 24, Some("owner".to_owned())) + .await + .unwrap(); + assert_eq!(result.outcome, TerminalResizeOutcome::Unchanged); + let acknowledgement = next_attachment_event(&mut owner).await; + assert!( + matches!(&acknowledgement, TerminalEvent::Resized { size, .. } if (size.cols, size.rows) == (80, 24) && size.size_claim.as_deref() == Some("owner")) + ); + assert_eq!(result.sequence, Some(acknowledgement.sequence())); + let wire = serde_json::to_value(&acknowledgement).unwrap(); + assert!(wire.get("onlyFor").is_none() && wire.get("only_for").is_none()); + + pty.emit("after"); + assert!( + matches!(next_attachment_event(&mut mirror).await, TerminalEvent::Output { ref data, .. } if data == "after"), + "another claim's acknowledgement is not delivered" + ); + assert!( + matches!(next_attachment_event(&mut owner).await, TerminalEvent::Output { ref data, .. } if data == "after") + ); + assert!( + pty.resizes.lock().unwrap().is_empty(), + "a no-op does not resize the PTY" + ); + manager.shutdown().await; + } + + /// An owner's equal claim is acknowledged on its own stream only for a caller that reads + /// the structured answer; an older client gets nothing, as before that answer (#348). + #[tokio::test] + async fn an_equal_claim_is_acknowledged_only_to_a_caller_that_reads_the_answer() { + let (_root, backend, manager) = size_fixture(80, 24).await; + let mut owner = manager.attach(size_attachment("owner")).await.unwrap(); + let mut mirror = manager.attach(size_attachment("mirror")).await.unwrap(); + let pty = backend.latest(); + // Skip what attaching published. + pty.emit("attached"); + for attachment in [&mut owner, &mut mirror] { + while !matches!( + next_attachment_event(attachment).await, + TerminalEvent::Output { .. } + ) {} + } + for acknowledge in [false, true] { + let result = manager + .resize_acknowledging( + "sizing", + "term", + 80, + 24, + Some("owner".to_owned()), + acknowledge, + ) + .await + .unwrap(); + assert_eq!(result.outcome, TerminalResizeOutcome::Unchanged); + pty.emit("next"); + let first = next_attachment_event(&mut owner).await; + assert_eq!( + matches!(first, TerminalEvent::Resized { .. }), + acknowledge, + "acknowledge: {acknowledge}, got {first:?}" + ); + if acknowledge { + assert!(matches!( + next_attachment_event(&mut owner).await, + TerminalEvent::Output { .. } + )); + } + assert!(matches!( + next_attachment_event(&mut mirror).await, + TerminalEvent::Output { .. } + )); + } + manager.shutdown().await; + } + + /// The PTY refused a size the session records: the next equal request sends it again + /// instead of answering it as a no-op (#348). + #[tokio::test] + async fn an_equal_resize_retries_a_size_the_pty_refused() { + let (_root, backend, manager) = size_fixture(80, 24).await; + let _owner = manager.attach(size_attachment("owner")).await.unwrap(); + let pty = backend.latest(); + manager + .resize("sizing", "term", 80, 24, Some("owner".to_owned())) + .await + .unwrap(); + assert!(pty.resizes.lock().unwrap().is_empty()); + pty.resize_needs_retry + .store(true, std::sync::atomic::Ordering::Release); + let retried = manager + .resize("sizing", "term", 80, 24, Some("owner".to_owned())) + .await + .unwrap(); + assert_eq!(retried.outcome, TerminalResizeOutcome::Unchanged); + assert_eq!(*pty.resizes.lock().unwrap(), [(80, 24)]); + manager.shutdown().await; + } + + /// The PTY kept refusing a size the session published: the size it kept is published + /// with a new sequence, so every viewer converges on it. Asking for the refused size again + /// then changes nothing, until another size is asked for (#348). + #[tokio::test] + async fn a_size_the_pty_kept_refusing_is_replaced_by_the_size_it_kept() { + let (_root, backend, manager) = size_fixture(80, 24).await; + let mut owner = manager.attach(size_attachment("owner")).await.unwrap(); + let mut mirror = manager.attach(size_attachment("mirror")).await.unwrap(); + let pty = backend.latest(); + let applied = manager + .resize("sizing", "term", 100, 30, Some("owner".to_owned())) + .await + .unwrap(); + for attachment in [&mut owner, &mut mirror] { + assert!(matches!( + next_attachment_event(attachment).await, + TerminalEvent::Resized { .. } + )); + } + let report = |requests| ExhaustedResize { + requests, + cols: 80, + rows: 24, + }; + // A report a newer request overtook changes nothing. + pty.resize_exhausted.send_replace(Some(report(0))); + pty.emit("after a stale report"); + assert!(matches!( + next_attachment_event(&mut mirror).await, + TerminalEvent::Output { .. } + )); + assert!(matches!( + next_attachment_event(&mut owner).await, + TerminalEvent::Output { .. } + )); + + pty.resize_exhausted.send_replace(Some(report(1))); + for attachment in [&mut owner, &mut mirror] { + match next_attachment_event(attachment).await { + TerminalEvent::Resized { size, sequence, .. } => { + assert_eq!((size.cols, size.rows), (80, 24)); + assert_eq!(size.size_claim.as_deref(), Some("owner")); + assert!(sequence > applied.sequence.unwrap()); + } + other => panic!("expected the size the PTY kept, got {other:?}"), + } + } + + let again = manager + .resize("sizing", "term", 100, 30, Some("owner".to_owned())) + .await + .unwrap(); + assert_eq!(again.outcome, TerminalResizeOutcome::Unchanged); + assert_eq!( + again.size.as_ref().map(|size| (size.cols, size.rows)), + Some((80, 24)) + ); + assert_eq!(*pty.resizes.lock().unwrap(), [(100, 30)]); + pty.emit("nothing published for the refused size"); + assert!(matches!( + next_attachment_event(&mut owner).await, + TerminalEvent::Output { .. } + )); + + for (cols, rows) in [(90, 30), (100, 30)] { + manager + .resize("sizing", "term", cols, rows, Some("owner".to_owned())) + .await + .unwrap(); + } + assert_eq!( + *pty.resizes.lock().unwrap(), + [(100, 30), (90, 30), (100, 30)] + ); + manager.shutdown().await; + } + + /// Real-pair case 5 (#348): the client takes the size by a click, the host takes it back + /// by a keypress, and the client's next click takes it again, at equal and different sizes. + #[tokio::test] + async fn each_claim_by_another_window_takes_the_size_back() { + let (_root, _backend, manager) = size_fixture(80, 24).await; + // The host attached first and owns the size. + let _host = manager.attach(size_attachment("host")).await.unwrap(); + let mut client = manager.attach(size_attachment("client")).await.unwrap(); + for (claim, cols, rows) in [ + ("client", 80, 24), + ("host", 80, 24), + ("client", 80, 24), + ("host", 120, 50), + ("client", 100, 30), + ] { + let result = manager + .resize("sizing", "term", cols, rows, Some(claim.to_owned())) + .await + .unwrap(); + assert_eq!(result.outcome, TerminalResizeOutcome::Applied, "{claim}"); + match next_attachment_event(&mut client).await { + TerminalEvent::Resized { size, .. } => { + assert_eq!(size.size_claim.as_deref(), Some(claim)); + assert_eq!((size.cols, size.rows), (cols, rows)); + } + other => panic!("expected {claim}'s claim to publish, got {other:?}"), + } + } + manager.shutdown().await; + } + + /// Snapshots, restarts, exits and closes name the process they belong to: a counter that + /// only grows and survives the process's exit, so a client can order states without pids. + #[tokio::test] + async fn terminal_states_carry_an_increasing_generation() { + let (root, backend, manager) = size_fixture(80, 24).await; + let mut attachment = manager.attach(size_attachment("owner")).await.unwrap(); + let first = attachment.initial.generation.clone().expect("a generation"); + manager + .restart(TerminalOpenInput::new( + "sizing", + "term", + root.path().to_path_buf(), + 80, + 24, + )) .await .unwrap(); - backend.latest().emit("after noop"); - assert!( - matches!(next_attachment_event(&mut attachment).await, TerminalEvent::Output { ref data, .. } if data == "after noop") - ); - assert_eq!(*backend.latest().resizes.lock().unwrap(), [(91, 42)]); + let restarted = loop { + if let TerminalEvent::Restarted { snapshot, .. } = + next_attachment_event(&mut attachment).await + { + break snapshot.generation.expect("a restart's generation"); + } + }; + assert_eq!(restarted.boot, first.boot); + assert!(restarted.process > first.process); + + backend.latest().exit(3); + let exited = loop { + if let TerminalEvent::Exited { generation, .. } = + next_attachment_event(&mut attachment).await + { + break generation.expect("an exit's generation"); + } + }; + assert_eq!(exited, restarted); + let after_exit = manager + .attach(TerminalAttachInput::existing("sizing", "term")) + .await + .unwrap() + .initial; + assert_eq!(after_exit.pid, None); + assert_eq!(after_exit.generation.as_ref(), Some(&restarted)); + + manager.close("sizing", Some("term")).await.unwrap(); + let closed = loop { + if let TerminalEvent::Closed { generation, .. } = + next_attachment_event(&mut attachment).await + { + break generation.expect("a close's generation"); + } + }; + assert_eq!(closed, restarted); manager.shutdown().await; } @@ -5845,7 +6973,8 @@ mod tests { TerminalSize { cols: 91, rows: 42, - size_claim: Some("new-owner".to_owned()) + size_claim: Some("new-owner".to_owned()), + reply_claim: None, } ); input.terminal_id = "term".to_owned(); @@ -5894,7 +7023,8 @@ mod tests { TerminalSize { cols: 80, rows: 24, - size_claim: claim.clone() + size_claim: claim.clone(), + reply_claim: None, } ); manager @@ -5933,7 +7063,8 @@ mod tests { TerminalSize { cols: 80, rows: 24, - size_claim: Some("owner".to_owned()) + size_claim: Some("owner".to_owned()), + reply_claim: None, } ); backend.latest().emit("unchanged"); @@ -6216,7 +7347,8 @@ mod tests { let mut mirror = manager.attach(size_attachment("mirror")).await.unwrap(); drop(owner); assert!( - matches!(next_attachment_event(&mut mirror).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.is_none() && (size.cols, size.rows) == (80, 24)) + matches!(next_attachment_event(&mut mirror).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.as_deref() == Some("mirror") && (size.cols, size.rows) == (80, 24)), + "a lone remaining viewer takes the size at once" ); manager .resize("sizing", "term", 91, 42, Some("mirror".to_owned())) @@ -6244,7 +7376,7 @@ mod tests { let mut watcher = manager.attach(size_attachment("watcher")).await.unwrap(); drop(replacement); assert!( - matches!(next_attachment_event(&mut watcher).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.is_none()) + matches!(next_attachment_event(&mut watcher).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.as_deref() == Some("watcher")) ); manager .resize("sizing", "term", 10, 10, Some("reconnect".to_owned())) @@ -6274,21 +7406,372 @@ mod tests { )) .await .unwrap(); + // The owner still attached keeps the size, so the surviving windows do not race (#348). + assert!( + matches!(next_attachment_event(&mut watcher).await, TerminalEvent::Restarted { ref snapshot, .. } if snapshot.size.as_ref().unwrap().size_claim.as_deref() == Some("owner")) + ); + drop(owner); + assert!( + matches!(next_attachment_event(&mut watcher).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.as_deref() == Some("watcher")) + ); + manager.shutdown().await; + } + + /// The owner leaves while a restart is about to register the new session: its release finds + /// no session to update, so the restart must not hand the size to the departed owner (#348). + #[tokio::test] + async fn an_owner_leaving_before_the_restarted_session_registers_does_not_keep_the_size() { + let (root, _backend, manager) = size_fixture(80, 24).await; + let owner = manager.attach(size_attachment("owner")).await.unwrap(); + let mut watcher = manager.attach(size_attachment("watcher")).await.unwrap(); + let barrier = Arc::new(PublisherBarrier { + started: tokio::sync::Notify::new(), + release: tokio::sync::Notify::new(), + }); + *manager + .inner + .restart_before_register_barrier + .lock() + .expect("restart barrier lock") = Some(barrier.clone()); + let restarting = manager.clone(); + let cwd = root.path().to_path_buf(); + let restart = tokio::spawn(async move { + restarting + .restart(TerminalOpenInput::new("sizing", "term", cwd, 91, 42)) + .await + }); + tokio::time::timeout(Duration::from_secs(2), barrier.started.notified()) + .await + .expect("restart reaches the registration barrier"); + let tasks = manager.inner.attachment_tasks.len(); + drop(owner); + tokio::time::timeout(Duration::from_secs(2), async { + while manager.inner.attachment_tasks.len() >= tasks { + tokio::time::sleep(Duration::from_millis(1)).await; + } + }) + .await + .expect("the owner's release finishes"); + barrier.release.notify_one(); + restart.await.expect("restart task").expect("restart"); + assert!( + matches!(next_attachment_event(&mut watcher).await, TerminalEvent::Restarted { ref snapshot, .. } if snapshot.size.as_ref().unwrap().size_claim.as_deref() != Some("owner")) + ); + manager.shutdown().await; + } + + /// The window last used sizes the terminal (#348): when its stream drops, the lone idle + /// viewer takes the size so it keeps working, and the owner gets it back on return without + /// a click, unless someone typed in the other window meanwhile. With several viewers + /// left, none is assumed to be in use. + #[tokio::test] + async fn a_returning_owner_takes_the_size_back_from_an_unused_window() { + let (_root, _backend, manager) = size_fixture(80, 24).await; + let client = manager.attach(size_attachment("client")).await.unwrap(); + let mut host = manager.attach(size_attachment("host")).await.unwrap(); + drop(client); assert!( - matches!(next_attachment_event(&mut watcher).await, TerminalEvent::Restarted { ref snapshot, .. } if snapshot.size.as_ref().unwrap().size_claim.is_none()) + matches!(next_attachment_event(&mut host).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.as_deref() == Some("host")) ); + // The host fits the size to its own box, which is not use. manager - .resize("sizing", "term", 91, 42, Some("owner".to_owned())) + .resize("sizing", "term", 100, 30, Some("host".to_owned())) .await .unwrap(); assert!(matches!( - next_attachment_event(&mut watcher).await, + next_attachment_event(&mut host).await, + TerminalEvent::Resized { .. } + )); + let returned = manager.attach(size_attachment("client")).await.unwrap(); + assert_eq!( + returned + .initial + .size + .as_ref() + .and_then(|size| size.size_claim.as_deref()), + Some("client") + ); + assert!( + matches!(next_attachment_event(&mut host).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.as_deref() == Some("client")) + ); + + // Typed in the host while the client was away: the host keeps the size. + drop(returned); + assert!(matches!( + next_attachment_event(&mut host).await, + TerminalEvent::Resized { .. } + )); + manager.write("sizing", "term", "x").await.unwrap(); + let back = manager.attach(size_attachment("client")).await.unwrap(); + assert_eq!( + back.initial + .size + .as_ref() + .and_then(|size| size.size_claim.as_deref()), + Some("host") + ); + + // Two viewers left: neither is known to be in use. + let _third = manager.attach(size_attachment("third")).await.unwrap(); + drop(host); + let mut back = back; + assert!( + matches!(next_attachment_event(&mut back).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.is_none()) + ); + manager.shutdown().await; + } + + fn owner_of(attachment: &TerminalAttachment) -> Option<&str> { + attachment + .initial + .size + .as_ref() + .and_then(|size| size.size_claim.as_deref()) + } + + async fn ordered_write_from( + manager: &TerminalManager, + connection: uuid::Uuid, + input_id: &str, + sequence: u64, + data: &str, + origin: TerminalInputOrigin<'_>, + ) { + manager + .prepare_input_write(connection, "sizing", "term", input_id, sequence, data) + .unwrap() + .with_origin(origin) + .write() + .await + .unwrap(); + } + + /// A terminal's own answer to a status query is input from the window that inherited the + /// size, but nobody used that window: the window last used still gets the size back when it + /// returns (#348). An older client cannot tell its replies from typing, so all its input + /// counts as use, as before. + #[tokio::test] + async fn an_automatic_reply_does_not_keep_the_size_from_the_returning_owner() { + let (_root, _backend, manager) = size_fixture(80, 24).await; + let connection = uuid::Uuid::new_v4(); + let input = manager + .begin_input( + connection, + CancellationToken::new(), + "sizing", + "term", + 0, + None, + ) + .await + .unwrap() + .input_id; + let mut sequence = 0; + let mut client = manager.attach(size_attachment("client")).await.unwrap(); + let mut host = manager.attach(size_attachment("host")).await.unwrap(); + for (path, origin, client_returns) in [ + ("unary", TerminalInputOrigin::Automatic, true), + ("ordered", TerminalInputOrigin::Automatic, true), + ("ordered", TerminalInputOrigin::Unmarked, false), + ] { + drop(client); + assert!( + matches!(next_attachment_event(&mut host).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.as_deref() == Some("host")), + "the idle host inherits the size" + ); + if path == "unary" { + manager + .write_from("sizing", "term", "\x1b[0n", origin) + .await + .unwrap(); + } else { + ordered_write_from(&manager, connection, &input, sequence, "\x1b[0n", origin).await; + sequence += 1; + } + client = manager.attach(size_attachment("client")).await.unwrap(); + let expected = if client_returns { "client" } else { "host" }; + assert_eq!(owner_of(&client), Some(expected), "{path} {origin:?}"); + if client_returns { + assert!(matches!( + next_attachment_event(&mut host).await, + TerminalEvent::Resized { .. } + )); + } else { + // Give the size back to the client for the next round. + manager + .resize("sizing", "term", 80, 24, Some("client".to_owned())) + .await + .unwrap(); + assert!(matches!( + next_attachment_event(&mut host).await, + TerminalEvent::Resized { .. } + )); + } + } + manager.shutdown().await; + } + + /// With several viewers left when the window last used goes away, the size goes to the one + /// a user typed in last; a terminal's own replies are not typing (#348). + #[tokio::test] + async fn the_size_goes_to_the_remaining_viewer_typed_in_last() { + let (_root, _backend, manager) = size_fixture(80, 24).await; + let owner = manager.attach(size_attachment("owner")).await.unwrap(); + let mut left = manager.attach(size_attachment("left")).await.unwrap(); + let mut right = manager.attach(size_attachment("right")).await.unwrap(); + for (data, origin) in [ + ("r", TerminalInputOrigin::User("right")), + ("l", TerminalInputOrigin::User("left")), + ("\x1b[0n", TerminalInputOrigin::Automatic), + ("o", TerminalInputOrigin::User("owner")), + ] { + manager + .write_from("sizing", "term", data, origin) + .await + .unwrap(); + } + drop(owner); + assert!( + matches!(next_attachment_event(&mut left).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.as_deref() == Some("left")), + "the viewer typed in last takes the size" + ); + assert!(matches!( + next_attachment_event(&mut right).await, TerminalEvent::Resized { .. } )); + // The owner that left gets it back if it returns before anyone types in `left`. + let back = manager.attach(size_attachment("owner")).await.unwrap(); + assert_eq!(owner_of(&back), Some("owner")); + manager.shutdown().await; + } + + fn resized_size(event: TerminalEvent) -> TerminalSize { + match event { + TerminalEvent::Resized { size, .. } => size, + other => panic!("expected a resize, got {other:?}"), + } + } + + /// The owner left several viewers, none used: an idle window that attaches (a reload, a + /// tab shown again) does not take the size left un-owned, the owner that left does, and a + /// lone viewer left takes it (#348). + #[tokio::test] + async fn an_attach_does_not_take_a_size_left_unowned_until_used() { + let (_root, _backend, manager) = size_fixture(80, 24).await; + let owner = manager.attach(size_attachment("owner")).await.unwrap(); + let mut left = manager.attach(size_attachment("left")).await.unwrap(); + let right = manager.attach(size_attachment("right")).await.unwrap(); + drop(owner); + assert_eq!( + resized_size(next_attachment_event(&mut left).await).size_claim, + None + ); + let reloaded = manager.attach(size_attachment("reloaded")).await.unwrap(); + assert_eq!(owner_of(&reloaded), None, "an idle window does not take it"); + drop(reloaded); + let back = manager.attach(size_attachment("owner")).await.unwrap(); + assert_eq!(owner_of(&back), Some("owner"), "the window last used does"); + assert_eq!( + resized_size(next_attachment_event(&mut left).await) + .size_claim + .as_deref(), + Some("owner") + ); + drop(back); + assert_eq!( + resized_size(next_attachment_event(&mut left).await).size_claim, + None + ); + drop(right); + assert_eq!( + resized_size(next_attachment_event(&mut left).await) + .size_claim + .as_deref(), + Some("left"), + "the lone viewer left takes it" + ); + // A fresh session's first attach and a lone viewer are still granted. + let (_root, _backend, manager) = size_fixture(80, 24).await; + let first = manager.attach(size_attachment("first")).await.unwrap(); + assert_eq!(owner_of(&first), Some("first")); + manager.shutdown().await; + } + + /// While the size is un-owned every viewer would answer the terminal's queries: the server + /// names one, and names another when it leaves (#348). + #[tokio::test] + async fn one_viewer_answers_queries_while_the_size_is_unowned() { + let (_root, _backend, manager) = size_fixture(80, 24).await; + let owner = manager.attach(size_attachment("owner")).await.unwrap(); + let a = manager.attach(size_attachment("a")).await.unwrap(); + let _b = manager.attach(size_attachment("b")).await.unwrap(); + let mut c = manager.attach(size_attachment("c")).await.unwrap(); + assert_eq!(c.initial.size.as_ref().unwrap().reply_claim, None); + drop(owner); + let size = resized_size(next_attachment_event(&mut c).await); + assert_eq!( + (size.size_claim, size.reply_claim.as_deref()), + (None, Some("a")) + ); + drop(a); + let size = resized_size(next_attachment_event(&mut c).await); + assert_eq!( + (size.size_claim, size.reply_claim.as_deref()), + (None, Some("b")) + ); + // A window that is used claims the size, and the owner answers again. + manager + .resize("sizing", "term", 90, 30, Some("c".to_owned())) + .await + .unwrap(); + let size = resized_size(next_attachment_event(&mut c).await); + assert_eq!( + (size.size_claim.as_deref(), size.reply_claim), + (Some("c"), None) + ); + manager.shutdown().await; + } + + /// A window hidden and shown again comes back under a new claim. The claims it left behind + /// must not fill the bounded typing record, or the viewer typed in last is not recorded + /// and an older one takes the size when the owner leaves (#348). + #[tokio::test] + async fn departed_viewers_do_not_crowd_out_the_viewer_typed_in_last() { + let (_root, _backend, manager) = size_fixture(80, 24).await; + let owner = manager.attach(size_attachment("owner")).await.unwrap(); + let mut early = manager.attach(size_attachment("early")).await.unwrap(); + let type_in = |claim: String| { + let manager = &manager; + async move { + manager + .write_from("sizing", "term", "x", TerminalInputOrigin::User(&claim)) + .await + .unwrap(); + } + }; + type_in("early".to_owned()).await; + for round in 0..=MAX_USER_INPUT_CLAIMS + 5 { + let shown = manager + .attach(size_attachment(&format!("shown-{round}"))) + .await + .unwrap(); + type_in(format!("shown-{round}")).await; + type_in("owner".to_owned()).await; + drop(shown); + tokio::task::yield_now().await; + } + let last = format!("shown-{}", MAX_USER_INPUT_CLAIMS + 6); + let mut shown = manager.attach(size_attachment(&last)).await.unwrap(); + type_in(last.clone()).await; drop(owner); assert!( - matches!(next_attachment_event(&mut watcher).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.is_none()) + matches!(next_attachment_event(&mut shown).await, TerminalEvent::Resized { ref size, .. } if size.size_claim.as_deref() == Some(last.as_str())), + "the viewer typed in last takes the size" ); + assert!(matches!( + next_attachment_event(&mut early).await, + TerminalEvent::Resized { .. } + )); manager.shutdown().await; } @@ -10010,6 +11493,90 @@ mod tests { ); } + /// Through the real PTY launch path (Unix runs the provider executable as the PTY root, in + /// its own session and group): a stand-in for the Claude CLI that reports `SIGCONT` with + /// the size it reads from its terminal gets the resume after a lossy attach, at the size it + /// started with (#348). + #[cfg(unix)] + #[tokio::test] + async fn a_lossy_attach_resumes_a_real_claude_terminal_at_its_size() { + use std::os::unix::fs::PermissionsExt; + let root = tempfile::tempdir().unwrap(); + let fixture = root.path().join("claude"); + std::fs::write( + &fixture, + "#!/bin/sh\n\ + trap 'echo \"RESUMED $(stty size)\"' CONT\n\ + echo \"START $(stty size)\"\n\ + i=0; while [ $i -lt 20 ]; do echo \"line $i\"; i=$((i+1)); done\n\ + echo READY\n\ + while :; do sleep 0.05; done\n", + ) + .unwrap(); + std::fs::set_permissions(&fixture, std::fs::Permissions::from_mode(0o755)).unwrap(); + let manager = TerminalManager::new( + Arc::new(PortablePtyBackend), + TerminalManagerOptions { + history_line_limit: 5, + subprocess_poll_interval: Duration::ZERO, + ..TerminalManagerOptions::default() + }, + ); + let mut input = + TerminalOpenInput::new("claude", "term", root.path().to_path_buf(), 100, 30); + input.command = Some(TerminalLaunchCommand { + executable: fixture.to_string_lossy().into_owned(), + args: Vec::new(), + label: None, + activity: Some(crate::terminal::ProviderTerminalActivityLaunch { + driver_kind: "claudeAgent".to_owned(), + provider_instance_id: "claudeAgent".to_owned(), + }), + env: None, + }); + manager.open(input).await.unwrap(); + tokio::time::timeout(Duration::from_secs(10), async { + while !manager + .read_history("claude", "term", None, 1_000) + .await + .unwrap() + .window + .text + .contains("READY") + { + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + .await + .expect("the fixture started"); + + let mut attachment = manager + .attach(TerminalAttachInput::existing("claude", "term")) + .await + .unwrap(); + assert!(!attachment.initial.history.contains("RESUMED")); + let resumed = tokio::time::timeout(Duration::from_secs(10), async { + let mut output = String::new(); + loop { + match attachment.recv().await { + Some(AttachmentItem::Event(TerminalEvent::Output { data, .. })) => { + output.push_str(&data); + } + Some(_) => {} + None => panic!("the attachment ended: {output}"), + } + if let Some(line) = output.lines().find(|line| line.starts_with("RESUMED")) { + break line.trim_end().to_owned(); + } + } + }) + .await + .expect("the fixture got SIGCONT after the lossy attach"); + assert_eq!(resumed, "RESUMED 30 100", "the PTY keeps its size"); + manager.close("claude", Some("term")).await.unwrap(); + manager.shutdown().await; + } + #[tokio::test] async fn manager_covers_live_lifecycle_attachments_and_metadata() { let root = tempfile::tempdir().unwrap(); diff --git a/apps/server/src/terminal/mod.rs b/apps/server/src/terminal/mod.rs index 19491ed66f..3d9d8b18e3 100644 --- a/apps/server/src/terminal/mod.rs +++ b/apps/server/src/terminal/mod.rs @@ -19,8 +19,11 @@ pub use manager::{ }; pub use model::{ ProviderTerminalActivityLaunch, TerminalAgentStatus, TerminalAgentStatusState, - TerminalAttachInput, TerminalConsoleTheme, TerminalEvent, TerminalLaunchCommand, - TerminalMetadataEvent, TerminalOpenInput, TerminalRemovalReason, TerminalRestartInput, + TerminalAttachInput, TerminalConsoleTheme, TerminalEvent, TerminalGeneration, + TerminalInputOrigin, TerminalLaunchCommand, TerminalMetadataEvent, TerminalOpenInput, + TerminalRemovalReason, TerminalResizeOutcome, TerminalResizeResult, TerminalRestartInput, TerminalSessionSnapshot, TerminalSize, TerminalStatus, TerminalSummary, }; -pub use pty::{PortablePtyBackend, PtyBackend, PtyExit, PtyProcess, PtySpawnInput}; +pub use pty::{ + ExhaustedResize, PortablePtyBackend, PtyBackend, PtyExit, PtyProcess, PtySpawnInput, +}; diff --git a/apps/server/src/terminal/model.rs b/apps/server/src/terminal/model.rs index c98c3b1138..1e95357cc3 100644 --- a/apps/server/src/terminal/model.rs +++ b/apps/server/src/terminal/model.rs @@ -161,6 +161,66 @@ pub struct TerminalSize { pub cols: u16, pub rows: u16, pub size_claim: Option, + /// While the size is un-owned because its owner left several viewers, none used since: + /// the one viewer that answers the terminal's queries (#348). Omitted otherwise. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reply_claim: Option, +} + +/// The process a terminal state belongs to: `boot` names this manager instance, `process` +/// grows with every process it starts and is never reused (#348). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalGeneration { + pub boot: String, + pub process: u64, +} + +/// What a resize request did. `Applied` and `Unchanged` are acknowledged by a `resized` event +/// on the requester's claimed stream; the rest changed and published nothing. +#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub enum TerminalResizeOutcome { + Applied, + Unchanged, + SessionMissing, + ClaimNotRegistered, + ProcessAbsent, + GenerationInvalidated, +} + +/// Who produced a piece of terminal input, for the rule that the window last used sizes the +/// terminal (#348). +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum TerminalInputOrigin<'a> { + /// A caller that does not say, such as an older client: all its input counts as use. + Unmarked, + /// The terminal answering a query by itself: not use. + Automatic, + /// A user in the window that holds this size claim. + User(&'a str), +} + +/// The size the server holds after a resize request. +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalResizeResult { + pub outcome: TerminalResizeOutcome, + pub size: Option, + pub generation: Option, + /// The last size event's sequence: the acknowledgement's when there is one. + pub sequence: Option, +} + +impl TerminalResizeResult { + pub const fn session_missing() -> Self { + Self { + outcome: TerminalResizeOutcome::SessionMissing, + size: None, + generation: None, + sequence: None, + } + } } #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] @@ -182,6 +242,8 @@ pub struct TerminalSessionSnapshot { pub sequence: u64, #[serde(default, skip_serializing_if = "Option::is_none")] pub size: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub generation: Option, #[serde(default)] pub osc_color_responder_active: bool, #[serde(default)] @@ -269,6 +331,10 @@ pub enum TerminalEvent { terminal_id: String, sequence: u64, size: TerminalSize, + /// Set on an acknowledgement of an unchanged size: only that claimed attachment + /// receives it. Never on the wire. + #[serde(skip)] + only_for: Option, }, Exited { #[serde(rename = "threadId")] @@ -280,6 +346,8 @@ pub enum TerminalEvent { exit_code: Option, #[serde(rename = "exitSignal")] exit_signal: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + generation: Option, }, Closed { #[serde(rename = "threadId")] @@ -287,6 +355,9 @@ pub enum TerminalEvent { #[serde(rename = "terminalId")] terminal_id: String, sequence: u64, + /// At or above every process the session had, so a client applies it by generation. + #[serde(default, skip_serializing_if = "Option::is_none")] + generation: Option, }, Error { #[serde(rename = "threadId")] @@ -335,6 +406,66 @@ mod tests { assert!(input.cwd.is_none()); assert!(!input.restart_if_not_running); } + + /// The resize answer and the generation fields decode in their new shapes, and events from + /// an older server, without a generation, still decode (#348). + #[test] + fn resize_results_and_generations_decode_in_new_and_old_shapes() { + let result: TerminalResizeResult = serde_json::from_value(serde_json::json!({ + "outcome": "claimNotRegistered", + "size": { "cols": 80, "rows": 24, "sizeClaim": "owner" }, + "generation": { "boot": "b", "process": 3 }, + "sequence": 9, + })) + .unwrap(); + assert_eq!(result.outcome, TerminalResizeOutcome::ClaimNotRegistered); + assert_eq!(result.generation.unwrap().process, 3); + assert_eq!( + serde_json::to_value(TerminalResizeResult::session_missing()).unwrap(), + serde_json::json!({ + "outcome": "sessionMissing", "size": null, "generation": null, "sequence": null + }) + ); + let closed: TerminalEvent = serde_json::from_value(serde_json::json!({ + "type": "closed", "threadId": "t", "terminalId": "x", "sequence": 4, + })) + .unwrap(); + assert!(matches!( + closed, + TerminalEvent::Closed { + generation: None, + .. + } + )); + let exited: TerminalEvent = serde_json::from_value(serde_json::json!({ + "type": "exited", "threadId": "t", "terminalId": "x", "sequence": 4, + "exitCode": 0, "exitSignal": null, "generation": { "boot": "b", "process": 2 }, + })) + .unwrap(); + assert!(matches!( + exited, + TerminalEvent::Exited { + generation: Some(_), + .. + } + )); + let resized: TerminalEvent = serde_json::from_value(serde_json::json!({ + "type": "resized", "threadId": "t", "terminalId": "x", "sequence": 5, + "size": { "cols": 80, "rows": 24, "sizeClaim": null }, + })) + .unwrap(); + assert!(matches!( + resized, + TerminalEvent::Resized { only_for: None, .. } + )); + let snapshot: TerminalSessionSnapshot = serde_json::from_value(serde_json::json!({ + "threadId": "t", "terminalId": "x", "cwd": "/", "worktreePath": null, + "status": "exited", "pid": null, "history": "", "exitCode": 0, "exitSignal": null, + "label": "l", "updatedAt": "u", "sequence": 3, + })) + .unwrap(); + assert_eq!(snapshot.generation, None); + } } impl TerminalEvent { diff --git a/apps/server/src/terminal/pty.rs b/apps/server/src/terminal/pty.rs index a6b92ff281..7a9326cfae 100644 --- a/apps/server/src/terminal/pty.rs +++ b/apps/server/src/terminal/pty.rs @@ -6,7 +6,11 @@ use std::{ fmt, io::{Read, Write}, path::{Path, PathBuf}, - sync::{Arc, Mutex, OnceLock, mpsc}, + sync::{ + Arc, Mutex, OnceLock, + atomic::{AtomicBool, AtomicU64, Ordering}, + mpsc, + }, thread, time::Duration, }; @@ -156,7 +160,29 @@ pub trait PtyProcess: fmt::Debug + Send + Sync { } fn write(&self, data: &str) -> Result<(), String>; fn resize(&self, cols: u16, rows: u16) -> Result<(), String>; + /// The PTY refused the last size it was asked for, so it is not at the size the manager + /// records: an equal request must be sent again rather than treated as a no-op (#348). + fn resize_needs_retry(&self) -> bool { + false + } + /// Reports the size the PTY still has once it kept refusing a size past every retry; + /// `None` for a backend that never refuses. The manager publishes that size (#348). + fn subscribe_resize_exhausted(&self) -> Option>> { + None + } + /// How many sizes were sent to the PTY: an [`ExhaustedResize`] is current only while its + /// `requests` still equals it, since a newer request settles on its own. + fn resize_requests_sent(&self) -> u64 { + 0 + } fn kill(&self) -> Result<(), String>; + /// Sends `SIGCONT` to the session's own process group, and only while the waiter still + /// reserves it, so a reaped or closing group is never signalled. Ownership is unchanged: + /// close still kills the group afterwards. `Ok(false)`: nothing was sent (#348). + #[cfg(unix)] + fn resume(&self) -> Result { + Ok(false) + } fn wait_for_process_tree_exit(&self, _timeout: Duration) -> Result, String> { Ok(None) } @@ -351,6 +377,24 @@ impl Drop for SpawnedChildGuard { } } +/// A size the PTY refused on every retry: after `requests` requests, it is still at +/// `cols` x `rows`, the last size it accepted. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct ExhaustedResize { + pub requests: u64, + pub cols: u16, + pub rows: u16, +} + +/// How long the resize worker waits before each retry of a size the PTY refused, without +/// waiting for another request: the manager has already published that size, and a client +/// holding it never asks again (#348). About 2.6 s in all, then the PTY's own size is published. +const RESIZE_RETRY_DELAYS: [Duration; 3] = [ + Duration::from_millis(100), + Duration::from_millis(500), + Duration::from_secs(2), +]; + /// Collapses a burst of queued resizes down to the newest one. /// /// A window drag emits many sizes while a single `master.resize` is still in @@ -359,14 +403,114 @@ impl Drop for SpawnedChildGuard { /// actually looking at, so intermediate sizes are skipped rather than replayed. /// Skipping them must never skip the last one: a PTY left at an intermediate /// size strands every full-screen TUI that anchors to the reported row count. -fn coalesce_pending_resizes(first: PtySize, requests: &mpsc::Receiver) -> PtySize { +/// `received` counts every request taken off the queue, the first included. +fn coalesce_pending_resizes( + first: PtySize, + requests: &mpsc::Receiver, + received: &mut u64, +) -> PtySize { let mut newest = first; + *received += 1; while let Ok(size) = requests.try_recv() { newest = size; + *received += 1; } newest } +/// Applies queued sizes, newest first, until the process drops its sender. `failed` says +/// whether the PTY refused the last one: the manager has already recorded and published that +/// size, so the next request for it must reach the PTY again instead of being a no-op. A +/// refused size is retried after each of `retry_delays` unless a newer request replaces it; +/// when they run out, `exhausted` gets the size the PTY still has, from `read_size` (#348). +#[allow(clippy::too_many_arguments)] +fn run_resize_worker( + pid: u32, + initial: PtySize, + requests: &mpsc::Receiver, + failed: &AtomicBool, + retry_delays: &[Duration], + mut resize: impl FnMut(PtySize) -> Result<(), E>, + mut read_size: impl FnMut() -> Option, + mut exhausted: impl FnMut(ExhaustedResize), +) { + // The last size the PTY accepted, for when its size cannot be read back. A Unix child can + // set its own size (`stty`), so the size read back is the one reported. + let mut accepted = initial; + let mut received = 0; + let mut refused: Option = None; + let mut retries = 0; + loop { + let size = match refused { + None => match requests.recv() { + Ok(size) => coalesce_pending_resizes(size, requests, &mut received), + Err(_) => return, + }, + Some(size) => match requests.recv_timeout(retry_delays[retries]) { + Ok(newer) => { + retries = 0; + coalesce_pending_resizes(newer, requests, &mut received) + } + Err(mpsc::RecvTimeoutError::Timeout) => { + retries += 1; + size + } + Err(mpsc::RecvTimeoutError::Disconnected) => return, + }, + }; + match resize(size) { + Ok(()) => { + failed.store(false, Ordering::Release); + accepted = size; + refused = None; + retries = 0; + } + Err(error) => { + failed.store(true, Ordering::Release); + if retries < retry_delays.len() { + // A dropped resize strands full-screen TUIs at a stale row count, so make + // the first failure visible; the retries are quiet until they run out. + if retries == 0 { + tracing::warn!( + %error, + pid, + cols = size.cols, + rows = size.rows, + "failed to resize PTY; retrying" + ); + } + refused = Some(size); + continue; + } + // A child can set any size, 0 by 0 included; publish only one the wire accepts. + let kept = read_size() + .filter(|size| { + super::manager::validate_dimensions(size.cols, size.rows).is_ok() + }) + .unwrap_or(accepted); + tracing::warn!( + %error, + pid, + cols = size.cols, + rows = size.rows, + kept_cols = kept.cols, + kept_rows = kept.rows, + "PTY refused a size on every retry; publishing the size it kept" + ); + refused = None; + retries = 0; + // The manager publishes the size the PTY has, so nothing needs sending again. + failed.store(false, Ordering::Release); + exhausted(ExhaustedResize { + requests: received, + cols: kept.cols, + rows: kept.rows, + }); + } + } + } +} + impl PortablePtyBackend { fn spawn_command( &self, @@ -454,6 +598,16 @@ impl PortablePtyBackend { let (output, initial_output) = broadcast::channel(1024); let (exit, _) = watch::channel(None); let (resize, resize_requests) = mpsc::channel(); + let resize_failed = Arc::new(AtomicBool::new(false)); + let worker_resize_failed = Arc::clone(&resize_failed); + let (resize_exhausted, _) = watch::channel(None); + let worker_resize_exhausted = resize_exhausted.clone(); + let initial_size = PtySize { + rows: input.rows, + cols: input.cols, + pixel_width: 0, + pixel_height: 0, + }; // Answer OSC color queries (e.g. OpenCode's OSC 11 light/dark probe) at // the PTY layer, using the app's resolved theme colors, so detection @@ -479,21 +633,18 @@ impl PortablePtyBackend { if let Err(error) = thread::Builder::new() .name(format!("bibcode-pty-resize-{pid}")) .spawn(move || { - while let Ok(size) = resize_requests.recv() { - let size = coalesce_pending_resizes(size, &resize_requests); - if let Err(error) = pair.master.resize(size) { - // A dropped resize strands full-screen TUIs at a stale - // row count, so make the failure visible instead of - // leaving the PTY silently out of sync with the client. - tracing::warn!( - %error, - pid, - cols = size.cols, - rows = size.rows, - "failed to resize PTY" - ); - } - } + run_resize_worker( + pid, + initial_size, + &resize_requests, + &worker_resize_failed, + &RESIZE_RETRY_DELAYS, + |size| pair.master.resize(size), + || pair.master.get_size().ok(), + |report| { + worker_resize_exhausted.send_replace(Some(report)); + }, + ); }) { return Err(error.to_string()); @@ -605,6 +756,9 @@ impl PortablePtyBackend { process_identity, osc_color_responder_active, resize, + resize_failed, + resize_requests: AtomicU64::new(0), + resize_exhausted, writer, #[cfg(not(windows))] killer: Mutex::new(killer), @@ -1034,6 +1188,11 @@ struct PortablePtyProcess { process_identity: Option, osc_color_responder_active: bool, resize: mpsc::Sender, + /// Set by the resize worker while the PTY refuses the last size it was sent. + resize_failed: Arc, + /// Sizes sent to the resize worker, counted before each is queued. + resize_requests: AtomicU64, + resize_exhausted: watch::Sender>, writer: SharedPtyWriter, #[cfg(not(windows))] killer: Mutex>, @@ -1101,6 +1260,7 @@ impl PtyProcess for PortablePtyProcess { // Never drop a request: callers and `TerminalSession` both record the // requested size as applied, so a discarded resize desyncs the PTY from // the client permanently. The worker coalesces any backlog instead. + self.resize_requests.fetch_add(1, Ordering::AcqRel); match self.resize.send(PtySize { rows, cols, @@ -1112,6 +1272,18 @@ impl PtyProcess for PortablePtyProcess { } } + fn resize_needs_retry(&self) -> bool { + self.resize_failed.load(Ordering::Acquire) + } + + fn subscribe_resize_exhausted(&self) -> Option>> { + Some(self.resize_exhausted.subscribe()) + } + + fn resize_requests_sent(&self) -> u64 { + self.resize_requests.load(Ordering::Acquire) + } + fn kill(&self) -> Result<(), String> { #[cfg(unix)] if let Some(process_group) = self.process_group { @@ -1169,6 +1341,19 @@ impl PtyProcess for PortablePtyProcess { Ok(Some(exited)) } + #[cfg(unix)] + fn resume(&self) -> Result { + let Some(process_group) = self.process_group else { + return Ok(false); + }; + resume_reserved_unix_process_group( + process_group, + &self.process_group_ownership, + continue_unix_process_group, + ) + .map_err(|error| error.to_string()) + } + fn subscribe_output(&self) -> broadcast::Receiver { self.initial_output .lock() @@ -1226,6 +1411,41 @@ fn signal_reserved_unix_process_group_with( Ok(()) } +/// Signals the reserved group without consuming it, unlike +/// [`signal_reserved_unix_process_group_with`]: a resumed group is still the close path's to +/// kill. A close holding the ownership lock (it may wait for the root) means the group is +/// going away, so nothing is sent rather than blocking the caller. +#[cfg(unix)] +fn resume_reserved_unix_process_group( + process_group: i32, + ownership: &Mutex, + signal: impl FnOnce(i32) -> std::io::Result, +) -> std::io::Result { + let ownership = match ownership.try_lock() { + Ok(ownership) => ownership, + Err(std::sync::TryLockError::Poisoned(poisoned)) => poisoned.into_inner(), + Err(std::sync::TryLockError::WouldBlock) => return Ok(false), + }; + if *ownership == UnixProcessGroupOwnership::Released { + return Ok(false); + } + signal(process_group) +} + +/// `SIGCONT` to the whole group. `Ok(false)` when no member is left to receive it. +#[cfg(unix)] +fn continue_unix_process_group(process_group: i32) -> std::io::Result { + if unsafe { kill(-process_group, libc::SIGCONT) } == 0 { + return Ok(true); + } + let error = std::io::Error::last_os_error(); + if error.raw_os_error() == Some(libc::ESRCH) { + Ok(false) + } else { + Err(error) + } +} + /// Kills the owned group once. `root_exited` is asked only when the kill is refused: with the /// root exited but kept waitable, `EPERM` means the group has no signalable member left (any /// live same-user descendant would make the signal succeed), so the group counts as stopped. @@ -1259,10 +1479,28 @@ fn close_unix_process_group( kill_group: fn(i32) -> std::io::Result<()>, ) -> std::io::Result<()> { kill_reserved_unix_process_group(process_group, ownership, kill_group, || { - matches!(probe_unix_child_exit_without_reaping(root_pid), Ok(true)) + // macOS refuses the kill of a dying group before its root is waitable (a probe saw + // it in nearly every one of 3,000 quick exits, waitable within 3 ms), so wait for it. + // shortcut: blocks the caller's (runtime) thread up to 250 ms when the root really + // runs; move the kill to a blocking task if a slower dying root is ever seen. + let deadline = std::time::Instant::now() + CLOSE_ROOT_EXIT_WAIT; + loop { + match probe_unix_child_exit_without_reaping(root_pid) { + Ok(true) => return true, + Ok(false) if std::time::Instant::now() < deadline => { + thread::sleep(Duration::from_millis(1)); + } + _ => return false, + } + } }) } +/// How long a refused close waits for the root to become waitable: far past the 2.7 ms the +/// probe saw, short enough not to stall the runtime thread it blocks. +#[cfg(unix)] +const CLOSE_ROOT_EXIT_WAIT: Duration = Duration::from_millis(250); + /// Waits for the child to exit without reaping it, and returns its exit status. #[cfg(unix)] fn wait_for_unix_child_exit_without_reaping( @@ -1863,6 +2101,65 @@ mod tests { assert!(killed.load(std::sync::atomic::Ordering::Acquire)); } + /// A resume signals the session's own group and leaves it reserved: the close that follows + /// still kills it (#348). + #[cfg(unix)] + #[test] + fn a_resume_signals_the_reserved_group_and_close_still_kills_it() { + let ownership = Mutex::new(UnixProcessGroupOwnership::Reserved); + let mut resumed = Vec::new(); + let sent = resume_reserved_unix_process_group(4242, &ownership, |group| { + resumed.push(group); + Ok(true) + }) + .expect("resume"); + assert!(sent); + assert_eq!(resumed, [4242]); + assert_eq!( + *ownership.lock().unwrap(), + UnixProcessGroupOwnership::Reserved + ); + + let mut killed = Vec::new(); + kill_reserved_unix_process_group( + 4242, + &ownership, + |group| { + killed.push(group); + Ok(()) + }, + || false, + ) + .expect("close kill"); + assert_eq!(killed, [4242]); + assert_eq!( + *ownership.lock().unwrap(), + UnixProcessGroupOwnership::Released + ); + } + + /// A released group (reaped root, or already killed) and one a close is busy with get no + /// signal at all. + #[cfg(unix)] + #[test] + fn a_resume_never_signals_a_released_or_closing_group() { + let released = Mutex::new(UnixProcessGroupOwnership::Released); + let sent = resume_reserved_unix_process_group(4242, &released, |_| { + panic!("a released group was signalled") + }) + .expect("resume"); + assert!(!sent); + + let closing = Mutex::new(UnixProcessGroupOwnership::Reserved); + let held = closing.lock().unwrap(); + let sent = resume_reserved_unix_process_group(4242, &closing, |_| { + panic!("a group being closed was signalled") + }) + .expect("resume"); + assert!(!sent); + drop(held); + } + /// macOS refuses (`EPERM`) a group kill once every member has exited unreaped; a close /// that lands then must succeed and leave the root's status for the waiter (#237). #[cfg(unix)] @@ -1893,6 +2190,25 @@ mod tests { assert_eq!(root.wait().expect("root still waitable").code(), Some(7)); } + /// macOS refuses the kill of a dying group a moment before its root becomes waitable: a + /// close that lands then waits for the root instead of failing (#348 flake). + #[cfg(unix)] + #[test] + fn close_waits_for_a_dying_root_whose_group_kill_is_refused() { + fn refused(_: i32) -> std::io::Result<()> { + Err(std::io::Error::from_raw_os_error(libc::EPERM)) + } + let mut root = std::process::Command::new("/bin/sh") + .args(["-c", "exit 7"]) + .spawn() + .expect("spawn root"); + let pid = root.id(); + let ownership = Mutex::new(UnixProcessGroupOwnership::Reserved); + close_unix_process_group(pid as i32, &ownership, pid, refused) + .expect("the root becomes waitable within the close's wait"); + assert_eq!(root.wait().expect("root still waitable").code(), Some(7)); + } + #[cfg(unix)] #[test] fn close_keeps_a_refused_kill_of_a_running_roots_group_a_failure() { @@ -1905,8 +2221,15 @@ mod tests { .expect("spawn root"); let pid = root.id(); let ownership = Mutex::new(UnixProcessGroupOwnership::Reserved); + let started = std::time::Instant::now(); let error = close_unix_process_group(pid as i32, &ownership, pid, refused) .expect_err("EPERM while the root runs is a failure"); + // The wait blocks a runtime thread, so it gives up quickly on a root that keeps running. + assert!( + started.elapsed() < Duration::from_millis(500), + "refused close waited {:?}", + started.elapsed() + ); assert_eq!(error.raw_os_error(), Some(libc::EPERM)); assert_eq!( *ownership @@ -2264,7 +2587,7 @@ mod tests { let command = build_pty_command_on(Platform::Windows, &input).unwrap(); let raw_arguments = format!( - "/e:ON /v:OFF /d /c \"\"{}\" --flag \"value with spaces\" \"&literal\" \"%%cd:~,%%PATH%%cd:~,%%\" \"!literal!\" \"\"\"", + "/e:ON /v:OFF /d /c \"\"{}\" --flag \"value with spaces\" \"&literal\" \"%%cd:~,%PATH%%cd:~,%\" \"!literal!\" \"\"\"", executable.display() ); let expected = vec![ @@ -3185,10 +3508,228 @@ mod tests { resize.send(size(206, 69)).unwrap(); let first = requests.recv().unwrap(); - assert_eq!(coalesce_pending_resizes(first, &requests), size(206, 69)); + assert_eq!( + coalesce_pending_resizes(first, &requests, &mut 0), + size(206, 69) + ); assert!(requests.try_recv().is_err(), "backlog must be drained"); } + struct RefusingResizeWorker { + requests: mpsc::Sender, + /// The size the child sees after each attempt. + attempts: mpsc::Receiver<(u16, u16)>, + exhausted: mpsc::Receiver, + failed: Arc, + worker: thread::JoinHandle<()>, + } + + /// The resize worker on a real PTY at 80x30 whose resize is refused `refusals` times, an + /// injected failure of `MasterPty::resize`, before it reaches the PTY. + fn refusing_resize_worker( + refusals: usize, + retry_delays: &'static [Duration], + ) -> RefusingResizeWorker { + let initial = PtySize { + rows: 30, + cols: 80, + pixel_width: 0, + pixel_height: 0, + }; + let pair = native_pty_system().openpty(initial).unwrap(); + let (requests, worker_requests) = mpsc::channel(); + let (attempted, attempts) = mpsc::channel(); + let (reported, exhausted) = mpsc::channel(); + let failed = Arc::new(AtomicBool::new(false)); + let worker = { + let failed = Arc::clone(&failed); + thread::spawn(move || { + let mut refused = 0; + run_resize_worker( + 7, + initial, + &worker_requests, + &failed, + retry_delays, + |size| { + let result = if refused < refusals { + refused += 1; + Err("injected refusal".to_owned()) + } else { + pair.master.resize(size).map_err(|error| error.to_string()) + }; + // The master reads back the size the PTY holds. + let held = pair.master.get_size().unwrap(); + attempted.send((held.cols, held.rows)).unwrap(); + result + }, + || pair.master.get_size().ok(), + |report| reported.send(report).unwrap(), + ); + }) + }; + RefusingResizeWorker { + requests, + attempts, + exhausted, + failed, + worker, + } + } + + const QUICK_RETRIES: [Duration; 3] = [Duration::from_millis(5); 3]; + const NO_RETRY_YET: [Duration; 1] = [Duration::from_secs(30)]; + + fn pty_size(cols: u16, rows: u16) -> PtySize { + PtySize { + rows, + cols, + pixel_width: 0, + pixel_height: 0, + } + } + + /// The manager has already published a size the PTY refused, and a client holding it + /// never asks again: the worker retries it by itself until the PTY takes it (#348). + #[test] + fn a_refused_resize_is_retried_without_another_request() { + let pty = refusing_resize_worker(2, &QUICK_RETRIES); + pty.requests.send(pty_size(80, 20)).unwrap(); + let next = || pty.attempts.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!([next(), next(), next()], [(80, 30), (80, 30), (80, 20)]); + drop(pty.requests); + pty.worker.join().unwrap(); + assert!(!pty.failed.load(Ordering::Acquire)); + assert!(pty.exhausted.try_recv().is_err(), "the PTY took the size"); + } + + /// A size refused on every retry is given up, reporting the size the PTY kept so the + /// manager publishes it and every viewer converges on the real one (#348). + #[test] + fn a_size_refused_on_every_retry_reports_the_size_the_pty_kept() { + let pty = refusing_resize_worker(usize::MAX, &QUICK_RETRIES); + pty.requests.send(pty_size(80, 20)).unwrap(); + let report = pty + .exhausted + .recv_timeout(Duration::from_secs(5)) + .expect("the worker gives up"); + assert_eq!( + report, + ExhaustedResize { + requests: 1, + cols: 80, + rows: 30, + } + ); + assert_eq!( + pty.attempts.try_iter().collect::>(), + [(80, 30); 1 + QUICK_RETRIES.len()], + "one attempt and one per retry, none reaching the PTY" + ); + // Nothing to send again: the manager publishes the size the PTY has. + assert!(!pty.failed.load(Ordering::Acquire)); + drop(pty.requests); + pty.worker.join().unwrap(); + } + + /// A child can set its own size (`stty`): the size reported when a refused size runs out + /// of retries is the one read back from the PTY, not the last one the worker sent (#348). + #[test] + fn a_size_refused_on_every_retry_reports_the_size_read_back_from_the_pty() { + let initial = pty_size(80, 30); + let pair = native_pty_system().openpty(initial).unwrap(); + let (requests, worker_requests) = mpsc::channel(); + let (reported, exhausted) = mpsc::channel(); + let worker = thread::spawn(move || { + run_resize_worker( + 7, + initial, + &worker_requests, + &AtomicBool::new(false), + &QUICK_RETRIES, + |_| { + // The child resizes its terminal itself; the master sees that size. + pair.master.resize(pty_size(90, 25)).unwrap(); + Err("injected refusal") + }, + || pair.master.get_size().ok(), + |report| reported.send(report).unwrap(), + ); + }); + requests.send(pty_size(80, 20)).unwrap(); + let report = exhausted + .recv_timeout(Duration::from_secs(5)) + .expect("the worker gives up"); + assert_eq!( + report, + ExhaustedResize { + requests: 1, + cols: 90, + rows: 25, + } + ); + drop(requests); + worker.join().unwrap(); + } + + /// A child can set a size the wire rejects (`stty cols 0 rows 0`, or past the maximum): + /// such a readback is not reported, the last accepted size is (#348). + #[test] + fn a_size_read_back_outside_the_wire_bounds_reports_the_last_accepted_size() { + for (cols, rows) in [(0, 0), (1_001, 30), (80, 501)] { + let (requests, worker_requests) = mpsc::channel(); + let (reported, exhausted) = mpsc::channel(); + let worker = thread::spawn(move || { + run_resize_worker( + 7, + pty_size(80, 30), + &worker_requests, + &AtomicBool::new(false), + &QUICK_RETRIES, + |_| Err("injected refusal"), + || Some(pty_size(cols, rows)), + |report| reported.send(report).unwrap(), + ); + }); + requests.send(pty_size(80, 20)).unwrap(); + let report = exhausted + .recv_timeout(Duration::from_secs(5)) + .expect("the worker gives up"); + assert_eq!( + report, + ExhaustedResize { + requests: 1, + cols: 80, + rows: 30, + }, + "readback {cols}x{rows}" + ); + drop(requests); + worker.join().unwrap(); + } + } + + /// A newer size replaces a refused one waiting for its retry. + #[test] + fn a_newer_request_replaces_a_refused_size_waiting_for_its_retry() { + let pty = refusing_resize_worker(1, &NO_RETRY_YET); + pty.requests.send(pty_size(80, 20)).unwrap(); + let next = || pty.attempts.recv_timeout(Duration::from_secs(5)).unwrap(); + assert_eq!(next(), (80, 30)); + pty.requests.send(pty_size(100, 40)).unwrap(); + assert_eq!(next(), (100, 40)); + assert!( + pty.attempts + .recv_timeout(Duration::from_millis(50)) + .is_err(), + "the refused size is not tried again" + ); + drop(pty.requests); + pty.worker.join().unwrap(); + assert!(!pty.failed.load(Ordering::Acquire)); + assert!(pty.exhausted.try_recv().is_err()); + } + #[test] fn coalescing_pending_resizes_passes_through_a_lone_size() { let (resize, requests) = mpsc::channel::(); @@ -3201,7 +3742,7 @@ mod tests { resize.send(only).unwrap(); let first = requests.recv().unwrap(); - assert_eq!(coalesce_pending_resizes(first, &requests), only); + assert_eq!(coalesce_pending_resizes(first, &requests, &mut 0), only); } #[cfg(not(windows))] @@ -3219,6 +3760,9 @@ mod tests { pid: 44, process_identity: None, resize, + resize_failed: Arc::default(), + resize_requests: AtomicU64::new(0), + resize_exhausted: watch::channel(None).0, writer: Arc::new(Mutex::new(Box::new(TestWriter::WriteError))), killer: Mutex::new(Box::new(TestKiller { fail: false })), output, @@ -3238,7 +3782,7 @@ mod tests { process.resize(206, 69).unwrap(); let first = resize_requests.recv().unwrap(); - let applied = coalesce_pending_resizes(first, &resize_requests); + let applied = coalesce_pending_resizes(first, &resize_requests, &mut 0); assert_eq!((applied.cols, applied.rows), (206, 69)); } @@ -3253,6 +3797,9 @@ mod tests { pid: 42, process_identity: None, resize, + resize_failed: Arc::default(), + resize_requests: AtomicU64::new(0), + resize_exhausted: watch::channel(None).0, writer: Arc::new(Mutex::new(Box::new(TestWriter::WriteError))), killer: Mutex::new(Box::new(TestKiller { fail: true })), output, @@ -3301,6 +3848,9 @@ mod tests { pid: 43, process_identity: None, resize, + resize_failed: Arc::default(), + resize_requests: AtomicU64::new(0), + resize_exhausted: watch::channel(None).0, writer: Arc::new(Mutex::new(Box::new(TestWriter::FlushError))), killer: Mutex::new(Box::new(TestKiller { fail: false })), output, diff --git a/apps/server/tests/auth_http.rs b/apps/server/tests/auth_http.rs index b0371301d4..1ba9caf531 100644 --- a/apps/server/tests/auth_http.rs +++ b/apps/server/tests/auth_http.rs @@ -1451,6 +1451,30 @@ async fn websocket_authorizes_rpc_scopes_and_streams_auth_access_changes() { stream_denied["exit"]["cause"][0]["error"]["requiredScope"], "orchestration:read" ); + send_ws_json( + &mut operate_socket, + json!({ + "_tag": "Request", + "id": "1080", + "tag": "server.subscribeHostMetrics", + "payload": {}, + "headers": [] + }), + ) + .await; + // The first message is the refusal: no host reading is sent before it. + let host_denied = next_ws_json(&mut operate_socket).await; + assert_eq!(host_denied["_tag"], "Exit", "{host_denied}"); + assert_eq!(host_denied["requestId"], "1080"); + assert_eq!(host_denied["exit"]["_tag"], "Failure"); + assert_eq!( + host_denied["exit"]["cause"][0]["error"]["_tag"], + "EnvironmentAuthorizationError" + ); + assert_eq!( + host_denied["exit"]["cause"][0]["error"]["requiredScope"], + "orchestration:read" + ); for (id, tag) in [ ("109", "activity.cancelSubtree"), ("1090", "activity.retrySubtreeCancellation"), @@ -2882,6 +2906,14 @@ async fn start_desktop_server(temp: &TempDir) -> ServerHandle { .expect("activity fixture chunk"); receiver }); + // A reading ready at once, so a refusal that came after it would show (#292). + registry.register_stream("server.subscribeHostMetrics", |_request, _cancellation| { + let (sender, receiver) = tokio::sync::mpsc::channel(1); + sender + .try_send(Ok(vec![json!({ "tag": "server.subscribeHostMetrics" })])) + .expect("host metrics fixture chunk"); + receiver + }); ServerRuntime::start_with_registry(config, registry) .await .expect("server starts") diff --git a/apps/server/tests/git_manager_commit.rs b/apps/server/tests/git_manager_commit.rs index d85ac134bb..0934c15f73 100644 --- a/apps/server/tests/git_manager_commit.rs +++ b/apps/server/tests/git_manager_commit.rs @@ -132,6 +132,7 @@ async fn commit_amend_undo_discard_scope_and_concurrency_follow_the_wire_contrac updated_at: "2026-08-31T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project projection"); diff --git a/apps/server/tests/host_metrics_exit.rs b/apps/server/tests/host_metrics_exit.rs new file mode 100644 index 0000000000..6347e4aa88 --- /dev/null +++ b/apps/server/tests/host_metrics_exit.rs @@ -0,0 +1,133 @@ +//! R84: a disk read that never returns does not keep the server process from exiting. The child +//! is shaped like `main.rs`: a multi-thread runtime dropped after `block_on`. + +use std::{ + path::Path, + process::{Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::{Duration, Instant}, +}; + +use bibcode_server::diagnostics::{ + SamplingError, + host_metrics::{ + CpuMemoryReading, HostFacts, HostMetricsCollector, HostMetricsIdentity, HostMetricsRates, + HostMetricsSource, VolumeDescriptor, VolumeSpace, + }, +}; + +const CHILD_ENV: &str = "BIBCODE_TEST_HOST_METRICS_EXIT_CHILD"; +const TEST_NAME: &str = "a_held_disk_read_does_not_block_exit"; +const EXIT_DEADLINE: Duration = Duration::from_secs(5); + +/// Lists one volume and blocks its space read forever. +#[derive(Debug, Default)] +struct ForeverSource { + read_started: AtomicBool, +} + +impl HostMetricsSource for ForeverSource { + fn host_facts(&self) -> HostFacts { + HostFacts { + os: std::env::consts::OS, + arch: std::env::consts::ARCH, + cpu_count: 1, + memory_total_bytes: 1, + } + } + + fn read_cpu_memory(&self) -> Result { + Ok(CpuMemoryReading { + cpu_percent: 1.0, + memory_total_bytes: 1, + memory_used_bytes: 0, + }) + } + + fn list_volumes(&self) -> Vec { + vec![VolumeDescriptor { + mount_point: "/mnt/held".into(), + name: String::new(), + file_system: "ext4".to_owned(), + removable: false, + read_only: false, + is_file: false, + }] + } + + fn read_volume_space(&self, _mount_point: &Path) -> Result { + self.read_started.store(true, Ordering::Release); + loop { + std::thread::park(); + } + } +} + +fn run_child() { + let source = Arc::new(ForeverSource::default()); + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("runtime"); + runtime.block_on({ + let source = source.clone(); + async move { + let collector = HostMetricsCollector::new( + source.clone(), + HostMetricsRates { + background: Duration::from_millis(100), + live: Duration::from_millis(100), + volumes_every: 1, + ..HostMetricsRates::default() + }, + HostMetricsIdentity { + boot_id: None, + started_at_ms: 0, + }, + ); + collector.start_background(); + let started = Instant::now(); + while !source.read_started.load(Ordering::Acquire) { + assert!( + started.elapsed() < EXIT_DEADLINE, + "the space read never started" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + collector.shutdown().await; + } + }); + drop(runtime); +} + +#[test] +fn a_held_disk_read_does_not_block_exit() { + if std::env::var_os(CHILD_ENV).is_some() { + run_child(); + return; + } + let mut child = Command::new(std::env::current_exe().expect("current test binary")) + .args(["--exact", TEST_NAME, "--nocapture", "--test-threads=1"]) + .env(CHILD_ENV, "1") + .stdout(Stdio::null()) + .stderr(Stdio::inherit()) + .spawn() + .expect("start the child"); + let deadline = Instant::now() + EXIT_DEADLINE; + let status = loop { + if let Some(status) = child.try_wait().expect("poll the child") { + break Some(status); + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + std::thread::sleep(Duration::from_millis(10)); + }; + let status = status.expect("the child exits within 5 s while a disk read is held"); + assert!(status.success(), "the child failed: {status}"); +} diff --git a/apps/server/tests/orchestration.rs b/apps/server/tests/orchestration.rs index 08457125d5..7370938377 100644 --- a/apps/server/tests/orchestration.rs +++ b/apps/server/tests/orchestration.rs @@ -534,6 +534,260 @@ async fn omitted_worktree_discovery_metadata_update_preserves_custom_policy_thro restarted.shutdown().await; } +async fn rebuild_project_projection(repositories: &Repositories) -> OrchestrationEngine { + repositories + .database() + .call(|connection| { + connection.execute("DELETE FROM projection_projects", [])?; + connection.execute( + "UPDATE projection_state SET last_applied_sequence = 0 WHERE projector = 'projection.projects'", + [], + )?; + Ok(()) + }) + .await + .expect("rewind project projection"); + OrchestrationEngine::start(repositories.database().clone(), EngineOptions::default()) + .await + .expect("engine restarts") +} + +async fn review_prompt(engine: &OrchestrationEngine) -> String { + load_snapshot(&engine.repositories()) + .await + .expect("project projection") + .projects[0] + .code_review_prompt + .clone() +} + +fn review_project_create() -> OrchestrationCommand { + decode(json!({ + "type": "project.create", + "commandId": "review-create", + "projectId": "review-project", + "title": "Review project", + "workspaceRoot": "C:/review-project", + "defaultModelSelection": null, + "createdAt": CREATED_AT, + })) +} + +#[tokio::test] +async fn code_review_prompt_survives_omitted_update_reset_and_replay() { + let repositories = migrated_repositories().await; + let engine = + OrchestrationEngine::start(repositories.database().clone(), EngineOptions::default()) + .await + .expect("engine starts"); + engine + .dispatch(review_project_create()) + .await + .expect("project creates"); + assert_eq!(review_prompt(&engine).await, ""); + engine + .dispatch(decode(json!({ + "type": "project.meta.update", + "commandId": "review-set", + "projectId": "review-project", + "codeReviewPrompt": "Project text", + }))) + .await + .expect("prompt persists"); + engine + .dispatch(decode(json!({ + "type": "project.meta.update", + "commandId": "review-title", + "projectId": "review-project", + "title": "Renamed review project", + }))) + .await + .expect("title-only update persists"); + assert_eq!(review_prompt(&engine).await, "Project text"); + engine.shutdown().await; + + let restarted = rebuild_project_projection(&repositories).await; + assert_eq!(review_prompt(&restarted).await, "Project text"); + restarted + .dispatch(decode(json!({ + "type": "project.meta.update", + "commandId": "review-reset", + "projectId": "review-project", + "codeReviewPrompt": "", + }))) + .await + .expect("reset persists"); + assert_eq!(review_prompt(&restarted).await, ""); + restarted.shutdown().await; + + let rebuilt = rebuild_project_projection(&repositories).await; + assert_eq!(review_prompt(&rebuilt).await, ""); + rebuilt.shutdown().await; +} + +#[tokio::test] +async fn code_review_prompt_replays_events_recorded_without_it() { + let repositories = migrated_repositories().await; + let engine = + OrchestrationEngine::start(repositories.database().clone(), EngineOptions::default()) + .await + .expect("engine starts"); + engine + .dispatch(review_project_create()) + .await + .expect("project creates"); + engine + .dispatch(decode(json!({ + "type": "project.meta.update", + "commandId": "review-title", + "projectId": "review-project", + "title": "Renamed review project", + }))) + .await + .expect("title-only update persists"); + engine.shutdown().await; + // Events recorded before the field existed carry no `codeReviewPrompt` key. + let stripped = repositories + .database() + .call(|connection| { + Ok(connection.execute( + "UPDATE orchestration_events SET payload_json = json_remove(payload_json, '$.codeReviewPrompt') WHERE event_type IN ('project.created', 'project.meta-updated')", + [], + )?) + }) + .await + .expect("strip the field from recorded events"); + assert_eq!(stripped, 2); + + let rebuilt = rebuild_project_projection(&repositories).await; + let project = load_snapshot(&rebuilt.repositories()) + .await + .expect("replayed project projection") + .projects[0] + .clone(); + assert_eq!(project.title, "Renamed review project"); + assert_eq!(project.code_review_prompt, ""); + rebuilt.shutdown().await; +} + +/// #293: a stored project review prompt holds no control character but tab, line feed and +/// carriage return (C0, DEL and C1, as the web checks), and one stored before the rule loads. +#[tokio::test] +async fn code_review_prompt_rejects_control_characters_on_write_only() { + let repositories = migrated_repositories().await; + let engine = + OrchestrationEngine::start(repositories.database().clone(), EngineOptions::default()) + .await + .expect("engine starts"); + engine + .dispatch(review_project_create()) + .await + .expect("project creates"); + let update = |command_id: &str, prompt: &str| { + decode(json!({ + "type": "project.meta.update", + "commandId": command_id, + "projectId": "review-project", + "codeReviewPrompt": prompt, + })) + }; + engine + .dispatch(update("review-set", "Kept\tline\r\nnext\u{a0}")) + .await + .expect("tab, line breaks and U+00A0 are allowed"); + for (index, control) in [ + '\u{0}', '\u{8}', '\u{b}', '\u{1b}', '\u{7f}', '\u{85}', '\u{9f}', + ] + .into_iter() + .enumerate() + { + let error = engine + .dispatch(update( + &format!("review-control-{index}"), + &format!("a{control}b"), + )) + .await + .expect_err("a control character is rejected"); + assert!( + format!("{error:?}").contains("control character"), + "{control:?}: {error:?}" + ); + } + assert_eq!(review_prompt(&engine).await, "Kept\tline\r\nnext\u{a0}"); + engine.shutdown().await; + + // A prompt stored before the rule existed still loads and does not block other updates. + repositories + .database() + .call(|connection| { + Ok(connection.execute( + "UPDATE orchestration_events SET payload_json = json_set(payload_json, '$.codeReviewPrompt', 'Old' || char(27) || 'text') WHERE event_type = 'project.meta-updated'", + [], + )?) + }) + .await + .expect("store an old prompt"); + let rebuilt = rebuild_project_projection(&repositories).await; + assert_eq!(review_prompt(&rebuilt).await, "Old\u{1b}text"); + rebuilt + .dispatch(decode(json!({ + "type": "project.meta.update", + "commandId": "review-title", + "projectId": "review-project", + "title": "Renamed review project", + }))) + .await + .expect("a title update leaves the old prompt alone"); + assert_eq!(review_prompt(&rebuilt).await, "Old\u{1b}text"); + rebuilt.shutdown().await; +} + +#[tokio::test] +async fn code_review_prompt_over_limit_is_rejected() { + let repositories = migrated_repositories().await; + let engine = + OrchestrationEngine::start(repositories.database().clone(), EngineOptions::default()) + .await + .expect("engine starts"); + engine + .dispatch(review_project_create()) + .await + .expect("project creates"); + engine + .dispatch(decode(json!({ + "type": "project.meta.update", + "commandId": "review-set", + "projectId": "review-project", + "codeReviewPrompt": "Kept", + }))) + .await + .expect("prompt persists"); + // 7,681 UTF-16 units: the limit plus one character outside the basic plane counts two. + let oversized = format!("{}😀", "a".repeat(7_679)); + assert!( + engine + .dispatch(decode(json!({ + "type": "project.meta.update", + "commandId": "review-oversized", + "projectId": "review-project", + "codeReviewPrompt": oversized, + }))) + .await + .is_err() + ); + assert_eq!(review_prompt(&engine).await, "Kept"); + engine + .dispatch(decode(json!({ + "type": "project.meta.update", + "commandId": "review-at-limit", + "projectId": "review-project", + "codeReviewPrompt": "a".repeat(7_680), + }))) + .await + .expect("7,680 units fit"); + engine.shutdown().await; +} + #[tokio::test] async fn default_thread_cannot_be_archived_or_deleted_directly() { let repositories = migrated_repositories().await; diff --git a/apps/server/tests/production_git_manager_rpc.rs b/apps/server/tests/production_git_manager_rpc.rs index 8c10525a9f..ef48544782 100644 --- a/apps/server/tests/production_git_manager_rpc.rs +++ b/apps/server/tests/production_git_manager_rpc.rs @@ -143,6 +143,7 @@ impl Fixture { updated_at: "2026-09-01T00:00:00Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project projection"); @@ -207,6 +208,7 @@ impl Fixture { deleted_at: None, host_thread_id: None, compaction: None, + code_review: false, }) .await .expect("thread owning the linked worktree"); diff --git a/apps/server/tests/production_git_vcs_rpc.rs b/apps/server/tests/production_git_vcs_rpc.rs index b1c0fac6b9..70e72a9eaf 100644 --- a/apps/server/tests/production_git_vcs_rpc.rs +++ b/apps/server/tests/production_git_vcs_rpc.rs @@ -129,8 +129,16 @@ async fn workspace_unavailable_rejects_git_status_and_mutation_before_process_la impl GitServerHarness { async fn start(temp: &TempDir, parallelism_permit: OwnedSemaphorePermit) -> Self { + Self::start_with_services(temp, parallelism_permit, git_vcs_services(temp)).await + } + + async fn start_with_services( + temp: &TempDir, + parallelism_permit: OwnedSemaphorePermit, + services: GitVcsRpcServices, + ) -> Self { let mut registry = RpcRegistry::empty(); - register_git_vcs_rpc(&mut registry, git_vcs_services(temp)); + register_git_vcs_rpc(&mut registry, services); let handle = ServerRuntime::start_with_registry(test_config(temp), registry) .await .expect("server starts"); @@ -2682,6 +2690,590 @@ async fn interrupting_a_clone_stops_git_and_removes_the_destination_it_created() server.shutdown().await; } +#[tokio::test] +async fn a_real_commit_survives_a_held_and_failed_push() { + let parallelism_permit = acquire_git_rpc_fixture().await; + if relaunch_with_isolated_git_config("a_real_commit_survives_a_held_and_failed_push") { + return; + } + + let temp = TempDir::new().expect("temporary server directory"); + let repository = TempDir::new().expect("temporary repository"); + // An explicit empty proxy keeps Git on the loopback remote even when the host sets one. + run_git(&repository, &["config", "--global", "http.proxy", ""]); + initialize_repository(&repository); + commit_file(repository.path(), "base.txt", "base\n", "test: base"); + let (remote, url) = stalled_http_remote().await; + run_git(&repository, &["remote", "add", "origin", &url]); + fs::write(repository.path().join("held.txt"), "held\n").expect("write held change"); + let cwd = repository.path().to_string_lossy().into_owned(); + + let mut server = GitServerHarness::start(&temp, parallelism_permit).await; + request( + server.socket(), + "1", + "git.runStackedAction", + json!({ + "actionId": "held-push", + "cwd": cwd, + "action": "commit_push", + "commitMessage": "test: held push", + }), + ) + .await; + let connection = accept_git(&remote).await; + assert_eq!( + git_stdout(&repository, &["log", "-1", "--format=%s"]).trim(), + "test: held push", + "the commit is made before the push connects" + ); + + request( + server.socket(), + "2", + "vcs.refreshStatus", + json!({ "cwd": cwd }), + ) + .await; + let mut action_events = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + match pump_server_message( + server.socket(), + deadline, + "a status refresh while the push is held", + &mut action_events, + ) + .await + { + ServerMessage::Exit { + request_id, + exit: RpcExit::Success { value }, + } if request_id.as_str() == "2" => break value.unwrap_or(Value::Null), + ServerMessage::Exit { request_id, exit } if request_id.as_str() != "1" => { + panic!("the status refresh failed during the held push: {exit:?}") + } + ServerMessage::Exit { exit, .. } => { + panic!("the action ended while the push was held: {exit:?}") + } + _ => {} + } + }; + assert_eq!( + status["hasWorkingTreeChanges"], false, + "the refresh shows the committed tree: {status}" + ); + + drop(connection); + drop(remote); + let deadline = Instant::now() + STACKED_ACTION_COMPLETION_INTEGRATION_DEADLINE; + loop { + if let ServerMessage::Exit { request_id, .. } = pump_server_message( + server.socket(), + deadline, + "the failed push ending the action", + &mut action_events, + ) + .await + && request_id.as_str() == "1" + { + break; + } + } + let ended = action_events + .iter() + .find(|event| event["kind"] == "action_failed" || event["kind"] == "action_finished") + .unwrap_or_else(|| panic!("no terminal action event in {action_events:?}")); + assert_eq!(ended["kind"], "action_failed", "{ended}"); + assert_eq!(ended["phase"], "push", "{ended}"); + assert_eq!( + git_stdout(&repository, &["log", "-1", "--format=%s"]).trim(), + "test: held push", + "the commit stays after the push failed" + ); + + fs::write(repository.path().join("after.txt"), "after\n").expect("write later change"); + let second = run_stacked_action( + server.socket(), + "3", + json!({ + "actionId": "after-push", + "cwd": cwd, + "action": "commit", + "commitMessage": "test: after the push", + }), + ) + .await; + assert_eq!(second["kind"], "action_finished", "{second}"); + assert_eq!(second["result"]["commit"]["status"], "created", "{second}"); + + server.shutdown().await; +} + +#[tokio::test] +async fn a_failed_push_leaves_subscribers_with_the_clean_status() { + let parallelism_permit = acquire_git_rpc_fixture().await; + if relaunch_with_isolated_git_config("a_failed_push_leaves_subscribers_with_the_clean_status") { + return; + } + + let temp = TempDir::new().expect("temporary server directory"); + let repository = TempDir::new().expect("temporary repository"); + run_git(&repository, &["config", "--global", "http.proxy", ""]); + initialize_repository(&repository); + commit_file(repository.path(), "base.txt", "base\n", "test: base"); + let (remote, url) = stalled_http_remote().await; + run_git(&repository, &["remote", "add", "origin", &url]); + fs::write(repository.path().join("dirty.txt"), "dirty\n").expect("write dirty change"); + let cwd = repository.path().to_string_lossy().into_owned(); + + let mut server = GitServerHarness::start(&temp, parallelism_permit).await; + request( + server.socket(), + "9", + "subscribeVcsStatus", + json!({ "cwd": cwd }), + ) + .await; + let snapshot = next_server_message_for(server.socket(), "the subscription snapshot").await; + assert!( + matches!(&snapshot, ServerMessage::Chunk { request_id, .. } if request_id.as_str() == "9"), + "{snapshot:?}" + ); + + // The snapshot stays unacknowledged, so the next 8 dirty states fill the stream's + // channel and the ones after wait in the stream task, as behind a slow client. + let mut ignored = Vec::new(); + for index in 0..10 { + fs::write( + repository.path().join(format!("dirty-{index}.txt")), + "dirty\n", + ) + .expect("write another dirty change"); + let id = (100 + index).to_string(); + request( + server.socket(), + &id, + "vcs.refreshStatus", + json!({ "cwd": cwd }), + ) + .await; + let deadline = Instant::now() + GIT_STATUS_INTEGRATION_DEADLINE; + loop { + if let ServerMessage::Exit { request_id, exit } = pump_server_message_holding( + server.socket(), + deadline, + "a dirty status refresh", + &mut ignored, + Some("9"), + ) + .await + && request_id.as_str() == id + { + assert!(matches!(exit, RpcExit::Success { .. }), "{exit:?}"); + break; + } + } + } + + request( + server.socket(), + "1", + "git.runStackedAction", + json!({ + "actionId": "failed-push", + "cwd": cwd, + "action": "commit_push", + "commitMessage": "test: failed push", + }), + ) + .await; + let connection = accept_git(&remote).await; + request( + server.socket(), + "2", + "vcs.refreshStatus", + json!({ "cwd": cwd }), + ) + .await; + let mut action_events = Vec::new(); + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + match pump_server_message_holding( + server.socket(), + deadline, + "a status refresh while the push is held", + &mut action_events, + Some("9"), + ) + .await + { + ServerMessage::Exit { + request_id, + exit: RpcExit::Success { value }, + } if request_id.as_str() == "2" => break value.unwrap_or(Value::Null), + ServerMessage::Exit { request_id, exit } => { + panic!("request {request_id:?} ended while the push was held: {exit:?}") + } + _ => {} + } + }; + assert_eq!( + status["hasWorkingTreeChanges"], false, + "the clean status is in the broadcaster cache: {status}" + ); + + drop(connection); + drop(remote); + let deadline = Instant::now() + STACKED_ACTION_COMPLETION_INTEGRATION_DEADLINE; + loop { + if let ServerMessage::Exit { request_id, .. } = pump_server_message_holding( + server.socket(), + deadline, + "the failed push ending the action", + &mut action_events, + Some("9"), + ) + .await + && request_id.as_str() == "1" + { + break; + } + } + assert!( + action_events + .iter() + .any(|event| event["kind"] == "action_failed" && event["phase"] == "push"), + "the push failed: {action_events:?}" + ); + // Time for the settle-time trailing read to finish before forwarding resumes. + tokio::time::sleep(Duration::from_secs(1)).await; + + send_json(server.socket(), json!({ "_tag": "Ack", "requestId": "9" })).await; + let mut status_events = Vec::new(); + let latest_has_changes = |events: &[Value]| { + events + .iter() + .rev() + .find_map(|event| event.get("local")) + .map(|local| local["hasWorkingTreeChanges"].clone()) + }; + let deadline = Instant::now() + Duration::from_secs(5); + while latest_has_changes(&status_events) != Some(Value::Bool(false)) { + if Instant::now() >= deadline { + let delivered = status_events + .iter() + .filter_map(|event| event.get("local")) + .map(|local| local["hasWorkingTreeChanges"].clone()) + .collect::>(); + panic!( + "the subscriber kept the dirty status after the failed push; \ + hasWorkingTreeChanges per delivered local status: {delivered:?}" + ); + } + if let Ok(Some(Ok(Message::Text(text)))) = + timeout_at(deadline, next_frame_past_heartbeat(server.socket())).await + && let Ok(ServerMessage::Chunk { request_id, values }) = + serde_json::from_str::(&text) + { + status_events.extend(values); + send_json( + server.socket(), + json!({ "_tag": "Ack", "requestId": request_id.as_str() }), + ) + .await; + } + } + + server.shutdown().await; +} + +/// A `gh` that answers `pr list` from marker files in `STATE`. A `slow` file holds a delay +/// in seconds, and an `open` file makes it report pull request 42 for the branch. +#[cfg(unix)] +const MARKER_GH: &str = r#"#!/bin/sh +case "$1:$2" in + pr:list) + printf '%s\n' "$4" >> 'STATE/calls' + if [ -f 'STATE/slow' ]; then sleep "$(cat 'STATE/slow')"; fi + if [ -f 'STATE/open' ]; then + printf '[{"number":42,"title":"PR for %s","url":"https://github.example.test/example-org/sample-project/pull/42","baseRefName":"main","headRefName":"%s","state":"OPEN"}]\n' "$4" "$4" + else + printf '[]\n' + fi ;; + *) exit 64 ;; +esac +"#; + +/// A repository whose `origin` is a loopback host recorded as GitHub, served by `MARKER_GH`. +#[cfg(unix)] +struct PullRequestLookupFixture { + temp: TempDir, + repository: TempDir, + bin: PathBuf, + state: PathBuf, +} + +#[cfg(unix)] +impl PullRequestLookupFixture { + fn new() -> Self { + use std::os::unix::fs::PermissionsExt; + + let temp = TempDir::new().expect("temporary server directory"); + let repository = TempDir::new().expect("temporary repository"); + initialize_repository(&repository); + commit_file(repository.path(), "base.txt", "base\n", "test: base"); + // Nothing listens on port 9, so a fetch fails at once and never leaves the host. + run_git( + &repository, + &[ + "remote", + "add", + "origin", + "http://127.0.0.1:9/example-org/sample-project.git", + ], + ); + let bin = temp.path().join("hosting-bin"); + let state = temp.path().join("gh-state"); + fs::create_dir(&bin).expect("hosting bin"); + fs::create_dir(&state).expect("gh state"); + let gh = bin.join("gh"); + fs::write(&gh, MARKER_GH.replace("STATE", &state.to_string_lossy())) + .expect("write gh stub"); + fs::set_permissions(&gh, fs::Permissions::from_mode(0o755)).expect("gh is executable"); + Self { + temp, + repository, + bin, + state, + } + } + + fn services(&self) -> GitVcsRpcServices { + let hosts = Arc::new(bibcode_server::source_control::ProviderHosts::default()); + hosts.record( + "127.0.0.1:9", + bibcode_server::source_control::ProviderKind::Github, + ); + let repository = Arc::new( + bibcode_server::git::GitRepository::default().with_provider_hosts(hosts.clone()), + ); + GitVcsRpcServices::with_repository(repository, hosts) + .with_hosting_executable_dir_for_integration_test(self.bin.clone()) + } + + fn cwd(&self) -> String { + self.repository.path().to_string_lossy().into_owned() + } + + fn set(&self, marker: &str, contents: &str) { + fs::write(self.state.join(marker), contents).expect("gh marker"); + } + + fn lookups(&self) -> usize { + fs::read_to_string(self.state.join("calls")) + .unwrap_or_default() + .lines() + .count() + } +} + +#[cfg(unix)] +#[tokio::test] +async fn slow_pull_request_lookup_does_not_delay_the_refresh() { + let parallelism_permit = acquire_git_rpc_fixture().await; + if relaunch_with_isolated_git_config("slow_pull_request_lookup_does_not_delay_the_refresh") { + return; + } + let fixture = PullRequestLookupFixture::new(); + fixture.set("open", ""); + fixture.set("slow", "12"); + let mut server = GitServerHarness::start_with_services( + &fixture.temp, + parallelism_permit, + fixture.services(), + ) + .await; + + request( + server.socket(), + "1", + "vcs.refreshStatus", + json!({ "cwd": fixture.cwd() }), + ) + .await; + let status = timeout(Duration::from_secs(5), success_value(server.socket(), "1")) + .await + .expect("the refresh answers within a few seconds while gh takes 12 seconds"); + assert_eq!(status["isRepo"], true, "{status}"); + assert_eq!(status["refName"], "main", "{status}"); + assert!(status["pr"].is_null(), "{status}"); + assert_eq!(fixture.lookups(), 1, "the refresh asked gh once"); + + server.shutdown().await; +} + +#[cfg(unix)] +#[tokio::test] +async fn slow_pull_request_lookup_reaches_subscribers_when_it_finishes() { + let parallelism_permit = acquire_git_rpc_fixture().await; + if relaunch_with_isolated_git_config( + "slow_pull_request_lookup_reaches_subscribers_when_it_finishes", + ) { + return; + } + let fixture = PullRequestLookupFixture::new(); + let cwd = fixture.cwd(); + let mut server = GitServerHarness::start_with_services( + &fixture.temp, + parallelism_permit, + fixture.services(), + ) + .await; + + request( + server.socket(), + "9", + "subscribeVcsStatus", + json!({ "cwd": cwd }), + ) + .await; + let mut status_events = Vec::new(); + let deadline = Instant::now() + GIT_STATUS_INTEGRATION_DEADLINE; + pump_server_message( + server.socket(), + deadline, + "the subscription snapshot", + &mut status_events, + ) + .await; + while fixture.lookups() == 0 { + assert!( + Instant::now() < deadline, + "the subscription never looked up its pull request" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // A finished mutation forgets the remembered "no pull request" answer. Waiting for its + // trailing status keeps the next refresh from publishing anything new. + fs::write(fixture.repository.path().join("staged.txt"), "staged\n").expect("write file"); + request( + server.socket(), + "2", + "vcs.stageFiles", + json!({ "cwd": cwd, "filePaths": ["staged.txt"] }), + ) + .await; + let is_staged = |events: &[Value]| { + events + .iter() + .rev() + .find_map(|event| event.get("local")) + .is_some_and(|local| { + local["workingTree"]["files"] + .as_array() + .is_some_and(|files| { + files + .iter() + .any(|file| file["path"] == "staged.txt" && file["area"] == "staged") + }) + }) + }; + let mut staged = false; + while !staged || !is_staged(&status_events) { + if let ServerMessage::Exit { request_id, exit } = pump_server_message( + server.socket(), + deadline, + "the staged status reaching the subscriber", + &mut status_events, + ) + .await + { + assert_eq!(request_id.as_str(), "2", "{exit:?}"); + assert!(matches!(exit, RpcExit::Success { .. }), "{exit:?}"); + staged = true; + } + } + + fixture.set("open", ""); + fixture.set("slow", "12"); + let lookups_before_refresh = fixture.lookups(); + request( + server.socket(), + "3", + "vcs.refreshStatus", + json!({ "cwd": cwd }), + ) + .await; + let deadline = Instant::now() + Duration::from_secs(25); + let has_pull_request = |events: &[Value]| { + events + .iter() + .any(|event| event["remote"]["pr"]["number"] == 42) + }; + while !has_pull_request(&status_events) { + pump_server_message( + server.socket(), + deadline, + "the subscriber receiving the pull request once gh answers", + &mut status_events, + ) + .await; + } + assert_eq!( + fixture.lookups(), + lookups_before_refresh + 1, + "the subscriber reused the refresh's lookup" + ); + + server.shutdown().await; +} + +/// Reads one message, acknowledging a stream chunk and keeping its values. +async fn pump_server_message( + socket: &mut TestSocket, + deadline: Instant, + context: &str, + chunks: &mut Vec, +) -> ServerMessage { + pump_server_message_holding(socket, deadline, context, chunks, None).await +} + +/// Like `pump_server_message`, but a chunk of the `held` stream must not arrive, because +/// its previous chunk is left unacknowledged. +async fn pump_server_message_holding( + socket: &mut TestSocket, + deadline: Instant, + context: &str, + chunks: &mut Vec, + held: Option<&str>, +) -> ServerMessage { + let frame = timeout_at(deadline, next_frame_past_heartbeat(socket)) + .await + .unwrap_or_else(|_| panic!("{context}: no server message before the deadline")) + .expect("WebSocket remains open") + .expect("valid WebSocket frame"); + let Message::Text(text) = frame else { + panic!("{context}: expected a text WebSocket message, got {frame:?}"); + }; + let message: ServerMessage = serde_json::from_str(&text).expect("valid server RPC message"); + if let ServerMessage::Chunk { request_id, values } = &message { + assert_ne!( + Some(request_id.as_str()), + held, + "{context}: the held stream sent a chunk before its Ack" + ); + chunks.extend(values.iter().cloned()); + send_json( + socket, + json!({ "_tag": "Ack", "requestId": request_id.as_str() }), + ) + .await; + } + message +} + async fn interrupt_clone(socket: &mut TestSocket, id: &str, method: &str) { send_json(socket, json!({ "_tag": "Interrupt", "requestId": id })).await; let interrupted = next_server_message(socket).await; diff --git a/apps/server/tests/production_server_terminal_rpc.rs b/apps/server/tests/production_server_terminal_rpc.rs index e51b1dff9c..6b1fcb7bce 100644 --- a/apps/server/tests/production_server_terminal_rpc.rs +++ b/apps/server/tests/production_server_terminal_rpc.rs @@ -1799,6 +1799,136 @@ async fn terminal_rpc_clear_resize_restart_exit_and_restart_if_not_running_round } } +/// Two viewers of one terminal (#348). The owner's renderer holds a stale size, so its fit +/// differs from what it shows while the server already holds that fit: the repair claim is an +/// exact no-op. The server answers with the size it holds and acknowledges the claim on the +/// owner's stream alone, in order, so the owner ends at the right size with no width change. +#[tokio::test] +async fn terminal_rpc_repair_claim_for_the_held_size_is_answered_and_acknowledged_in_order() { + let temp = TempDir::new().expect("temporary directory"); + let mut registry = RpcRegistry::empty(); + register_server_terminal_rpc(&mut registry, fixture_services()); + let handle = ServerRuntime::start_with_registry(test_config(&temp), registry) + .await + .expect("Rust server starts"); + let mut owner = Some(open_socket(handle.local_addr()).await); + let mut mirror = Some(open_socket(handle.local_addr()).await); + let mut control = Some(open_socket(handle.local_addr()).await); + let ids = json!({ "threadId": "thread-repair", "terminalId": "term-repair" }); + + let result = AssertUnwindSafe(async { + let owner = owner.as_mut().expect("owner socket"); + let mirror = mirror.as_mut().expect("mirror socket"); + let control = control.as_mut().expect("control socket"); + success_value( + request( + control, + "1", + "terminal.open", + json!({ + "threadId": "thread-repair", + "terminalId": "term-repair", + "cwd": temp.path().to_string_lossy(), + "cols": 80, + "rows": 30, + "env": { + "HOME": hermetic_providers::isolated_terminal_home(temp.path()), + "USERPROFILE": hermetic_providers::isolated_terminal_home(temp.path()), + } + }), + ) + .await, + ); + let attach = |claim: &str| { + let mut input = ids.clone(); + input["sizeClaim"] = json!(claim); + input + }; + send_request(owner, "1", "terminal.attach", attach("owner-viewer")).await; + let owned = next_chunk_and_ack(owner, "1").await; + assert_eq!(owned[0]["snapshot"]["size"]["sizeClaim"], "owner-viewer"); + let generation = owned[0]["snapshot"]["generation"].clone(); + assert!( + generation["process"] + .as_u64() + .is_some_and(|process| process >= 1) + ); + send_request(mirror, "1", "terminal.attach", attach("mirror-viewer")).await; + next_chunk_and_ack(mirror, "1").await; + + // The owner fits 20 rows; both viewers see the applied size. + let mut resize = ids.clone(); + resize["cols"] = json!(80); + resize["rows"] = json!(20); + resize["sizeClaim"] = json!("owner-viewer"); + // A current client reads the structured answer; an older one gets null (#348). + resize["structuredResult"] = json!(true); + let applied = success_value(request(control, "2", "terminal.resize", resize.clone()).await); + assert_eq!(applied["outcome"], "applied"); + assert_eq!(applied["generation"], generation); + for socket in [&mut *owner, &mut *mirror] { + let event = next_terminal_event_and_ack(socket, "1", "applied size", |value| { + value["type"] == "resized" + }) + .await; + assert_eq!(event["sequence"], applied["sequence"]); + assert_eq!(event["size"]["rows"], 20); + } + + // The owner's renderer kept 30 rows (a stale size); it claims its fit again, which the + // server already holds. + let repair = success_value(request(control, "3", "terminal.resize", resize).await); + assert_eq!(repair["outcome"], "unchanged"); + assert_eq!( + repair["size"], + json!({ "cols": 80, "rows": 20, "sizeClaim": "owner-viewer" }) + ); + let acknowledgement = next_terminal_event_and_ack(owner, "1", "acknowledgement", |value| { + value["type"] == "resized" + }) + .await; + assert_eq!(acknowledgement["sequence"], repair["sequence"]); + assert_eq!(acknowledgement["size"]["rows"], 20); + assert_eq!(acknowledgement["size"]["cols"], 80); + + // The mirror gets no acknowledgement of another viewer's claim. + maybe_prime_terminal(control, "thread-repair", "term-repair").await; + let token = "BIBCODE_RPC_REPAIR"; + let mut write = ids.clone(); + write["data"] = json!(shell_echo_command(token)); + assert_success(request(control, "4", "terminal.write", write).await); + let next = next_terminal_event_and_ack(mirror, "1", "mirror output", |value| { + value["type"] == "resized" + || value["data"] + .as_str() + .is_some_and(|data| data.contains(token)) + }) + .await; + assert_eq!(next["type"], "output"); + + // A claim from a viewer the server does not know changes nothing and says so. + let mut unknown = ids.clone(); + unknown["cols"] = json!(90); + unknown["rows"] = json!(40); + unknown["sizeClaim"] = json!("departed-viewer"); + unknown["structuredResult"] = json!(true); + let declined = success_value(request(control, "5", "terminal.resize", unknown).await); + assert_eq!(declined["outcome"], "claimNotRegistered"); + assert_eq!(declined["size"]["rows"], 20); + }) + .catch_unwind() + .await; + + close_socket(&mut owner).await; + close_socket(&mut mirror).await; + close_socket(&mut control).await; + handle.shutdown(); + handle.join().await.expect("server joins"); + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} + /// In a process of its own: it checks that client batches are refused while the profiler is /// off, and the profiler is process-wide, so a sibling test that turns it on would make them /// accepted. @@ -2086,6 +2216,8 @@ fn registrar_source_contains_every_owned_rpc_name() { "server.getConfig", "server.getProcessDiagnostics", "server.getProcessResourceHistory", + "server.subscribeHostMetrics", + "server.getHostMetrics", "server.getProfilerStatus", "server.reportProfilerClientBatch", "server.getProviderUsage", @@ -2568,3 +2700,188 @@ fn expected_killed_exit_code() -> Value { fn expected_killed_exit_signal() -> Value { Value::Null } + +/// A host source for the host metrics stream tests: fixed readings at once, no volumes. +#[derive(Debug)] +struct FixtureHostMetricsSource; + +impl diagnostics::HostMetricsSource for FixtureHostMetricsSource { + fn host_facts(&self) -> diagnostics::host_metrics::HostFacts { + diagnostics::host_metrics::HostFacts { + os: "linux", + arch: "x86_64", + cpu_count: 12, + memory_total_bytes: 34_359_738_368, + } + } + + fn read_cpu_memory( + &self, + ) -> Result { + Ok(diagnostics::host_metrics::CpuMemoryReading { + cpu_percent: 23.5, + memory_total_bytes: 34_359_738_368, + memory_used_bytes: 12_025_908_428, + }) + } + + fn list_volumes(&self) -> Vec { + Vec::new() + } + + fn read_volume_space( + &self, + _mount_point: &std::path::Path, + ) -> Result { + Err("no volumes".to_owned()) + } +} + +fn fixture_host_metrics() -> Arc { + Arc::new(diagnostics::HostMetricsCollector::new( + Arc::new(FixtureHostMetricsSource), + diagnostics::HostMetricsRates::default(), + diagnostics::HostMetricsIdentity { + boot_id: Some("00000000-0000-0000-0000-000000000000".to_owned()), + started_at_ms: 1_700_000_000_000, + }, + )) +} + +fn sampled_at_ms(item: &Value) -> i128 { + let text = item["sample"]["sampledAt"].as_str().expect("sampledAt"); + time::OffsetDateTime::parse(text, &time::format_description::well_known::Rfc3339) + .expect("an RFC 3339 time") + .unix_timestamp_nanos() + / 1_000_000 +} + +#[tokio::test] +async fn host_metrics_stream_sends_a_snapshot_then_samples() { + let temp = TempDir::new().expect("temporary directory"); + let collector = fixture_host_metrics(); + let mut registry = RpcRegistry::empty(); + register_server_terminal_rpc( + &mut registry, + fixture_services().with_host_metrics(collector.clone()), + ); + let handle = ServerRuntime::start_with_registry(test_config(&temp), registry) + .await + .expect("Rust server starts"); + let mut socket = Some(open_socket(handle.local_addr()).await); + let result = AssertUnwindSafe(async { + let socket = socket.as_mut().expect("socket"); + send_request(socket, "1", "server.subscribeHostMetrics", json!({})).await; + + let snapshot = next_chunk_and_ack(socket, "1").await; + let [snapshot] = snapshot.as_slice() else { + panic!("one snapshot, got {snapshot:?}"); + }; + assert_eq!(snapshot["_tag"], "Snapshot"); + assert_eq!(snapshot["bootId"], "00000000-0000-0000-0000-000000000000"); + assert!(snapshot["startedAt"].is_string()); + assert_eq!(snapshot["host"]["cpuCount"], 12); + let mut samples = Vec::new(); + while samples.len() < 2 { + samples.extend(next_chunk_and_ack(socket, "1").await); + } + for sample in &samples { + assert_eq!(sample["_tag"], "Sample"); + assert_eq!(sample["cpu"]["current"], 23.5); + } + // Liveness only: the cadence is tested on a paused clock in the collector's own tests. + let sequence = |sample: &Value| sample["sample"]["sequence"].as_u64().expect("a sequence"); + assert!(sequence(&samples[1]) > sequence(&samples[0]), "{samples:?}"); + let gap = sampled_at_ms(&samples[1]) - sampled_at_ms(&samples[0]); + assert!((1..=10_000).contains(&gap), "samples {gap} ms apart"); + }) + .catch_unwind() + .await; + + close_socket(&mut socket).await; + handle.shutdown(); + handle.join().await.expect("server joins"); + collector.shutdown().await; + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} + +/// R82: the MCP `host_metrics` tool reads one Snapshot through `server.getHostMetrics`. +#[tokio::test] +async fn host_metrics_unary_answers_one_snapshot() { + let temp = TempDir::new().expect("temporary directory"); + let collector = fixture_host_metrics(); + collector.start_background(); + let mut registry = RpcRegistry::empty(); + register_server_terminal_rpc( + &mut registry, + fixture_services().with_host_metrics(collector.clone()), + ); + let handle = ServerRuntime::start_with_registry(test_config(&temp), registry) + .await + .expect("Rust server starts"); + let mut socket = Some(open_socket(handle.local_addr()).await); + let result = AssertUnwindSafe(async { + let socket = socket.as_mut().expect("socket"); + let snapshot = + success_value(request(socket, "1", "server.getHostMetrics", json!({})).await); + assert_eq!(snapshot["_tag"], "Snapshot"); + assert_eq!(snapshot["host"]["cpuCount"], 12); + assert_eq!(snapshot["sample"]["cpuPercent"], 23.5); + assert_eq!(snapshot["hiddenVolumes"], json!([])); + // A unary read never holds the collector at the live rate. + assert_eq!(collector.live_subscribers(), 0); + }) + .catch_unwind() + .await; + + close_socket(&mut socket).await; + handle.shutdown(); + handle.join().await.expect("server joins"); + collector.shutdown().await; + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} + +#[tokio::test] +async fn host_metrics_stream_ends_when_the_client_leaves() { + let temp = TempDir::new().expect("temporary directory"); + let collector = fixture_host_metrics(); + let mut registry = RpcRegistry::empty(); + register_server_terminal_rpc( + &mut registry, + fixture_services().with_host_metrics(collector.clone()), + ); + let handle = ServerRuntime::start_with_registry(test_config(&temp), registry) + .await + .expect("Rust server starts"); + let mut socket = Some(open_socket(handle.local_addr()).await); + let result = AssertUnwindSafe(async { + let socket_ref = socket.as_mut().expect("socket"); + send_request(socket_ref, "1", "server.subscribeHostMetrics", json!({})).await; + let _ = next_chunk_and_ack(socket_ref, "1").await; + assert_eq!(collector.live_subscribers(), 1); + + close_socket(&mut socket).await; + let deadline = std::time::Instant::now() + Duration::from_secs(1); + while collector.live_subscribers() != 0 { + assert!( + std::time::Instant::now() < deadline, + "the stream still holds the collector live" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + }) + .catch_unwind() + .await; + + close_socket(&mut socket).await; + handle.shutdown(); + handle.join().await.expect("server joins"); + collector.shutdown().await; + if let Err(panic) = result { + std::panic::resume_unwind(panic); + } +} diff --git a/apps/server/tests/production_worktree_catalog_rpc.rs b/apps/server/tests/production_worktree_catalog_rpc.rs index 852ed505c3..f913cd93c0 100644 --- a/apps/server/tests/production_worktree_catalog_rpc.rs +++ b/apps/server/tests/production_worktree_catalog_rpc.rs @@ -4802,6 +4802,7 @@ impl CatalogRpcFixture { updated_at: "2026-08-09T00:00:01Z".to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .expect("project projection created"); @@ -4841,6 +4842,7 @@ impl CatalogRpcFixture { deleted_at: None, host_thread_id: None, compaction: None, + code_review: false, }) .await .expect("project thread projection created"); diff --git a/apps/server/tests/profiler_worktree_audit.rs b/apps/server/tests/profiler_worktree_audit.rs index aa2d2341a5..e611754236 100644 --- a/apps/server/tests/profiler_worktree_audit.rs +++ b/apps/server/tests/profiler_worktree_audit.rs @@ -22,7 +22,7 @@ use bibcode_server::{ }, git::GitRepository, orchestration::{EngineOptions, OrchestrationEngine}, - persistence::{Database, Repositories, run_migrations}, + persistence::{Database, Repositories, WorktreeRemovalReceipt, run_migrations}, production::worktree_catalog_rpc::{ WorktreeCatalogOperationRuntime, WorktreeCatalogRpcServices, register_worktree_catalog_rpc, }, @@ -50,6 +50,7 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { let mut fixture = Fixture::new().await; let orphan = fixture.managed.join("orphan"); fs::create_dir_all(&orphan).expect("orphan directory"); + fixture.record_creation(&orphan).await; // As in production: the detector starts before the server binds the catalog, and scans // as soon as it is bound rather than ten minutes later. @@ -68,7 +69,7 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { assert!(sources.set(fixture.sources()).is_ok()); let orphan_finding = wait_for_finding(&runtime, "worktree.orphan-dir").await; assert_eq!(orphan_finding["severity"], "warn"); - assert!(orphan.exists(), "the scan is read-only"); + assert!(orphan.exists(), "the scan leaves the folder in place"); let events = shutdown_and_read_events(runtime, state.path()).await; assert!( events @@ -90,25 +91,109 @@ async fn the_audit_and_removal_outcomes_reach_the_profiler() { let sources = fixture.sources(); let processes = ProcessAttributionRegistry::new(); - scan_once(&sources, &processes).await; - let remaining_id = finding(&runtime, "worktree.orphan-dir")["id"].clone(); + // A second leftover with evidence, and a folder that was never a worktree. let other_orphan = fixture.managed.join("other-orphan"); fs::create_dir(&other_orphan).expect("second orphan directory"); + fixture.record_creation(&other_orphan).await; + let container = fixture.managed.join("container"); + fs::create_dir_all(container.join("project")).expect("container directory"); + fs::write(container.join("project/README.md"), "not a worktree\n").expect("container file"); + scan_once(&sources, &processes).await; scan_once(&sources, &processes).await; let orphans = runtime.status()["findings"].as_array().unwrap().clone(); - assert_eq!(orphans.len(), 2, "each orphan has its own finding"); + assert_eq!( + orphans.len(), + 2, + "each leftover has its own finding, and the container none: {orphans:?}" + ); assert_ne!(orphans[0]["id"], orphans[1]["id"]); + assert!( + orphans.iter().all(|finding| finding["count"] == 2), + "{orphans:?}" + ); + // The orphan's ID is computed from its managed parent and name, as before. + let parent = fs::canonicalize(&fixture.managed).expect("managed directory"); + let remaining = fingerprint( + "worktree.orphan-dir", + &[ + "unowned-unregistered".to_owned(), + fingerprint("worktree.parent", &[parent.to_string_lossy().into_owned()]), + fingerprint("worktree.directory", &["orphan".to_owned()]), + ], + ); + let remaining_id = orphans + .iter() + .find(|finding| finding["fingerprint"] == remaining.as_str()) + .unwrap_or_else(|| panic!("no finding for the first leftover: {orphans:?}"))["id"] + .clone(); fs::remove_dir(&other_orphan).expect("remove one orphan"); scan_once(&sources, &processes).await; let next = runtime.status()["findings"].as_array().unwrap().clone(); assert_eq!(next.len(), 2); for finding in next { - let expected = if finding["id"] == remaining_id { 3 } else { 1 }; + let expected = if finding["id"] == remaining_id { 3 } else { 2 }; assert_eq!( finding["count"], expected, "only the remaining orphan recurs" ); } + assert_eq!( + fixture.creation_records().await, + [orphan.to_string_lossy().into_owned()], + "the audit deleted the removed leftover's creation record" + ); + + // Evidence written under another spelling of the project root still counts: the audit + // matches it by the worktree path. + let respelled = format!("{}/", fixture.main.to_string_lossy()); + let by_record = fixture.managed.join("respelled-record"); + let by_receipt = fixture.managed.join("respelled-receipt"); + fs::create_dir(&by_record).expect("respelled record directory"); + fs::create_dir(&by_receipt).expect("respelled receipt directory"); + fixture + .repositories + .record_worktree_creation(&respelled, &by_record.to_string_lossy()) + .await + .expect("creation record"); + fixture + .repositories + .prepare_worktree_removal_receipt(WorktreeRemovalReceipt { + owner_thread_id: "respelled-owner".to_owned(), + project_cwd: respelled, + worktree_path: by_receipt.to_string_lossy().into_owned(), + identity_nonce: "respelled-nonce".to_owned(), + state: "prepared".to_owned(), + created_at: String::new(), + updated_at: String::new(), + }) + .await + .expect("removal receipt"); + scan_once(&sources, &processes).await; + let fingerprints: Vec<_> = runtime.status()["findings"] + .as_array() + .unwrap() + .iter() + .map(|finding| { + finding["fingerprint"] + .as_str() + .unwrap_or_default() + .to_owned() + }) + .collect(); + for name in ["respelled-record", "respelled-receipt"] { + let expected = fingerprint( + "worktree.orphan-dir", + &[ + "unowned-unregistered".to_owned(), + fingerprint("worktree.parent", &[parent.to_string_lossy().into_owned()]), + fingerprint("worktree.directory", &[name.to_owned()]), + ], + ); + assert!( + fingerprints.contains(&expected), + "{name} is an orphan: {fingerprints:?}" + ); + } let thread_id = adopt(&mut fixture).await; fs::write(fixture.worktree.join("dirty.txt"), "dirty\n").expect("dirty file"); @@ -569,6 +654,24 @@ impl Fixture { } } + /// Records `path` as a worktree BiBCode created for the project. + async fn record_creation(&self, path: &Path) { + self.repositories + .record_worktree_creation(&self.main.to_string_lossy(), &path.to_string_lossy()) + .await + .expect("creation record"); + } + + async fn creation_records(&self) -> Vec { + self.repositories + .list_worktree_creation_records() + .await + .expect("creation records") + .into_iter() + .map(|record| record.worktree_path) + .collect() + } + fn socket(&mut self) -> &mut TestSocket { self.socket.as_mut().expect("active socket") } diff --git a/apps/server/tests/provider_terminal_supervisor.rs b/apps/server/tests/provider_terminal_supervisor.rs index 2e1842b891..780ea80886 100644 --- a/apps/server/tests/provider_terminal_supervisor.rs +++ b/apps/server/tests/provider_terminal_supervisor.rs @@ -11452,6 +11452,97 @@ async fn claude_unsafe_or_unproven_settings_composition_is_pass_through() { } } +#[cfg(unix)] +fn prepared_args(preparation: TerminalLaunchPreparation) -> Vec { + match preparation { + TerminalLaunchPreparation::Prepared(prepared) + | TerminalLaunchPreparation::Admitted(prepared, _) => prepared.args, + TerminalLaunchPreparation::PassThrough => panic!("expected an observed launch"), + } +} + +/// #293: server-added options go before the `--` that ends a review's options, so the prompt +/// stays the last, positional argument; an ordinary command still gets them at the end. +#[cfg(unix)] +#[tokio::test] +async fn claude_observer_settings_precede_the_end_of_options() { + let fixture = claude_hook_fixture(); + let help = fixture["rootHelp"].as_str().expect("root help"); + let review = [ + "--permission-mode", + "plan", + "--model", + "sonnet", + "--", + "Review --settings and this", + ] + .map(str::to_owned) + .to_vec(); + let args = prepared_args( + claude_direct_preparation("2.1.220 (Claude Code)", help, review.clone()).await, + ); + let end = args + .iter() + .position(|arg| arg == "--") + .expect("end of options"); + assert_eq!(&args[..4], &review[..4]); + assert_eq!(args[4], "--settings"); + assert_eq!(end, 6, "{args:?}"); + assert_eq!(&args[end..], &review[4..], "the prompt stays last"); + + // A prompt is data: one that reads like a settings switch does not unobserve the review. + let flag_like = ["--", "--bare"].map(str::to_owned).to_vec(); + let args = prepared_args( + claude_direct_preparation("2.1.220 (Claude Code)", help, flag_like.clone()).await, + ); + assert_eq!(&args[2..], &flag_like[..], "{args:?}"); + + let ordinary = ["--model", "sonnet"].map(str::to_owned).to_vec(); + let args = prepared_args( + claude_direct_preparation("2.1.220 (Claude Code)", help, ordinary.clone()).await, + ); + assert_eq!(&args[..2], &ordinary[..]); + assert_eq!(args[2], "--settings"); + assert_eq!(args.len(), 4); + + // The same review opened through the terminal manager spawns that argv. + let root = tempfile::tempdir().expect("fixture root"); + let configured = claude_link_to_target(&root, "versions/2.1.312", b"claude 2.1.312"); + let factory = Arc::new(ClaudeTerminalObserverFactory::with_pinner( + Arc::new(CachedClaudeCapabilityProbe::with_attestor( + VersionedClaudeProbeRunner::new("2.1.312 (Claude Code)\n"), + Arc::new(CountingRangeAttestor::default()), + )), + Arc::new(CopyClaudeExecutablePinner), + )); + let (manager, backend) = + claude_manager(claude_supervisor_with_factory(&root, &configured, factory).await); + let mut input = claude_pinning_open_input(&root, &configured, "terminal-claude-review"); + let review = [ + "--permission-mode", + "plan", + "--", + "Review the branch.\nLook at --settings too.", + ] + .map(str::to_owned) + .to_vec(); + input.command = Some( + serde_json::from_value(serde_json::json!({ + "executable": configured, + "args": review, + "label": "Claude Review", + "activity": {"driverKind": "claudeAgent", "providerInstanceId": "claudeAgent"}, + })) + .expect("Claude review launch"), + ); + manager.open(input).await.expect("Claude review terminal"); + let spawned = backend.spawns().pop().expect("Claude PTY spawn").args; + assert_eq!(&spawned[..2], &review[..2]); + assert_eq!(spawned[2], "--settings"); + assert_eq!(&spawned[4..], &review[2..], "{spawned:?}"); + manager.shutdown().await; +} + async fn claude_fixture_terminal( terminal_id: &str, ) -> ( @@ -17003,6 +17094,91 @@ fn codex_preparation_input( } } +/// #293: a review ends its options with `--` and a prompt; the observed launch keeps that tail +/// last and puts `--remote` before it, and an ordinary command still ends with `--remote`. +#[cfg(unix)] +#[tokio::test] +async fn codex_observer_keeps_a_review_prompt_after_the_end_of_options() { + let fixture = codex_remote_fixture(); + let root = tempfile::tempdir().expect("fixture root"); + let configured = root.path().join("configured-codex"); + std::fs::write(&configured, b"configured").expect("configured executable"); + let helper = Arc::new(CodexFixtureHelperLauncher::default()); + let (supervisor, _projection) = codex_fixture_supervisor( + &root, + &configured, + helper.clone(), + Arc::new(CodexFixtureRemoteFactory { + state: Arc::new(CodexFixtureRemoteState::default()), + }), + ) + .await; + let review = [ + "--sandbox", + "read-only", + "--ask-for-approval", + "never", + "-m", + "gpt-5.4", + "--", + "Review --remote unix://x and -C elsewhere", + ] + .map(str::to_owned) + .to_vec(); + let mut input = codex_preparation_input(&fixture, &configured, &root, "terminal-review"); + input.args = review.clone(); + let args = prepared_args(supervisor.prepare(input).await); + let endpoint = helper.launches.lock().expect("helper launches")[0] + .endpoint + .clone(); + let mut expected = review[..6].to_vec(); + expected.extend(["--remote".to_owned(), endpoint, "--".to_owned()]); + expected.push(review[7].clone()); + assert_eq!(args, expected); + + let args = prepared_args( + supervisor + .prepare(codex_preparation_input( + &fixture, + &configured, + &root, + "terminal-ordinary", + )) + .await, + ); + assert_eq!(args[args.len() - 2], "--remote"); + assert!(!args.iter().any(|arg| arg == "--")); + + // The same review opened through the terminal manager spawns that argv. + let backend = Arc::new(RecordingBackend::new(Arc::new(Mutex::new(Vec::new())))); + let manager = TerminalManager::new( + backend.clone(), + TerminalManagerOptions { + launch_preparer: Some(Arc::new(supervisor)), + ..TerminalManagerOptions::default() + }, + ); + let mut input = codex_fixture_open_input(&fixture, &configured, &root, "terminal-open"); + let multi_line = "Review the branch.\nThen -C elsewhere."; + let mut review_args = review[..7].to_vec(); + review_args.push(multi_line.to_owned()); + input.command = Some( + serde_json::from_value(serde_json::json!({ + "executable": configured, + "args": review_args, + "label": "Codex Review", + "activity": {"driverKind": "codex", "providerInstanceId": "codex"}, + })) + .expect("Codex review launch"), + ); + manager.open(input).await.expect("Codex review terminal"); + let spawned = backend.spawns().pop().expect("Codex PTY spawn").args; + assert_eq!(&spawned[..6], &review[..6]); + assert_eq!(spawned[6], "--remote"); + assert_eq!(&spawned[8..], ["--", multi_line], "{spawned:?}"); + manager.shutdown().await; +} + #[cfg(unix)] #[tokio::test] async fn codex_preparation_that_runs_out_of_time_names_the_helper_stage() { diff --git a/apps/server/tests/remote_push_guidance.rs b/apps/server/tests/remote_push_guidance.rs new file mode 100644 index 0000000000..81d713c748 --- /dev/null +++ b/apps/server/tests/remote_push_guidance.rs @@ -0,0 +1,812 @@ +//! #343: a push that fails for missing credentials on a remote server says where Git ran and +//! how to recover, in Git Manager and in Source Control's stacked action, while the local +//! desktop keeps today's message and no caller is ever offered a sign-in it may not get. +//! +//! Every test re-runs itself in a child process whose global Git configuration is a throwaway +//! file, so the credential helper is the test's own and no real credential store is read. +#![cfg(unix)] + +#[path = "support/git_http_auth_host.rs"] +mod git_http_auth_host; +#[path = "support/hermetic_providers.rs"] +mod hermetic_providers; +#[path = "support/isolated_git_config.rs"] +mod isolated_git_config; +#[path = "support/websocket_frames.rs"] +mod websocket_frames; + +use std::{ + ffi::{OsStr, OsString}, + fs, + path::{Path, PathBuf}, + process::Command, + sync::Mutex, + time::Duration, +}; + +use bibcode_server::{RpcExit, ServerConfig, ServerHandle, ServerMessage, ServerRuntime}; +use futures_util::SinkExt; +use git_http_auth_host::GitHttpAuthHost; +use isolated_git_config::IsolatedGitConfig; +use reqwest::{Client, StatusCode}; +use serde_json::{Value, json}; +use tempfile::TempDir; +use tokio::{net::TcpStream, time::timeout}; +use tokio_tungstenite::{MaybeTlsStream, WebSocketStream, connect_async, tungstenite::Message}; +use websocket_frames::next_frame_past_heartbeat; + +type TestSocket = WebSocketStream>; + +const ISOLATED: &str = "BIBCODE_REMOTE_PUSH_GUIDANCE_ISOLATED"; +static RELAUNCH: Mutex<()> = Mutex::new(()); +const FRAME_BOUND: Duration = Duration::from_secs(60); +const BOOTSTRAP: &str = "remote-push-guidance-bootstrap"; +const TOKEN_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; +const ACCESS_TOKEN_TYPE: &str = "urn:ietf:params:oauth:token-type:access_token"; +const BOOTSTRAP_TOKEN_TYPE: &str = "urn:bibcode:params:oauth:token-type:environment-bootstrap"; +const USER: &str = "test-user"; +const PASSWORD: &str = "test-password"; + +const KNOWN_HOST_GUIDANCE: &str = "Push ran on the remote server example-server. In a terminal on example-server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode."; +const UNKNOWN_HOST_GUIDANCE: &str = "Push ran on the remote server. In a terminal on the remote server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode."; +const LOCAL_AUTHENTICATION_MESSAGE: &str = + "Authentication failed. Check the configured credentials and try again."; +/// A credential helper that prints Git Credential Manager's refusal and answers nothing. +const GCM_REFUSING_HELPER: &str = "!f() { echo 'fatal: Cannot prompt because user interactivity has been disabled.' >&2; exit 1; }; f"; +/// A credential helper that reports a timed-out lookup and answers nothing. +const TIMED_OUT_HELPER: &str = "!f() { echo 'credential lookup timed out' >&2; }; f"; +/// A credential helper that reports a cancelled lookup and answers nothing. +const CANCELLED_HELPER: &str = "!f() { echo 'credential lookup cancelled' >&2; }; f"; + +/// Re-runs `test_name` alone in a child with an isolated global Git configuration; true in the +/// parent, which then returns. +fn relaunched(test_name: &str) -> bool { + if std::env::var_os(ISOLATED).is_some() { + return false; + } + let _guard = RELAUNCH + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let config = IsolatedGitConfig::new(); + let home = TempDir::new().expect("isolated home"); + let mut command = Command::new(std::env::current_exe().expect("test executable")); + config.apply_to_command(&mut command); + isolate_network_environment(&mut command, home.path()); + let output = command + .args(["--exact", test_name, "--nocapture", "--test-threads=1"]) + .env(ISOLATED, "1") + .output() + .expect("run the isolated test"); + assert!( + output.status.success(), + "isolated test {test_name} failed:\nstdout:\n{}\nstderr:\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + true +} + +fn is_proxy_or_netrc(name: &OsStr) -> bool { + let name = name.to_string_lossy().to_ascii_lowercase(); + name == "netrc" || name.ends_with("_proxy") +} + +/// Keeps Git from authenticating with the developer's `.netrc` or reaching a proxy: a throwaway +/// `HOME`, no `NETRC` and no proxy variable, inherited or already set on `command`. +fn isolate_network_environment(command: &mut Command, home: &Path) { + let names: Vec = std::env::vars_os() + .map(|(name, _)| name) + .chain(command.get_envs().map(|(name, _)| name.to_owned())) + .filter(|name| is_proxy_or_netrc(name)) + .collect(); + for name in names { + command.env_remove(name); + } + command + .env("HOME", home) + .env("XDG_CONFIG_HOME", home.join(".config")) + .env("NO_PROXY", "*") + .env("no_proxy", "*"); +} + +#[test] +fn the_child_gets_a_throwaway_home_and_no_netrc_or_proxy() { + let home = TempDir::new().expect("isolated home"); + let mut command = Command::new("true"); + command + .env("NETRC", "/nonexistent/netrc") + .env("https_proxy", "http://127.0.0.1:9") + .env("HTTP_PROXY", "http://127.0.0.1:9") + .env("ALL_PROXY", "http://127.0.0.1:9"); + isolate_network_environment(&mut command, home.path()); + let envs: Vec<(String, Option)> = command + .get_envs() + .map(|(name, value)| { + ( + name.to_string_lossy().into_owned(), + value.map(|value| value.to_string_lossy().into_owned()), + ) + }) + .collect(); + let value = |key: &str| { + envs.iter() + .find(|(name, _)| name == key) + .map(|(_, value)| value.clone()) + }; + for removed in ["NETRC", "https_proxy", "HTTP_PROXY", "ALL_PROXY"] { + assert_eq!(value(removed), Some(None), "{removed} is removed"); + } + for (name, _) in std::env::vars_os() { + let name = name.to_string_lossy().into_owned(); + if is_proxy_or_netrc(OsStr::new(&name)) && !name.eq_ignore_ascii_case("no_proxy") { + assert_eq!(value(&name), Some(None), "inherited {name} is removed"); + } + } + let home_path = home.path().to_string_lossy().into_owned(); + assert_eq!(value("HOME"), Some(Some(home_path.clone()))); + assert_eq!( + value("XDG_CONFIG_HOME"), + Some(Some( + home.path().join(".config").to_string_lossy().into_owned() + )) + ); + assert_eq!(value("NO_PROXY"), Some(Some("*".to_owned()))); + assert_eq!(value("no_proxy"), Some(Some("*".to_owned()))); +} + +#[tokio::test] +async fn the_relaunched_child_runs_in_the_isolated_environment() { + if relaunched("the_relaunched_child_runs_in_the_isolated_environment") { + return; + } + let home = PathBuf::from(std::env::var_os("HOME").expect("child HOME")); + assert!(home.is_dir(), "the throwaway home exists"); + assert!( + home.starts_with(std::env::temp_dir()), + "HOME is a temporary directory: {home:?}" + ); + assert!( + !home.join(".netrc").exists(), + "no .netrc in the throwaway home" + ); + assert_eq!( + std::env::var_os("XDG_CONFIG_HOME").map(PathBuf::from), + Some(home.join(".config")) + ); + for (name, value) in std::env::vars_os() { + if name.eq_ignore_ascii_case("no_proxy") { + assert_eq!(value, "*"); + } else { + assert!(!is_proxy_or_netrc(&name), "{name:?} reached the child"); + } + } +} + +fn git(cwd: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .current_dir(cwd) + .args(args) + .output() + .expect("run git"); + assert!( + output.status.success(), + "git {args:?} failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8_lossy(&output.stdout).trim().to_owned() +} + +/// Replaces every inherited credential helper with `helper` in the isolated global config. +fn use_credential_helper(cwd: &Path, helper: &str) { + let _ = Command::new("git") + .current_dir(cwd) + .args(["config", "--global", "--unset-all", "credential.helper"]) + .status(); + // An empty value clears helpers from every lower-priority file, the system one included. + git( + cwd, + &["config", "--global", "--add", "credential.helper", ""], + ); + git( + cwd, + &["config", "--global", "--add", "credential.helper", helper], + ); + let helpers = git(cwd, &["config", "--get-all", "credential.helper"]); + assert_eq!( + helpers, helper, + "only the test's helper after the empty reset" + ); +} + +enum Mode { + /// The desktop's in-process server, with the desktop app's own session. + Local, + /// A server the desktop did not start, named by `label`, with a paired session. + Remote { label: &'static str }, +} + +struct Fixture { + root: TempDir, + _state: TempDir, + _handle: ServerHandle, + socket: TestSocket, + checkout: PathBuf, + bare: PathBuf, + credentials: PathBuf, + host: GitHttpAuthHost, + next_id: usize, +} + +impl Fixture { + async fn start(mode: Mode) -> Self { + let root = TempDir::new().expect("fixture root"); + let bare = root.path().join("served.git"); + let checkout = root.path().join("checkout"); + fs::create_dir(&checkout).expect("checkout directory"); + git( + root.path(), + &["init", "-q", "--bare", "-b", "main", "served.git"], + ); + git(&checkout, &["init", "-q", "-b", "main"]); + git(&checkout, &["config", "user.name", "Test User"]); + git(&checkout, &["config", "user.email", "user@example.test"]); + fs::write(checkout.join("tracked.txt"), "base\n").expect("base file"); + git(&checkout, &["add", "tracked.txt"]); + git(&checkout, &["commit", "-q", "-m", "base"]); + let bare_path = bare.to_string_lossy().into_owned(); + git(&checkout, &["remote", "add", "origin", &bare_path]); + git(&checkout, &["push", "-q", "-u", "origin", "main"]); + let host = GitHttpAuthHost::start(&bare, USER, PASSWORD); + git(&checkout, &["remote", "set-url", "origin", &host.url()]); + fs::write(checkout.join("tracked.txt"), "change to push\n").expect("change"); + git(&checkout, &["commit", "-q", "-am", "change to push"]); + let credentials = root.path().join("credentials"); + fs::write(&credentials, "").expect("empty credential store"); + use_credential_helper( + &checkout, + &format!("store --file {}", credentials.to_string_lossy()), + ); + + let state = TempDir::new().expect("server state"); + let mut config = ServerConfig::new(state.path()) + .with_bind("127.0.0.1", 0) + .with_desktop(BOOTSTRAP) + .expect("desktop configuration"); + match mode { + Mode::Local => config.interactive_git_sign_in = true, + Mode::Remote { label } => { + config.interactive_git_sign_in = false; + config.environment_label = label.to_owned(); + } + } + hermetic_providers::ensure_hermetic_settings(&config.state_dir()); + let handle = ServerRuntime::start(config).await.expect("server starts"); + let client = Client::new(); + let administrator = exchange_token(&client, &handle, BOOTSTRAP).await; + let mut admin_socket = authenticated_socket(&client, &handle, &administrator).await; + request( + &mut admin_socket, + "1", + "orchestration.dispatchCommand", + json!({ + "type": "project.create", + "commandId": "create-project", + "projectId": "project-1", + "title": "Remote push guidance", + "workspaceRoot": checkout, + "defaultModelSelection": null, + "createdAt": "2026-11-09T00:00:00Z" + }), + ) + .await; + drain(&mut admin_socket, "1").await; + let socket = match mode { + Mode::Local => admin_socket, + Mode::Remote { .. } => { + let pairing = response_json( + client + .post(http_url(&handle, "/api/auth/pairing-token")) + .bearer_auth(access_token(&administrator)) + .json(&json!({ + "label": "Paired client", + "scopes": ["orchestration:read", "orchestration:operate"] + })) + .send() + .await + .expect("pairing request"), + StatusCode::OK, + ) + .await; + let paired = exchange_token( + &client, + &handle, + pairing["credential"].as_str().expect("pairing credential"), + ) + .await; + authenticated_socket(&client, &handle, &paired).await + } + }; + Self { + root, + _state: state, + _handle: handle, + socket, + checkout, + bare, + credentials, + host, + next_id: 0, + } + } + + fn id(&mut self) -> String { + self.next_id += 1; + (self.next_id + 1).to_string() + } + + /// The terminal event of a Git Manager push: `finished` or `failed`. + async fn git_manager_push(&mut self) -> Value { + let payload = json!({ + "_tag": "push", "cwd": self.checkout, "projectId": "project-1", + "remote": "origin", "localBranch": "main", "remoteBranch": null + }); + self.git_manager(payload).await + } + + /// The terminal event of a Git Manager tag create that pushes the tag to `origin`. + async fn git_manager_tag_create_and_push(&mut self) -> Value { + let payload = json!({ + "_tag": "tag-create", "cwd": self.checkout, "projectId": "project-1", + "name": "v1", "sha": self.local_head(), "pushRemote": "origin" + }); + self.git_manager(payload).await + } + + async fn git_manager(&mut self, payload: Value) -> Value { + let id = self.id(); + self.send_claiming_sign_in(&id, "gitManager.runOperation", payload) + .await; + let events = drain(&mut self.socket, &id).await; + events + .into_iter() + .rfind(|event| matches!(event["_tag"].as_str(), Some("finished" | "failed"))) + .expect("terminal Git Manager event") + } + + /// The terminal event of a Source Control stacked push. + async fn stacked_push(&mut self) -> Value { + let id = self.id(); + let payload = + json!({ "actionId": format!("action-{id}"), "cwd": self.checkout, "action": "push" }); + self.send_claiming_sign_in(&id, "git.runStackedAction", payload) + .await; + let events = drain(&mut self.socket, &id).await; + events + .into_iter() + .rfind(|event| { + matches!( + event["kind"].as_str(), + Some("action_finished" | "action_failed") + ) + }) + .expect("terminal stacked action event") + } + + /// A header claiming sign-in eligibility, which the server must ignore (R10). + async fn send_claiming_sign_in(&mut self, id: &str, tag: &str, payload: Value) { + send( + &mut self.socket, + json!({ + "_tag": "Request", "id": id, "tag": tag, "payload": payload, + "headers": [["x-bibcode-interactive-git-sign-in", "true"]] + }), + ) + .await; + } + + fn store_matching_credential(&self) { + let url = self.host.url(); + let authority = url + .strip_prefix("http://") + .and_then(|rest| rest.split('/').next()) + .expect("host authority"); + fs::write( + &self.credentials, + format!("http://{USER}:{PASSWORD}@{authority}\n"), + ) + .expect("stored credential"); + } + + fn served_head(&self) -> String { + git(&self.bare, &["rev-parse", "main"]) + } + + fn local_head(&self) -> String { + git(&self.checkout, &["rev-parse", "HEAD"]) + } +} + +fn message(event: &Value) -> &str { + event["message"].as_str().expect("failure message") +} + +fn assert_guidance(event: &Value, guidance: &str) { + let message = message(event); + assert!( + message.starts_with(&format!("{guidance}\n\n")), + "guidance, a blank line, then Git's text: {message}" + ); + let last = message.trim_end().lines().last().unwrap_or_default(); + assert!( + last.starts_with("fatal: could not read Username"), + "ends with Git's own line: {message}" + ); + assert!(event.get("signIn").is_none(), "no sign-in offer: {event}"); +} + +#[tokio::test] +async fn remote_push_guidance_known_host_git_manager() { + if relaunched("remote_push_guidance_known_host_git_manager") { + return; + } + let mut fixture = Fixture::start(Mode::Remote { + label: "example-server", + }) + .await; + let failed = fixture.git_manager_push().await; + assert_eq!(failed["_tag"], "failed", "{failed}"); + assert_eq!(failed["code"], "authentication", "{failed}"); + assert_guidance(&failed, KNOWN_HOST_GUIDANCE); +} + +#[tokio::test] +async fn remote_push_guidance_after_a_timed_out_helper() { + if relaunched("remote_push_guidance_after_a_timed_out_helper") { + return; + } + let mut fixture = Fixture::start(Mode::Remote { + label: "example-server", + }) + .await; + use_credential_helper(&fixture.checkout, TIMED_OUT_HELPER); + let git_manager = fixture.git_manager_push().await; + assert_eq!(git_manager["code"], "authentication", "{git_manager}"); + let stacked = fixture.stacked_push().await; + for failed in [&git_manager, &stacked] { + assert_guidance(failed, KNOWN_HOST_GUIDANCE); + assert!( + message(failed).contains("credential lookup timed out"), + "{failed}" + ); + } +} + +#[tokio::test] +async fn local_cancelled_helper_keeps_todays_message() { + if relaunched("local_cancelled_helper_keeps_todays_message") { + return; + } + let mut fixture = Fixture::start(Mode::Local).await; + use_credential_helper(&fixture.checkout, CANCELLED_HELPER); + let git_manager = fixture.git_manager_push().await; + assert_eq!(git_manager["code"], "cancelled", "{git_manager}"); + assert_eq!( + message(&git_manager), + "The Git Manager operation was cancelled." + ); + assert!(git_manager.get("signIn").is_none(), "{git_manager}"); +} + +#[tokio::test] +async fn remote_tag_push_guidance_keeps_the_created_tag_message() { + if relaunched("remote_tag_push_guidance_keeps_the_created_tag_message") { + return; + } + let mut fixture = Fixture::start(Mode::Remote { + label: "example-server", + }) + .await; + let failed = fixture.git_manager_tag_create_and_push().await; + assert_eq!(failed["_tag"], "failed", "{failed}"); + assert_eq!(failed["code"], "tag-push-failed", "{failed}"); + let message = message(&failed); + let rest = message + .strip_prefix("Created tag v1 locally, but pushing it to origin failed.\n\n") + .unwrap_or_else(|| panic!("the partial-success line comes first: {message}")); + assert!( + rest.starts_with(&format!("{KNOWN_HOST_GUIDANCE}\n\n")), + "{message}" + ); + assert!( + rest.trim_end() + .lines() + .last() + .is_some_and(|line| line.starts_with("fatal: could not read Username")), + "{message}" + ); + assert_eq!(git(&fixture.checkout, &["tag", "--list", "v1"]), "v1"); +} + +#[tokio::test] +async fn remote_push_guidance_known_host_stacked_action() { + if relaunched("remote_push_guidance_known_host_stacked_action") { + return; + } + let mut fixture = Fixture::start(Mode::Remote { + label: "example-server", + }) + .await; + let failed = fixture.stacked_push().await; + assert_eq!(failed["kind"], "action_failed", "{failed}"); + assert_eq!(failed["phase"], "push", "{failed}"); + assert_guidance(&failed, KNOWN_HOST_GUIDANCE); +} + +#[tokio::test] +async fn remote_push_guidance_unknown_host() { + if relaunched("remote_push_guidance_unknown_host") { + return; + } + let mut fixture = Fixture::start(Mode::Remote { label: "Local" }).await; + for failed in [ + fixture.git_manager_push().await, + fixture.stacked_push().await, + ] { + assert_guidance(&failed, UNKNOWN_HOST_GUIDANCE); + let (guidance, git_text) = message(&failed).split_once("\n\n").expect("two parts"); + assert_eq!(guidance, UNKNOWN_HOST_GUIDANCE, "no invented name or path"); + assert!(!git_text.contains("Local"), "{git_text}"); + assert!(!message(&failed).contains("example-server"), "{failed}"); + } +} + +#[tokio::test] +async fn remote_push_never_offers_sign_in() { + if relaunched("remote_push_never_offers_sign_in") { + return; + } + let mut fixture = Fixture::start(Mode::Remote { + label: "example-server", + }) + .await; + let credential_store = format!("store --file {}", fixture.credentials.to_string_lossy()); + for helper in [credential_store.as_str(), GCM_REFUSING_HELPER] { + use_credential_helper(&fixture.checkout, helper); + for surface in ["git-manager", "stacked"] { + let before = fixture.host.receive_pack_requests(); + let failed = if surface == "git-manager" { + fixture.git_manager_push().await + } else { + fixture.stacked_push().await + }; + assert!( + failed.get("signIn").is_none(), + "{helper} {surface}: {failed}" + ); + assert_guidance(&failed, KNOWN_HOST_GUIDANCE); + assert_eq!( + fixture.host.receive_pack_requests(), + before + 1, + "{helper} {surface}: the push ran once" + ); + } + } + assert_ne!( + fixture.served_head(), + fixture.local_head(), + "nothing pushed" + ); +} + +#[tokio::test] +async fn local_missing_username_keeps_todays_message() { + if relaunched("local_missing_username_keeps_todays_message") { + return; + } + let mut fixture = Fixture::start(Mode::Local).await; + let git_manager = fixture.git_manager_push().await; + assert_eq!(git_manager["code"], "authentication", "{git_manager}"); + assert_eq!(message(&git_manager), LOCAL_AUTHENTICATION_MESSAGE); + let stacked = fixture.stacked_push().await; + assert_eq!(stacked["kind"], "action_failed", "{stacked}"); + for failed in [&git_manager, &stacked] { + assert!(!message(failed).contains("remote server"), "{failed}"); + assert!(failed.get("signIn").is_none(), "{failed}"); + } +} + +#[tokio::test] +async fn plain_http_never_offers_sign_in() { + if relaunched("plain_http_never_offers_sign_in") { + return; + } + // The desktop's own session with GCM's refusal: only an HTTPS remote gets an offer. + let mut fixture = Fixture::start(Mode::Local).await; + use_credential_helper(&fixture.checkout, GCM_REFUSING_HELPER); + let git_manager = fixture.git_manager_push().await; + assert_eq!(git_manager["code"], "authentication", "{git_manager}"); + let stacked = fixture.stacked_push().await; + for failed in [&git_manager, &stacked] { + assert!(failed.get("signIn").is_none(), "{failed}"); + assert!(!message(failed).contains("remote server"), "{failed}"); + } +} + +#[tokio::test] +async fn stored_credential_push_succeeds_in_both_modes() { + if relaunched("stored_credential_push_succeeds_in_both_modes") { + return; + } + for mode in [ + Mode::Remote { + label: "example-server", + }, + Mode::Local, + ] { + let mut fixture = Fixture::start(mode).await; + fixture.store_matching_credential(); + let finished = fixture.git_manager_push().await; + assert_eq!(finished["_tag"], "finished", "{finished}"); + assert_eq!(fixture.served_head(), fixture.local_head()); + + fs::write(fixture.checkout.join("tracked.txt"), "stacked change\n").expect("change"); + git( + &fixture.checkout, + &["commit", "-q", "-am", "stacked change"], + ); + let finished = fixture.stacked_push().await; + assert_eq!(finished["kind"], "action_finished", "{finished}"); + assert_eq!(fixture.served_head(), fixture.local_head()); + } +} + +#[tokio::test] +async fn unrelated_push_failures_keep_their_message() { + if relaunched("unrelated_push_failures_keep_their_message") { + return; + } + let mut fixture = Fixture::start(Mode::Remote { + label: "example-server", + }) + .await; + fixture.store_matching_credential(); + + // Non-fast-forward: another clone moves the served branch first. + let other = fixture.root.path().join("other"); + let bare = fixture.bare.to_string_lossy().into_owned(); + git(fixture.root.path(), &["clone", "-q", &bare, "other"]); + git(&other, &["config", "user.name", "Test User"]); + git(&other, &["config", "user.email", "user@example.test"]); + fs::write(other.join("other.txt"), "elsewhere\n").expect("other change"); + git(&other, &["add", "other.txt"]); + git(&other, &["commit", "-q", "-m", "elsewhere"]); + git(&other, &["push", "-q", "origin", "main"]); + let rejected = fixture.git_manager_push().await; + assert_eq!(rejected["code"], "non-fast-forward", "{rejected}"); + let stacked = fixture.stacked_push().await; + assert_eq!(stacked["kind"], "action_failed", "{stacked}"); + for failed in [&rejected, &stacked] { + assert!(!message(failed).contains("Push ran on"), "{failed}"); + } + + // Refused connection: a port nothing listens on. + let closed = std::net::TcpListener::bind("127.0.0.1:0").expect("free port"); + let address = closed.local_addr().expect("free address"); + drop(closed); + let url = format!("http://{address}/served.git"); + git(&fixture.checkout, &["remote", "set-url", "origin", &url]); + let refused = fixture.git_manager_push().await; + assert_eq!(refused["_tag"], "failed", "{refused}"); + let stacked = fixture.stacked_push().await; + assert_eq!(stacked["kind"], "action_failed", "{stacked}"); + for failed in [&refused, &stacked] { + assert!(!message(failed).contains("Push ran on"), "{failed}"); + } +} + +fn http_url(handle: &ServerHandle, path: &str) -> String { + format!("http://{}{}", handle.local_addr(), path) +} + +async fn exchange_token(client: &Client, handle: &ServerHandle, credential: &str) -> Value { + response_json( + client + .post(http_url(handle, "/oauth/token")) + .form(&[ + ("grant_type", TOKEN_GRANT_TYPE), + ("subject_token", credential), + ("subject_token_type", BOOTSTRAP_TOKEN_TYPE), + ("requested_token_type", ACCESS_TOKEN_TYPE), + ]) + .send() + .await + .expect("token exchange"), + StatusCode::OK, + ) + .await +} + +fn access_token(response: &Value) -> &str { + response["access_token"].as_str().expect("access token") +} + +async fn authenticated_socket(client: &Client, handle: &ServerHandle, token: &Value) -> TestSocket { + let ticket = response_json( + client + .post(http_url(handle, "/api/auth/websocket-ticket")) + .bearer_auth(access_token(token)) + .send() + .await + .expect("WebSocket ticket"), + StatusCode::OK, + ) + .await["ticket"] + .as_str() + .expect("ticket") + .to_owned(); + connect_async(format!("ws://{}/ws?wsTicket={ticket}", handle.local_addr())) + .await + .expect("WebSocket connects") + .0 +} + +async fn response_json(response: reqwest::Response, expected: StatusCode) -> Value { + let status = response.status(); + let body = response.text().await.expect("response body"); + assert_eq!(status, expected, "unexpected response: {body}"); + serde_json::from_str(&body).expect("JSON response") +} + +async fn send(socket: &mut TestSocket, frame: Value) { + socket + .send(Message::Text(frame.to_string().into())) + .await + .expect("send frame"); +} + +async fn request(socket: &mut TestSocket, id: &str, tag: &str, payload: Value) { + send( + socket, + json!({"_tag": "Request", "id": id, "tag": tag, "payload": payload, "headers": []}), + ) + .await; +} + +/// Every value `id` streams, acknowledging each chunk, until it exits successfully. +async fn drain(socket: &mut TestSocket, id: &str) -> Vec { + let mut values = Vec::new(); + loop { + let frame = timeout(FRAME_BOUND, next_frame_past_heartbeat(socket)) + .await + .expect("bounded RPC frame") + .expect("WebSocket open") + .expect("valid frame"); + let Message::Text(text) = frame else { + panic!("expected a text frame: {frame:?}"); + }; + match serde_json::from_str::(&text).expect("server message") { + ServerMessage::Chunk { + request_id, + values: chunk, + } => { + if request_id.as_str() == id { + values.extend(chunk); + } + send( + socket, + json!({"_tag": "Ack", "requestId": request_id.as_str()}), + ) + .await; + } + ServerMessage::Exit { + request_id, + exit: RpcExit::Success { .. }, + } if request_id.as_str() == id => return values, + ServerMessage::Exit { request_id, exit } if request_id.as_str() == id => { + panic!("{id} failed: {exit:?}"); + } + ServerMessage::Exit { .. } => {} + other => panic!("unexpected frame while waiting for {id}: {other:?}"), + } + } +} diff --git a/apps/server/tests/repositories.rs b/apps/server/tests/repositories.rs index 0d451e9898..1e828bd6d5 100644 --- a/apps/server/tests/repositories.rs +++ b/apps/server/tests/repositories.rs @@ -82,9 +82,43 @@ fn project(id: &str, created_at: &str) -> ProjectionProject { updated_at: created_at.to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), } } +#[tokio::test] +async fn project_code_review_prompt_round_trips() { + let repositories = migrated_repositories().await; + let mut row = project("p-review", T0); + row.code_review_prompt = "Check our test naming. Base is {base}.".to_owned(); + repositories.upsert_project(row).await.expect("upsert"); + let stored = repositories + .get_project("p-review".to_owned()) + .await + .expect("read") + .expect("project exists"); + assert_eq!( + stored.code_review_prompt, + "Check our test naming. Base is {base}." + ); + assert_eq!( + repositories.list_projects().await.expect("list")[0].code_review_prompt, + stored.code_review_prompt + ); + let mut cleared = project("p-review", T1); + cleared.code_review_prompt = String::new(); + repositories.upsert_project(cleared).await.expect("clear"); + assert_eq!( + repositories + .get_project("p-review".to_owned()) + .await + .expect("read") + .expect("project exists") + .code_review_prompt, + "" + ); +} + #[tokio::test] async fn project_repository_identity_round_trips() { let repositories = migrated_repositories().await; @@ -285,6 +319,7 @@ fn thread(id: &str, project_id: &str, created_at: &str) -> ProjectionThread { deleted_at: None, host_thread_id: None, compaction: None, + code_review: false, } } @@ -452,6 +487,11 @@ fn public_repository_api_inventory_is_explicit() { "upsert_turn_by_id", "verify_prepared_command_receipt", "complete_worktree_removal_receipt", + "list_incomplete_worktree_removal_paths", + "record_worktree_creation", + "delete_worktree_creation_record", + "reap_worktree_creation_record", + "list_worktree_creation_records", "register_mcp_oauth_client", "get_mcp_oauth_client", "create_mcp_oauth_authorization", diff --git a/apps/server/tests/rpc_wire.rs b/apps/server/tests/rpc_wire.rs index 7dd0bca990..928ee568a2 100644 --- a/apps/server/tests/rpc_wire.rs +++ b/apps/server/tests/rpc_wire.rs @@ -89,17 +89,17 @@ fn rust_registry_matches_the_active_typescript_rpc_group() { .collect::>(); assert_eq!(rust_methods, manifest.methods); - assert_eq!(rust_methods.len(), 196); + assert_eq!(rust_methods.len(), 198); let rust_stream_count = rust_methods .iter() .filter(|method| method.mode == MethodMode::Stream) .count(); - assert_eq!(rust_stream_count, 29); + assert_eq!(rust_stream_count, 30); assert_eq!(rust_stream_count, manifest.stream_method_count); - assert_eq!(manifest.expected_top_level_stream_shapes, 94); + assert_eq!(manifest.expected_top_level_stream_shapes, 96); assert_eq!(manifest.expected_orchestration_event_shapes, 27); - assert_eq!(manifest.stream_shape_fixtures.len(), 94); - assert_eq!(manifest.typed_failure_fixtures.len(), 413); + assert_eq!(manifest.stream_shape_fixtures.len(), 96); + assert_eq!(manifest.typed_failure_fixtures.len(), 415); assert_eq!( manifest.stale_method_identifiers, ["projects.add", "projects.list", "projects.remove"] diff --git a/apps/server/tests/server_runtime.rs b/apps/server/tests/server_runtime.rs index 1ee459e992..02d6a8f6e5 100644 --- a/apps/server/tests/server_runtime.rs +++ b/apps/server/tests/server_runtime.rs @@ -1063,8 +1063,9 @@ async fn streams_static_assets_with_security_and_cache_headers() { "media-src 'self' blob:", "base-uri 'self'", "frame-ancestors 'none'", - // Browser mode frames this host's preview gateways, each on its own port. - "frame-src 'self' http://127.0.0.1:*", + // Browser mode frames this host's preview gateways, each on its own port, and the + // PDF preview's own blob. + "frame-src 'self' blob: http://127.0.0.1:*", ] { assert!(csp.contains(directive), "missing {directive} in {csp}"); } diff --git a/apps/server/tests/support/git_http_auth_host.rs b/apps/server/tests/support/git_http_auth_host.rs new file mode 100644 index 0000000000..2cb88e3cf6 --- /dev/null +++ b/apps/server/tests/support/git_http_auth_host.rs @@ -0,0 +1,193 @@ +//! A Git smart-HTTP host on `127.0.0.1` that requires HTTP Basic credentials and serves one +//! bare repository through `git http-backend`, so pushes exercise Git's real credential path. + +use std::{ + io::{BufRead, BufReader, Read, Write}, + net::{SocketAddr, TcpListener, TcpStream}, + path::{Path, PathBuf}, + process::{Command, Stdio}, + sync::{ + Arc, + atomic::{AtomicUsize, Ordering}, + }, + thread, +}; + +use base64::Engine; + +pub struct GitHttpAuthHost { + address: SocketAddr, + name: String, + receive_pack_requests: Arc, +} + +struct Served { + project_root: PathBuf, + authorization: String, + receive_pack_requests: Arc, +} + +impl GitHttpAuthHost { + /// Serves the bare repository `repo` to `user`/`password` only, with `http.receivepack` on. + // shortcut: the accept thread lives until the test process exits, fine for test binaries. + pub fn start(repo: &Path, user: &str, password: &str) -> Self { + let status = Command::new("git") + .arg("-C") + .arg(repo) + .args(["config", "http.receivepack", "true"]) + .status() + .expect("configure the served repository"); + assert!(status.success(), "http.receivepack"); + let listener = TcpListener::bind("127.0.0.1:0").expect("Git host listener"); + let address = listener.local_addr().expect("Git host address"); + let receive_pack_requests = Arc::new(AtomicUsize::new(0)); + let served = Arc::new(Served { + project_root: repo.parent().expect("repository parent").to_path_buf(), + authorization: format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(format!("{user}:{password}")) + ), + receive_pack_requests: receive_pack_requests.clone(), + }); + thread::spawn(move || { + for stream in listener.incoming().flatten() { + let served = served.clone(); + thread::spawn(move || served.answer(stream)); + } + }); + Self { + address, + name: repo + .file_name() + .expect("repository name") + .to_string_lossy() + .into_owned(), + receive_pack_requests, + } + } + + pub fn url(&self) -> String { + format!("http://{}/{}", self.address, self.name) + } + + /// How many times Git asked to start a push (`info/refs?service=git-receive-pack`). + pub fn receive_pack_requests(&self) -> usize { + self.receive_pack_requests.load(Ordering::SeqCst) + } +} + +impl Served { + /// One request per connection; `Connection: close` makes Git reconnect for the next. + fn answer(&self, stream: TcpStream) { + let mut reader = BufReader::new(stream.try_clone().expect("clone connection")); + let mut request_line = String::new(); + if reader.read_line(&mut request_line).unwrap_or(0) == 0 { + return; + } + let mut parts = request_line.split_whitespace(); + let method = parts.next().unwrap_or_default().to_owned(); + let target = parts.next().unwrap_or_default().to_owned(); + let mut headers = Vec::new(); + loop { + let mut line = String::new(); + if reader.read_line(&mut line).unwrap_or(0) == 0 { + return; + } + let line = line.trim_end(); + if line.is_empty() { + break; + } + if let Some((name, value)) = line.split_once(':') { + headers.push((name.trim().to_ascii_lowercase(), value.trim().to_owned())); + } + } + let header = |name: &str| { + headers + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.clone()) + }; + let (path, query) = target.split_once('?').unwrap_or((target.as_str(), "")); + if path.ends_with("/info/refs") && query == "service=git-receive-pack" { + self.receive_pack_requests.fetch_add(1, Ordering::SeqCst); + } + let mut stream = stream; + if header("authorization").as_deref() != Some(self.authorization.as_str()) { + let _ = stream.write_all( + b"HTTP/1.1 401 Unauthorized\r\nWWW-Authenticate: Basic realm=\"test\"\r\nContent-Length: 0\r\nConnection: close\r\n\r\n", + ); + return; + } + // shortcut: reads a Content-Length body only; a push over http.postBuffer (1 MiB) is + // chunked and would need chunked decoding here. + let length = header("content-length") + .and_then(|value| value.parse::().ok()) + .unwrap_or(0); + let mut body = vec![0; length]; + if reader.read_exact(&mut body).is_err() { + return; + } + let mut backend = Command::new("git") + .arg("http-backend") + .env("GIT_PROJECT_ROOT", &self.project_root) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("REQUEST_METHOD", &method) + .env("PATH_INFO", path) + .env("QUERY_STRING", query) + .env("CONTENT_TYPE", header("content-type").unwrap_or_default()) + .env("CONTENT_LENGTH", length.to_string()) + .env( + "HTTP_CONTENT_ENCODING", + header("content-encoding").unwrap_or_default(), + ) + .env("REMOTE_USER", "test-user") + .env("REMOTE_ADDR", "127.0.0.1") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("start git http-backend"); + backend + .stdin + .take() + .expect("backend stdin") + .write_all(&body) + .expect("request body"); + let output = backend.wait_with_output().expect("git http-backend output"); + let split = output + .stdout + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|at| (at, 4)) + .or_else(|| { + output + .stdout + .windows(2) + .position(|window| window == b"\n\n") + .map(|at| (at, 2)) + }) + .expect("CGI headers"); + let cgi_headers = String::from_utf8_lossy(&output.stdout[..split.0]).into_owned(); + let response_body = &output.stdout[split.0 + split.1..]; + let mut status = "200 OK".to_owned(); + let mut response = String::new(); + for line in cgi_headers.lines() { + match line.split_once(':') { + Some((name, value)) if name.eq_ignore_ascii_case("status") => { + status = value.trim().to_owned(); + } + Some(_) => { + response.push_str(line.trim_end()); + response.push_str("\r\n"); + } + None => {} + } + } + let head = format!( + "HTTP/1.1 {status}\r\n{response}Content-Length: {}\r\nConnection: close\r\n\r\n", + response_body.len() + ); + let _ = stream.write_all(head.as_bytes()); + let _ = stream.write_all(response_body); + } +} diff --git a/apps/server/tests/support/load_fixture.rs b/apps/server/tests/support/load_fixture.rs index 2c7b1d719f..7b44e19079 100644 --- a/apps/server/tests/support/load_fixture.rs +++ b/apps/server/tests/support/load_fixture.rs @@ -182,6 +182,7 @@ fn thread_row( last_activity_at: Some(LONG_AGO.to_owned()), requester: None, side_chat: false, + code_review: false, } } @@ -492,6 +493,7 @@ pub async fn seed(repositories: &Repositories, workspace_parent: &Path) { updated_at: timestamp(project, 0), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), }) .await .unwrap(); diff --git a/apps/server/tests/turn_delivery_recovery.rs b/apps/server/tests/turn_delivery_recovery.rs index 53b3b269af..09a8de0773 100644 --- a/apps/server/tests/turn_delivery_recovery.rs +++ b/apps/server/tests/turn_delivery_recovery.rs @@ -1496,7 +1496,7 @@ async fn bootstrap_restart_after_setup_launch_reuses_worktree_and_terminal_for_p }); let repository = Arc::new(GitRepository::default()); let provider_factory = Arc::new(NeverProvider::default()); - let worktree_path = { + let (worktree_path, created_records) = { let database = Database::create_new(&database_path) .await .expect("database"); @@ -1632,10 +1632,26 @@ async fn bootstrap_restart_after_setup_launch_reuses_worktree_and_terminal_for_p .expect("persisted thread") .worktree_path .expect("worktree path"); + let records = engine + .repositories() + .list_worktree_creation_records() + .await + .expect("creation records"); + assert_eq!( + records + .iter() + .map(|record| (record.project_cwd.clone(), record.worktree_path.clone())) + .collect::>(), + [( + repository_root.path().to_string_lossy().into_owned(), + path.clone() + )], + "the bootstrap records the worktree it created under the thread's path" + ); provider.shutdown().await.expect("provider shutdown"); effects.shutdown().await; engine.shutdown().await; - path + (path, records) }; { @@ -1700,6 +1716,15 @@ async fn bootstrap_restart_after_setup_launch_reuses_worktree_and_terminal_for_p thread.worktree_path.as_deref(), Some(worktree_path.as_str()) ); + assert_eq!( + engine + .repositories() + .list_worktree_creation_records() + .await + .expect("creation records"), + created_records, + "a restart that reuses the worktree records nothing" + ); delivery.shutdown().await; provider.shutdown().await.expect("provider shutdown"); effects.shutdown().await; diff --git a/apps/server/tests/windows_terminal_shims.rs b/apps/server/tests/windows_terminal_shims.rs index b39ad8654a..127457ee03 100644 --- a/apps/server/tests/windows_terminal_shims.rs +++ b/apps/server/tests/windows_terminal_shims.rs @@ -630,6 +630,108 @@ async fn portable_backend_runs_windows_command_and_powershell_shims() { .await; } +const REVIEW_ARGV_MARKER: &str = "BIBCODE_REVIEW_ARGV"; + +/// Sends a review prompt through an npm-style `%*` shim to `node` and checks `process.argv`. +async fn assert_review_prompt_reaches_node(sample: &str) { + let node = std::process::Command::new("node") + .arg("--version") + .output() + .expect("node must be on PATH for the review prompt shim tests"); + assert!(node.status.success(), "node --version failed: {node:?}"); + + let directory = tempfile::tempdir().unwrap(); + let shim = directory.path().join("review.cmd"); + std::fs::write(&shim, "@echo off\r\nnode \"%~dp0argv.js\" %*\r\n").unwrap(); + // The array also goes to a file: a long line wraps in the 80-column terminal. + std::fs::write( + directory.path().join("argv.js"), + format!( + "const argv = JSON.stringify(process.argv.slice(2));\nrequire('fs').writeFileSync(process.env.BIBCODE_TEST_ARGV_FILE, argv);\nconsole.log('{REVIEW_ARGV_MARKER}:' + argv);\n" + ), + ) + .unwrap(); + let argv_file = directory.path().join("argv.json"); + let arguments = [ + "--permission-mode".to_owned(), + "plan".to_owned(), + "--".to_owned(), + format!("Code review requested from BiBCode. {sample}"), + ]; + assert_shim_output( + &shim, + &arguments, + BTreeMap::from([( + "BIBCODE_TEST_ARGV_FILE".to_owned(), + argv_file.to_string_lossy().into_owned(), + )]), + REVIEW_ARGV_MARKER, + ) + .await; + let received: Vec = + serde_json::from_str(&std::fs::read_to_string(&argv_file).unwrap()).unwrap(); + assert_eq!(received, arguments, "node received other arguments"); + for redirect_target in ["b", "d", "e"] { + assert!( + !directory.path().join(redirect_target).exists(), + "a redirect or second command created {redirect_target}" + ); + } +} + +#[tokio::test] +async fn review_prompt_shim_passes_quote() { + assert_review_prompt_reaches_node(r#"say "hi""#).await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_percent() { + assert_review_prompt_reaches_node("100% %PATH%").await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_ampersand() { + assert_review_prompt_reaches_node("a & b").await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_caret() { + assert_review_prompt_reaches_node("a ^ b").await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_pipe() { + assert_review_prompt_reaches_node("a | b").await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_redirect() { + assert_review_prompt_reaches_node("c < d > e").await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_bang() { + assert_review_prompt_reaches_node("!x!").await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_trailing_backslash() { + assert_review_prompt_reaches_node(r"ends with \\\").await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_backslash_before_quote() { + assert_review_prompt_reaches_node(r#"back\\"slash"#).await; +} + +#[tokio::test] +async fn review_prompt_shim_passes_combined() { + assert_review_prompt_reaches_node( + r#"say "hi" 100% %PATH% a & b a ^ b a | b c < d > e !x! back\\"slash ends with \\\"#, + ) + .await; +} + #[tokio::test] async fn killing_a_batch_terminal_terminates_its_descendant_process() { let directory = tempfile::tempdir().unwrap(); diff --git a/apps/server/tests/worktree_catalog.rs b/apps/server/tests/worktree_catalog.rs index 69e8476100..48c6a91368 100644 --- a/apps/server/tests/worktree_catalog.rs +++ b/apps/server/tests/worktree_catalog.rs @@ -581,6 +581,7 @@ fn project(main: &Path) -> ProjectionProject { updated_at: NOW.to_owned(), deleted_at: None, repository_identity: None, + code_review_prompt: String::new(), } } @@ -611,6 +612,7 @@ fn workspace_thread(path: &Path) -> ProjectionThread { deleted_at: None, host_thread_id: None, compaction: None, + code_review: false, } } diff --git a/apps/web/THIRD_PARTY_NOTICES.md b/apps/web/THIRD_PARTY_NOTICES.md index c9a675ef41..f0589437ce 100644 --- a/apps/web/THIRD_PARTY_NOTICES.md +++ b/apps/web/THIRD_PARTY_NOTICES.md @@ -9,3 +9,56 @@ Copyright (c) 2016 Roberto Huertas Licensed under the MIT License. The full license text is available in the upstream repository: . + +## Orca + +The rendered Markdown editor in `src/components/files/richMarkdown/` is adapted +from [Orca](https://github.com/stablyai/orca): the collapsible-section +extension, the formatting toolbar's control set, the link popover rules, the +extension set, and the line-ending detection in `markdownReconcile.ts`. Each of +those files names its Orca source file at the top. + +Copyright (c) 2026 Lovecast Inc. + +Licensed under the MIT License. The full license text is in +`src/components/files/richMarkdown/detailsExtension.ts` and in the upstream +repository: . + +## TipTap + +The rendered Markdown editor uses these [TipTap](https://github.com/ueberdosis/tiptap) +packages, all at version 3.31.4: `@tiptap/core`, `@tiptap/pm`, +`@tiptap/starter-kit`, `@tiptap/markdown`, `@tiptap/extension-list`, +`@tiptap/extension-table`, `@tiptap/extension-image`, `@tiptap/extension-details` +and `@tiptap/extension-code-block`. + +Copyright (c) 2025, Tiptap GmbH + +Licensed under the MIT License. The full license text is in each package's +`LICENSE.md` and in the upstream repository: +. + +## Omarchy and the Tokyo Night projects + +The values in `src/themes/builtin/tokyo-night.json` come from the Tokyo Night +theme of [Omarchy](https://github.com/basecamp/omarchy). That palette originates +in the [Tokyo Night VS Code theme](https://github.com/tokyo-night/tokyo-night-vscode-theme) +(MIT License) and in [`tokyonight.nvim`](https://github.com/folke/tokyonight.nvim) +(Apache License 2.0). Only color values are used. + +Omarchy: Copyright (c) David Heinemeier Hansson + +Licensed under the MIT License. The full license text is available in the +upstream repository: . +The Apache License 2.0 text for `tokyonight.nvim` is available at +. + +## omarchy-matrix-theme + +The values in `src/themes/builtin/matrix-dark.json` come from +[`omarchy-matrix-theme`](https://github.com/BVisagie/omarchy-matrix-theme). + +Copyright (c) 2026 Bernard Visagie + +Licensed under the MIT License. The full license text is available in the +upstream repository: . diff --git a/apps/web/package.json b/apps/web/package.json index e2a569f1c0..e85d87cae3 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -31,6 +31,15 @@ "@tanstack/react-pacer": "^0.23.0", "@tanstack/react-router": "^1.170.32", "@tauri-apps/api": "catalog:", + "@tiptap/core": "3.31.4", + "@tiptap/extension-code-block": "3.31.4", + "@tiptap/extension-details": "3.31.4", + "@tiptap/extension-image": "3.31.4", + "@tiptap/extension-list": "3.31.4", + "@tiptap/extension-table": "3.31.4", + "@tiptap/markdown": "3.31.4", + "@tiptap/pm": "3.31.4", + "@tiptap/starter-kit": "3.31.4", "@xterm/addon-fit": "^0.11.0", "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", diff --git a/apps/web/public/theme-bootstrap.js b/apps/web/public/theme-bootstrap.js index c2624cabf3..953297c335 100644 --- a/apps/web/public/theme-bootstrap.js +++ b/apps/web/public/theme-bootstrap.js @@ -1,22 +1,157 @@ (() => { const LIGHT_BACKGROUND = "#ffffff"; const DARK_BACKGROUND = "#161616"; + // The supported theme tokens (packages/contracts/src/theme.ts); a test keeps them equal. + const COLOR_TOKENS = [ + "background", + "app-chrome-background", + "foreground", + "foreground-strong", + "card", + "card-foreground", + "popover", + "popover-foreground", + "primary", + "primary-foreground", + "primary-hover", + "primary-active", + "secondary", + "secondary-foreground", + "muted", + "muted-foreground", + "accent", + "accent-foreground", + "selected", + "selected-foreground", + "placeholder", + "destructive", + "destructive-foreground", + "border", + "input", + "ring", + "panel-separator", + "sidebar-well", + "sidebar-card", + "sidebar-card-border", + "sidebar-env-band", + "sidebar-env-accent", + "sidebar-primary-badge", + "sidebar-primary-badge-foreground", + "sidebar-worktree", + "sidebar-worktree-border", + "sidebar-worktree-icon", + "sidebar-chat-foreground", + "sidebar-indent-guide", + "link", + "info", + "info-foreground", + "success", + "success-foreground", + "warning", + "warning-foreground", + "find-match", + "find-match-foreground", + "find-match-active", + "find-match-active-foreground", + ]; + // Allowed lengths in px; a rem is 16px. + const LENGTH_RANGES = { + radius: [0, 16], + "focus-ring-width": [1, 3], + "focus-ring-offset": [0, 3], + }; + const KEYWORDS = { + "sidebar-card-shadow": ["none"], + "control-shadow": ["none", "default"], + }; + const MAX_RECORD_LENGTH = 32 * 1024; + const HEX = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i; + const OPAQUE_HEX = /^#[0-9a-f]{6}$/i; + const LENGTH = /^(\d+(?:\.\d+)?)(px|rem)$/; + const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key); + + function allowed(name, value) { + if (typeof name !== "string" || typeof value !== "string" || name.slice(0, 2) !== "--") { + return false; + } + const token = name.slice(2); + if (COLOR_TOKENS.indexOf(token) >= 0) return HEX.test(value); + if (own(LENGTH_RANGES, token)) { + const match = LENGTH.exec(value); + if (!match) return false; + const px = Number(match[1]) * (match[2] === "rem" ? 16 : 1); + return px >= LENGTH_RANGES[token][0] && px <= LENGTH_RANGES[token][1]; + } + return own(KEYWORDS, token) && KEYWORDS[token].indexOf(value) >= 0; + } + + /** The paint record for `mode`, or null unless every field passes (plan decisions P18, P22). */ + function readPaintRecord(storage, mode) { + const raw = storage.getItem("bibcode:theme-paint:v1"); + const slotsRaw = storage.getItem("bibcode:theme-slots:v1"); + if (typeof raw !== "string" || typeof slotsRaw !== "string" || raw.length > MAX_RECORD_LENGTH) { + return null; + } + const slots = JSON.parse(slotsRaw); + const records = JSON.parse(raw); + const record = records && typeof records === "object" ? records[mode] : null; + if ( + !slots || + typeof slots !== "object" || + !record || + typeof record !== "object" || + typeof record.stamp !== "string" || + record.stamp === "" || + record.stamp !== slots.paintStamp || + record.base !== mode || + typeof record.background !== "string" || + !OPAQUE_HEX.test(record.background) || + !record.vars || + typeof record.vars !== "object" + ) { + return null; + } + const names = Object.keys(record.vars); + for (const name of names) { + if (!allowed(name, record.vars[name])) return null; + } + return record; + } + + const root = document.documentElement; const themeColorMeta = document.querySelector('meta[name="theme-color"]'); try { - const storedTheme = window.localStorage.getItem("bibcode:theme"); + const storage = window.localStorage; + const storedTheme = storage.getItem("bibcode:theme"); const theme = storedTheme === "light" || storedTheme === "dark" || storedTheme === "system" ? storedTheme : "system"; const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const isDark = theme === "dark" || (theme === "system" && prefersDark); - document.documentElement.classList.toggle("dark", isDark); - const chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; - document.documentElement.style.backgroundColor = chromeColor; + root.classList.toggle("dark", isDark); + let chromeColor = isDark ? DARK_BACKGROUND : LIGHT_BACKGROUND; + let record = null; + try { + record = readPaintRecord(storage, isDark ? "dark" : "light"); + } catch { + record = null; + } + if (record) { + for (const name of Object.keys(record.vars)) root.style.setProperty(name, record.vars[name]); + if (own(record.vars, "--focus-ring-width") || own(record.vars, "--focus-ring-offset")) { + root.setAttribute("data-theme-focus-ring", ""); + } + if (record.vars["--control-shadow"] === "none") { + root.setAttribute("data-theme-control-shadow", "none"); + } + chromeColor = record.background; + } + root.style.backgroundColor = chromeColor; themeColorMeta?.setAttribute("content", chromeColor); } catch { - document.documentElement.classList.add("dark"); - document.documentElement.style.backgroundColor = DARK_BACKGROUND; + root.classList.add("dark"); + root.style.backgroundColor = DARK_BACKGROUND; themeColorMeta?.setAttribute("content", DARK_BACKGROUND); } })(); diff --git a/apps/web/src/AppRoot.lifecycle.test.tsx b/apps/web/src/AppRoot.lifecycle.test.tsx index 36d2ff9559..cbbf1c7046 100644 --- a/apps/web/src/AppRoot.lifecycle.test.tsx +++ b/apps/web/src/AppRoot.lifecycle.test.tsx @@ -79,6 +79,9 @@ vi.mock("./components/preview/OpenPromptBanner", () => ({ vi.mock("./components/settings/UpdateServerDialog", () => ({ RemoteUpdateConfirmationCoordinator: () => null, })); +vi.mock("./components/hostDashboard/HostDashboardSheet", () => ({ + HostDashboardCoordinator: () => null, +})); vi.mock("./components/ServerReloadPrompt", () => ({ ServerReloadPrompt: () => null, })); diff --git a/apps/web/src/AppRoot.test.tsx b/apps/web/src/AppRoot.test.tsx index c9ac0f727c..0320523804 100644 --- a/apps/web/src/AppRoot.test.tsx +++ b/apps/web/src/AppRoot.test.tsx @@ -22,6 +22,7 @@ import { ThreadLifecycleReconciler } from "./ThreadLifecycleReconciler"; import { registerPreviewRuntimeCapabilities } from "./previewRuntimeCapabilities"; import { AppRoot, ProjectDataRecoveryCoordinator, ShareExposureReconciler } from "./AppRoot"; import { RemoteUpdateConfirmationCoordinator } from "./components/settings/UpdateServerDialog"; +import { HostDashboardCoordinator } from "./components/hostDashboard/HostDashboardSheet"; import { ServerReloadPrompt } from "./components/ServerReloadPrompt"; import { McpAuthorizationNotifier } from "./components/McpAuthorizationNotifier"; import { UiIntentNotifier } from "./components/UiIntentNotifier"; @@ -56,7 +57,7 @@ describe("AppRoot", () => { const children = Children.toArray( (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(12); + expect(children).toHaveLength(13); expect(isValidElement(children[0]) && children[0].type).toBe(ConnectionDatabaseRecoveryDialog); expect(isValidElement(children[1]) && children[1].type).toBe(ShareExposureReconciler); expect(isValidElement(children[2]) && children[2].type).toBe(ThreadLifecycleReconciler); @@ -65,16 +66,18 @@ describe("AppRoot", () => { expect(isValidElement(children[5]) && children[5].type).toBe( RemoteUpdateConfirmationCoordinator, ); - expectTopBannerStack(children[6], router); - expect(isValidElement(children[7]) && children[7].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[8]) && children[8].type).toBe(PreviewNewWindowRouter); - expect(isValidElement(children[9]) && children[9].type).toBe(OpenRequestRouter); + // The one Host dashboard sheet sits outside the router and navigates through it (#292). + expect(isValidElement(children[6]) && children[6].type).toBe(HostDashboardCoordinator); + expectTopBannerStack(children[7], router); + expect(isValidElement(children[8]) && children[8].type).toBe(PreviewAutomationHosts); + expect(isValidElement(children[9]) && children[9].type).toBe(PreviewNewWindowRouter); + expect(isValidElement(children[10]) && children[10].type).toBe(OpenRequestRouter); // An outside agent's UI suggestions raise notices outside the router, too. - expect(isValidElement(children[10]) && children[10].type).toBe(UiIntentNotifier); - expect((children[10] as ReactElement<{ router: AppRouter }>).props.router).toBe(router); - // So do a remote AI tool's requests to connect (#139, slice R). - expect(isValidElement(children[11]) && children[11].type).toBe(McpAuthorizationNotifier); + expect(isValidElement(children[11]) && children[11].type).toBe(UiIntentNotifier); expect((children[11] as ReactElement<{ router: AppRouter }>).props.router).toBe(router); + // So do a remote AI tool's requests to connect (#139, slice R). + expect(isValidElement(children[12]) && children[12].type).toBe(McpAuthorizationNotifier); + expect((children[12] as ReactElement<{ router: AppRouter }>).props.router).toBe(router); }); it("mounts preview automation hosts for a preview bridge without full automation", () => { @@ -94,8 +97,8 @@ describe("AppRoot", () => { (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(12); - expect(isValidElement(children[7]) && children[7].type).toBe(PreviewAutomationHosts); + expect(children).toHaveLength(13); + expect(isValidElement(children[8]) && children[8].type).toBe(PreviewAutomationHosts); }); it("mounts browser-mode automation hosts and open routing without a preview bridge", () => { @@ -106,10 +109,10 @@ describe("AppRoot", () => { (root as ReactElement<{ readonly children: ReactNode }>).props.children, ); - expect(children).toHaveLength(12); + expect(children).toHaveLength(13); expect(isValidElement(children[0]) && children[0].type).toBe(ConnectionDatabaseRecoveryDialog); expect(isValidElement(children[4]) && children[4].type).toBe(RouterProvider); - expect(isValidElement(children[7]) && children[7].type).toBe(PreviewAutomationHosts); - expect(isValidElement(children[9]) && children[9].type).toBe(OpenRequestRouter); + expect(isValidElement(children[8]) && children[8].type).toBe(PreviewAutomationHosts); + expect(isValidElement(children[10]) && children[10].type).toBe(OpenRequestRouter); }); }); diff --git a/apps/web/src/AppRoot.tsx b/apps/web/src/AppRoot.tsx index c74022bda4..71e3f713bb 100644 --- a/apps/web/src/AppRoot.tsx +++ b/apps/web/src/AppRoot.tsx @@ -19,6 +19,7 @@ import { useEnvironments } from "./state/environments"; import { isDesktopLocalConnectionTarget } from "./connection/desktopLocal"; import { useShareExposureReconciler } from "./state/shareExposureReconciler"; import { RemoteUpdateConfirmationCoordinator } from "./components/settings/UpdateServerDialog"; +import { HostDashboardCoordinator } from "./components/hostDashboard/HostDashboardSheet"; import { RemoteUpdateRequestNotifier } from "./components/RemoteUpdateRequestNotifier"; import { ServerReloadPrompt } from "./components/ServerReloadPrompt"; import { McpAuthorizationNotifier } from "./components/McpAuthorizationNotifier"; @@ -162,6 +163,9 @@ export function AppRoot({ router }: { readonly router: AppRouter }) { + void router.navigate({ to: "/settings/remote-servers" })} + /> {isDesktopHost ? ( { diff --git a/apps/web/src/browser/annotationTheme.test.ts b/apps/web/src/browser/annotationTheme.test.ts index 4d451a1caa..8c6c1318f9 100644 --- a/apps/web/src/browser/annotationTheme.test.ts +++ b/apps/web/src/browser/annotationTheme.test.ts @@ -1,4 +1,5 @@ -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { Window } from "happy-dom"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { readPreviewAnnotationTheme } from "./annotationTheme"; @@ -53,3 +54,72 @@ describe("readPreviewAnnotationTheme", () => { }); }); }); + +describe("annotation colors follow the applied theme (#290, R58)", () => { + /** A browser for the theme modules; import them after calling this. */ + async function installBrowser() { + const browser = new Window({ url: "https://bibcode.test/" }); + browser.localStorage.setItem("bibcode:theme", "dark"); + vi.stubGlobal("window", browser); + vi.stubGlobal("document", browser.document); + vi.stubGlobal("getComputedStyle", browser.getComputedStyle.bind(browser)); + vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { + callback(0); + return 1; + }); + vi.resetModules(); + return { + browser, + annotation: await import("./annotationTheme"), + store: await import("../themes/customThemes"), + theme: await import("../hooks/useTheme"), + setMode: (mode: string) => { + browser.localStorage.setItem("bibcode:theme", mode); + browser.dispatchEvent(new browser.StorageEvent("storage", { key: "bibcode:theme" })); + }, + }; + } + + afterEach(() => { + vi.unstubAllGlobals(); + vi.resetModules(); + }); + + it("the annotation reader returns the new primary after a theme change", async () => { + const { annotation, store } = await installBrowser(); + store.setSlot("dark", "tokyo-night"); + expect(annotation.readPreviewAnnotationTheme().primary).toBe("#7aa2f7"); + }); + + it("watchAnnotationTheme pushes once and again after a slot change, a mode change, a preview and a Cancel", async () => { + const { annotation, store, theme, setMode } = await installBrowser(); + const pushes: { colorScheme: string; primary: string }[] = []; + annotation.watchAnnotationTheme((colors) => pushes.push(colors)); + expect(pushes).toHaveLength(1); + + store.setSlot("dark", "tokyo-night"); + expect(pushes.at(-1)?.primary).toBe("#7aa2f7"); + setMode("light"); + expect(pushes.at(-1)?.colorScheme).toBe("light"); + theme.setThemePreview({ + version: 1, + id: "draft", + name: "Draft", + base: "light", + ui: { primary: "#087f5b" }, + }); + expect(pushes.at(-1)?.primary).toBe("#087f5b"); + theme.setThemePreview(null); + expect(pushes).toHaveLength(5); + expect(pushes.at(-1)?.primary).not.toBe("#087f5b"); + }); + + it("the returned function stops further pushes", async () => { + const { annotation, store } = await installBrowser(); + const push = vi.fn(); + const stop = annotation.watchAnnotationTheme(push); + stop(); + store.setSlot("dark", "tokyo-night"); + expect(push).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/browser/annotationTheme.ts b/apps/web/src/browser/annotationTheme.ts index 407817e265..ef3a8aafaf 100644 --- a/apps/web/src/browser/annotationTheme.ts +++ b/apps/web/src/browser/annotationTheme.ts @@ -1,5 +1,7 @@ import type { DesktopPreviewAnnotationTheme } from "@bibcode/contracts"; +import { subscribeAppliedTheme } from "../hooks/useTheme"; + const readVariable = (styles: CSSStyleDeclaration, name: string, fallback: string): string => styles.getPropertyValue(name).trim() || fallback; @@ -26,3 +28,14 @@ export function readPreviewAnnotationTheme(): DesktopPreviewAnnotationTheme { fontMono: readVariable(styles, "--font-mono", "ui-monospace, monospace"), }; } + +/** + * Pushes the annotation colors now and after every effective theme application (R58). Both + * preview bridges ignore them today (spec amendment A1). + */ +export function watchAnnotationTheme( + push: (theme: DesktopPreviewAnnotationTheme) => void, +): () => void { + push(readPreviewAnnotationTheme()); + return subscribeAppliedTheme(() => push(readPreviewAnnotationTheme())); +} diff --git a/apps/web/src/centerPanelActions.test.ts b/apps/web/src/centerPanelActions.test.ts index bbb2c909d0..6830a27abb 100644 --- a/apps/web/src/centerPanelActions.test.ts +++ b/apps/web/src/centerPanelActions.test.ts @@ -927,3 +927,109 @@ describe("side chats", () => { expect(h.drafts.get(hostRef.threadId)?.prompt).toBe(`${submittedText} and more`); }); }); + +describe("prompted chat panels (code reviews)", () => { + const claude = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "opus" }; + const prompt = "Code review requested from BiBCode. Do not edit files. Scope: x.\n\nReview."; + const review = () => ({ + hostRef, + projectId: ProjectId.make("project-1"), + modelSelection: claude, + providerLabel: "Claude", + label: "Code review · Claude", + prompt, + runtimeMode: "approval-required" as const, + interactionMode: "plan" as const, + codeReview: true, + }); + const panelRef = { environmentId: hostRef.environmentId, threadId: "new-panel-thread" }; + + it("creates a review panel with its modes and flag, then sends the prompt (R95)", async () => { + const actions = useCenterPanelActions({ onCloseTerminal }); + + await expect(actions.createPromptedChatPanel(review())).resolves.toBe("new-panel-thread"); + + expect(h.createPanel).toHaveBeenCalledOnce(); + expect(h.createPanel).toHaveBeenCalledWith({ + environmentId: hostRef.environmentId, + input: { + commandId: "command-panel-create", + hostThreadId: hostRef.threadId, + threadId: "new-panel-thread", + title: "Panel: Code review · Claude", + threadDefaults: { + modelSelection: claude, + runtimeMode: "approval-required", + interactionMode: "plan", + }, + codeReview: true, + }, + }); + expect(h.reserveChatPanel).toHaveBeenCalledWith( + hostRef, + "new-panel-thread", + "Code review · Claude", + ); + expect(h.startTurn).toHaveBeenCalledOnce(); + expect(h.startTurn).toHaveBeenCalledWith({ + environmentId: hostRef.environmentId, + input: { + threadId: "new-panel-thread", + message: { messageId: "side-question", role: "user", text: prompt, attachments: [] }, + modelSelection: claude, + runtimeMode: "approval-required", + interactionMode: "plan", + }, + }); + }); + + it("creates nothing more while a review for the same host is pending", async () => { + const creation = deferred(); + h.createPanel.mockReturnValueOnce(creation.promise); + const actions = useCenterPanelActions({ onCloseTerminal }); + + const opening = actions.createPromptedChatPanel(review()); + await flush(); + await expect(actions.createPromptedChatPanel(review())).resolves.toBeNull(); + expect(h.createPanel).toHaveBeenCalledOnce(); + + creation.resolve({ _tag: "Success", value: undefined }); + await opening; + expect(h.startTurn).toHaveBeenCalledOnce(); + }); + + it("puts the prompt in the panel's composer when the first message is refused", async () => { + h.startTurn.mockResolvedValueOnce({ _tag: "Failure", cause: new Error("offline") }); + const actions = useCenterPanelActions({ onCloseTerminal }); + + await actions.createPromptedChatPanel(review()); + + expect(h.drafts.get("new-panel-thread")?.prompt).toBe(prompt); + expect(h.addToast).toHaveBeenCalledWith( + expect.objectContaining({ + title: "Couldn't start the review", + description: "The review prompt is in its composer.", + }), + ); + }); + + it("sends nothing when the tab closed while the panel was created", async () => { + const creation = deferred(); + h.createPanel.mockReturnValueOnce(creation.promise); + const actions = useCenterPanelActions({ onCloseTerminal }); + const opening = actions.createPromptedChatPanel(review()); + await flush(); + const surface = { id: "chat:new-panel-thread", kind: "chat", threadId: panelRef.threadId }; + h.closeSurface.mockReturnValueOnce([surface]); + actions.closeSurface(hostRef, "group-left", surface as never); + creation.resolve({ _tag: "Success", value: undefined }); + await opening; + await flush(); + + expect(h.startTurn).not.toHaveBeenCalled(); + expect(h.archiveThread).toHaveBeenCalledWith({ + environmentId: hostRef.environmentId, + input: { threadId: "new-panel-thread" }, + }); + }); +}); diff --git a/apps/web/src/centerPanelActions.ts b/apps/web/src/centerPanelActions.ts index 5df39fb335..7e8de8cc86 100644 --- a/apps/web/src/centerPanelActions.ts +++ b/apps/web/src/centerPanelActions.ts @@ -30,6 +30,8 @@ import type { CommandId, ModelSelection, ProjectId, + ProviderInteractionMode, + RuntimeMode, ScopedThreadRef, ThreadId, } from "@bibcode/contracts"; @@ -77,6 +79,19 @@ export interface CreateSideChatInput extends CreateChatPanelInput { readonly submittedText?: string; } +/** A chat panel that starts with a prompt and its own modes: a code review (#293). */ +export interface CreatePromptedChatPanelInput extends CreateChatPanelInput { + /** The tab label; the thread title is this behind the panel prefix. */ + readonly label: string; + readonly prompt: string; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; + readonly codeReview: boolean; +} + +export const CODE_REVIEW_SEND_FAILED_TITLE = "Couldn't start the review"; +export const CODE_REVIEW_SEND_FAILED_DESCRIPTION = "The review prompt is in its composer."; + export const SIDE_CHAT_SEND_FAILED_TITLE = "Couldn't send to the side chat"; export const SIDE_CHAT_SEND_FAILED_DESCRIPTION = "Your question is in its composer."; @@ -85,6 +100,30 @@ export const sideChatLabel = (providerLabel: string) => `Side chat · ${provider /** Hosts with a side chat being created, so a second Enter on the same `:btw` opens nothing. */ const pendingSideChatHosts = new Set(); +/** Hosts with a prompted panel (a review) being created, so a double click opens one. */ +const pendingPromptedPanelHosts = new Set(); + +/** What a new panel thread starts with, and the flags the server keeps on it. */ +interface PanelThreadKind { + readonly sideChat: boolean; + readonly codeReview: boolean; + readonly runtimeMode: RuntimeMode; + readonly interactionMode: ProviderInteractionMode; +} + +const PLAIN_PANEL: PanelThreadKind = { + sideChat: false, + codeReview: false, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, +}; +// A side chat asks before it edits the host's checkout; its picker can change it. +const SIDE_CHAT_PANEL: PanelThreadKind = { + sideChat: true, + codeReview: false, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, +}; /** * Each creation this page waits on, by scoped thread key: `closed` once its tab closed. The @@ -154,6 +193,12 @@ export interface CenterPanelActions extends PanelThreadOpener { * creation failed and was shown). */ createSideChat: (input: CreateSideChatInput) => Promise; + /** + * Opens a chat panel of `hostRef` with the given modes and sends `prompt` there. Resolves the + * panel's thread, or `null` when none opened (one is already being created for this host, or + * the creation failed and was shown). + */ + createPromptedChatPanel: (input: CreatePromptedChatPanelInput) => Promise; /** * Continues a terminal session in a chat panel of `hostRef`, which becomes the active tab when * the panel is `hostRef`'s. Resolves where the session landed, or `null` once a failure was @@ -330,9 +375,10 @@ export function useCenterPanelActions({ input: CreateChatPanelInput, panelRef: ScopedThreadRef, label: string, - sideChat: boolean, + kind: PanelThreadKind, ): Promise<{ readonly threadId: ThreadId; readonly created: boolean } | null> => { const { hostRef, modelSelection } = input; + const { sideChat, codeReview } = kind; const { environmentId, threadId } = panelRef; const result = await createPanel({ environmentId, @@ -343,11 +389,11 @@ export function useCenterPanelActions({ title: `Panel: ${label}`, threadDefaults: { modelSelection, - // A side chat asks before it edits the host's checkout; its picker can change it. - runtimeMode: sideChat ? "approval-required" : DEFAULT_RUNTIME_MODE, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: kind.runtimeMode, + interactionMode: kind.interactionMode, }, ...(sideChat ? { sideChat: true } : {}), + ...(codeReview ? { codeReview: true } : {}), }, }); const store = useCenterPanelStore.getState(); @@ -384,77 +430,127 @@ export function useCenterPanelActions({ const createPanelThread = useCallback( async ( input: CreateChatPanelInput, - sideChat: boolean, + kind: PanelThreadKind, + label: string, focusComposer = false, ): Promise<{ readonly threadId: ThreadId; readonly created: boolean } | null> => { - const { hostRef, providerLabel } = input; + const { hostRef } = input; const environmentId = hostRef.environmentId; const threadId = newThreadId(); const panelRef = { environmentId, threadId } satisfies ScopedThreadRef; - const label = sideChat ? sideChatLabel(providerLabel) : providerLabel; useCenterPanelStore.getState().reserveChatPanel(hostRef, threadId, label); const key = scopedThreadKey(panelRef); creations.set(key, { closed: false }); if (focusComposer) composerFocusRequests.add(key); - return settlePanelCreation(input, panelRef, label, sideChat).finally(() => - creations.delete(key), - ); + return settlePanelCreation(input, panelRef, label, kind).finally(() => creations.delete(key)); }, [settlePanelCreation], ); const createChatPanel = useCallback( async (input: CreateChatPanelInput): Promise => - (await createPanelThread(input, false))?.threadId ?? null, + (await createPanelThread(input, PLAIN_PANEL, input.providerLabel))?.threadId ?? null, [createPanelThread], ); + /** + * Sends a new panel's first message. A refused send puts the text in the panel's composer + * and shows `failure`; resolves false only when the outcome is unknown. + */ + const sendFirstPanelMessage = useCallback( + async ( + input: CreateChatPanelInput, + panelThreadId: ThreadId, + text: string, + kind: PanelThreadKind, + failure: { readonly title: string; readonly description: string }, + ): Promise => { + const panelRef = scopeThreadRef(input.hostRef.environmentId, panelThreadId); + // The panel's own draft is never written on success: what the user types or attaches + // there meanwhile stays. + markChatPanelSent(panelRef); + const result = await startTurn({ + environmentId: input.hostRef.environmentId, + input: { + threadId: panelThreadId, + message: { messageId: newMessageId(), role: "user", text, attachments: [] }, + modelSelection: input.modelSelection, + runtimeMode: kind.runtimeMode, + interactionMode: kind.interactionMode, + }, + }); + if (result._tag === "Failure" && isAtomCommandInterrupted(result)) return false; + if (result._tag === "Failure") { + const drafts = useComposerDraftStore.getState(); + const current = drafts.getComposerDraft(panelRef)?.prompt ?? ""; + drafts.setPrompt( + panelRef, + current === text ? current : [current, text].filter(Boolean).join("\n\n"), + ); + toastManager.add(stackedThreadToast({ type: "error", ...failure })); + } + return true; + }, + [startTurn], + ); + const openSideChat = useCallback( async (input: CreateSideChatInput): Promise => { - const { hostRef, question, submittedText } = input; + const { hostRef, question, submittedText, providerLabel } = input; // An empty side chat is opened to type in. - const panel = await createPanelThread(input, true, question.length === 0); + const panel = await createPanelThread( + input, + SIDE_CHAT_PANEL, + sideChatLabel(providerLabel), + question.length === 0, + ); // Not created, or closed meanwhile: nothing is sent and the host draft keeps the text. if (!panel?.created) return panel?.threadId ?? null; - const sideRef = scopeThreadRef(hostRef.environmentId, panel.threadId); if (question.length > 0) { - // The side chat's own draft is never written on success: what the user types or - // attaches there meanwhile stays. - markChatPanelSent(sideRef); - const result = await startTurn({ - environmentId: hostRef.environmentId, - input: { - threadId: panel.threadId, - message: { messageId: newMessageId(), role: "user", text: question, attachments: [] }, - modelSelection: input.modelSelection, - runtimeMode: "approval-required", - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - }, - }); + const settled = await sendFirstPanelMessage( + input, + panel.threadId, + question, + SIDE_CHAT_PANEL, + { title: SIDE_CHAT_SEND_FAILED_TITLE, description: SIDE_CHAT_SEND_FAILED_DESCRIPTION }, + ); // The send may have landed: the `:btw` text stays in the host draft, nothing is lost. - if (result._tag === "Failure" && isAtomCommandInterrupted(result)) { - return panel.threadId; - } - if (result._tag === "Failure") { - const drafts = useComposerDraftStore.getState(); - const current = drafts.getComposerDraft(sideRef)?.prompt ?? ""; - drafts.setPrompt( - sideRef, - current === question ? current : [current, question].filter(Boolean).join("\n\n"), - ); - toastManager.add( - stackedThreadToast({ - type: "error", - title: SIDE_CHAT_SEND_FAILED_TITLE, - description: SIDE_CHAT_SEND_FAILED_DESCRIPTION, - }), - ); - } + if (!settled) return panel.threadId; } if (submittedText !== undefined) clearSubmittedHostDraft(hostRef, submittedText); return panel.threadId; }, - [createPanelThread, startTurn], + [createPanelThread, sendFirstPanelMessage], + ); + + const openPromptedChatPanel = useCallback( + async (input: CreatePromptedChatPanelInput): Promise => { + const kind: PanelThreadKind = { + sideChat: false, + codeReview: input.codeReview, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }; + const panel = await createPanelThread(input, kind, input.label); + // Not created, or closed meanwhile: the prompt is not sent. + if (!panel?.created) return panel?.threadId ?? null; + await sendFirstPanelMessage(input, panel.threadId, input.prompt, kind, { + title: CODE_REVIEW_SEND_FAILED_TITLE, + description: CODE_REVIEW_SEND_FAILED_DESCRIPTION, + }); + return panel.threadId; + }, + [createPanelThread, sendFirstPanelMessage], + ); + + const createPromptedChatPanel = useCallback( + async (input: CreatePromptedChatPanelInput): Promise => { + const hostKey = scopedThreadKey(input.hostRef); + if (pendingPromptedPanelHosts.has(hostKey)) return null; + pendingPromptedPanelHosts.add(hostKey); + return openPromptedChatPanel(input).finally(() => pendingPromptedPanelHosts.delete(hostKey)); + }, + [openPromptedChatPanel], ); const createSideChat = useCallback( @@ -587,6 +683,7 @@ export function useCenterPanelActions({ return { createChatPanel, createSideChat, + createPromptedChatPanel, reopenChatPanel, openPanelThread, openSessionPanel, diff --git a/apps/web/src/codeReview.test.ts b/apps/web/src/codeReview.test.ts new file mode 100644 index 0000000000..4ccb3d08f1 --- /dev/null +++ b/apps/web/src/codeReview.test.ts @@ -0,0 +1,266 @@ +import { + DEFAULT_CODE_REVIEW_PROMPT, + DEFAULT_SERVER_SETTINGS, + ProviderDriverKind, + ProviderInstanceId, + type ServerProvider, +} from "@bibcode/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + CODE_REVIEW_INSTRUCTIONS_MAX_LENGTH, + codeReviewGuardLine, + codeReviewScopeText, + resolveCodeReviewChatModes, + resolveCodeReviewModelSelection, + resolveCodeReviewPrompt, + resolveSavedCodeReviewer, +} from "./codeReview"; +import { buildProviderAgentActions } from "./components/chat/providerAgentActions"; +import { deriveProviderInstanceEntries } from "./providerInstances"; + +const BRANCH = { kind: "branch", base: "origin/main" } as const; +const UNCOMMITTED = { kind: "uncommitted", base: "origin/main" } as const; +const BRANCH_LEAD = + "Code review requested from BiBCode. Do not edit files. Scope: the commits on the current branch that are not on origin/main, plus all staged, unstaged and untracked changes. Base: origin/main."; +const UNCOMMITTED_LEAD = + "Code review requested from BiBCode. Do not edit files. Scope: the staged, unstaged and untracked changes in the working tree."; + +describe("codeReviewScopeText", () => { + it("names both scopes", () => { + expect(codeReviewScopeText(BRANCH)).toBe( + "the commits on the current branch that are not on origin/main, plus all staged, unstaged and untracked changes", + ); + expect(codeReviewScopeText(UNCOMMITTED)).toBe( + "the staged, unstaged and untracked changes in the working tree", + ); + }); +}); + +describe("resolveCodeReviewPrompt", () => { + it("puts the project prompt behind the lead-in when all three are set (R92)", () => { + expect( + resolveCodeReviewPrompt({ + project: "Check our test naming. Base is {base}.", + user: "User text", + scope: BRANCH, + }), + ).toBe(`${BRANCH_LEAD}\n\nCheck our test naming. Base is origin/main.`); + }); + + it("uses the user prompt when the project prompt is blank", () => { + expect(resolveCodeReviewPrompt({ project: "", user: "User text", scope: BRANCH })).toBe( + `${BRANCH_LEAD}\n\nUser text`, + ); + }); + + it("falls through whitespace-only values to the default", () => { + const expectedDefault = DEFAULT_CODE_REVIEW_PROMPT.replaceAll( + "{scope}", + codeReviewScopeText(BRANCH), + ).replaceAll("{base}", "origin/main"); + expect(resolveCodeReviewPrompt({ project: " ", user: "", scope: BRANCH })).toBe( + `${BRANCH_LEAD}\n\n${expectedDefault}`, + ); + expect(resolveCodeReviewPrompt({ project: "\n\t ", user: " ", scope: BRANCH })).toBe( + `${BRANCH_LEAD}\n\n${expectedDefault}`, + ); + }); + + it("leaves Base out of the lead-in for the uncommitted scope", () => { + expect( + resolveCodeReviewPrompt({ project: "Scope {scope}", user: "", scope: UNCOMMITTED }), + ).toBe( + `${UNCOMMITTED_LEAD}\n\nScope the staged, unstaged and untracked changes in the working tree`, + ); + }); + + it("falls back to HEAD for a missing base", () => { + expect( + resolveCodeReviewPrompt({ + project: "diff {base}", + user: "", + scope: { kind: "uncommitted", base: null }, + }), + ).toBe(`${UNCOMMITTED_LEAD}\n\ndiff HEAD`); + }); + + it("replaces every placeholder once, without reading $ patterns in the base", () => { + expect( + resolveCodeReviewPrompt({ + project: "{base} {base} {scope} {base}", + user: "", + scope: { kind: "uncommitted", base: "origin/a$&b" }, + }), + ).toBe( + `${UNCOMMITTED_LEAD}\n\norigin/a$&b origin/a$&b the staged, unstaged and untracked changes in the working tree origin/a$&b`, + ); + }); + + it("starts with the lead-in when the project prompt is a CLI command word", () => { + const resolved = resolveCodeReviewPrompt({ project: "purge", user: "", scope: BRANCH }); + expect(resolved.startsWith("Code review requested from BiBCode.")).toBe(true); + expect(resolved).toBe(`${BRANCH_LEAD}\n\npurge`); + }); + + it("appends one-off instructions after a blank line, without expanding them (R105)", () => { + expect( + resolveCodeReviewPrompt({ + project: "Rules for {base}.", + user: "", + scope: BRANCH, + extra: "Focus on the parser. Not {base}.", + }), + ).toBe(`${BRANCH_LEAD}\n\nRules for origin/main.\n\nFocus on the parser. Not {base}.`); + expect( + resolveCodeReviewPrompt({ project: "Rules", user: "", scope: BRANCH, extra: " \n " }), + ).toBe(`${BRANCH_LEAD}\n\nRules`); + expect(CODE_REVIEW_INSTRUCTIONS_MAX_LENGTH).toBe(1_000); + }); +}); + +describe("resolveCodeReviewChatModes", () => { + it("uses plan only for Claude and approval everywhere", () => { + expect(resolveCodeReviewChatModes(ProviderDriverKind.make("claudeAgent"))).toEqual({ + runtimeMode: "approval-required", + interactionMode: "plan", + }); + for (const driver of ["codex", "cursor", "grok", "opencode"]) { + expect(resolveCodeReviewChatModes(ProviderDriverKind.make(driver))).toEqual({ + runtimeMode: "approval-required", + interactionMode: "default", + }); + } + }); +}); + +describe("codeReviewGuardLine", () => { + const line = (kind: "chat" | "terminal", driver: string) => + codeReviewGuardLine({ kind, driver: ProviderDriverKind.make(driver) }); + + it("returns the design note's line for each of the seven reviewers", () => { + expect(line("chat", "claudeAgent")).toBe("Starts in Claude's plan mode."); + expect(line("chat", "codex")).toBe( + "Runs in Codex's read-only sandbox. Writes ask for your approval.", + ); + expect(line("chat", "opencode")).toBe("Edits and commands ask for your approval."); + expect(line("chat", "cursor")).toBe( + "Told not to edit. BiBCode cannot block edits for this agent.", + ); + expect(line("chat", "grok")).toBe( + "Told not to edit. BiBCode cannot block edits for this agent.", + ); + expect(line("terminal", "claudeAgent")).toBe("Starts in Claude's plan mode."); + expect(line("terminal", "codex")).toBe("Runs in Codex's read-only sandbox."); + }); + + it("never promises that a review cannot edit", () => { + for (const kind of ["chat", "terminal"] as const) { + for (const driver of ["claudeAgent", "codex", "opencode", "cursor", "grok", "other"]) { + const text = line(kind, driver); + expect(text).not.toMatch(/blocks|cannot edit/i); + expect(text).not.toContain("—"); + } + } + }); + + it("treats an unknown driver as instructed only", () => { + expect(line("chat", "other")).toBe( + "Told not to edit. BiBCode cannot block edits for this agent.", + ); + }); +}); + +function provider(instanceId: string, driver: string, displayName: string): ServerProvider { + return { + instanceId: ProviderInstanceId.make(instanceId), + driver, + displayName, + enabled: true, + installed: true, + status: "ready", + availability: "available", + models: [], + } as unknown as ServerProvider; +} + +describe("resolveSavedCodeReviewer", () => { + const settings = { + ...DEFAULT_SERVER_SETTINGS, + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, enabled: false }, + }, + }; + const providers = [ + provider("claudeAgent", "claudeAgent", "Claude"), + provider("cursor", "cursor", "Cursor"), + ]; + const actions = buildProviderAgentActions(providers, settings); + const known = deriveProviderInstanceEntries(providers); + + it("never falls back from a disabled saved reviewer (R90)", () => { + expect( + resolveSavedCodeReviewer( + actions, + { kind: "chat", instanceId: ProviderInstanceId.make("claudeAgent") }, + known, + ), + ).toEqual({ + action: null, + reason: "Claude is not available. Change the code review agent in Settings → Agents.", + }); + }); + + it("names a listed reviewer that is not ready", () => { + const notReady = buildProviderAgentActions( + [{ ...provider("codex", "codex", "Codex"), status: "error" } as ServerProvider], + DEFAULT_SERVER_SETTINGS, + ); + expect( + resolveSavedCodeReviewer(notReady, { + kind: "terminal", + instanceId: ProviderInstanceId.make("codex"), + }), + ).toEqual({ + action: null, + reason: "Codex Terminal is not available. Change the code review agent in Settings → Agents.", + }); + }); + + it("names an unlisted reviewer", () => { + expect( + resolveSavedCodeReviewer( + actions, + { kind: "chat", instanceId: ProviderInstanceId.make("codex") }, + known, + ), + ).toEqual({ + action: null, + reason: + "The code review agent is not available on this environment. Change it in Settings → Agents.", + }); + }); + + it("matches kind and instance exactly", () => { + const resolved = resolveSavedCodeReviewer(actions, { + kind: "chat", + instanceId: ProviderInstanceId.make("cursor"), + }); + expect(resolved.reason).toBeNull(); + expect(resolved.action?.kind).toBe("chat"); + expect(resolved.action?.entry.instanceId).toBe("cursor"); + }); +}); + +describe("resolveCodeReviewModelSelection", () => { + const stored = { instanceId: ProviderInstanceId.make("claudeAgent"), model: "opus" }; + + it("keeps the stored model only for its own instance", () => { + expect(resolveCodeReviewModelSelection(stored, ProviderInstanceId.make("claudeAgent"))).toBe( + stored, + ); + expect(resolveCodeReviewModelSelection(stored, ProviderInstanceId.make("codex"))).toBeNull(); + expect(resolveCodeReviewModelSelection(null, ProviderInstanceId.make("codex"))).toBeNull(); + }); +}); diff --git a/apps/web/src/codeReview.ts b/apps/web/src/codeReview.ts new file mode 100644 index 0000000000..f878c57355 --- /dev/null +++ b/apps/web/src/codeReview.ts @@ -0,0 +1,151 @@ +import { + DEFAULT_CODE_REVIEW_PROMPT, + type DefaultAgentSelection, + type ModelSelection, + type ProviderDriverKind, + type ProviderInstanceId, +} from "@bibcode/contracts"; + +import type { ProviderAgentAction } from "./components/chat/providerAgentActions"; +import { CODE_REVIEW_DRIVERS_WITH_TERMINALS } from "./components/chat/providerTerminalActions"; +import type { ProviderInstanceEntry } from "./providerInstances"; +import type { CodeReviewScope } from "./components/SourceControlPanel.logic"; + +export function codeReviewScopeText(scope: CodeReviewScope): string { + return scope.kind === "branch" + ? `the commits on the current branch that are not on ${scope.base ?? "HEAD"}, plus all staged, unstaged and untracked changes` + : "the staged, unstaged and untracked changes in the working tree"; +} + +/** + * The fixed lead-in keeps a saved prompt from ever reaching a CLI as its first token and keeps + * the scope and "do not edit" line in every review (design note, "Prompt lead-in"). + */ +export const CODE_REVIEW_INSTRUCTIONS_MAX_LENGTH = 1_000; + +export function resolveCodeReviewPrompt(input: { + readonly project: string; + readonly user: string; + readonly scope: CodeReviewScope; + /** One review's added instructions (R105), appended after the placeholders are replaced. */ + readonly extra?: string; +}): string { + const scopeText = codeReviewScopeText(input.scope); + const base = input.scope.base ?? "HEAD"; + const lead = + `Code review requested from BiBCode. Do not edit files. Scope: ${scopeText}.` + + (input.scope.kind === "branch" ? ` Base: ${base}.` : ""); + const body = + [input.project, input.user].find((value) => value.trim().length > 0) ?? + DEFAULT_CODE_REVIEW_PROMPT; + // One pass with a replacer: a ref name may hold `$`, and replaced text is never re-expanded. + const resolved = body.replace(/\{scope\}|\{base\}/g, (match) => + match === "{scope}" ? scopeText : base, + ); + const extra = input.extra?.trim() ? `\n\n${input.extra}` : ""; + return `${lead}\n\n${resolved}${extra}`; +} + +export function resolveCodeReviewChatModes(driver: ProviderDriverKind): { + readonly runtimeMode: "approval-required"; + readonly interactionMode: "plan" | "default"; +} { + return { + runtimeMode: "approval-required", + interactionMode: driver === "claudeAgent" ? "plan" : "default", + }; +} + +const INSTRUCTED_GUARD_LINE = "Told not to edit. BiBCode cannot block edits for this agent."; + +/** The mechanism each reviewer starts with; never a promise of the outcome (design note table). */ +export function codeReviewGuardLine(reviewer: { + readonly kind: "chat" | "terminal"; + readonly driver: ProviderDriverKind; +}): string { + if (reviewer.driver === "claudeAgent") return "Starts in Claude's plan mode."; + if (reviewer.driver === "codex") { + return reviewer.kind === "terminal" + ? "Runs in Codex's read-only sandbox." + : "Runs in Codex's read-only sandbox. Writes ask for your approval."; + } + if (reviewer.driver === "opencode" && reviewer.kind === "chat") { + return "Edits and commands ask for your approval."; + } + return INSTRUCTED_GUARD_LINE; +} + +export const CODE_REVIEW_SERVER_UPDATE_REASON = + "Update the BiBCode server on this environment to use Review."; +export const CODE_REVIEW_DRAFT_REASON = "Send a first message in this chat, then review from here."; +export const CODE_REVIEW_UNLISTED_REASON = + "The code review agent is not available on this environment. Change it in Settings → Agents."; + +const unavailableReviewerReason = (label: string) => + `${label} is not available. Change the code review agent in Settings → Agents.`; + +/** + * Exact match only: a fallback would silently change the provider and its guard (D48). + * `knownEntries` are every instance the environment reports, including the disabled ones that + * `actions` leave out, so a disabled reviewer is named instead of called unlisted. + */ +export function resolveSavedCodeReviewer( + actions: ReadonlyArray, + selection: DefaultAgentSelection, + knownEntries: ReadonlyArray> = [], +): + | { readonly action: ProviderAgentAction; readonly reason: null } + | { readonly action: null; readonly reason: string } { + const saved = actions.find( + (action) => action.kind === selection.kind && action.entry.instanceId === selection.instanceId, + ); + if (saved && !saved.disabled) return { action: saved, reason: null }; + if (saved) return { action: null, reason: unavailableReviewerReason(saved.label) }; + const known = knownEntries.find((entry) => entry.instanceId === selection.instanceId); + if (known) { + const label = + selection.kind === "terminal" ? `${known.displayName} Terminal` : known.displayName; + return { action: null, reason: unavailableReviewerReason(label) }; + } + return { action: null, reason: CODE_REVIEW_UNLISTED_REASON }; +} + +/** The reviewers Settings and the review menu offer: chats, and terminals only for the CLIs whose read-only flags are verified (design note). */ +export function codeReviewerActions( + actions: ReadonlyArray, +): ReadonlyArray { + return actions.filter( + (action) => + action.kind === "chat" || CODE_REVIEW_DRIVERS_WITH_TERMINALS.has(action.entry.driverKind), + ); +} + +/** A reviewer the Source Control review menu offers for one review (R103). */ +export interface CodeReviewMenuReviewer { + readonly value: string; + readonly label: string; + readonly selection: DefaultAgentSelection; + readonly guardLine: string; + readonly disabledReason: string | null; +} + +export function buildCodeReviewMenuReviewers( + actions: ReadonlyArray, + reasonFor: (action: ProviderAgentAction) => string | null, +): ReadonlyArray { + return codeReviewerActions(actions).map((action) => ({ + value: action.value, + label: action.label, + selection: action.selection, + guardLine: codeReviewGuardLine({ kind: action.kind, driver: action.entry.driverKind }), + disabledReason: reasonFor(action), + })); +} + +/** The stored review model applies only to its own instance (D49). */ +export function resolveCodeReviewModelSelection( + stored: ModelSelection | null, + instanceId: ProviderInstanceId, +): ModelSelection | null { + return stored?.instanceId === instanceId ? stored : null; +} diff --git a/apps/web/src/components/CenterPanelTabs.tsx b/apps/web/src/components/CenterPanelTabs.tsx index bc3477172c..3059e37d12 100644 --- a/apps/web/src/components/CenterPanelTabs.tsx +++ b/apps/web/src/components/CenterPanelTabs.tsx @@ -150,7 +150,7 @@ function SortableCenterTab({ className={cn( "group flex h-7 min-w-25 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-sm", active - ? "bg-accent text-foreground" + ? "bg-selected text-selected-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", isDragging && "opacity-40", )} diff --git a/apps/web/src/components/ChatView.hooks.test.tsx b/apps/web/src/components/ChatView.hooks.test.tsx index 192058b414..35f600e009 100644 --- a/apps/web/src/components/ChatView.hooks.test.tsx +++ b/apps/web/src/components/ChatView.hooks.test.tsx @@ -385,6 +385,7 @@ vi.mock("../keybindings", () => ({ shortcutLabelForCommand: () => h.shortcutLabel, // Find in thread opens only for a resolved chat.find here; its fallback has its own tests. shouldOpenChatFind: (command: string | null) => command === "chat.find", + shouldStartCodeReview: (command: string | null) => command === "sourceControl.review", })); vi.mock("../commandPaletteContext", () => ({ @@ -759,6 +760,7 @@ function makeProject(overrides: Partial = {}): Project { repositoryIdentity: null, defaultModelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, scripts: [], + codeReviewPrompt: "", worktreeDiscovery: { visibility: "hidden", initialPromptDismissedAt: null, baselinePaths: [] }, createdAt: now, updatedAt: now, diff --git a/apps/web/src/components/ChatView.test.tsx b/apps/web/src/components/ChatView.test.tsx index da768b9be9..f9d0fce6bf 100644 --- a/apps/web/src/components/ChatView.test.tsx +++ b/apps/web/src/components/ChatView.test.tsx @@ -75,6 +75,10 @@ const h = vi.hoisted(() => { captured: {} as Record, /** Renders the real ChatComposer and ThreadErrorBanner behind their capturing mocks. */ realChildren: false, + /** With `realChildren`, also renders the real model picker instead of its stub. */ + realModelPicker: false, + /** With `realChildren`, also renders the real provider status banner and composer banners. */ + realStatusBanners: false, realTimeline: false, /** The list the mocked timeline hands ChatView through `listRef`, as LegendList does. */ timelineList: null as unknown, @@ -246,9 +250,14 @@ vi.mock("./device/deviceAvailability", () => ({ })); // What the real ChatComposer reads when `h.realChildren` renders it; ChatView reads none of it. -vi.mock("./chat/ProviderModelPicker", () => ({ - ProviderModelPicker: () =>
, -})); +vi.mock("./chat/ProviderModelPicker", async (importOriginal) => { + const actual = await importOriginal(); + const RealPicker = actual.ProviderModelPicker as unknown as (props: object) => ReactNode; + return { + ProviderModelPicker: (props: Record) => + h.realModelPicker ? :
, + }; +}); vi.mock("../state/providerCapabilities", () => ({ useProviderCapabilities: () => ({ data: { slashCommands: [], skills: [], agents: [], issues: [] }, @@ -399,6 +408,7 @@ vi.mock("../hooks/useSettings", () => ({ // Read by the real composer's model picker when `h.realChildren` renders it. useClientSettings: (selector?: (settings: Record) => unknown) => selector ? selector(h.settings) : h.settings, + useUpdateClientSettings: () => () => undefined, })); vi.mock("../assets/assetUrls", () => ({ @@ -594,12 +604,20 @@ vi.mock("./chat/PanelLayoutControls", () => ({ }, })); -vi.mock("./chat/ProviderStatusBanner", () => ({ - ProviderStatusBanner: (props: Record) => { - h.captured["providerStatusBanner"] = props; - return
; - }, -})); +vi.mock("./chat/ProviderStatusBanner", async (importOriginal) => { + const actual = await importOriginal(); + const RealBanner = actual.ProviderStatusBanner as unknown as (props: object) => ReactNode; + return { + ProviderStatusBanner: (props: Record) => { + h.captured["providerStatusBanner"] = props; + return h.realStatusBanners ? ( + + ) : ( +
+ ); + }, + }; +}); vi.mock("./chat/ThreadErrorBanner", async (importOriginal) => { const actual = await importOriginal(); @@ -617,12 +635,20 @@ vi.mock("./chat/ThreadErrorBanner", async (importOriginal) => { }; }); -vi.mock("./chat/ComposerBannerStack", () => ({ - ComposerBannerStack: (props: Record) => { - h.captured["composerBannerStack"] = props; - return
; - }, -})); +vi.mock("./chat/ComposerBannerStack", async (importOriginal) => { + const actual = await importOriginal(); + const RealStack = actual.ComposerBannerStack as unknown as (props: object) => ReactNode; + return { + ComposerBannerStack: (props: Record) => { + h.captured["composerBannerStack"] = props; + return h.realStatusBanners ? ( + + ) : ( +
+ ); + }, + }; +}); vi.mock("./PullRequestThreadDialog", () => ({ PullRequestThreadDialog: (props: Record) => { @@ -749,7 +775,10 @@ vi.mock("./DiffPanel", () => ({ }, })); vi.mock("./SourceControlPanel", () => ({ - default: () =>
, + default: (props: Record) => { + h.captured["sourceControlPanel"] = props; + return
; + }, })); vi.mock("./files/FilePreviewPanel", () => { let nextInstanceId = 0; @@ -883,6 +912,7 @@ function makeProject(overrides: Partial = {}): Project { repositoryIdentity: null, defaultModelSelection: { instanceId: codexInstanceId, model: "gpt-5.4" }, scripts: [], + codeReviewPrompt: "", worktreeDiscovery: { visibility: "hidden", initialPromptDismissedAt: null, baselinePaths: [] }, createdAt: now, updatedAt: now, @@ -1084,6 +1114,8 @@ beforeEach(() => { h.openThreadFindTargets.length = 0; h.captured = {}; h.realChildren = false; + h.realModelPicker = false; + h.realStatusBanners = false; h.timelineList = null; h.atomValuesByKey.clear(); h.atomValuesByKey.set("atom:keybindings", []); @@ -3873,7 +3905,7 @@ describe("ChatView", () => { expect(capturedProps>("centerWorkspace")["hostLabel"]).toBe("Codex"); }); - it("updates the pre-start host tab label when the selected provider changes", () => { + it("keeps a never-started main chat on its provider when another provider is selected (#347)", () => { const claudeInstanceId = ProviderInstanceId.make("claude"); const claudeProvider: ServerProvider = { ...codexProvider, @@ -3896,7 +3928,7 @@ describe("ChatView", () => { renderServerRoute(); expect(capturedProps>("centerWorkspace")["hostLabel"]).toBe("Codex"); const initialComposer = capturedProps>("chatComposer"); - expect(initialComposer["lockProviderPickerToActiveInstance"]).toBe(false); + expect(initialComposer["lockProviderPickerToActiveInstance"]).toBe(true); const initialSurfaces = useCenterPanelStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.surfaces; @@ -3908,7 +3940,10 @@ describe("ChatView", () => { publishSeededStoreState(useComposerDraftStore); renderServerRoute(); - expect(capturedProps>("centerWorkspace")["hostLabel"]).toBe("Claude"); + expect(capturedProps>("centerWorkspace")["hostLabel"]).toBe("Codex"); + expect(useComposerDraftStore.getState().getComposerDraft(threadRef)?.activeProvider).not.toBe( + "claude", + ); expect( useCenterPanelStore.getState().byThreadKey[scopedThreadKey(threadRef)]?.surfaces, ).toEqual(initialSurfaces); @@ -5776,6 +5811,724 @@ describe("ChatView handlers (captured from mocked children)", () => { ).toBeUndefined(); }); + describe("a code review thread (R96)", () => { + const reviewTurnId = TurnId.make("turn-review"); + const planThread = (codeReview: boolean) => + makeThread({ + interactionMode: "plan", + ...(codeReview ? { codeReview: true } : {}), + latestTurn: { + turnId: reviewTurnId, + state: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + assistantMessageId: null, + }, + proposedPlans: [ + { + id: "plan-review" as Thread["proposedPlans"][number]["id"], + turnId: reviewTurnId, + planMarkdown: "# Fix the parser\n\n- Low: example finding", + implementedAt: null, + implementationThreadId: null, + createdAt: now, + updatedAt: now, + }, + ], + }); + + async function renderPlanThread(codeReview: boolean) { + h.realTimeline = true; + h.realChildren = true; + seedConnectedServerThread(planThread(codeReview)); + useRightPanelStore.getState().open(threadRef, "plan"); + vi.unstubAllGlobals(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + const composer = capturedProps>("chatComposer"); + const composerRef = composer["composerRef"] as RefObject; + composerRef.current = composerHandle(); + (composer["promptRef"] as RefObject).current = ""; + return { + container, + composer, + unmount: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; + } + + const buttonLabels = (container: HTMLElement) => + [...container.querySelectorAll("button")].map((button) => button.textContent ?? ""); + + it("offers no Implement, sends nothing on an empty Enter and titles the card Review findings", async () => { + const view = await renderPlanThread(true); + try { + expect(view.composer["showPlanFollowUpPrompt"]).toBe(false); + const labels = buttonLabels(view.container); + expect(labels.some((label) => /Implement|Refine/.test(label))).toBe(false); + expect(view.container.textContent).toContain("Review findings"); + expect(view.container.textContent).not.toContain("Fix the parser"); + expect(capturedProps>("planSidebar")["label"]).toBe( + "Review findings", + ); + await act(async () => (view.composer["onSend"] as () => Promise)()); + expect(commandCallsFor("thread.startTurn")).toEqual([]); + } finally { + await view.unmount(); + } + }); + + it("keeps the Tasks label for a review without a proposed plan", async () => { + h.realTimeline = true; + seedConnectedServerThread(makeThread({ codeReview: true })); + useRightPanelStore.getState().open(threadRef, "plan"); + vi.unstubAllGlobals(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + try { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + expect(capturedProps>("planSidebar")["label"]).toBe("Tasks"); + } finally { + await act(async () => root.unmount()); + container.remove(); + } + }); + + it("keeps today's plan actions without the flag", async () => { + const view = await renderPlanThread(false); + try { + expect(view.composer["showPlanFollowUpPrompt"]).toBe(true); + expect(buttonLabels(view.container)).toContain("Implement"); + expect(view.container.textContent).toContain("Fix the parser"); + expect(capturedProps>("planSidebar")["label"]).toBe("Plan"); + } finally { + await view.unmount(); + } + }); + }); + + describe("Review changes from Source Control (R90, R95, R97, R98)", () => { + type ReviewProp = { + readonly unavailableReason: string | null; + readonly guardLine: string | null; + readonly onStart: (scope: { kind: "branch" | "uncommitted"; base: string | null }) => void; + }; + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const cursorInstanceId = ProviderInstanceId.make("cursor"); + const claudeProvider: ServerProvider = { + ...codexProvider, + instanceId: claudeInstanceId, + driver: ProviderDriverKind.make("claudeAgent"), + models: [{ slug: "opus", name: "Opus", isCustom: false, capabilities: null }], + }; + const cursorProvider: ServerProvider = { + ...codexProvider, + instanceId: cursorInstanceId, + driver: ProviderDriverKind.make("cursor"), + models: [{ slug: "auto", name: "Auto", isCustom: false, capabilities: null }], + }; + const SCOPE = { kind: "uncommitted", base: "origin/main" } as const; + const DRAFT_REASON = "Send a first message in this chat, then review from here."; + + function seedReviewEnvironment(input: { + readonly reviewer: { kind: "chat" | "terminal"; instanceId: ProviderInstanceId }; + readonly os?: string; + readonly codeReviews?: boolean; + readonly providers?: ReadonlyArray; + readonly settings?: Record; + }): void { + seedEnvironment( + makeEnvironmentPresentation({ + serverConfig: { + providers: input.providers ?? [codexProvider, claudeProvider], + environment: { + label: "Local", + platform: { os: input.os ?? "linux", arch: "x64" }, + capabilities: { + activityProtocolVersion: 2, + worktreeCatalog: true, + codeReviews: input.codeReviews ?? true, + }, + }, + }, + } as never), + ); + seedProject(makeProject()); + seedGitStatus(true); + h.settings = { ...h.settings, codeReviewAgent: input.reviewer, ...input.settings }; + } + + async function mountReview(route: ReactElement): Promise<{ + readonly review: () => ReviewProp; + readonly unmount: () => Promise; + }> { + vi.unstubAllGlobals(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => { + root.render(route); + await Promise.resolve(); + }); + await vi.waitFor(() => expect(h.captured["sourceControlPanel"]).toBeDefined()); + return { + review: () => + (h.captured["sourceControlPanel"] as { codeReview: ReviewProp | null }).codeReview!, + unmount: async () => { + await act(async () => root.unmount()); + container.remove(); + }, + }; + } + + async function mountServerReview() { + useRightPanelStore.getState().open(threadRef, "sourceControl"); + return mountReview( + , + ); + } + + it("starts a Codex chat review as a panel of the current thread", async () => { + seedReviewEnvironment({ reviewer: { kind: "chat", instanceId: codexInstanceId } }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + expect(view.review().unavailableReason).toBeNull(); + expect(view.review().guardLine).toBe( + "Runs in Codex's read-only sandbox. Writes ask for your approval.", + ); + await act(async () => view.review().onStart(SCOPE)); + await vi.waitFor(() => expect(commandCallsFor("thread.startTurn")).toHaveLength(1)); + const creations = commandCallsFor("worktree.createPanel"); + expect(creations).toHaveLength(1); + expect(creations[0]!.input).toMatchObject({ + environmentId, + input: { + hostThreadId: threadId, + title: "Panel: Code review · Codex", + threadDefaults: { + modelSelection: expect.objectContaining({ instanceId: codexInstanceId }), + runtimeMode: "approval-required", + interactionMode: "default", + }, + codeReview: true, + }, + }); + const turn = commandCallsFor("thread.startTurn")[0]!.input as { + input: { message: { text: string } }; + }; + expect(turn.input.message.text).toMatch( + /^Code review requested from BiBCode\. Do not edit files\. Scope: the staged, unstaged and untracked changes in the working tree\.\n\n/, + ); + expect(commandCallsFor("terminal.open")).toEqual([]); + } finally { + await view.unmount(); + } + }); + + it("opens a Claude Terminal review with the review command", async () => { + seedReviewEnvironment({ reviewer: { kind: "terminal", instanceId: claudeInstanceId } }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + expect(view.review().unavailableReason).toBeNull(); + expect(view.review().guardLine).toBe("Starts in Claude's plan mode."); + await act(async () => view.review().onStart(SCOPE)); + await vi.waitFor(() => expect(commandCallsFor("terminal.open")).toHaveLength(1)); + const opened = commandCallsFor("terminal.open")[0]!.input as { + input: { command: { executable: string; args: string[] } }; + }; + expect(opened.input.command.executable).toBe("claude"); + expect(opened.input.command.args.slice(0, 2)).toEqual(["--permission-mode", "plan"]); + expect(opened.input.command.args.at(-2)).toBe("--"); + expect(opened.input.command.args.at(-1)).toMatch(/^Code review requested from BiBCode\./); + expect(opened.input.command.args).not.toContain("--dangerously-skip-permissions"); + expect(commandCallsFor("worktree.createPanel")).toEqual([]); + } finally { + await view.unmount(); + } + }); + + it("opens one terminal review per double click, and again after a failed open", async () => { + seedReviewEnvironment({ reviewer: { kind: "terminal", instanceId: claudeInstanceId } }); + seedServerThread(makeThread()); + const firstOpen = deferredResult>>(); + h.commandResults["terminal.open"] = () => firstOpen.promise; + const view = await mountServerReview(); + try { + await act(async () => { + view.review().onStart(SCOPE); + view.review().onStart(SCOPE); + }); + await vi.waitFor(() => expect(commandCallsFor("terminal.open")).toHaveLength(1)); + await act(async () => { + firstOpen.resolve(AsyncResult.failure(Cause.fail(new Error("spawn failed"))) as never); + await firstOpen.promise; + }); + h.commandResults["terminal.open"] = () => AsyncResult.success(undefined); + await act(async () => view.review().onStart(SCOPE)); + await vi.waitFor(() => expect(commandCallsFor("terminal.open")).toHaveLength(2)); + } finally { + await view.unmount(); + } + }); + + it.each([ + ["chat Codex", { kind: "chat" as const, instanceId: codexInstanceId }], + ["Claude Terminal", { kind: "terminal" as const, instanceId: claudeInstanceId }], + ])("is unavailable in a draft chat for %s", async (_name, reviewer) => { + seedReviewEnvironment({ reviewer }); + const draftId = newDraftId(); + useComposerDraftStore + .getState() + .setLogicalProjectDraftThreadId( + "logical-project-1", + scopeProjectRef(environmentId, projectId), + draftId, + { threadId, createdAt: now, envMode: "local", worktreePath: null }, + ); + useRightPanelStore.getState().open(threadRef, "sourceControl"); + publishSeededStoreState(useComposerDraftStore); + publishSeededStoreState(useRightPanelStore); + const view = await mountReview( + , + ); + try { + expect(view.review().unavailableReason).toBe(DRAFT_REASON); + await act(async () => view.review().onStart(SCOPE)); + expect(commandCallsFor("worktree.createPanel")).toEqual([]); + expect(commandCallsFor("terminal.open")).toEqual([]); + } finally { + await view.unmount(); + } + }); + + it("never falls back from a disabled saved reviewer", async () => { + seedReviewEnvironment({ + reviewer: { kind: "chat", instanceId: claudeInstanceId }, + providers: [claudeProvider, cursorProvider], + settings: { + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, enabled: false }, + }, + }, + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + expect(view.review().unavailableReason).toBe( + "Claude is not available. Change the code review agent in Settings → Agents.", + ); + await act(async () => view.review().onStart(SCOPE)); + expect(commandCallsFor("worktree.createPanel")).toEqual([]); + expect(commandCallsFor("terminal.open")).toEqual([]); + } finally { + await view.unmount(); + } + }); + + it("needs a server that advertises code reviews", async () => { + seedReviewEnvironment({ + reviewer: { kind: "chat", instanceId: codexInstanceId }, + codeReviews: false, + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + expect(view.review().unavailableReason).toBe( + "Update the BiBCode server on this environment to use Review.", + ); + } finally { + await view.unmount(); + } + }); + + it.each(["windows", "darwin", "linux"])("offers a Claude Terminal review on %s", async (os) => { + seedReviewEnvironment({ + reviewer: { kind: "terminal", instanceId: claudeInstanceId }, + os, + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + expect(view.review().unavailableReason).toBeNull(); + await act(async () => view.review().onStart(SCOPE)); + await vi.waitFor(() => expect(commandCallsFor("terminal.open")).toHaveLength(1)); + } finally { + await view.unmount(); + } + }); + + describe("with a one-off reviewer from the menu (R103)", () => { + type MenuReviewer = { + readonly label: string; + readonly selection: { kind: "chat" | "terminal"; instanceId: ProviderInstanceId }; + readonly guardLine: string | null; + readonly disabledReason: string | null; + }; + const reviewers = (view: { review: () => ReviewProp }) => + (view.review() as ReviewProp & { reviewers: ReadonlyArray }).reviewers; + const reviewerByLabel = (view: { review: () => ReviewProp }, label: string) => + reviewers(view).find((reviewer) => reviewer.label === label)!; + const startWith = ( + view: { review: () => ReviewProp }, + selection: MenuReviewer["selection"], + ) => + (view.review().onStart as (scope: typeof SCOPE, reviewer: typeof selection) => void)( + SCOPE, + selection, + ); + + it("opens a Claude review terminal and leaves the saved chat Codex reviewer alone", async () => { + const saved = { kind: "chat" as const, instanceId: codexInstanceId }; + seedReviewEnvironment({ reviewer: saved }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + expect(reviewers(view).map((reviewer) => reviewer.label)).toEqual([ + "Codex", + "Claude", + "Codex Terminal", + "Claude Terminal", + ]); + const claudeTerminal = reviewerByLabel(view, "Claude Terminal"); + expect(claudeTerminal).toMatchObject({ + selection: { kind: "terminal", instanceId: claudeInstanceId }, + guardLine: "Starts in Claude's plan mode.", + disabledReason: null, + }); + await act(async () => startWith(view, claudeTerminal.selection)); + await vi.waitFor(() => expect(commandCallsFor("terminal.open")).toHaveLength(1)); + const opened = commandCallsFor("terminal.open")[0]!.input as { + input: { command: { executable: string; args: string[] } }; + }; + expect(opened.input.command.executable).toBe("claude"); + expect(opened.input.command.args.slice(0, 2)).toEqual(["--permission-mode", "plan"]); + expect(commandCallsFor("worktree.createPanel")).toEqual([]); + expect(h.commandCalls.filter((call) => /settings/i.test(call.key))).toEqual([]); + expect(h.settings["codeReviewAgent"]).toEqual(saved); + } finally { + await view.unmount(); + } + }); + + it("names a disabled one-off reviewer's own reason, not the saved reviewer's", async () => { + // A listed provider that is not ready yet is a disabled menu item. + seedReviewEnvironment({ + reviewer: { kind: "chat", instanceId: codexInstanceId }, + providers: [codexProvider, { ...claudeProvider, status: "error" }], + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + const claude = reviewerByLabel(view, "Claude"); + expect(claude.disabledReason).not.toBeNull(); + expect(claude.disabledReason).not.toContain("code review agent"); + await act(async () => startWith(view, claude.selection)); + expect(commandCallsFor("worktree.createPanel")).toEqual([]); + expect(reviewerByLabel(view, "Codex").disabledReason).toBeNull(); + } finally { + await view.unmount(); + } + }); + + it("starts a one-off chat reviewer while the saved reviewer is unavailable", async () => { + seedReviewEnvironment({ + reviewer: { kind: "chat", instanceId: claudeInstanceId }, + settings: { + providers: { + ...DEFAULT_SERVER_SETTINGS.providers, + claudeAgent: { ...DEFAULT_SERVER_SETTINGS.providers.claudeAgent, enabled: false }, + }, + }, + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + expect(view.review().unavailableReason).toBe( + "Claude is not available. Change the code review agent in Settings → Agents.", + ); + const codex = reviewerByLabel(view, "Codex"); + expect(codex.disabledReason).toBeNull(); + await act(async () => startWith(view, codex.selection)); + await vi.waitFor(() => expect(commandCallsFor("worktree.createPanel")).toHaveLength(1)); + expect(commandCallsFor("worktree.createPanel")[0]!.input).toMatchObject({ + input: { title: "Panel: Code review · Codex", codeReview: true }, + }); + } finally { + await view.unmount(); + } + }); + + it.each(["windows", "darwin", "linux"])( + "offers the Claude and Codex Terminals on %s", + async (os) => { + seedReviewEnvironment({ reviewer: { kind: "chat", instanceId: codexInstanceId }, os }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + expect(reviewerByLabel(view, "Claude Terminal").disabledReason).toBeNull(); + expect(reviewerByLabel(view, "Codex Terminal").disabledReason).toBeNull(); + expect(reviewerByLabel(view, "Codex").disabledReason).toBeNull(); + await act(async () => + startWith(view, reviewerByLabel(view, "Claude Terminal").selection), + ); + await vi.waitFor(() => expect(commandCallsFor("terminal.open")).toHaveLength(1)); + } finally { + await view.unmount(); + } + }, + ); + + it("gives every reviewer the environment's reason on a server without code reviews", async () => { + seedReviewEnvironment({ + reviewer: { kind: "chat", instanceId: codexInstanceId }, + codeReviews: false, + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + for (const reviewer of reviewers(view)) { + expect(reviewer.disabledReason).toBe( + "Update the BiBCode server on this environment to use Review.", + ); + } + await act(async () => startWith(view, reviewerByLabel(view, "Codex").selection)); + expect(commandCallsFor("worktree.createPanel")).toEqual([]); + } finally { + await view.unmount(); + } + }); + }); + + describe("with one-off instructions (R105)", () => { + const EXTRA = "Focus on the parser."; + const startWithInstructions = (view: { review: () => ReviewProp }) => + ( + view.review().onStart as (scope: typeof SCOPE, reviewer: undefined, extra: string) => void + )(SCOPE, undefined, EXTRA); + + it("ends the chat panel's first message with them and saves nothing", async () => { + seedReviewEnvironment({ + reviewer: { kind: "chat", instanceId: codexInstanceId }, + settings: { codeReviewPrompt: "User rules" }, + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + await act(async () => startWithInstructions(view)); + await vi.waitFor(() => expect(commandCallsFor("thread.startTurn")).toHaveLength(1)); + const turn = commandCallsFor("thread.startTurn")[0]!.input as { + input: { message: { text: string } }; + }; + expect(turn.input.message.text.endsWith(`\n\nUser rules\n\n${EXTRA}`)).toBe(true); + expect(h.settings["codeReviewPrompt"]).toBe("User rules"); + expect(h.commandCalls.filter((call) => /settings|project\.meta/i.test(call.key))).toEqual( + [], + ); + } finally { + await view.unmount(); + } + }); + + it.each(["darwin", "linux"])( + "ends the review terminal's prompt argument with them on %s", + async (os) => { + seedReviewEnvironment({ + reviewer: { kind: "terminal", instanceId: claudeInstanceId }, + os, + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + await act(async () => startWithInstructions(view)); + await vi.waitFor(() => expect(commandCallsFor("terminal.open")).toHaveLength(1)); + const opened = commandCallsFor("terminal.open")[0]!.input as { + input: { command: { args: string[] } }; + }; + expect(opened.input.command.args.at(-1)!.endsWith(`\n\n${EXTRA}`)).toBe(true); + } finally { + await view.unmount(); + } + }, + ); + + type StartWith = (scope: typeof SCOPE, reviewer: undefined, extra: string) => unknown; + it.each([ + [ + "too long", + "x".repeat(8_200), + "These instructions make the review prompt longer than 8,192 characters. Shorten them to start the review.", + ], + [ + "holding a control character", + "Look at \u0007 this", + "These instructions hold a control character other than tab or a line break. Remove it to start the review.", + ], + ])( + "returns the reason for instructions that are %s, with no toast and no launch", + async (_name, instructions, reason) => { + seedReviewEnvironment({ + reviewer: { kind: "terminal", instanceId: claudeInstanceId }, + }); + seedServerThread(makeThread()); + const add = vi.spyOn(toastManager, "add").mockReturnValue("refusal-toast"); + const view = await mountServerReview(); + try { + let result: unknown; + await act(async () => { + result = (view.review().onStart as StartWith)(SCOPE, undefined, instructions); + }); + expect(result).toBe(reason); + expect(add).not.toHaveBeenCalled(); + expect(commandCallsFor("terminal.open")).toEqual([]); + } finally { + add.mockRestore(); + await view.unmount(); + } + }, + ); + + it("still sends the Settings reason when the saved prompt itself is refused", async () => { + seedReviewEnvironment({ + reviewer: { kind: "terminal", instanceId: claudeInstanceId }, + settings: { codeReviewPrompt: "y".repeat(8_200) }, + }); + seedServerThread(makeThread()); + const view = await mountServerReview(); + try { + let result: unknown; + await act(async () => { + result = (view.review().onStart as StartWith)(SCOPE, undefined, EXTRA); + }); + expect(result).toBe( + "Review prompt is longer than 8,192 characters. Shorten it in Settings → Agents.", + ); + } finally { + await view.unmount(); + } + }); + }); + + describe("from Mod+Alt+Shift+R (R102)", () => { + const isMac = () => navigator.platform.toLowerCase().includes("mac"); + const shortcut = () => + new KeyboardEvent("keydown", { + key: "R", + code: "KeyR", + metaKey: isMac(), + ctrlKey: !isMac(), + altKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }); + const DIRTY_STATUS = { + isRepo: true, + refName: "feature", + defaultRefName: "main", + aheadOfDefaultCount: 0, + hasWorkingTreeChanges: true, + workingTree: { files: [] }, + }; + + async function mountWithKeydown(): Promise<{ + readonly keydown: EventListener; + readonly cleanup: () => Promise; + }> { + // Spy after the globals are restored, as mountReview restores them too. + vi.unstubAllGlobals(); + const addWindowListener = vi.spyOn(window, "addEventListener"); + const view = await mountServerReview(); + const keydown = addWindowListener.mock.calls.findLast( + ([eventName]) => eventName === "keydown", + )?.[1] as EventListener; + return { + keydown, + cleanup: async () => { + addWindowListener.mockRestore(); + await view.unmount(); + }, + }; + } + + it("starts the review the button would start, and nothing from a focused terminal", async () => { + seedReviewEnvironment({ reviewer: { kind: "chat", instanceId: codexInstanceId } }); + seedServerThread(makeThread()); + h.queryDataByKey.set("vcs.status", DIRTY_STATUS); + const terminalHost = document.createElement("div"); + terminalHost.dataset["terminalOwner"] = "center-panel"; + const terminalInput = document.createElement("textarea"); + terminalHost.append(terminalInput); + document.body.append(terminalHost); + const { keydown, cleanup } = await mountWithKeydown(); + try { + terminalInput.focus(); + await act(async () => keydown(shortcut())); + expect(commandCallsFor("worktree.createPanel")).toEqual([]); + + terminalInput.blur(); + const event = shortcut(); + await act(async () => keydown(event)); + expect(event.defaultPrevented).toBe(true); + await vi.waitFor(() => expect(commandCallsFor("thread.startTurn")).toHaveLength(1)); + expect(commandCallsFor("worktree.createPanel")).toHaveLength(1); + const turn = commandCallsFor("thread.startTurn")[0]!.input as { + input: { message: { text: string } }; + }; + expect(turn.input.message.text).toMatch( + /^Code review requested from BiBCode\. Do not edit files\. Scope: the staged, unstaged and untracked changes in the working tree\.\n\n/, + ); + } finally { + terminalHost.remove(); + await cleanup(); + } + }); + + it.each([ + ["the server lacks code reviews", { codeReviews: false }, DIRTY_STATUS], + ["nothing changed", {}, { ...DIRTY_STATUS, hasWorkingTreeChanges: false }], + ])("does nothing when the button is disabled because %s", async (_name, env, status) => { + seedReviewEnvironment({ reviewer: { kind: "chat", instanceId: codexInstanceId }, ...env }); + seedServerThread(makeThread()); + h.queryDataByKey.set("vcs.status", status); + const { keydown, cleanup } = await mountWithKeydown(); + try { + await act(async () => keydown(shortcut())); + expect(commandCallsFor("worktree.createPanel")).toEqual([]); + expect(commandCallsFor("terminal.open")).toEqual([]); + } finally { + await cleanup(); + } + }); + }); + }); + it("keeps an exact session account in the label, picker guard, and outgoing turn", async () => { const customInstanceId = ProviderInstanceId.make("codex_personal"); const startedThread = makeThread({ @@ -5792,6 +6545,11 @@ describe("ChatView handlers (captured from mocked children)", () => { }, }); seedConnectedServerThread(startedThread); + // Configured but not reported yet; an instance in neither is removed and blocks Send (#347). + h.settings = { + ...h.settings, + providerInstances: { codex_personal: { driver: "codex", enabled: true } }, + }; useComposerDraftStore.getState().setModelSelection(threadRef, { instanceId: customInstanceId, model: "gpt-5.4", @@ -7699,3 +8457,368 @@ describe("ChatView long-lived handlers (#204)", () => { expect(bannerError()).toBe("Compaction refused."); }); }); + +describe("ChatView composer model picker provider scope (#347)", () => { + const claudeInstanceId = ProviderInstanceId.make("claudeAgent"); + const claudeProvider: ServerProvider = { + ...codexProvider, + instanceId: claudeInstanceId, + driver: ProviderDriverKind.make("claudeAgent"), + displayName: "Claude", + models: [ + { slug: "claude-opus-4-6", name: "Claude Opus 4.6", isCustom: false, capabilities: null }, + { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6", isCustom: false, capabilities: null }, + ], + }; + // A second account of the Claude driver with its own catalog. + const claudeWorkProvider: ServerProvider = { + ...claudeProvider, + instanceId: ProviderInstanceId.make("claude_work"), + displayName: "Claude Work", + models: [{ slug: "claude-work-only", name: "Work Only", isCustom: false, capabilities: null }], + }; + const codexSelection = { instanceId: codexInstanceId, model: "gpt-5.4" }; + const claudeSelection = { instanceId: claudeInstanceId, model: "claude-opus-4-6" }; + + function enableClaudeWorkInstance(): void { + h.settings = { + ...h.settings, + providerInstances: { claude_work: { driver: "claudeAgent", enabled: true } }, + }; + } + + let mounted: { container: HTMLDivElement; root: Root } | null = null; + + afterEach(async () => { + if (!mounted) return; + const { container, root } = mounted; + mounted = null; + await act(async () => root.unmount()); + container.remove(); + }); + + async function mount(element: ReactElement): Promise { + vi.unstubAllGlobals(); + Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + h.realChildren = true; + h.realModelPicker = true; + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + mounted = { container, root }; + await act(async () => { + root.render(element); + await Promise.resolve(); + }); + return container; + } + + /** Opens the real picker from its trigger and returns the instance of every listed model. */ + async function openPickerAndListInstances(container: HTMLElement): Promise { + const trigger = container.querySelector( + "[data-chat-provider-model-picker='true']", + ); + expect(trigger, "model picker trigger").not.toBeNull(); + await act(async () => { + trigger!.click(); + await Promise.resolve(); + }); + await vi.waitFor(() => + expect(document.querySelector("[data-model-picker-content]")).not.toBeNull(), + ); + return [...document.querySelectorAll("[data-model-picker-instance-id]")].map( + (row) => row.dataset.modelPickerInstanceId ?? "", + ); + } + + function seedTwoReadyProviders(): void { + seedEnvironment( + makeEnvironmentPresentation({ + serverConfig: { + providers: [codexProvider, claudeProvider], + environment: { label: "Local" }, + }, + }), + ); + seedGitStatus(true); + } + + const serverRoute = ( + + ); + + it.each([ + { label: "Claude", selection: claudeSelection, other: codexInstanceId }, + { label: "Codex", selection: codexSelection, other: claudeInstanceId }, + ])( + "lists only $label models in a never-started main chat created with $label (A1, A2)", + async ({ selection, other }) => { + seedTwoReadyProviders(); + seedProject(makeProject()); + seedServerThread(makeThread({ modelSelection: selection })); + + const container = await mount(serverRoute); + const listed = await openPickerAndListInstances(container); + + expect(listed.length).toBeGreaterThan(0); + expect(listed).not.toContain(other); + expect(new Set(listed)).toEqual(new Set([selection.instanceId])); + }, + ); + + it("leaves out a second account of the same driver in a never-started main chat (A5)", async () => { + seedEnvironment( + makeEnvironmentPresentation({ + serverConfig: { + providers: [codexProvider, claudeProvider, claudeWorkProvider], + environment: { label: "Local" }, + }, + }), + ); + seedGitStatus(true); + enableClaudeWorkInstance(); + seedProject(makeProject()); + seedServerThread(makeThread({ modelSelection: claudeSelection })); + + const container = await mount(serverRoute); + + expect(new Set(await openPickerAndListInstances(container))).toEqual( + new Set([claudeInstanceId]), + ); + }); + + it("rejects a selection for another provider and keeps the chat, model and draft text (A4)", async () => { + seedTwoReadyProviders(); + seedProject(makeProject()); + seedServerThread(makeThread({ modelSelection: claudeSelection })); + useComposerDraftStore.getState().setPrompt(threadRef, "keep this text"); + publishSeededStoreState(useComposerDraftStore); + await mount(serverRoute); + + const select = capturedProps>("chatComposer")[ + "onProviderModelSelect" + ] as (instanceId: ProviderInstanceId, model: string) => void; + await act(async () => select(codexInstanceId, "gpt-5.4")); + + const draft = useComposerDraftStore.getState().getComposerDraft(threadRef); + expect(draft?.activeProvider ?? claudeInstanceId).toBe(claudeInstanceId); + expect(draft?.modelSelectionByProvider?.[codexInstanceId]).toBeUndefined(); + expect(draft?.prompt).toBe("keep this text"); + + await act(async () => select(claudeInstanceId, "claude-sonnet-4-6")); + expect( + useComposerDraftStore.getState().getComposerDraft(threadRef)?.modelSelectionByProvider?.[ + claudeInstanceId + ]?.model, + ).toBe("claude-sonnet-4-6"); + }); + + const claudeSession = { + threadId, + status: "ready" as const, + providerName: "claudeAgent", + providerInstanceId: claudeInstanceId, + runtimeMode: "full-access" as const, + activeTurnId: null, + lastError: null, + updatedAt: now, + }; + + function disableClaudeInSettings(): void { + const providerSettings = h.settings["providers"] as Record; + h.settings = { + ...h.settings, + providers: { + ...providerSettings, + claudeAgent: { ...providerSettings["claudeAgent"], enabled: false }, + }, + }; + } + + function sendButton(container: HTMLElement): HTMLButtonElement | undefined { + return [...container.querySelectorAll("button[type='submit']")].find( + (button) => (button.getAttribute("aria-label") ?? button.textContent ?? "").includes("Send"), + ); + } + + it("keeps a not-ready provider's chat on it and lets its status banner explain (A6)", async () => { + seedEnvironment( + makeEnvironmentPresentation({ + serverConfig: { + providers: [codexProvider, { ...claudeProvider, status: "error" as const }], + environment: { label: "Local" }, + }, + }), + ); + seedGitStatus(true); + seedProject(makeProject()); + seedServerThread(makeThread({ modelSelection: claudeSelection })); + h.realStatusBanners = true; + + const container = await mount(serverRoute); + + expect(container.textContent).toContain("Claude provider is unavailable."); + expect( + container + .querySelector("[data-chat-provider-model-picker='true']") + ?.getAttribute("aria-label"), + ).toBe("Claude · Claude Opus 4.6"); + expect(await openPickerAndListInstances(container)).toEqual([]); + }); + + it.each([ + { label: "a disabled provider in a never-started chat", started: false, removed: false }, + { label: "a removed provider in a never-started chat", started: false, removed: true }, + { label: "a disabled provider in a started chat", started: true, removed: false }, + { label: "a removed provider in a started chat", started: true, removed: true }, + ])("explains $label and blocks Send with that reason (A6)", async ({ started, removed }) => { + seedEnvironment( + makeEnvironmentPresentation({ + serverConfig: { + providers: removed + ? [codexProvider] + : [codexProvider, { ...claudeProvider, enabled: false, status: "disabled" as const }], + environment: { label: "Local" }, + }, + }), + ); + seedGitStatus(true); + if (!removed) disableClaudeInSettings(); + seedProject(makeProject()); + seedServerThread( + makeThread({ + modelSelection: claudeSelection, + ...(started ? { session: claudeSession } : {}), + }), + ); + useComposerDraftStore.getState().setPrompt(threadRef, "hello"); + publishSeededStoreState(useComposerDraftStore); + h.realStatusBanners = true; + + const container = await mount(serverRoute); + + expect(container.textContent).toMatch(/is not available/); + expect(container.textContent).toContain("Settings → Providers"); + expect(container.textContent).toContain("+"); + const send = sendButton(container); + expect(send?.getAttribute("aria-label")).toMatch(/^Send unavailable\. .*is not available/); + const form = container.querySelector("form"); + await act(async () => { + form!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + expect(h.commandCalls.some((call) => call.key === "thread.startTurn")).toBe(false); + expect( + capturedProps>("chatComposer")["providerBindingInstanceId"], + ).toBe(claudeInstanceId); + }); + + it("explains an enabled provider the server reports unavailable and blocks Send (#347)", async () => { + seedEnvironment( + makeEnvironmentPresentation({ + serverConfig: { + providers: [codexProvider, { ...claudeProvider, availability: "unavailable" as const }], + environment: { label: "Local" }, + }, + }), + ); + seedGitStatus(true); + seedProject(makeProject()); + seedServerThread(makeThread({ modelSelection: claudeSelection, session: claudeSession })); + useComposerDraftStore.getState().setPrompt(threadRef, "hello"); + publishSeededStoreState(useComposerDraftStore); + h.realStatusBanners = true; + + const container = await mount(serverRoute); + + expect(container.textContent).toMatch(/is not available/); + // It is enabled, so the banner asks for its setup to be checked, not for enabling it. + expect(container.textContent).toContain("Check its install and sign-in"); + expect(container.textContent).not.toContain("Enable it in Settings"); + const send = sendButton(container); + expect(send?.getAttribute("aria-label")).toMatch(/^Send unavailable\. .*is not available/); + const form = container.querySelector("form"); + await act(async () => { + form!.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true })); + }); + expect(h.commandCalls.some((call) => call.key === "thread.startTurn")).toBe(false); + }); + + it("still lists every ready provider in a blank new draft, where the picker chooses the provider", async () => { + seedTwoReadyProviders(); + seedProject(makeProject()); + const draftId = newDraftId(); + useComposerDraftStore + .getState() + .setLogicalProjectDraftThreadId( + "logical-project-1", + scopeProjectRef(environmentId, projectId), + draftId, + { threadId, createdAt: now, envMode: "local" }, + ); + useComposerDraftStore.getState().setModelSelection(draftId, codexSelection); + publishSeededStoreState(useComposerDraftStore); + const container = await mount( + , + ); + + expect(new Set(await openPickerAndListInstances(container))).toEqual( + new Set([codexInstanceId, claudeInstanceId]), + ); + const select = capturedProps>("chatComposer")[ + "onProviderModelSelect" + ] as (instanceId: ProviderInstanceId, model: string) => void; + await act(async () => select(claudeInstanceId, "claude-opus-4-6")); + expect(useComposerDraftStore.getState().getComposerDraft(draftId)?.activeProvider).toBe( + claudeInstanceId, + ); + }); + + it("keeps a started chat on its driver, where its own accounts stay switchable", async () => { + seedEnvironment( + makeEnvironmentPresentation({ + serverConfig: { + providers: [codexProvider, claudeProvider, claudeWorkProvider], + environment: { label: "Local" }, + }, + }), + ); + seedGitStatus(true); + enableClaudeWorkInstance(); + seedProject(makeProject()); + seedServerThread( + makeThread({ + modelSelection: claudeSelection, + session: { + threadId, + status: "ready", + providerName: "claudeAgent", + providerInstanceId: claudeInstanceId, + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: now, + }, + }), + ); + + const container = await mount(serverRoute); + + expect(new Set(await openPickerAndListInstances(container))).toEqual( + new Set([claudeInstanceId, claudeWorkProvider.instanceId]), + ); + }); +}); diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ce5dab2bd2..dbb6deb8e1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4,6 +4,7 @@ import { type AgentSessionOpenMode, type ApprovalRequestId, type CheckpointRevertMode, + type DefaultAgentSelection, DEFAULT_MODEL, defaultInstanceIdForDriver, type EnvironmentId, @@ -168,8 +169,10 @@ import { type TurnDiffSummary, } from "../types"; import { useTheme } from "../hooks/useTheme"; +import { slotTheme } from "../themes/customThemes"; import { mergeTerminalSpawnEnv, + resolveTerminalPalette, resolveTerminalThemeMode, usesPersistentWindowsConsoleTheme, type TerminalThemeMode, @@ -208,7 +211,11 @@ import { type CenterPanelDropRequest, type CenterPanelLayoutPath, } from "../centerPanelLayout"; -import { takeComposerFocusRequest, useCenterPanelActions } from "../centerPanelActions"; +import { + CODE_REVIEW_SEND_FAILED_TITLE, + takeComposerFocusRequest, + useCenterPanelActions, +} from "../centerPanelActions"; import { markChatPanelSent } from "../centerPanelClose"; import { applyProviderInstanceSettings, @@ -247,6 +254,7 @@ import { resolveShortcutCommand, shortcutLabelForCommand, shouldOpenChatFind, + shouldStartCodeReview, } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; import ThreadTerminalPanel, { @@ -333,7 +341,28 @@ import { ExpandedImageDialog } from "./chat/ExpandedImageDialog"; import { PullRequestThreadDialog } from "./PullRequestThreadDialog"; import { MessagesTimeline, type TimelineOlderHistory } from "./chat/MessagesTimeline"; import { ChatHeaderActions } from "./chat/ChatHeaderActions"; -import { type ProviderTerminalAction } from "./chat/providerTerminalActions"; +import { + type ProviderTerminalAction, + describeInstructionsRefusal, + resolveProviderReviewTerminalAction, +} from "./chat/providerTerminalActions"; +import { buildProviderAgentActions } from "./chat/providerAgentActions"; +import { + CODE_REVIEW_DRAFT_REASON, + CODE_REVIEW_SERVER_UPDATE_REASON, + CODE_REVIEW_UNLISTED_REASON, + buildCodeReviewMenuReviewers, + codeReviewGuardLine, + resolveCodeReviewChatModes, + resolveCodeReviewModelSelection, + resolveCodeReviewPrompt, + resolveSavedCodeReviewer, +} from "../codeReview"; +import { + type CodeReviewScope, + resolveCodeReviewButtonState, + resolveCodeReviewScope, +} from "./SourceControlPanel.logic"; import { PanelLayoutControls, RightPanelMaximizeControl } from "./chat/PanelLayoutControls"; import { type ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { ChatPanelOpeningState, NoActiveThreadState } from "./NoActiveThreadState"; @@ -403,6 +432,8 @@ const PreviewPanel = lazy(() => ); const DiffPanel = lazy(() => import("./DiffPanel")); const SourceControlPanel = lazy(() => import("./SourceControlPanel")); +/** Hosts with a terminal review being opened, so a double click opens one (#293). */ +const pendingTerminalReviewHosts = new Set(); const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); const DevicePanel = lazy(() => import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), @@ -2145,8 +2176,34 @@ function ChatViewContent(props: ChatViewProps) { projectDefaultModelSelection: activeProject?.defaultModelSelection, selectedProviderInstanceId: selectedProviderByThreadId, providers: providerStatuses, + providerChosen: isServerThread, }); const providerBindingConflictReason = providerBinding.conflict?.reason ?? null; + // A chat keeps its provider when that provider is disabled or removed: name it and refuse + // Send instead of submitting an instance the server rejects (#347). + const boundProviderEntry = providerInstanceEntries.find( + (entry) => entry.instanceId === providerBinding.instanceId, + ); + const boundInstanceSettings = settings.providerInstances?.[providerBinding.instanceId]; + // An instance the server has not reported but settings still configure is only not known yet. + const boundProviderUnavailable = boundProviderEntry + ? !boundProviderEntry.enabled || !boundProviderEntry.isAvailable + : boundInstanceSettings + ? boundInstanceSettings.enabled === false + : providerStatuses.length > 0; + const unavailableProviderName = + isServerThread && + serverConfig !== null && + activeEnvironmentUnavailableState === null && + providerBinding.conflict === null && + boundProviderUnavailable + ? (boundProviderEntry?.displayName ?? formatProviderSlugLabel(providerBinding.instanceId)) + : null; + const unavailableProviderReason = + unavailableProviderName === null ? null : `${unavailableProviderName} is not available`; + const unavailableProviderNeedsSetup = boundProviderEntry?.enabled === true; + const unavailableProviderOnPrimary = + activeThread !== undefined && activeThread.environmentId === primaryEnvironment?.environmentId; const versionMismatch = resolveServerConfigVersionMismatch(serverConfig); const versionMismatchDismissKey = versionMismatch && activeThread @@ -2235,6 +2292,32 @@ function ChatViewContent(props: ChatViewProps) { description: providerBinding.conflict.reason, }); } + if (unavailableProviderReason) { + items.push({ + id: `provider-unavailable:${providerBinding.instanceId}`, + variant: "warning", + icon: , + title: unavailableProviderReason, + // An agent that is enabled but not usable (not installed, not signed in) needs its setup + // checked, not enabling. + description: unavailableProviderOnPrimary + ? unavailableProviderNeedsSetup + ? "This chat stays on it. Check its install and sign-in in Settings → Providers, or add a chat with another agent from the + menu." + : "This chat stays on it. Enable it in Settings → Providers, or add a chat with another agent from the + menu." + : unavailableProviderNeedsSetup + ? "This chat stays on it. Check its install and sign-in on this chat's server, or add a chat with another agent from the + menu." + : "This chat stays on it. Enable it on this chat's server, or add a chat with another agent from the + menu.", + ...(unavailableProviderOnPrimary + ? { + actions: ( + + ), + } + : {}), + }); + } if (showVersionMismatchBanner && versionMismatch && versionMismatchDismissKey) { items.push({ id: `version-mismatch:${versionMismatchDismissKey}`, @@ -2263,6 +2346,10 @@ function ChatViewContent(props: ChatViewProps) { navigate, presentation, providerBinding.conflict, + providerBinding.instanceId, + unavailableProviderNeedsSetup, + unavailableProviderOnPrimary, + unavailableProviderReason, setDismissedVersionMismatchKey, showVersionMismatchBanner, versionMismatch, @@ -2353,8 +2440,16 @@ function ChatViewContent(props: ChatViewProps) { () => deriveActivePlanState(threadActivities, activeLatestTurn?.turnId ?? undefined), [activeLatestTurn?.turnId, threadActivities], ); - const planSidebarLabel = sidebarProposedPlan || interactionMode === "plan" ? "Plan" : "Tasks"; + // A review panel reports findings; implementing them is not a review's job (D42). + const isCodeReviewThread = activeThread?.codeReview === true; + const planSidebarLabel = + isCodeReviewThread && sidebarProposedPlan + ? "Review findings" + : sidebarProposedPlan || interactionMode === "plan" + ? "Plan" + : "Tasks"; const showPlanFollowUpPrompt = + !isCodeReviewThread && pendingUserInputs.length === 0 && interactionMode === "plan" && latestTurnSettled && @@ -4678,6 +4773,10 @@ function ChatViewContent(props: ChatViewProps) { runtimeEnv: baseLaunch.env, commandEnv: launchCommand?.env, resolvedTheme: terminalTheme, + palette: resolveTerminalPalette( + settings.terminalThemePreference, + slotTheme(resolvedTheme === "dark" ? "dark" : "light"), + ), windowsConsoleTheme: usesPersistentWindowsConsoleTheme(launchCommand), }), ...(options?.label !== undefined ? { label: options.label } : {}), @@ -4799,11 +4898,201 @@ function ChatViewContent(props: ChatViewProps) { const focusedGroup = selectFocusedCenterPanelGroup( selectThreadCenterPanelState(useCenterPanelStore.getState().byThreadKey, activeThreadRef), ); - void openCenterTerminal( + return openCenterTerminal( { type: "tab", groupId: focusedGroup.id }, { label: action.label, command: action.command }, ); }); + // Review changes (#293): the saved reviewer exactly as saved (D48), with this environment's + // settings (D49). The prompt and the launch are resolved only on click. + const codeReviewAgentActions = useMemo( + () => buildProviderAgentActions(providerStatuses, settings), + [providerStatuses, settings], + ); + const savedCodeReviewer = useMemo( + () => + resolveSavedCodeReviewer( + codeReviewAgentActions, + settings.codeReviewAgent, + providerInstanceEntries, + ), + [codeReviewAgentActions, providerInstanceEntries, settings.codeReviewAgent], + ); + const codeReviewPlatformOs = serverConfig?.environment.platform.os ?? null; + const codeReviewsSupported = serverConfig?.environment.capabilities?.codeReviews === true; + const hasCodeReviewLaunchContext = isServerThread && centerPanelLaunchContext !== null; + // Reasons that hold for every reviewer on this thread and environment. + const codeReviewEnvironmentReason = !codeReviewsSupported + ? CODE_REVIEW_SERVER_UPDATE_REASON + : (workspaceUnavailable ?? + // A draft has no panel host and no terminal launch context (D40). + (hasCodeReviewLaunchContext ? null : CODE_REVIEW_DRAFT_REASON)); + // A resolved reviewer's own reason; a terminal also takes this environment's terminal reason. + const codeReviewerReason = useCallback( + (resolved: ReturnType): string | null => { + if (resolved.reason !== null) return resolved.reason; + const reviewer = resolved.action; + if (reviewer.kind !== "terminal") return null; + const terminal = resolveProviderReviewTerminalAction(reviewer.entry, settings, { + prompt: "", + platformOs: codeReviewPlatformOs, + modelSelection: null, + }); + return terminal === null ? CODE_REVIEW_UNLISTED_REASON : terminal.disabledReason; + }, + [codeReviewPlatformOs, settings], + ); + const codeReviewUnavailableReason = useMemo( + () => codeReviewEnvironmentReason ?? codeReviewerReason(savedCodeReviewer), + [codeReviewEnvironmentReason, codeReviewerReason, savedCodeReviewer], + ); + // The one-off reviewers of the review menu (R103), each with its own reason. + const codeReviewMenuReviewers = useMemo( + () => + buildCodeReviewMenuReviewers( + codeReviewAgentActions, + // A menu item names its own reason, not the saved reviewer's "change it in Settings". + (action) => + codeReviewEnvironmentReason ?? + (action.disabled + ? (action.disabledReason ?? `${action.label} is not available.`) + : codeReviewerReason({ action, reason: null })), + ), + [codeReviewAgentActions, codeReviewEnvironmentReason, codeReviewerReason], + ); + const codeReviewGuard = savedCodeReviewer.action + ? codeReviewGuardLine({ + kind: savedCodeReviewer.action.kind, + driver: savedCodeReviewer.action.entry.driverKind, + }) + : null; + // `selection` is a one-off reviewer from the menu; the saved reviewer stays as saved (R103). + // `instructions` are added to this review only, in both the chat and the terminal (R105). + const handleStartCodeReview = useLatestHandler( + ( + scope: CodeReviewScope, + selection?: DefaultAgentSelection, + instructions?: string, + ): string | null => { + const resolved = + selection === undefined + ? savedCodeReviewer + : resolveSavedCodeReviewer(codeReviewAgentActions, selection, providerInstanceEntries); + const reviewer = resolved.action; + if (reviewer === null) return null; + if ((codeReviewEnvironmentReason ?? codeReviewerReason(resolved)) !== null) return null; + if (!activeThread || !centerPanelLaunchContext) return null; + const { entry } = reviewer; + const prompt = resolveCodeReviewPrompt({ + project: activeProject?.codeReviewPrompt ?? "", + user: settings.codeReviewPrompt, + scope, + ...(instructions === undefined ? {} : { extra: instructions }), + }); + const modelSelection = resolveCodeReviewModelSelection( + settings.codeReviewModelSelection, + entry.instanceId, + ); + if (reviewer.kind === "terminal") { + const action = resolveProviderReviewTerminalAction(entry, settings, { + prompt, + platformOs: codeReviewPlatformOs, + modelSelection, + }); + if (action === null || action.command === null) { + const reason = action?.disabledReason ?? CODE_REVIEW_UNLISTED_REASON; + if (instructions !== undefined) { + // Give the reason back to the instructions dialog, which stays open with the text, + // instead of closing it on a toast. When the prompt is refused even without the + // instructions, the reason is the saved prompt's and still points at Settings. + const withoutInstructions = resolveProviderReviewTerminalAction(entry, settings, { + prompt: resolveCodeReviewPrompt({ + project: activeProject?.codeReviewPrompt ?? "", + user: settings.codeReviewPrompt, + scope, + }), + platformOs: codeReviewPlatformOs, + modelSelection, + }); + return withoutInstructions?.command ? describeInstructionsRefusal(reason) : reason; + } + toastManager.add( + stackedThreadToast({ + type: "error", + title: CODE_REVIEW_SEND_FAILED_TITLE, + description: reason, + }), + ); + return null; + } + // Terminal ids are distinct per open, so only this guard stops a double click (as chat + // reviews are guarded in centerPanelActions); it is released however the open settles. + const hostKey = `${activeThread.environmentId}:${activeThread.hostThreadId ?? activeThread.id}`; + if (pendingTerminalReviewHosts.has(hostKey)) return null; + pendingTerminalReviewHosts.add(hostKey); + void handleOpenProviderTerminalPanel(action).finally(() => + pendingTerminalReviewHosts.delete(hostKey), + ); + return null; + } + const resolution = resolveProviderSessionDefault({ + driver: entry.driverKind, + instanceId: entry.instanceId, + models: entry.models, + configuredDefault: settings.providerSessionDefaults[entry.driverKind] ?? null, + explicitSelection: modelSelection, + }); + void centerPanelActions.createPromptedChatPanel({ + // A panel cannot host a panel: a review from one is hosted by its host. + hostRef: scopeThreadRef( + activeThread.environmentId, + activeThread.hostThreadId ?? activeThread.id, + ), + projectId: activeThread.projectId, + worktreePath: centerPanelLaunchContext.worktreePath, + branch: activeThread.branch ?? null, + modelSelection: resolution.modelSelection, + providerLabel: entry.displayName, + label: `Code review · ${entry.displayName}`, + prompt, + ...resolveCodeReviewChatModes(entry.driverKind), + codeReview: true, + }); + return null; + }, + ); + // The shortcut does what the button would do now, from the status the header already reads + // for the same checkout (R102). + const handleCodeReviewShortcut = useLatestHandler(() => { + const status = gitStatusQuery.data ?? null; + const scope = resolveCodeReviewScope(status); + const button = resolveCodeReviewButtonState({ + status, + scope, + unavailableReason: codeReviewUnavailableReason, + }); + if (!button.disabled && scope) handleStartCodeReview(scope); + }); + const sourceControlCodeReview = useMemo( + () => ({ + unavailableReason: codeReviewUnavailableReason, + guardLine: codeReviewGuard, + onStart: handleStartCodeReview, + reviewers: codeReviewMenuReviewers, + projectId: activeProject?.id ?? null, + projectPrompt: activeProject?.codeReviewPrompt ?? "", + userPrompt: settings.codeReviewPrompt, + }), + [ + activeProject?.codeReviewPrompt, + activeProject?.id, + codeReviewGuard, + codeReviewMenuReviewers, + codeReviewUnavailableReason, + handleStartCodeReview, + settings.codeReviewPrompt, + ], + ); const runProjectScript = useCallback( async ( script: ProjectScript, @@ -4863,6 +5152,10 @@ function ChatViewContent(props: ChatViewProps) { const scriptSpawnEnv = mergeTerminalSpawnEnv({ runtimeEnv, resolvedTheme: scriptTerminalTheme, + palette: resolveTerminalPalette( + settings.terminalThemePreference, + slotTheme(resolvedTheme === "dark" ? "dark" : "light"), + ), windowsConsoleTheme: false, }); const dimensions = readTerminalFittedSize( @@ -5010,6 +5303,12 @@ function ChatViewContent(props: ChatViewProps) { openThreadFindForTarget(event.target); return; } + if (shouldStartCodeReview(command, event, keybindings, shortcutContext)) { + event.preventDefault(); + event.stopPropagation(); + handleCodeReviewShortcut(); + return; + } if (!command) return; if (command === "terminal.newCenter") { @@ -5151,6 +5450,7 @@ function ChatViewContent(props: ChatViewProps) { activeThreadId, closeCenterPanelSurface, closePanelTerminal, + handleCodeReviewShortcut, hasTerminalSurface, openCenterTerminal, runProjectScript, @@ -5560,6 +5860,7 @@ function ChatViewContent(props: ChatViewProps) { isConnecting || workspaceUnavailable !== null || providerBinding.conflict !== null || + unavailableProviderReason !== null || stoppingQueueRef.current || sendInFlightRef.current ) @@ -7243,6 +7544,7 @@ function ChatViewContent(props: ChatViewProps) { gitCwd={gitCwd} projectRef={activeProjectRef} workspaceUnavailable={workspaceUnavailable} + codeReview={sourceControlCodeReview} /> ) : activeRightPanelSurface?.kind === "device" ? ( @@ -7408,6 +7710,7 @@ function ChatViewContent(props: ChatViewProps) { verboseChat={settings.verboseChat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} + isCodeReview={isCodeReviewThread} anchorMessageId={timelineAnchorMessageId} onAnchorReady={onTimelineAnchorReady} onAnchorSizeChanged={onTimelineAnchorSizeChanged} @@ -7524,7 +7827,9 @@ function ChatViewContent(props: ChatViewProps) { providerBindingInstanceId={providerBinding.instanceId} lockProviderPickerToActiveInstance={lockProviderPickerToActiveInstance} providerBindingConflictReason={ - workspaceUnavailable ?? providerBindingConflictReason + workspaceUnavailable ?? + providerBindingConflictReason ?? + unavailableProviderReason } providerStatuses={providerStatuses as ServerProvider[]} activeProjectDefaultModelSelection={ diff --git a/apps/web/src/components/CommandPalette.test.tsx b/apps/web/src/components/CommandPalette.test.tsx index bc8e34fc95..bdbd7856d9 100644 --- a/apps/web/src/components/CommandPalette.test.tsx +++ b/apps/web/src/components/CommandPalette.test.tsx @@ -393,6 +393,7 @@ function makeProject( repositoryIdentity: null, defaultModelSelection: null, scripts: [], + codeReviewPrompt: "", worktreeDiscovery: { visibility: "hidden", initialPromptDismissedAt: null, baselinePaths: [] }, createdAt: iso(600), updatedAt: iso(60), diff --git a/apps/web/src/components/CreateWorktreeDialog.test.tsx b/apps/web/src/components/CreateWorktreeDialog.test.tsx index 2b3e7c80a2..e675816ad3 100644 --- a/apps/web/src/components/CreateWorktreeDialog.test.tsx +++ b/apps/web/src/components/CreateWorktreeDialog.test.tsx @@ -1964,8 +1964,12 @@ if (browserRuntime) { await renderDialog(root, false, PROJECT_REF); await renderDialog(root, true, PROJECT_REF); + // A new opening starts in Smart mode (#346), with an empty field. expect( - requiredElement(container, "input[placeholder='Search branches']").value, + requiredElement( + container, + "input[placeholder='Type #1234 or search branches']", + ).value, ).toBe(""); expect(requiredButton(container, "Create worktree").disabled).toBe(true); @@ -2010,7 +2014,7 @@ if (browserRuntime) { container.remove(); }); - it("selects the branch the field still names when reopening the same scoped project", async () => { + it("does not select a picked branch again when reopening the same scoped project (#346)", async () => { testState.refs = [{ name: "main", current: false, worktreePath: null }]; const { container, root } = await mountDialog(true, PROJECT_REF); @@ -2019,19 +2023,24 @@ if (browserRuntime) { expect(requiredButton(container, "Create worktree").disabled).toBe(false); await renderDialog(root, false, PROJECT_REF); + // Refs refreshed while closed still name the branch; the new opening must not pick it. + testState.refs = [{ name: "main", current: false, worktreePath: null }]; await renderDialog(root, true, PROJECT_REF); - // A pick is the field's text, so it is read again from the refreshed refs. expect( - requiredElement(container, "input[placeholder='Search branches']").value, - ).toBe("main"); - expect(requiredButton(container, "Create worktree").disabled).toBe(false); + requiredElement( + container, + "input[placeholder='Type #1234 or search branches']", + ).value, + ).toBe(""); + expect(container.textContent).not.toContain("Reuse branch"); + expect(requiredButton(container, "Create worktree").disabled).toBe(true); await React.act(async () => root.unmount()); container.remove(); }); - it("keeps a stale Smart source invalid after reopening", async () => { + it("keeps a stale Smart source invalid within one opening", async () => { testState.refs = [{ name: "feature/login", isRemote: false }]; const { container, root } = await mountDialog(true, PROJECT_REF); const sourceInput = requiredElement( @@ -2045,8 +2054,7 @@ if (browserRuntime) { requiredElement(container, "input[placeholder='Worktree name']"), "Custom workspace", ); - await renderDialog(root, false, PROJECT_REF); - // The branch is gone by the time the dialog opens again. + // The branch is gone when the refs refresh while the dialog stays open. testState.refs = []; await renderDialog(root, true, PROJECT_REF); @@ -2163,6 +2171,432 @@ if (browserRuntime) { container.remove(); }); + describe("every opening starts fresh (#346)", () => { + const SOURCE_PLACEHOLDERS = new Set([ + "Type #1234 or search branches", + "Search branches", + "#1234 or a GitHub issue/PR URL", + ]); + const FRESH_FORM = { + name: "", + source: "", + mode: "Smart", + baseOverride: "", + advanced: false, + createMore: false, + reuseShown: false, + error: false, + createDisabled: true, + nameFocused: true, + }; + + function nameField(container: ParentNode): HTMLInputElement { + return requiredElement(container, "input[placeholder='Worktree name']"); + } + + function sourceField(container: ParentNode): HTMLInputElement { + const match = Array.from(container.querySelectorAll("input")).find( + (candidate) => SOURCE_PLACEHOLDERS.has(candidate.placeholder), + ); + if (!match) throw new Error("Missing Create From field"); + return match; + } + + function overrideField(container: ParentNode): HTMLInputElement { + return requiredElement( + container, + "input[placeholder='Defaults to the current branch']", + ); + } + + function formSnapshot(container: HTMLDivElement) { + const reuse = Array.from(container.querySelectorAll("label")) + .find((label) => label.textContent?.includes("Reuse branch")) + ?.querySelector("input[type='checkbox']"); + return { + name: nameField(container).value, + source: sourceField(container).value, + mode: container.querySelector("button[aria-pressed='true']")?.textContent ?? null, + baseOverride: overrideField(container).value, + advanced: captured.collapsibles.at(-1)?.open ?? null, + createMore: captured.switches.at(-1)?.checked ?? null, + reuseShown: reuse !== undefined, + error: container.textContent?.includes("Choose a project and enter a worktree name."), + createDisabled: requiredButton(container, "Create worktree").disabled, + nameFocused: document.activeElement === nameField(container), + }; + } + + function projectPickerValue(container: ParentNode): string | undefined { + return container.querySelectorAll("[data-select-value]")[0]?.dataset[ + "selectValue" + ]; + } + + function agentPickerValue(container: ParentNode): string | undefined { + return container.querySelectorAll("[data-select-value]")[1]?.dataset[ + "selectValue" + ]; + } + + async function nextFrame(): Promise { + await React.act(async () => new Promise((resolve) => setTimeout(resolve, 32))); + } + + async function closeAndReopen( + root: Root, + defaultProjectRef: ScopedProjectRef | null = PROJECT_REF, + closedProjectRef: ScopedProjectRef | null = defaultProjectRef, + ): Promise { + await renderDialog(root, false, closedProjectRef); + await renderDialog(root, true, defaultProjectRef); + await nextFrame(); + } + + async function create(container: HTMLDivElement): Promise { + await React.act(async () => requiredButton(container, "Create worktree").click()); + await React.act(async () => flushPromises()); + } + + function lastCreateInput(): Record | undefined { + return testState.createWorktree.mock.calls.at(-1)?.[0]; + } + + /** Branch mode, a picked branch, an edited Name, a base override and a manual agent. */ + async function fillEveryField(container: HTMLDivElement): Promise { + await React.act(async () => requiredButton(container, "Branch").click()); + await React.act(async () => requiredButton(container, "feature/login").click()); + await setInputValue(nameField(container), "fresh-dialog-check"); + await React.act(async () => captured.collapsibles.at(-1)?.onOpenChange?.(true)); + await setInputValue(overrideField(container), "release/1.0"); + await React.act(async () => captured.selects.at(-1)?.onValueChange?.("chat:claudeAgent")); + } + + beforeEach(() => { + testState.refs = [ + { name: "main", current: true, worktreePath: "/repo" }, + { name: "feature/login", isRemote: false, current: false, worktreePath: null }, + ]; + testState.serverConfigs.set( + ENVIRONMENT_ID, + serverConfig([readyProvider("codex"), readyProvider("claudeAgent")]), + ); + }); + + it("reopens fresh after an ordinary successful creation (A1, A8)", async () => { + const { container, root } = await mountDialog(true, PROJECT_REF); + await fillEveryField(container); + expect(formSnapshot(container)).toMatchObject({ + name: "fresh-dialog-check", + source: "feature/login", + mode: "Branch", + baseOverride: "release/1.0", + advanced: true, + }); + expect(agentPickerValue(container)).toBe("chat:claudeAgent"); + + await create(container); + expect(lastCreateInput()).toMatchObject({ + input: { title: "fresh-dialog-check", refName: "feature/login" }, + }); + expect(testState.onOpenChange).toHaveBeenCalledWith(false); + + await closeAndReopen(root); + + expect(formSnapshot(container)).toEqual(FRESH_FORM); + expect(projectPickerValue(container)).toBe(scopedProjectKey(PROJECT_REF)); + expect(agentPickerValue(container)).toBe("chat:codex"); + + // A refresh while the fresh dialog is open does not bring the old source back. + await renderDialog(root, true, PROJECT_REF); + expect(formSnapshot(container)).toEqual(FRESH_FORM); + + await setInputValue(nameField(container), "second-one"); + await create(container); + expect(lastCreateInput()).toEqual({ + environmentId: ENVIRONMENT_ID, + input: expect.objectContaining({ + projectId: PROJECT_ID, + title: "second-one", + refName: "HEAD", + newRefName: "second-one", + baseRefName: "HEAD", + threadDefaults: expect.objectContaining({ + modelSelection: expect.objectContaining({ instanceId: "codex" }), + }), + }), + }); + + await React.act(async () => root.unmount()); + container.remove(); + }); + + it("clears a typed Name in Smart mode after success and reopen", async () => { + const { container, root } = await mountDialog(true, PROJECT_REF); + await setInputValue(nameField(container), "typed-only"); + await create(container); + await closeAndReopen(root); + + expect(formSnapshot(container)).toEqual(FRESH_FORM); + + await React.act(async () => root.unmount()); + container.remove(); + }); + + it("discards a dismissed draft, Advanced, Create more and an error (A3)", async () => { + const { container, root } = await mountDialog(true, PROJECT_REF); + // A direct submission of an empty form records the form error. + await React.act(async () => + captured.buttons + .findLast((props) => collectText(props.children).includes("Create worktree")) + ?.onClick?.(), + ); + await React.act(async () => flushPromises()); + expect(formSnapshot(container).error).toBe(true); + await fillEveryField(container); + await React.act(async () => captured.switches.at(-1)?.onCheckedChange?.(true)); + + // Escape or Close asks the caller to close an idle dialog. + await React.act(async () => captured.dialogs.at(-1)?.onOpenChange?.(false)); + expect(testState.onOpenChange).toHaveBeenCalledWith(false); + await closeAndReopen(root); + + expect(formSnapshot(container)).toEqual(FRESH_FORM); + expect(agentPickerValue(container)).toBe("chat:codex"); + expect(testState.createWorktree).not.toHaveBeenCalled(); + + await React.act(async () => root.unmount()); + container.remove(); + }); + + it("clears an automatic, an edited and a GitHub name after success (A2)", async () => { + const { container, root } = await mountDialog(true, PROJECT_REF); + + // An untouched suggestion from a free local branch, with Reuse branch on. + await React.act(async () => requiredButton(container, "Branch").click()); + await React.act(async () => requiredButton(container, "feature/login").click()); + expect(formSnapshot(container)).toMatchObject({ name: "feature/login", reuseShown: true }); + await create(container); + await closeAndReopen(root); + expect(formSnapshot(container)).toEqual(FRESH_FORM); + + // A suggestion the user edited. + await React.act(async () => requiredButton(container, "Branch").click()); + await React.act(async () => requiredButton(container, "feature/login").click()); + await setInputValue(nameField(container), "feature/login-edited"); + await create(container); + await closeAndReopen(root); + expect(formSnapshot(container)).toEqual(FRESH_FORM); + + // A GitHub work item and its suggested name. + await React.act(async () => requiredButton(container, "GitHub").click()); + await setInputValue(sourceField(container), "#42"); + expect(formSnapshot(container)).toMatchObject({ name: "pr-42", createDisabled: false }); + await create(container); + await closeAndReopen(root); + expect(formSnapshot(container)).toEqual(FRESH_FORM); + expect(testState.createWorktree).toHaveBeenCalledTimes(3); + + await React.act(async () => root.unmount()); + container.remove(); + }); + + it("opens on the caller's project, else the first one, with none of the last opening's values (A4)", async () => { + const secondEnvironmentId = EnvironmentId.make("remote"); + const secondProjectRef = scopeProjectRef( + secondEnvironmentId, + ProjectId.make("project-two"), + ); + testState.projects = [ + project(), + project({ + id: secondProjectRef.projectId, + environmentId: secondEnvironmentId, + title: "Repo B", + workspaceRoot: "/repo-b", + }), + ]; + testState.serverConfigs.set( + secondEnvironmentId, + serverConfig([readyProvider("codex"), readyProvider("claudeAgent")]), + ); + const { container, root } = await mountDialog(true, PROJECT_REF); + await fillEveryField(container); + + await closeAndReopen(root, secondProjectRef, PROJECT_REF); + expect(projectPickerValue(container)).toBe(scopedProjectKey(secondProjectRef)); + expect(formSnapshot(container)).toEqual(FRESH_FORM); + + // Without a caller project the first project is the default, not the last selection. + await setInputValue(nameField(container), "typed-for-b"); + await closeAndReopen(root, null, secondProjectRef); + expect(projectPickerValue(container)).toBe(scopedProjectKey(PROJECT_REF)); + expect(formSnapshot(container)).toEqual(FRESH_FORM); + + // No projects: nothing is selected and Create stays disabled. + testState.projects = []; + await closeAndReopen(root, null); + expect(projectPickerValue(container)).toBeUndefined(); + await setInputValue(nameField(container), "late-edit"); + expect(requiredButton(container, "Create worktree").disabled).toBe(true); + + // Projects that arrive later supply the default without erasing the edit. + testState.projects = [project()]; + await renderDialog(root, true, null); + expect(projectPickerValue(container)).toBe(scopedProjectKey(PROJECT_REF)); + expect(nameField(container).value).toBe("late-edit"); + expect(requiredButton(container, "Create worktree").disabled).toBe(false); + + await React.act(async () => root.unmount()); + container.remove(); + }); + + it("follows the current Settings Default Agent, not the last manual pick (A5)", async () => { + function changeDefaultAgent(kind: "chat" | "terminal", instanceId: string): void { + setDefaultAgent(kind, instanceId); + const config = testState.serverConfigs.get(ENVIRONMENT_ID); + if (config) { + testState.serverConfigs.set(ENVIRONMENT_ID, { + ...config, + settings: testState.primarySettings, + }); + } + } + const { container, root } = await mountDialog(true, PROJECT_REF); + await React.act(async () => captured.selects.at(-1)?.onValueChange?.("chat:claudeAgent")); + expect(agentPickerValue(container)).toBe("chat:claudeAgent"); + + changeDefaultAgent("terminal", "codex"); + await closeAndReopen(root); + expect(agentPickerValue(container)).toBe("terminal:codex"); + + // An unavailable default keeps its fallback and the note that explains it. + changeDefaultAgent("chat", "cursor"); + await closeAndReopen(root); + expect(agentPickerValue(container)).toBe("chat:codex"); + expect(container.textContent).toContain( + "Your default agent is not available on Local, so Codex is selected.", + ); + + await React.act(async () => root.unmount()); + container.remove(); + }); + + it("keeps the inputs while open through a failure, a pending create and a refresh (A6)", async () => { + const filled = { + name: "fresh-dialog-check", + source: "feature/login", + mode: "Branch", + baseOverride: "release/1.0", + advanced: true, + createMore: false, + reuseShown: true, + error: false, + createDisabled: false, + }; + testState.createWorktree.mockResolvedValueOnce(failure(new Error("disk full"))); + const { container, root } = await mountDialog(true, PROJECT_REF); + await fillEveryField(container); + + await create(container); + expect(testState.toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ title: "Failed to create worktree", description: "disk full" }), + ); + expect(testState.onOpenChange).not.toHaveBeenCalled(); + expect(formSnapshot(container)).toMatchObject(filled); + expect(agentPickerValue(container)).toBe("chat:claudeAgent"); + + // Projects, refs and agents refresh while the dialog stays open. + testState.projects = [project()]; + testState.refs = testState.refs.map((ref) => ({ ...ref })); + testState.serverConfigs.set( + ENVIRONMENT_ID, + serverConfig([readyProvider("codex"), readyProvider("claudeAgent")]), + ); + await renderDialog(root, true, PROJECT_REF); + expect(formSnapshot(container)).toMatchObject(filled); + expect(agentPickerValue(container)).toBe("chat:claudeAgent"); + + // While the retry is pending, dismissal and a second submission are ignored. + let resolveCreate: ((result: unknown) => void) | undefined; + testState.createWorktree.mockReturnValueOnce( + new Promise((resolve) => { + resolveCreate = resolve; + }), + ); + await React.act(async () => requiredButton(container, "Create worktree").click()); + expect(requiredButton(container, "Creating...").disabled).toBe(true); + await React.act(async () => captured.dialogs.at(-1)?.onOpenChange?.(false)); + await React.act(async () => + requiredElement(container, "[data-testid='dialog-popup']").dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true }), + ), + ); + expect(testState.onOpenChange).not.toHaveBeenCalled(); + expect(testState.createWorktree).toHaveBeenCalledTimes(2); + expect(lastCreateInput()).toMatchObject({ + input: { title: "fresh-dialog-check", refName: "feature/login" }, + }); + + await React.act(async () => { + resolveCreate?.(success({ threadId: "thread-created" })); + await flushPromises(); + }); + expect(testState.onOpenChange).toHaveBeenCalledWith(false); + + await React.act(async () => root.unmount()); + container.remove(); + }); + + it("keeps a Create more batch's options until the dialog is reopened (A7)", async () => { + const { container, root } = await mountDialog(true, PROJECT_REF); + await fillEveryField(container); + await React.act(async () => captured.switches.at(-1)?.onCheckedChange?.(true)); + + await create(container); + await nextFrame(); + expect(testState.onOpenChange).not.toHaveBeenCalled(); + expect(testState.navigate).not.toHaveBeenCalled(); + expect(formSnapshot(container)).toEqual({ + // Cleared for the next worktree. + name: "", + source: "", + reuseShown: false, + error: false, + createDisabled: true, + nameFocused: true, + // Kept for the batch. + mode: "Branch", + baseOverride: "release/1.0", + advanced: true, + createMore: true, + }); + expect(projectPickerValue(container)).toBe(scopedProjectKey(PROJECT_REF)); + expect(agentPickerValue(container)).toBe("chat:claudeAgent"); + + await setInputValue(nameField(container), "batch-two"); + await create(container); + expect(lastCreateInput()).toMatchObject({ + input: { + title: "batch-two", + refName: "release/1.0", + baseRefName: "release/1.0", + threadDefaults: { + modelSelection: expect.objectContaining({ instanceId: "claudeAgent" }), + }, + }, + }); + + await React.act(async () => captured.dialogs.at(-1)?.onOpenChange?.(false)); + await closeAndReopen(root); + expect(formSnapshot(container)).toEqual(FRESH_FORM); + expect(agentPickerValue(container)).toBe("chat:codex"); + + await React.act(async () => root.unmount()); + container.remove(); + }); + }); + describe("Default Agent (#185)", () => { const REMOTE_ENVIRONMENT_ID = EnvironmentId.make("remote"); const REMOTE_PROJECT_REF = scopeProjectRef(REMOTE_ENVIRONMENT_ID, PROJECT_ID); diff --git a/apps/web/src/components/CreateWorktreeDialog.tsx b/apps/web/src/components/CreateWorktreeDialog.tsx index d2157700ce..8595bd59ad 100644 --- a/apps/web/src/components/CreateWorktreeDialog.tsx +++ b/apps/web/src/components/CreateWorktreeDialog.tsx @@ -164,8 +164,6 @@ export function CreateWorktreeDialog({ const [pickedAgentValue, setPickedAgentValue] = useState(null); const [formError, setFormError] = useState(null); const previousOpenRef = useRef(false); - // The project whose branches the Create From text names, set where the user types or picks it. - const sourceProjectRef = useRef(null); const refsEnabledRef = useRef(false); const selectProjectTarget = useCallback((nextProjectRef: ScopedProjectRef | null) => { @@ -208,14 +206,13 @@ export function CreateWorktreeDialog({ const handleSourceTextChange = useCallback( (value: string) => { setSourceText(value); - sourceProjectRef.current = projectRef; clearBranchSelection(); if (mode === "github" || mode === "smart") { const item = parseGitHubWorkItem(value); if (item) applySuggestedName(githubWorkItemBranchName(item)); } }, - [applySuggestedName, clearBranchSelection, mode, projectRef], + [applySuggestedName, clearBranchSelection, mode], ); const handleBranchSelect = useCallback( @@ -232,12 +229,11 @@ export function CreateWorktreeDialog({ const handleBranchRowPick = useCallback( (ref: RefLike) => { setSourceText(ref.name); - sourceProjectRef.current = projectRef; handleBranchSelect(ref); // The picked row goes away with the list, so focus stays on the field. sourceInputRef.current?.focus(); }, - [handleBranchSelect, projectRef], + [handleBranchSelect], ); const handleProjectChange = useCallback( @@ -255,28 +251,28 @@ export function CreateWorktreeDialog({ const wasOpen = previousOpenRef.current; previousOpenRef.current = open; if (!open) return; + // Every opening starts a new creation (#346): the dialog stays mounted while closed, so the + // last opening's input, including what Create more kept for its batch, is cleared here. if (!wasOpen) { - setPickedAgentValue(null); + setMode("smart"); + setName(EMPTY_WORKTREE_NAME); + setSourceText(""); setReuseSelectedBranch(false); - setName(forgetAutomaticName); - // Opening for a project other than the one whose branches the source names clears it, as - // changing Project does; the dialog may have last shown another project than it opened for. - if ( - defaultProjectRef !== null && - !sameProjectRef(sourceProjectRef.current, defaultProjectRef) - ) { - setSourceText(""); - } + setBaseBranchOverride(""); + setShowAdvanced(false); + setCreateMore(false); + setPickedAgentValue(null); + setFormError(null); } const firstProject = projects[0]; const firstProjectRef = firstProject ? scopeProjectRef(firstProject.environmentId, firstProject.id) : null; setProjectSelection((current) => { - const nextProjectRef = defaultProjectRef ?? current.projectRef ?? firstProjectRef; if (!wasOpen) { - return { projectRef: nextProjectRef, branchRefName: null }; + return { projectRef: defaultProjectRef ?? firstProjectRef, branchRefName: null }; } + const nextProjectRef = defaultProjectRef ?? current.projectRef ?? firstProjectRef; if (current.projectRef !== null) return current; return sameProjectRef(current.projectRef, nextProjectRef) ? current @@ -684,7 +680,9 @@ export function CreateWorktreeDialog({ type="button" className={cn( "hover:bg-accent flex w-full items-center justify-between px-3 py-1.5 text-left text-sm", - row.kind === "branch" && row.refName === selectedBranchRefName && "bg-accent", + row.kind === "branch" && + row.refName === selectedBranchRefName && + "bg-selected text-selected-foreground", )} onClick={() => { if (row.kind === "branch") { @@ -720,7 +718,7 @@ export function CreateWorktreeDialog({ type="button" className={cn( "hover:bg-accent flex w-full items-center px-3 py-1.5 text-left text-sm", - ref.name === selectedBranchRefName && "bg-accent", + ref.name === selectedBranchRefName && "bg-selected text-selected-foreground", )} onClick={() => handleBranchRowPick(ref)} > diff --git a/apps/web/src/components/GitActionsControl.test.tsx b/apps/web/src/components/GitActionsControl.test.tsx index 7f6e5b0e42..97d7b98375 100644 --- a/apps/web/src/components/GitActionsControl.test.tsx +++ b/apps/web/src/components/GitActionsControl.test.tsx @@ -14,7 +14,7 @@ import { VcsActionRemoteFailureError } from "@bibcode/client-runtime/state/vcs"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { AsyncResult } from "effect/reactivity"; -import type { Dispatch, ReactElement, ReactNode, SetStateAction } from "react"; +import { type Dispatch, type ReactElement, type ReactNode, type SetStateAction } from "react"; import * as React from "react"; import { createRoot, type Root } from "react-dom/client"; import { renderToStaticMarkup } from "react-dom/server"; @@ -1380,6 +1380,17 @@ staticDescribe("default branch confirmation", () => { }); }); +const REMOTE_PUSH_GUIDANCE = + "Push ran on the remote server example-server. In a terminal on example-server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode."; +const GIT_USERNAME_LINE = + "fatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled"; +const REMOTE_PUSH_FAILURE = `${REMOTE_PUSH_GUIDANCE}\n\n${GIT_USERNAME_LINE}`; + +/** The failure toast's description is the raw string; the shared toast renders the line breaks. */ +function expectMultiLineDescription(description: unknown): void { + expect(description).toBe(REMOTE_PUSH_FAILURE); +} + staticDescribe("stacked action results", () => { async function runPush(): Promise { testState.gitStatus = status({ aheadCount: 1 }); @@ -1426,6 +1437,30 @@ staticDescribe("stacked action results", () => { ); }); + it("a remote push failure shows the guidance and Git's own line", async () => { + testState.stackedAction.run.mockImplementationOnce(async (input: { actionId: string }) => + AsyncResult.failure( + Cause.fail( + new VcsActionRemoteFailureError({ + actionId: input.actionId, + transportActionId: input.actionId, + action: "push", + environmentId: ENVIRONMENT_ID, + cwd: "/repo", + phase: "push", + remoteMessage: REMOTE_PUSH_FAILURE, + }), + ), + ), + ); + await runPush(); + expect(testState.toast.update).toHaveBeenLastCalledWith( + "toast-1", + expect.objectContaining({ type: "error", title: "Push failed" }), + ); + expectMultiLineDescription(testState.toast.update.mock.calls.at(-1)?.[1].description); + }); + it("closes the toast quietly when interrupted", async () => { testState.stackedAction.run.mockResolvedValue(interrupted()); await runPush(); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index a49940bf7a..5f127bcfc4 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -442,7 +442,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { className={cn( "group flex h-7 min-w-25 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-sm", active - ? "bg-accent text-foreground" + ? "bg-selected text-selected-foreground" : "text-muted-foreground hover:bg-accent/60 hover:text-foreground", )} > diff --git a/apps/web/src/components/Sidebar.browser.test.tsx b/apps/web/src/components/Sidebar.browser.test.tsx index 8c30a0907d..988020023f 100644 --- a/apps/web/src/components/Sidebar.browser.test.tsx +++ b/apps/web/src/components/Sidebar.browser.test.tsx @@ -24,7 +24,12 @@ import { import { createRoot, type Root } from "react-dom/client"; import * as React from "react"; import { beforeEach, describe, expect, it, vi } from "vite-plus/test"; -import { ProjectId, WorktreeKey, WorktreeRepositoryKey } from "@bibcode/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + ProjectId, + WorktreeKey, + WorktreeRepositoryKey, +} from "@bibcode/contracts"; import { scopeProjectRef } from "@bibcode/client-runtime/environment"; import { derivePhysicalProjectKey } from "../logicalProject"; import type { EnvironmentThreadShell } from "@bibcode/client-runtime/state/shell"; @@ -337,6 +342,90 @@ describe("SidebarThreadRow browser interactions", () => { } }); + it("reopens the real Create worktree dialog fresh after a creation (#346)", async () => { + baseScenario(); + h.state.realCreateWorktreeDialog = true; + h.state.primaryDefaultAgent = DEFAULT_SERVER_SETTINGS.defaultAgent; + h.state.serverConfigs = new Map([ + [ + ENV_MAIN, + { + environment: { label: "Main" }, + providers: [ + { + instanceId: "codex", + driver: "codex", + enabled: true, + installed: true, + status: "ready", + models: [], + }, + ], + settings: DEFAULT_SERVER_SETTINGS, + }, + ], + ]); + const { container, root } = await mount(); + const nameInput = () => + requiredElement(container, "input[placeholder='Worktree name']"); + const createButton = () => { + const match = Array.from(container.querySelectorAll("button")).find((button) => + button.textContent?.startsWith("Create worktree"), + ); + if (!match) throw new Error("Missing Create worktree button"); + return match; + }; + const openFromProjectRow = () => + dispatch( + requiredElement(container, "[aria-label='New worktree in Repo A']"), + new MouseEvent("click", { bubbles: true, cancelable: true }), + ); + async function typeInto(input: HTMLInputElement, value: string): Promise { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set?.call(input, value); + await dispatch(input, new Event("input", { bubbles: true })); + } + try { + await openFromProjectRow(); + const githubTab = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "GitHub", + ); + if (!githubTab) throw new Error("Missing GitHub tab"); + await dispatch(githubTab, new MouseEvent("click", { bubbles: true })); + await typeInto( + requiredElement( + container, + "input[placeholder='#1234 or a GitHub issue/PR URL']", + ), + "#42", + ); + await typeInto(nameInput(), "sidebar-check"); + await dispatch(createButton(), new MouseEvent("click", { bubbles: true })); + await React.act(async () => flush()); + + const created = ( + h.state.commandCalls as Array<{ label: string; input: { input: { title: string } } }> + ).filter((call) => call.label === "worktree.createManaged"); + expect(created.map((call) => call.input.input.title)).toEqual(["sidebar-check"]); + // The dialog closed itself through the Sidebar's callback and opened the new thread. + expect(captured("Dialog").at(-1)?.props["open"]).toBe(false); + expect(h.spies.navigate).toHaveBeenCalledWith( + expect.objectContaining({ to: "/$environmentId/$threadId" }), + ); + + await openFromProjectRow(); + expect(nameInput().value).toBe(""); + expect( + requiredElement( + container, + "input[placeholder='Type #1234 or search branches']", + ).value, + ).toBe(""); + expect(createButton().disabled).toBe(true); + } finally { + await unmount(root, container); + } + }); + it("starts inline rename only for an unmodified row-body double-click", async () => { const thread = makeThread("thread-browser-rename", { title: "Rename me" }); const startThreadRename = vi.fn(); diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 250521e59d..6c79c9eb66 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -766,6 +766,20 @@ describe("resolveThreadStatusPill", () => { ).toMatchObject({ label: "Plan Ready", pulse: false }); }); + it("never shows plan ready for a code review's findings (#293)", () => { + expect( + resolveThreadStatusPill({ + thread: { + ...baseThread, + codeReview: true, + hasActionableProposedPlan: true, + latestTurn: makeLatestTurn(), + session: { ...baseThread.session, status: "ready", activeTurnId: null }, + }, + })?.label, + ).not.toBe("Plan Ready"); + }); + it("does not manufacture completed state without a client visit marker", () => { expect( resolveThreadStatusPill({ @@ -868,6 +882,7 @@ function makeProject(overrides: Partial = {}): Project { createdAt: "2026-03-09T10:00:00.000Z", updatedAt: "2026-03-09T10:00:00.000Z", scripts: [], + codeReviewPrompt: "", worktreeDiscovery: { visibility: "hidden", initialPromptDismissedAt: null, baselinePaths: [] }, ...rest, }; @@ -1764,6 +1779,28 @@ describe("summarizeWorkspaceChats", () => { expect(summaries.size).toBe(2); }); + it("does not raise Plan Ready for a worktree whose only plan belongs to a review panel (#293)", () => { + const settledPlan = { + worktreePath: "/wt/review", + interactionMode: "plan" as const, + hasActionableProposedPlan: true, + latestTurn: makeLatestTurn(), + }; + const key = workspaceCheckoutKey({ + environmentId, + projectId: ProjectId.make("project-1"), + worktreePath: "/wt/review", + }); + const review = summarizeWorkspaceChats( + [panel("r", { ...settledPlan, codeReview: true })], + () => null, + ); + expect(review.get(key)?.status).not.toEqual(WORKSPACE_CARD_STATUS.plan); + // The same panel without the review flag still raises it. + const plain = summarizeWorkspaceChats([panel("p", settledPlan)], () => null); + expect(plain.get(key)?.status).toEqual(WORKSPACE_CARD_STATUS.plan); + }); + it("keeps worktree keys apart across environments and projects", () => { expect( workspaceCheckoutKey({ environmentId: "a", projectId: "p", worktreePath: "/wt" }), diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index aba18b3f96..abffa43105 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -172,6 +172,7 @@ export interface ThreadStatusPill { type ThreadStatusInput = Pick< SidebarThreadSummary, + | "codeReview" | "hasActionableProposedPlan" | "hasPendingApprovals" | "hasPendingUserInput" @@ -517,7 +518,9 @@ export function resolveThreadStatusPill(input: { return { label: "Connecting", ...STATUS_TONES.working, pulse: true }; } + // A review reports findings, not a plan to implement (#293). const hasPlanReadyPrompt = + thread.codeReview !== true && !thread.hasPendingUserInput && thread.interactionMode === "plan" && isLatestTurnSettled(thread.latestTurn, thread.session) && diff --git a/apps/web/src/components/Sidebar.test.tsx b/apps/web/src/components/Sidebar.test.tsx index abb984e4fc..c2c0a0af43 100644 --- a/apps/web/src/components/Sidebar.test.tsx +++ b/apps/web/src/components/Sidebar.test.tsx @@ -40,6 +40,7 @@ import { threadIdle, threadKeyOf, } from "./Sidebar.testHarness"; +import { classOf, levelTextTags, textColorUtilities } from "./sidebar/sidebarLevels.testSupport"; import * as Cause from "effect/Cause"; import * as Schema from "effect/Schema"; import * as React from "react"; @@ -812,7 +813,7 @@ staticDescribe("Sidebar full render", () => { const markup = render(); // One repository card; its two environment checkouts are bands inside it, not nested cards. expect(markup.match(/data-sidebar-card="true"/g)).toHaveLength(1); - expect(markup.match(/bg-sidebar-env-band/g)).toHaveLength(2); + expect(markup.match(/data-sidebar-level="environment"/g)).toHaveLength(2); }); it("orders Repositories view groups by name by default, not by recent activity", () => { @@ -899,9 +900,7 @@ staticDescribe("Sidebar full render", () => { props["data-thread-selection-safe"] !== undefined && props["size"] === "sm" && props["className"] !== undefined && - String(props["className"]).includes( - "text-left text-xs text-muted-foreground hover:bg-accent", - ), + String(props["className"]).includes("text-left text-xs text-inherit hover:bg-accent"), "show more button", ); invoke(showMore, "onClick", mouseEvent()); @@ -1015,6 +1014,7 @@ staticDescribe("Sidebar full render", () => { expect(protectAndInstall).toBeDefined(); invoke(protectAndInstall!.props, "onClick", mouseEvent()); await flush(); + expect(h.spies.askBeforeLeaving).toHaveBeenCalledWith("update"); expect(installUpdate).toHaveBeenCalled(); expect(h.spies.toastAdd).toHaveBeenCalledWith( expect.objectContaining({ title: "Could not install update", description: "install failed" }), @@ -4510,3 +4510,237 @@ staticDescribe("project rename and grouping dialogs", () => { expect(markup).toContain("own sidebar row"); }); }); + +/** The `li` whose opening tag precedes `marker`, with its whole subtree, from static markup. */ +function liWith(markup: string, marker: string): string { + const at = markup.indexOf(marker); + const start = markup.lastIndexOf("]|<\/li>/g; + pattern.lastIndex = start; + for (let match = pattern.exec(markup); match; match = pattern.exec(markup)) { + depth += match[0] === "" ? -1 : 1; + if (depth === 0) return markup.slice(start, match.index + 5); + } + throw new Error(`unclosed li with ${marker}`); +} + +const openingTag = (element: string) => element.slice(0, element.indexOf(">") + 1); + +const svgWith = (element: string, iconClass: string) => + (element.match(/]*>/g) ?? []).filter( + (tag) => tag.includes(iconClass) && tag.includes("data-sidebar-icon"), + ); + +/** One repository in two environments: a primary checkout, two worktrees and their chats. */ +function levelsScenario() { + groupedScenario(); + h.state.threads = [ + ...h.state.threads, + threadActive, + makeThread("thread-docs", { + title: "Docs worktree", + branch: "feat/docs", + worktreePath: "C:/wt/docs", + }), + makeThread("panel-on-worktree", { kind: "panel", worktreePath: "C:/wt/x" }), + makeThread("panel-main", { kind: "panel" }), + ]; + h.state.routeParams = { environmentId: ENV_MAIN, threadId: "thread-active" }; + h.state.vcsStatusByCwd = { + "C:/repo-a": { refName: "main" }, + "C:/wt/x": { refName: "feat/x" }, + "C:/wt/docs": { refName: "feat/docs" }, + }; +} + +staticDescribe("Sidebar levels (R63, R64)", () => { + it.each(["environments", "repositories"] as const)( + "marks the checkout, worktree and chat levels in the %s view", + (view) => { + levelsScenario(); + h.uiStore.setState({ sidebarView: view }); + const markup = render(); + + const primary = liWith(markup, 'data-testid="primary-card-project-a"'); + expect(openingTag(primary)).toContain('data-sidebar-level="checkout"'); + expect(openingTag(primary)).toContain("data-primary"); + expect(svgWith(primary, "lucide-house")).toHaveLength(1); + const label = primary.match(/]*data-sidebar-label[^>]*>([^<]*)]*data-sidebar-badge[^>]*>/)?.[0] ?? ""; + expect(badge).not.toBe(""); + expect(badge).not.toMatch(/\bbg-muted\b|\btext-muted-foreground\b/); + const moreChats = primary.match(/]*data-sidebar-level="chat"[^>]*>/)?.[0] ?? ""; + expect(moreChats).not.toBe(""); + expect(moreChats).not.toContain("text-muted-foreground"); + + for (const [id, branch, title] of [ + ["thread-active", "feat/x", "Active worktree"], + ["thread-docs", "feat/docs", "Docs worktree"], + ] as const) { + const worktree = liWith(markup, `data-testid="thread-row-${id}"`); + expect(openingTag(worktree)).toContain('data-sidebar-level="worktree"'); + expect(openingTag(worktree)).not.toContain("data-primary"); + expect(svgWith(worktree, "lucide-git-branch")).toHaveLength(1); + // The leading branch icon replaces the branch line's own, as in the preview. + expect(worktree.match(/]*lucide-git-branch/g)).toHaveLength(1); + // A worktree row leads with its branch, as a primary card leads with its checkout, and + // shows the chat title below at chat level, as in the preview. + const label = worktree.match(/]*data-sidebar-label[^>]*>([^<]*)]*data-testid="thread-title-${id}"[^>]*>([^<]*)<`), + ); + expect(chat?.[1]).toBe(title); + expect(chat?.[0]).toContain('data-sidebar-level="chat"'); + expect(chat?.[0]).not.toContain("data-sidebar-label"); + expect(worktree.indexOf(label![0])).toBeLessThan(worktree.indexOf(chat![0])); + } + // The card's description reads the branch line, so Archive sits outside it. + const docs = liWith(markup, 'data-testid="thread-row-thread-docs"'); + expect(docs).toContain('data-testid="thread-archive-thread-docs"'); + const start = docs.search(/
]|<\/div>/g; + pattern.lastIndex = start; + let depth = 0; + let end = -1; + for (let match = pattern.exec(docs); match && end < 0; match = pattern.exec(docs)) { + depth += match[0] === "
" ? -1 : 1; + if (depth === 0) end = match.index; + } + expect(docs.slice(start, end)).toContain("feat/docs"); + expect(docs.slice(start, end)).not.toContain("thread-archive-"); + expect(openingTag(liWith(markup, 'data-testid="thread-row-thread-active"'))).toContain( + 'data-active="true"', + ); + expect(openingTag(liWith(markup, 'data-testid="thread-row-thread-docs"'))).not.toContain( + "data-active", + ); + // A chat in the main checkout that is not the primary one is a plain checkout. + const adHoc = openingTag(liWith(markup, 'data-testid="thread-row-thread-idle"')); + expect(adHoc).toContain('data-sidebar-level="checkout"'); + expect(adHoc).not.toContain("data-primary"); + }, + ); + + it("gives no level label or chat text a colour utility, read or unread", () => { + levelsScenario(); + h.metaStore.setState({ + unreadThreadKeys: [threadKeyOf(threadActive), threadKeyOf(threadIdle)], + }); + for (const view of ["environments", "repositories"] as const) { + h.uiStore.setState({ sidebarView: view }); + const markup = render(); + const tags = levelTextTags(markup); + expect(tags.length, view).toBeGreaterThan(4); + for (const tag of tags) expect(textColorUtilities(classOf(tag)), tag).toEqual([]); + // Unread keeps its announcement and gains a dot before the unread title. + for (const id of ["thread-active", "thread-idle"]) { + const row = liWith(markup, `data-testid="thread-row-${id}"`); + expect(row).toContain(">unread"); + const title = row.match(new RegExp(`]*data-testid="thread-title-${id}"[^>]*>`))!; + expect(title[0]).toContain('data-unread="true"'); + const dot = row.indexOf("data-sidebar-unread"); + expect(dot, id).toBeGreaterThan(-1); + expect(dot).toBeLessThan(row.indexOf(title[0])); + } + } + }); + + it("keeps one name line on a worktree whose branch equals its title", () => { + levelsScenario(); + h.state.threads = h.state.threads.map((thread: EnvironmentThreadShell) => + thread.id === threadActive.id ? { ...thread, title: "feat/x" } : thread, + ); + const markup = render(); + const worktree = liWith(markup, 'data-testid="thread-row-thread-active"'); + expect(worktree.match(/data-sidebar-label/g)).toHaveLength(1); + const title = worktree.match(/]*data-testid="thread-title-thread-active"[^>]*>/)?.[0]; + expect(title).toContain("data-sidebar-label"); + expect(title).not.toContain('data-sidebar-level="chat"'); + }); + + it("draws the repository as repo level and each environment as one band in the Repositories view", () => { + levelsScenario(); + h.uiStore.setState({ sidebarView: "repositories" }); + const markup = render(); + expect(markup.match(/data-sidebar-level="repo"/g)).toHaveLength(1); + expect( + openingTag(liWith(markup, 'data-testid="repository-group-github.com/acme/repo-a"')), + ).toContain('data-sidebar-level="repo"'); + expect(markup.match(/data-sidebar-level="environment"/g)).toHaveLength(2); + // The band holds its environment's rows, as in the theme editor's preview. + const band = liWith(markup, `data-testid="environment-card-header-${ENV_MAIN}"`); + expect(openingTag(band)).toContain('data-sidebar-level="environment"'); + expect(band).toContain('data-testid="primary-card-project-a"'); + }); + + it("draws each Environments view project as repo level, sorted or manual", () => { + levelsScenario(); + const markup = render(); + // The selected environment's project card. A local environment draws no context card, so + // this view has no environment band (EnvironmentContextCard.test covers a remote's band). + expect(markup.match(/data-sidebar-level="repo"/g)).toHaveLength(1); + expect(markup.match(/data-sidebar-card="true"[^>]*data-sidebar-level="repo"/g)).toHaveLength(1); + expect(markup).not.toContain('data-sidebar-level="environment"'); + h.state.clientSettings = { ...DEFAULT_CLIENT_SETTINGS, sidebarProjectSortOrder: "manual" }; + expect(render().match(/data-sidebar-level="repo"/g)).toHaveLength(1); + }); + + it("keeps the selected, active, multi-selected, resting and unavailable states", () => { + levelsScenario(); + h.state.environments = [ + h.state.environments[0], + environmentFixture({ + environmentId: ENV_REMOTE, + label: "Remote Box", + connectionId: "remote", + phase: "disconnected", + }), + ]; + h.selectionStore.setState({ + selectedThreadKeys: new Set([threadKeyOf(threadActive), threadKeyOf(threadIdle)]), + }); + h.uiStore.setState({ sidebarView: "repositories" }); + const markup = render(); + const selectedActive = openingTag(liWith(markup, 'data-testid="thread-row-thread-active"')); + expect(selectedActive).toContain('data-selected="true"'); + expect(selectedActive).toContain('data-active="true"'); + expect(selectedActive).toContain("border-primary/60"); + const selected = openingTag(liWith(markup, 'data-testid="thread-row-thread-idle"')); + expect(selected).toContain('data-selected="true"'); + expect(selected).not.toContain("data-active"); + expect(selected).toContain("border-primary/40"); + const resting = openingTag(liWith(markup, 'data-testid="thread-row-thread-docs"')); + expect(resting).not.toContain("data-selected"); + expect(resting).toContain("hover:bg-accent/60"); + expect(openingTag(liWith(markup, 'data-testid="primary-card-project-a"'))).not.toContain( + "data-selected", + ); + const remoteBand = liWith(markup, `data-testid="environment-card-header-${ENV_REMOTE}"`); + // The band carries the attribute and no opacity, so its rows keep full contrast and focus ring; + // only the header dims. + expect(openingTag(remoteBand)).toContain('data-sidebar-level="environment"'); + expect(openingTag(remoteBand)).toContain('data-unavailable="true"'); + expect(openingTag(remoteBand)).not.toMatch(/class="[^"]*\bopacity-/); + expect(remoteBand).toMatch( + /]*\bopacity-60\b[^>]*data-testid="environment-card-header-/, + ); + }); + + it("draws the hidden worktrees row as chat level without its own text colour", () => { + levelsScenario(); + h.state.clientSettings = { ...DEFAULT_CLIENT_SETTINGS, sidebarThreadPreviewCount: 1 }; + const markup = render(); + const showMore = liWith(markup, ">Show more<"); + expect(openingTag(showMore)).toContain('data-sidebar-level="chat"'); + expect(showMore).not.toMatch( + /class="[^"]*\btext-muted-foreground\b[^"]*"[^>]*>(?:<[^>]*>)*Show more/, + ); + }); +}); diff --git a/apps/web/src/components/Sidebar.testHarness.tsx b/apps/web/src/components/Sidebar.testHarness.tsx index 6f24764067..5067cd28c1 100644 --- a/apps/web/src/components/Sidebar.testHarness.tsx +++ b/apps/web/src/components/Sidebar.testHarness.tsx @@ -88,6 +88,9 @@ const h = vi.hoisted(() => { showJumpHintModifiers: false, terminalSurfaceOpen: false, presentationPolicy: null as unknown, + // A test that sets this renders the real CreateWorktreeDialog under the Sidebar. + realCreateWorktreeDialog: false, + primaryDefaultAgent: null as unknown, // mock stores ui: null, selection: null, @@ -203,6 +206,8 @@ const h = vi.hoisted(() => { closestCorners: vi.fn(), copyToClipboard: vi.fn(), windowConfirm: vi.fn(), + askBeforeLeaving: vi.fn(), + releaseLeaveHolds: vi.fn(), openProjectDataRecovery: vi.fn(async () => undefined), }; @@ -420,6 +425,7 @@ vi.mock("../state/query", () => ({ emission: { _tag: "Success" }, isPending: false, refresh: () => {}, + revalidate: () => {}, }; }, })); @@ -485,6 +491,7 @@ vi.mock("../state/threads", () => ({ vi.mock("../state/vcs", () => ({ vcsEnvironment: { status: (args: unknown) => ({ __q: "vcs.status", args }), + listRefs: (args: unknown) => ({ __q: "vcs.listRefs", args }), summary: (args: unknown) => ({ __q: "vcs.summary", args }), pull: { label: "vcs.pull" }, refreshStatus: { label: "vcs.refreshStatus" }, @@ -510,12 +517,19 @@ vi.mock("../state/worktrees", () => ({ updatePolicy: { label: "worktree.updatePolicy" }, addOne: { label: "worktree.addOne" }, addAll: { label: "worktree.addAll" }, + createManaged: { label: "worktree.createManaged" }, }, useWorktreeCatalogFocusRefresh: (projects: ReadonlyArray) => { h.state.discoveryFocusRefreshCalls.push([...projects]); }, })); +// The real module reads document.activeElement; these tests run without a DOM. +vi.mock("./files/unsavedFileEdits", () => ({ + askBeforeLeaving: h.spies.askBeforeLeaving, + releaseLeaveHolds: h.spies.releaseLeaveHolds, +})); + vi.mock("../state/projectDataSafety", () => ({ projectDataSafetyStore: { open: h.spies.openProjectDataRecovery, @@ -586,6 +600,7 @@ vi.mock("~/hooks/useSettings", () => ({ selector({ ...h.state.clientSettings }), useClientSettings: (selector: (settings: unknown) => unknown) => selector(h.state.clientSettings), useUpdateClientSettings: () => h.spies.updateSettings, + usePrimaryDefaultAgent: () => h.state.primaryDefaultAgent, })); vi.mock("~/hooks/useCopyToClipboard", () => ({ @@ -683,9 +698,17 @@ vi.mock("./ThreadStatusIndicators", () => ({ })); vi.mock("./ProjectFavicon", () => ({ ProjectFavicon: h.mk("ProjectFavicon", "span") })); -vi.mock("./CreateWorktreeDialog", () => ({ - CreateWorktreeDialog: h.mk("CreateWorktreeDialog"), -})); +vi.mock("./CreateWorktreeDialog", async (importOriginal) => { + const actual = await importOriginal(); + const Mocked = h.mk("CreateWorktreeDialog"); + const CreateWorktreeDialog = (props: Parameters[0]) => { + const R = h.state.React as typeof import("react"); + return h.state.realCreateWorktreeDialog + ? R.createElement(actual.CreateWorktreeDialog, props) + : R.createElement(Mocked, props as unknown as Record); + }; + return { CreateWorktreeDialog }; +}); vi.mock("./settings/SettingsSidebarNav", () => ({ SettingsSidebarNav: h.mk("SettingsSidebarNav"), })); @@ -752,6 +775,8 @@ vi.mock("./ui/number-field", () => ({ vi.mock("./ui/select", () => ({ Select: h.mk("Select"), + SelectGroup: h.mk("SelectGroup"), + SelectGroupLabel: h.mk("SelectGroupLabel"), SelectItem: h.mk("SelectItem"), SelectPopup: h.mk("SelectPopup"), SelectTrigger: h.mk("SelectTrigger", "button"), @@ -806,6 +831,7 @@ function makeProject( repositoryIdentity: null, defaultModelSelection: null, scripts: [], + codeReviewPrompt: "", worktreeDiscovery: { visibility: "hidden", initialPromptDismissedAt: null, baselinePaths: [] }, createdAt: iso(600), updatedAt: iso(60), @@ -1213,6 +1239,8 @@ beforeEach(() => { h.state.shortcutLabelOptions = []; h.state.showJumpHintModifiers = false; h.state.terminalSurfaceOpen = false; + h.state.realCreateWorktreeDialog = false; + h.state.primaryDefaultAgent = null; h.state.presentationPolicy = createEnvironmentPresentationPolicy({ surface: "browser", platform: "unknown", @@ -1228,6 +1256,7 @@ beforeEach(() => { h.spies.pointerWithin.mockReturnValue([]); h.spies.closestCorners.mockReturnValue([]); h.spies.windowConfirm.mockReturnValue(true); + h.spies.askBeforeLeaving.mockResolvedValue("proceed"); if (!browserRuntime) { vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => { diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 94a2b7ccf1..63b3e53de2 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,6 +7,7 @@ import { FolderPlusIcon, GitBranchIcon, GitPullRequestIcon, + HouseIcon, LoaderIcon, PlusIcon, SearchIcon, @@ -1174,6 +1175,29 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr Archive ); + // A worktree row leads with its branch, as the primary card leads with its checkout, and + // shows the chat title below it. A branch equal to the title stays one line. + const leadsWithBranch = thread.worktreePath !== null && showBranchText; + const branchTooltip = showBranchText + ? resolveWorkspaceCardBranchTooltip({ + branch: branchLabel, + worktreePath: thread.worktreePath, + checkoutPath: gitCwd, + }) + : null; + const indicators = ( + <> + {prStatus ? : null} + {dirty ? : null} + {terminalStatus ? ( + + ) : null} + + + ); return ( + {leadsWithBranch ? ( + + {indicators} + + ) : null} ) : undefined } - trailing={trailing} + trailing={leadsWithBranch ? undefined : trailing} badge={ thread.requester ? ( - {hasBranchLine ? ( + {hasBranchLine && !leadsWithBranch ? ( - {prStatus ? : null} - {dirty ? : null} - {terminalStatus ? ( - - ) : null} - + {indicators} ) : null} {hasSessionLine ? ( @@ -1530,6 +1554,9 @@ const SidebarPrimaryCard = memo(function SidebarPrimaryCard(props: SidebarPrimar className={resolveWorkspaceCardClassName({ isActive, isSelected: false })} idBase={idBase} isActive={isActive} + isSelected={false} + level="checkout" + primary hasFlags={isUnread || isPinned} hasBranchLine={hasBranchLine} hasSessionLine={hasSessionLine} @@ -1543,6 +1570,7 @@ const SidebarPrimaryCard = memo(function SidebarPrimaryCard(props: SidebarPrimar flagsId={cardIds.flags} title={title} titleTestId={`primary-card-title-${project.id}`} + icon={HouseIcon} unread={isUnread} pinned={isPinned} primary @@ -1770,12 +1798,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( })} {projectExpanded && hasOverflowingThreads && !isThreadListExpanded && ( - + { expandThreadListForProject(projectKey); }} @@ -1788,12 +1816,12 @@ const SidebarProjectThreadList = memo(function SidebarProjectThreadList( )} {projectExpanded && hasOverflowingThreads && isThreadListExpanded && ( - + { collapseThreadListForProject(projectKey); }} @@ -3381,13 +3409,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return ( <> -
+
@@ -4204,6 +4231,7 @@ function SortableProjectItem({ isOver && !isDragging && "ring-1 ring-primary/40", )} data-sidebar-card="true" + data-sidebar-level="repo" data-sidebar="menu-item" data-slot="sidebar-menu-item" > diff --git a/apps/web/src/components/SourceControlChangesList.behavior.test.tsx b/apps/web/src/components/SourceControlChangesList.behavior.test.tsx index 02f5510a3f..f4547c00a8 100644 --- a/apps/web/src/components/SourceControlChangesList.behavior.test.tsx +++ b/apps/web/src/components/SourceControlChangesList.behavior.test.tsx @@ -230,7 +230,7 @@ describe("SourceControlChangesList", () => { "Deselect src/file.ts", ]); expect(harness.buttons).toHaveLength(0); - expect(host.innerHTML).toContain("bg-accent/70"); + expect(host.innerHTML).toContain("bg-selected "); trigger(harness.checkboxes[1], "onCheckedChange", true); expect(section.onSelect).toHaveBeenCalledWith("README.md", true); expect(section.onToggle).not.toHaveBeenCalled(); diff --git a/apps/web/src/components/SourceControlChangesList.tsx b/apps/web/src/components/SourceControlChangesList.tsx index db73e6a4a4..bf2888e290 100644 --- a/apps/web/src/components/SourceControlChangesList.tsx +++ b/apps/web/src/components/SourceControlChangesList.tsx @@ -383,7 +383,7 @@ function SourceControlFileRow({ "group flex h-full items-center gap-2 rounded-md px-2 hover:bg-accent/50", // Indents a grouped row so its name lines up under the folder label. !showDirectory && "pl-7", - section.selectionMode && isSelected && "bg-accent/70", + section.selectionMode && isSelected && "bg-selected text-selected-foreground", )} onContextMenu={(event) => { event.preventDefault(); diff --git a/apps/web/src/components/SourceControlPanel.logic.test.ts b/apps/web/src/components/SourceControlPanel.logic.test.ts index a9926ed12f..3e2f4c2168 100644 --- a/apps/web/src/components/SourceControlPanel.logic.test.ts +++ b/apps/web/src/components/SourceControlPanel.logic.test.ts @@ -9,6 +9,9 @@ import { ignoreParentFolderPattern, isFileStaged, type PendingDiscard, + resolveCodeReviewButtonState, + resolveCodeReviewScope, + codeReviewTooltip, resolveDiscardDialogCopy, resolveStagingToggleAction, resolveVsBaseLabel, @@ -275,3 +278,112 @@ describe("discardPathsOf", () => { ).toEqual(["a", "b"]); }); }); + +describe("resolveCodeReviewScope", () => { + const dirty: VcsStatusResult = { + ...BASE_STATUS, + hasWorkingTreeChanges: true, + workingTree: { + files: [{ path: "a.ts", insertions: 1, deletions: 0, area: "unstaged" }], + insertions: 1, + deletions: 0, + }, + }; + + it("picks uncommitted scope for a dirty tree with no commits ahead", () => { + expect( + resolveCodeReviewScope({ ...dirty, defaultRefName: "main", aheadOfDefaultCount: 0 }), + ).toEqual({ kind: "uncommitted", base: "origin/main" }); + }); + + it("picks branch scope for a clean branch with commits ahead", () => { + expect( + resolveCodeReviewScope({ ...BASE_STATUS, defaultRefName: "main", aheadOfDefaultCount: 2 }), + ).toEqual({ kind: "branch", base: "origin/main" }); + }); + + it("picks branch scope when commits are ahead and the tree is dirty", () => { + expect( + resolveCodeReviewScope({ ...dirty, defaultRefName: "main", aheadOfDefaultCount: 2 }), + ).toEqual({ kind: "branch", base: "origin/main" }); + }); + + it("returns null with nothing to review", () => { + expect( + resolveCodeReviewScope({ ...BASE_STATUS, defaultRefName: "main", aheadOfDefaultCount: 0 }), + ).toBeNull(); + }); + + it("returns null for a clean tree when the server reports no count ahead", () => { + expect(resolveCodeReviewScope({ ...BASE_STATUS, defaultRefName: "main" })).toBeNull(); + }); + + it("picks uncommitted scope on the default branch with edits", () => { + expect( + resolveCodeReviewScope({ + ...dirty, + refName: "main", + isDefaultRef: true, + defaultRefName: "main", + aheadOfDefaultCount: 0, + }), + ).toEqual({ kind: "uncommitted", base: "origin/main" }); + }); + + it("has no base without a default ref, and no branch scope", () => { + expect(resolveCodeReviewScope({ ...dirty, aheadOfDefaultCount: 3 })).toEqual({ + kind: "uncommitted", + base: null, + }); + expect(resolveCodeReviewScope({ ...BASE_STATUS, aheadOfDefaultCount: 3 })).toBeNull(); + }); + + it("returns null without a status", () => { + expect(resolveCodeReviewScope(null)).toBeNull(); + expect(resolveCodeReviewScope(undefined)).toBeNull(); + }); +}); + +describe("codeReviewTooltip (R104)", () => { + it("names the scope and its base", () => { + expect(codeReviewTooltip({ kind: "branch", base: "origin/main" })).toBe( + "Review branch vs origin/main and uncommitted changes", + ); + expect(codeReviewTooltip({ kind: "uncommitted", base: "origin/main" })).toBe( + "Review uncommitted changes", + ); + }); +}); + +describe("resolveCodeReviewButtonState", () => { + const scope = { kind: "uncommitted", base: "origin/main" } as const; + + it("is disabled while the first status loads", () => { + expect( + resolveCodeReviewButtonState({ status: null, scope: null, unavailableReason: null }), + ).toEqual({ disabled: true, reason: "Checking for changes" }); + }); + + it("is disabled with nothing to review", () => { + expect( + resolveCodeReviewButtonState({ status: BASE_STATUS, scope: null, unavailableReason: null }), + ).toEqual({ disabled: true, reason: "No changes to review" }); + }); + + it("is enabled with a scope", () => { + expect( + resolveCodeReviewButtonState({ status: BASE_STATUS, scope, unavailableReason: null }), + ).toEqual({ disabled: false, reason: null }); + }); + + it("lets the unavailable reason take precedence", () => { + const unavailableReason = "Update the BiBCode server on this environment to use Review."; + expect(resolveCodeReviewButtonState({ status: BASE_STATUS, scope, unavailableReason })).toEqual( + { disabled: true, reason: unavailableReason }, + ); + expect(resolveCodeReviewButtonState({ status: null, scope: null, unavailableReason })).toEqual({ + disabled: true, + reason: unavailableReason, + }); + }); +}); diff --git a/apps/web/src/components/SourceControlPanel.logic.ts b/apps/web/src/components/SourceControlPanel.logic.ts index c8a6724eaf..c2c02979ca 100644 --- a/apps/web/src/components/SourceControlPanel.logic.ts +++ b/apps/web/src/components/SourceControlPanel.logic.ts @@ -125,6 +125,42 @@ export function resolveVsBaseLabel(status: VcsStatusResult | null | undefined): : `vs ${status.defaultRefName}`; } +/** What a code review covers (#293): branch commits plus the tree, or the tree alone. */ +export type CodeReviewScope = { + readonly kind: "branch" | "uncommitted"; + readonly base: string | null; +}; + +export function resolveCodeReviewScope( + status: VcsStatusResult | null | undefined, +): CodeReviewScope | null { + if (!status?.isRepo) return null; + const base = status.defaultRefName ? `origin/${status.defaultRefName}` : null; + if (base !== null && (status.aheadOfDefaultCount ?? 0) > 0) return { kind: "branch", base }; + if (status.hasWorkingTreeChanges || status.workingTree.files.length > 0) { + return { kind: "uncommitted", base }; + } + return null; +} + +/** The enabled button's tooltip names what a review covers (R104). */ +export function codeReviewTooltip(scope: CodeReviewScope): string { + return scope.kind === "branch" + ? `Review branch vs ${scope.base} and uncommitted changes` + : "Review uncommitted changes"; +} + +export function resolveCodeReviewButtonState(input: { + readonly status: VcsStatusResult | null; + readonly scope: CodeReviewScope | null; + readonly unavailableReason: string | null; +}): { readonly disabled: boolean; readonly reason: string | null } { + if (input.unavailableReason !== null) return { disabled: true, reason: input.unavailableReason }; + if (input.status === null) return { disabled: true, reason: "Checking for changes" }; + if (input.scope === null) return { disabled: true, reason: "No changes to review" }; + return { disabled: false, reason: null }; +} + /** * A pending destructive change awaiting the panel's confirm dialog. A single * row ("entry") derives its copy from the file's area/status; a section header diff --git a/apps/web/src/components/SourceControlPanel.test.tsx b/apps/web/src/components/SourceControlPanel.test.tsx index e43206fc95..0abe752eab 100644 --- a/apps/web/src/components/SourceControlPanel.test.tsx +++ b/apps/web/src/components/SourceControlPanel.test.tsx @@ -8,7 +8,7 @@ import { } from "@bibcode/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; -import type { Dispatch, ReactElement, SetStateAction } from "react"; +import { type Dispatch, type ReactElement, type SetStateAction } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; @@ -123,6 +123,7 @@ const testState = vi.hoisted(() => ({ primaryEnvironmentId: null as unknown, pullRequestBranchSelection: true, mergeCapabilities: true, + codeReviews: true, runGitManagerOperation: vi.fn(), availableEditors: [] as string[], preferredEditor: null as string | null, @@ -226,6 +227,24 @@ const captured = vi.hoisted(() => ({ }, })); +const reviewPromptDialog = vi.hoisted(() => ({ props: null as Record | null })); +vi.mock("./sourceControl/CodeReviewProjectPromptDialog", () => ({ + CodeReviewProjectPromptDialog: (props: Record) => { + reviewPromptDialog.props = props; + return null; + }, +})); + +const reviewInstructionsDialog = vi.hoisted(() => ({ + props: null as Record | null, +})); +vi.mock("./sourceControl/CodeReviewInstructionsDialog", () => ({ + CodeReviewInstructionsDialog: (props: Record) => { + reviewInstructionsDialog.props = props; + return null; + }, +})); + vi.mock("./gitManager/provider/GitManagerCreatePullRequestDialog", () => ({ GitManagerCreatePullRequestDialog: (props: GitManagerCreatePullRequestDialogProps) => { captured.createPullRequest = props; @@ -320,6 +339,7 @@ vi.mock("~/state/entities", () => ({ gitPullRequestBranchSelection: testState.pullRequestBranchSelection, gitManagerStashMergeOperations: testState.mergeCapabilities, gitManagerBranchSyncOperations: testState.mergeCapabilities, + codeReviews: testState.codeReviews, }, }, }), @@ -442,6 +462,17 @@ vi.mock("~/components/ui/button", () => ({ }, })); +vi.mock("~/components/ui/tooltip", () => ({ + Tooltip: (props: { children?: unknown }) => <>{props.children as never}, + TooltipTrigger: (props: { render: ReactElement; children?: unknown }) => { + const Trigger = props.render.type as (input: Record) => ReactElement; + return Trigger({ ...(props.render.props as object), children: props.children }); + }, + TooltipPopup: (props: { children?: unknown }) => ( +
{props.children as never}
+ ), +})); + vi.mock("~/components/ui/dialog", () => ({ Dialog: (props: CapturedDialogProps) => { captured.dialogs.push(props); @@ -467,6 +498,8 @@ vi.mock("~/components/ui/menu", () => ({ ), MenuPopup: (props: { children?: unknown }) =>
{props.children as never}
, MenuSeparator: () =>
, + MenuGroup: (props: { children?: unknown }) =>
{props.children as never}
, + MenuGroupLabel: (props: { children?: unknown }) =>
{props.children as never}
, MenuItem: (props: CapturedMenuItemProps) => { captured.menuItems.push(props); return ( @@ -717,6 +750,7 @@ beforeEach(() => { testState.primaryEnvironmentId = ENVIRONMENT_ID; testState.pullRequestBranchSelection = true; testState.mergeCapabilities = true; + testState.codeReviews = true; testState.runGitManagerOperation = vi.fn(() => ({ result: new Promise(() => undefined), cancel: vi.fn(), @@ -749,6 +783,17 @@ beforeEach(() => { testState.toast.close.mockReset(); }); +const REMOTE_PUSH_GUIDANCE = + "Push ran on the remote server example-server. In a terminal on example-server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode."; +const GIT_USERNAME_LINE = + "fatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled"; +const REMOTE_PUSH_FAILURE = `${REMOTE_PUSH_GUIDANCE}\n\n${GIT_USERNAME_LINE}`; + +/** The failure toast's description is the raw string; the shared toast renders the line breaks. */ +function expectMultiLineDescription(description: unknown): void { + expect(description).toBe(REMOTE_PUSH_FAILURE); +} + describe("SourceControlPanel", () => { it("pushes before opening the merge request review, and cancel does no further work", async () => { testState.statusQuery.data = status({ @@ -1082,6 +1127,227 @@ describe("SourceControlPanel", () => { ); }); + describe("Review changes (R90)", () => { + const GUARD = "Starts in Claude's plan mode."; + const reviewProps = (unavailableReason: string | null = null, onStart = vi.fn()) => + buildProps({ + codeReview: { + unavailableReason, + guardLine: GUARD, + onStart, + projectId: "project-1" as never, + projectPrompt: "", + userPrompt: "", + }, + }); + const reviewButton = () => + captured.buttons.find((button) => button["aria-label"] === "Review changes"); + const tooltipText = (markup: string) => + [...markup.matchAll(/data-testid="tooltip">(.*?)<\/div>/g)].map((match) => + match[1]!.replaceAll("'", "'"), + ); + + it("is absent without a review handler", () => { + render(buildProps()); + expect(reviewButton()).toBeUndefined(); + }); + + it("is enabled for a dirty tree and names the guard", () => { + testState.statusQuery.data = stagingStatus({ + refName: "main", + isDefaultRef: true, + defaultRefName: "main", + aheadOfDefaultCount: 0, + }); + const onStart = vi.fn(); + const markup = render(reviewProps(null, onStart)); + expect(reviewButton()).toBeDefined(); + expect((reviewButton() as Record)["aria-disabled"]).toBe(false); + expect(tooltipText(markup).join("|")).toContain("Review uncommitted changes"); + expect(tooltipText(markup).join("|")).toContain(GUARD); + reviewButton()!.onClick!(); + expect(onStart).toHaveBeenCalledWith({ kind: "uncommitted", base: "origin/main" }); + }); + + it("is enabled for a clean branch with commits ahead and starts a branch review", () => { + testState.statusQuery.data = status({ defaultRefName: "main", aheadOfDefaultCount: 2 }); + const onStart = vi.fn(); + const markup = render(reviewProps(null, onStart)); + // R104: the scope and base, then the guard line. + expect(tooltipText(markup)).toEqual([ + `Review branch vs origin/main and uncommitted changes${GUARD}`, + ]); + reviewButton()!.onClick!(); + expect(onStart).toHaveBeenCalledWith({ kind: "branch", base: "origin/main" }); + }); + + it.each([ + ["nothing to review", () => status({ defaultRefName: "main", aheadOfDefaultCount: 0 })], + ["no count ahead from the server", () => status({ defaultRefName: "main" })], + ])("is disabled with %s", (_name, makeStatus) => { + testState.statusQuery.data = makeStatus(); + const onStart = vi.fn(); + const markup = render(reviewProps(null, onStart)); + expect((reviewButton() as Record)["aria-disabled"]).toBe(true); + expect(tooltipText(markup)).toContain("No changes to review"); + reviewButton()!.onClick!(); + expect(onStart).not.toHaveBeenCalled(); + }); + + it("is disabled while the first status loads", () => { + testState.statusQuery = { data: null, error: null, isPending: true, refresh: vi.fn() }; + const markup = render(reviewProps()); + expect((reviewButton() as Record)["aria-disabled"]).toBe(true); + expect(tooltipText(markup)).toContain("Checking for changes"); + }); + + it("opens the project review prompt dialog from the review menu (R100)", () => { + render( + buildProps({ + codeReview: { + unavailableReason: null, + guardLine: GUARD, + onStart: vi.fn(), + projectId: "project-1" as never, + projectPrompt: "Our rules", + userPrompt: "User rules", + }, + }), + ); + expect(reviewPromptDialog.props?.["open"]).toBe(false); + const item = menuItemByText("Review prompt for this project…"); + expect(item?.disabled).toBe(false); + item!.onClick!(); + rerender(); + expect(reviewPromptDialog.props).toMatchObject({ + open: true, + environmentId: ENVIRONMENT_ID, + projectId: "project-1", + projectPrompt: "Our rules", + userPrompt: "User rules", + }); + }); + + it("disables the project prompt on a server without code reviews", () => { + testState.codeReviews = false; + render(reviewProps()); + const item = menuItemByText("Review prompt for this project…"); + expect(item?.disabled).toBe(true); + expect(item?.title).toBe("Update the BiBCode server on this environment to use Review."); + }); + + describe("one-off reviewers in the review menu (R103)", () => { + const WINDOWS_REASON = + "Terminal reviews are off on Windows for now. Pick a chat reviewer in Settings → Agents."; + const CLAUDE_TERMINAL = { kind: "terminal", instanceId: "claudeAgent" } as never; + const CODEX_TERMINAL = { kind: "terminal", instanceId: "codex" } as never; + const reviewerProps = (onStart = vi.fn()) => + buildProps({ + codeReview: { + unavailableReason: null, + guardLine: GUARD, + onStart, + projectId: "project-1" as never, + projectPrompt: "", + userPrompt: "", + reviewers: [ + { + value: "terminal:claudeAgent", + label: "Claude Terminal", + selection: CLAUDE_TERMINAL, + guardLine: GUARD, + disabledReason: null, + }, + { + value: "terminal:codex", + label: "Codex Terminal", + selection: CODEX_TERMINAL, + guardLine: "Runs in Codex's read-only sandbox.", + disabledReason: WINDOWS_REASON, + }, + ], + }, + }); + + it("lists each reviewer with its guard line and starts the picked one", () => { + testState.statusQuery.data = stagingStatus({ defaultRefName: "main" }); + const onStart = vi.fn(); + render(reviewerProps(onStart)); + const item = menuItemByText("Claude Terminal"); + expect(item?.disabled).toBe(false); + expect(flattenText(item?.children)).toContain(GUARD); + item!.onClick!(); + expect(onStart).toHaveBeenCalledWith( + { kind: "uncommitted", base: "origin/main" }, + CLAUDE_TERMINAL, + ); + }); + + it("shows a disabled reviewer's reason in place of its guard line and starts nothing", () => { + const onStart = vi.fn(); + render(reviewerProps(onStart)); + const item = menuItemByText("Codex Terminal"); + expect(item?.disabled).toBe(true); + expect(flattenText(item?.children)).toContain(WINDOWS_REASON); + expect(flattenText(item?.children)).not.toContain("read-only sandbox"); + item!.onClick?.(); + expect(onStart).not.toHaveBeenCalled(); + }); + + it("disables every reviewer with the reason while there is nothing to review", () => { + testState.statusQuery.data = status({ defaultRefName: "main", aheadOfDefaultCount: 0 }); + const onStart = vi.fn(); + render(reviewerProps(onStart)); + const item = menuItemByText("Claude Terminal"); + expect(item?.disabled).toBe(true); + expect(flattenText(item?.children)).toContain("No changes to review"); + item!.onClick?.(); + expect(onStart).not.toHaveBeenCalled(); + }); + }); + + describe("Review with instructions (R105)", () => { + it("opens the instructions dialog and starts the saved reviewer with them", () => { + testState.statusQuery.data = stagingStatus({ defaultRefName: "main" }); + const onStart = vi.fn(); + render(reviewProps(null, onStart)); + expect(reviewInstructionsDialog.props?.["open"]).toBe(false); + const item = menuItemByText("Review with instructions…"); + expect(item?.disabled).toBe(false); + item!.onClick!(); + rerender(); + expect(reviewInstructionsDialog.props?.["open"]).toBe(true); + (reviewInstructionsDialog.props!["onStart"] as (extra: string) => void)( + "Focus on the parser.", + ); + expect(onStart).toHaveBeenCalledWith( + { kind: "uncommitted", base: "origin/main" }, + undefined, + "Focus on the parser.", + ); + }); + + it("is disabled with the button's reason", () => { + testState.statusQuery.data = status({ defaultRefName: "main", aheadOfDefaultCount: 0 }); + render(reviewProps()); + const item = menuItemByText("Review with instructions…"); + expect(item?.disabled).toBe(true); + expect(flattenText(item?.children)).toContain("No changes to review"); + }); + }); + + it("shows the unavailable reason first", () => { + const reason = "Update the BiBCode server on this environment to use Review."; + const onStart = vi.fn(); + const markup = render(reviewProps(reason, onStart)); + expect((reviewButton() as Record)["aria-disabled"]).toBe(true); + expect(tooltipText(markup)).toContain(reason); + expect(tooltipText(markup).join("|")).not.toContain(GUARD); + reviewButton()!.onClick!(); + expect(onStart).not.toHaveBeenCalled(); + }); + }); + it("groups files into staged, unstaged and untracked sections", () => { render(buildProps()); expect(sectionByTitle("Staged Changes")?.files).toEqual([STAGED_FILE]); @@ -1580,6 +1846,33 @@ describe("SourceControlPanel", () => { ); }); + it("a failed push from the panel shows the guidance and Git's own line", async () => { + testState.runAction.mockImplementationOnce(async (input: { actionId: string }) => + AsyncResult.failure( + Cause.fail( + new VcsActionRemoteFailureError({ + actionId: input.actionId, + transportActionId: input.actionId, + action: "commit_push", + environmentId: ENVIRONMENT_ID, + cwd: "/repo", + phase: "push", + remoteMessage: REMOTE_PUSH_FAILURE, + }), + ), + ), + ); + render(buildProps()); + + buttonsByText("Commit (1)")[0]?.onClick?.(); + await flushPromises(); + expect(testState.toast.update).toHaveBeenLastCalledWith( + "toast-1", + expect.objectContaining({ type: "error", title: "Push failed" }), + ); + expectMultiLineDescription(testState.toast.update.mock.calls.at(-1)?.[1].description); + }); + it("renders the legacy flat section and commits by explicit file paths", async () => { testState.draft = { message: "Legacy message" }; testState.statusQuery.data = legacyStatus(); diff --git a/apps/web/src/components/SourceControlPanel.tsx b/apps/web/src/components/SourceControlPanel.tsx index c78186ae8d..d38336dccf 100644 --- a/apps/web/src/components/SourceControlPanel.tsx +++ b/apps/web/src/components/SourceControlPanel.tsx @@ -1,5 +1,6 @@ import { scopedThreadKey } from "@bibcode/client-runtime/environment"; import { + type DefaultAgentSelection, GitSignInRequiredError, type GitActionProgressEvent, type GitManagerOperationEvent, @@ -7,6 +8,7 @@ import { type GitManagerRefEntry, type GitRunStackedActionInput, type GitStackedAction, + type ProjectId, type ScopedProjectRef, type ScopedThreadRef, type VcsPullInput, @@ -28,6 +30,7 @@ import { GitCommitIcon, ListChecksIcon, ListIcon, + ScanSearchIcon, SparklesIcon, SquareIcon, Undo2Icon, @@ -43,8 +46,17 @@ import { DialogPopup, DialogTitle, } from "~/components/ui/dialog"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuItem, + MenuPopup, + MenuSeparator, + MenuTrigger, +} from "~/components/ui/menu"; import { Spinner } from "~/components/ui/spinner"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { Textarea } from "~/components/ui/textarea"; import { stackedThreadToast, toastManager, type ThreadToastData } from "~/components/ui/toast"; import { GitSignInPrompt } from "~/components/gitSignIn/GitSignInPrompt"; @@ -111,7 +123,11 @@ import { ignoreFileNamePattern, ignoreParentFolderPattern, isFileStaged, + type CodeReviewScope, + codeReviewTooltip, type PendingDiscard, + resolveCodeReviewButtonState, + resolveCodeReviewScope, resolveDiscardDialogCopy, resolveStagingToggleAction, resolveVsBaseLabel, @@ -125,6 +141,9 @@ import { GitManagerMergeDialog } from "./gitManager/merge/GitManagerMergeDialog" import { GitManagerOperationBanner } from "./gitManager/toolbar/GitManagerOperationBanner"; import { canOpenPullRequestInPanel } from "./pullRequests/pullRequestsAvailability"; import { SourceControlMergeChanges } from "./SourceControlMergeChanges"; +import { CodeReviewInstructionsDialog } from "./sourceControl/CodeReviewInstructionsDialog"; +import { CodeReviewProjectPromptDialog } from "./sourceControl/CodeReviewProjectPromptDialog"; +import { CODE_REVIEW_SERVER_UPDATE_REASON, type CodeReviewMenuReviewer } from "~/codeReview"; import { GitManagerCreatePullRequestDialog, type GitManagerCreatePullRequestDialogProps, @@ -137,6 +156,23 @@ interface SourceControlPanelProps { workspaceUnavailable?: string | null; /** The thread's project; Git Manager operations (fetch, merge) are scoped to it. */ projectRef?: ScopedProjectRef | null; + /** "Review changes" (#293); absent hides the button. */ + codeReview?: { + readonly unavailableReason: string | null; + readonly guardLine: string | null; + /** `reviewer` is a one-off pick from the review menu; absent means the saved reviewer. */ + readonly onStart: ( + scope: CodeReviewScope, + reviewer?: DefaultAgentSelection, + instructions?: string, + ) => string | null | undefined; + /** The reviewers the review menu offers for one review (R103). */ + readonly reviewers?: ReadonlyArray; + /** The thread's project, whose review prompt the menu edits; `null` hides the menu. */ + readonly projectId: ProjectId | null; + readonly projectPrompt: string; + readonly userPrompt: string; + } | null; } const EMPTY_REFS: ReadonlyArray = Object.freeze([]); @@ -187,6 +223,7 @@ export default function SourceControlPanel({ gitCwd, workspaceUnavailable = null, projectRef = null, + codeReview = null, }: SourceControlPanelProps) { const environmentId = threadRef.environmentId; const scope = useMemo(() => ({ environmentId, cwd: gitCwd }), [environmentId, gitCwd]); @@ -354,6 +391,35 @@ export default function SourceControlPanel({ const isDefaultRef = status?.isDefaultRef ?? false; const hasPrimaryRemote = status?.hasPrimaryRemote ?? false; const vsBaseLabel = useMemo(() => resolveVsBaseLabel(status), [status]); + const [reviewPromptDialogOpen, setReviewPromptDialogOpen] = useState(false); + const [reviewInstructionsOpen, setReviewInstructionsOpen] = useState(false); + // An older server would drop the project prompt without an error (D47). + const codeReviewsSupported = capabilities?.codeReviews === true; + // Read once at the click: a later status change does not alter a started review. + const codeReviewScope = useMemo(() => resolveCodeReviewScope(status), [status]); + const codeReviewUnavailableReason = codeReview?.unavailableReason ?? null; + const codeReviewButton = useMemo( + () => + resolveCodeReviewButtonState({ + status, + scope: codeReviewScope, + unavailableReason: codeReviewUnavailableReason, + }), + [codeReviewScope, codeReviewUnavailableReason, status], + ); + const codeReviewMenuReviewers = codeReview?.reviewers; + const codeReviewMenuItems = useMemo( + () => + (codeReviewMenuReviewers ?? []).map((reviewer) => ({ + reviewer, + state: resolveCodeReviewButtonState({ + status, + scope: codeReviewScope, + unavailableReason: reviewer.disabledReason, + }), + })), + [codeReviewMenuReviewers, codeReviewScope, status], + ); // Legacy servers (no staging areas) keep the chat-header cascade // (commit_push-first, commit-by-filePaths). The primary button and dropdown @@ -1093,6 +1159,102 @@ export default function SourceControlPanel({ {status.behindCount > 0 ? `↓${status.behindCount}` : ""} ) : null} + {codeReview ? ( + + {/* Stays focusable while disabled, so its tooltip can say why. */} + { + if (!codeReviewButton.disabled && codeReviewScope) { + codeReview.onStart(codeReviewScope); + } + }} + /> + } + > + + + {codeReviewButton.reason ?? ( + <> + + {codeReviewScope ? codeReviewTooltip(codeReviewScope) : "Review changes"} + + {codeReview.guardLine ? ( + {codeReview.guardLine} + ) : null} + + )} + + + ) : null} + {codeReview ? ( + + } + > + + + { + if (!codeReviewButton.disabled) setReviewInstructionsOpen(true); + }} + > + + Review with instructions… + {codeReviewButton.reason ? ( + {codeReviewButton.reason} + ) : null} + + + {codeReviewMenuItems.length > 0 ? : null} + {codeReviewMenuItems.length > 0 ? ( + + Review once with + {codeReviewMenuItems.map(({ reviewer, state }) => ( + { + if (!state.disabled && codeReviewScope) { + codeReview.onStart(codeReviewScope, reviewer.selection); + } + }} + > + + {reviewer.label} + {/* A disabled reviewer says why in place of its guard line. */} + + {state.reason ?? reviewer.guardLine} + + + + ))} + + ) : null} + {codeReview.projectId ? ( + <> + + setReviewPromptDialogOpen(true)} + > + Review prompt for this project… + + + ) : null} + + + ) : null}
); @@ -1567,6 +1729,28 @@ export default function SourceControlPanel({ ) : null}
+ {codeReview ? ( + { + // The scope at Start, as the button reads it at its click. + if (codeReviewButton.disabled || !codeReviewScope) return null; + return codeReview.onStart(codeReviewScope, undefined, instructions); + }} + /> + ) : null} + {codeReview?.projectId ? ( + + ) : null} + {pendingPullRequest !== null && pendingPullRequest.scope.environmentId === environmentId && pendingPullRequest.scope.cwd === gitCwd ? ( diff --git a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx index c8f16fa182..412df1485a 100644 --- a/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.interactions.test.tsx @@ -16,7 +16,9 @@ import { type ResolvedKeybindingsConfig, type TerminalAttachStreamEvent, type TerminalLaunchCommand, + type TerminalResizeResult, type TerminalSessionSnapshot, + type TerminalSize, } from "@bibcode/contracts"; import type { TerminalFontPreference, TerminalThemePreference } from "@bibcode/contracts/settings"; import * as Cause from "effect/Cause"; @@ -29,6 +31,7 @@ import type { TerminalThemeMode } from "./terminalTheme"; interface FakeTerminalInstance { readonly options: Record; + readonly element: HTMLElement | undefined; readonly modes: { mouseTrackingMode: "none" | "x10" | "vt200" | "drag" | "any"; bracketedPasteMode: boolean; @@ -112,7 +115,11 @@ const xtermState = vi.hoisted(() => ({ deferWrites: false, pendingWrites: [] as Array<() => void>, fitShouldThrow: false, + /** Makes fit resize the fake grid to the proposed size, as xterm's addon does. */ + fitAppliesProposal: false, proposedDimensions: { cols: 80, rows: 24 } as { cols: number; rows: number } | null, + // Per-terminal fit, for viewports that stand in for windows of different sizes. + proposeFor: null as ((terminal: object) => { cols: number; rows: number } | null) | null, loadWebglShouldThrow: false, })); @@ -146,8 +153,22 @@ const webglState = vi.hoisted(() => ({ vi.mock("@xterm/addon-fit", () => ({ FitAddon: class FitAddon { - readonly fit = vi.fn(); - readonly proposeDimensions = vi.fn(() => xtermState.proposedDimensions ?? undefined); + private terminal: { cols: number; rows: number } | null = null; + readonly fit = vi.fn(() => { + const proposed = xtermState.proposedDimensions; + if (!xtermState.fitAppliesProposal || !this.terminal || !proposed) return; + this.terminal.cols = proposed.cols; + this.terminal.rows = proposed.rows; + }); + activate(terminal: { cols: number; rows: number }): void { + this.terminal = terminal; + } + readonly proposeDimensions = vi.fn( + () => + (this.terminal && xtermState.proposeFor?.(this.terminal)) ?? + xtermState.proposedDimensions ?? + undefined, + ); constructor() { if (xtermState.fitShouldThrow) { this.fit.mockImplementation(() => { @@ -318,10 +339,12 @@ vi.mock("@xterm/xterm", () => ({ displayedText = ""; resetCount = 0; private disposed = false; + element: HTMLElement | undefined = undefined; readonly open = vi.fn((parent: HTMLElement) => { const screen = document.createElement("div"); screen.className = "xterm-screen"; parent.append(screen); + this.element = screen; }); readonly focus = vi.fn(); readonly refresh = vi.fn(); @@ -385,11 +408,17 @@ vi.mock("@xterm/xterm", () => ({ readonly bufferLines: Array = []; readonly buffer = { active: { + type: "normal" as const, viewportY: 0, baseY: 0, getLine: (index: number) => this.bufferLines[index], }, + get normal() { + return this.active; + }, + onBufferChange: () => ({ dispose() {} }), }; + readonly onRender = vi.fn(() => ({ dispose() {} })); dataHandler: ((data: string) => void) | null = null; linkProvider: FakeTerminalLinkProvider | null = null; selectionHandler: (() => void) | null = null; @@ -408,6 +437,8 @@ vi.mock("@xterm/xterm", () => ({ constructor(options: Record) { this.options = options; xtermState.terminals.push(this); + // After the renderer effect has stamped its mount's first measurement. + if (settleNewRenderers) queueMicrotask(elapseMountSettle); } attachCustomKeyEventHandler(handler: (event: KeyboardEvent) => boolean) { @@ -480,6 +511,7 @@ const testState = vi.hoisted(() => ({ capabilities?: { terminalOrderedInput?: boolean; terminalSizeOwnership?: boolean; + terminalUserInputClaim?: boolean; terminalImagePaste?: boolean; }; }; @@ -494,11 +526,14 @@ const testState = vi.hoisted(() => ({ transcriptRuntime: null as TerminalTranscriptRuntime | null, }, attachedSessionInputs: [] as unknown[], + // Per-environment sessions, for viewports that stand in for different machines. + sessionsByEnvironment: new Map(), writeCommand: vi.fn(), prepareInputCommand: vi.fn(), resetInputCommand: vi.fn(), reattachCommand: vi.fn(), resizeCommand: vi.fn(), + readSizeCommand: vi.fn(), restartCommand: vi.fn(), stageImagePasteCommand: vi.fn(), previewCommand: vi.fn(), @@ -558,6 +593,7 @@ vi.mock("../state/terminal", () => ({ prepareInput: "terminal-prepare-input", resetInput: "terminal-reset-input", resize: "terminal-resize", + readSize: "terminal-read-size", restart: "terminal-restart", stageImagePaste: "terminal-stage-image-paste", }, @@ -568,15 +604,17 @@ vi.mock("../state/use-atom-command", () => ({ if (command === "terminal-prepare-input") return testState.prepareInputCommand; if (command === "terminal-reset-input") return testState.resetInputCommand; if (command === "terminal-resize") return testState.resizeCommand; + if (command === "terminal-read-size") return testState.readSizeCommand; if (command === "terminal-restart") return testState.restartCommand; if (command === "terminal-stage-image-paste") return testState.stageImagePasteCommand; return testState.previewCommand; }, })); vi.mock("../state/terminalSessions", () => ({ - useAttachedTerminalSession: (input: unknown) => { + useAttachedTerminalSession: (input: { readonly environmentId: string }) => { testState.attachedSessionInputs.push(input); - return { ...testState.session, reattach: testState.reattachCommand }; + const session = testState.sessionsByEnvironment.get(input.environmentId) ?? testState.session; + return { ...(session as object), reattach: testState.reattachCommand }; }, })); vi.mock("../hooks/useSettings", () => ({ @@ -662,6 +700,8 @@ vi.mock("~/components/ui/popover", () => ({ import ThreadTerminalPanel, { enqueueTerminalInput, releaseTerminalUiResources, + TERMINAL_AUTOMATIC_CLAIM_MAX_WAIT_MS, + TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS, TerminalViewport, } from "./ThreadTerminalPanel"; import { decodeTerminalLaunchCommand } from "../lib/terminalLaunchCommand"; @@ -696,6 +736,17 @@ const resizeObserverInstances: Array<{ }> = []; const componentTimers = new Set(); const componentTimerCallbacks = new Map<() => void, unknown>(); +/** + * Added to `performance.now()`. A fresh renderer's automatic claims wait until its mount has + * held still since its first measurement; tests that are not about that wait step the clock + * past it once the renderer exists (#348). + */ +let settleClockOffset = 0; +/** False keeps fresh renderers unsettled, as real ones are for 100 ms after creation. */ +let settleNewRenderers = true; +function elapseMountSettle(): void { + settleClockOffset += TERMINAL_AUTOMATIC_CLAIM_MAX_WAIT_MS + 1; +} const animationFrames = new Map(); let nextAnimationFrame = 1; let beginComponentTimerCapture = () => 0; @@ -953,7 +1004,8 @@ function trackedTranscriptRuntime(initialSnapshot: string): { const detachRendererSpy = vi.fn(); return { runtime: { - ingest: (event) => source.ingest(event), + ingest: (event, stream) => source.ingest(event, stream), + releaseSource: (stream) => source.releaseSource(stream), attachRenderer: (sink, sizeClaim) => { const attachment = source.attachRenderer(sink, sizeClaim); let detached = false; @@ -1045,6 +1097,7 @@ async function mountViewport( readonly focusRequestId?: number; readonly terminalId?: string; readonly terminalFontPreference?: TerminalFontPreference; + readonly command?: TerminalLaunchCommand; } = {}, ) { testState.webglEnabled = options.webglEnabled ?? false; @@ -1061,6 +1114,7 @@ async function mountViewport( onSessionExited, autoFocus: options.autoFocus ?? false, focusRequestId: options.focusRequestId ?? 0, + ...(options.command ? { command: options.command } : {}), } as Partial); testState.session = { buffer: options.initialSnapshot ?? "", @@ -1072,9 +1126,11 @@ async function mountViewport( transcriptRuntime: options.publishRuntime === false ? null : tracked.runtime, }; const mounted = await mount(); + elapseMountSettle(); const rerender = async () => { await act(async () => mounted.root.render()); + elapseMountSettle(); }; return { @@ -1242,10 +1298,13 @@ beforeEach(() => { xtermState.terminals = []; xtermState.fitAddons = []; xtermState.fitShouldThrow = false; + xtermState.fitAppliesProposal = false; xtermState.answerQueriesOnWrite = false; xtermState.deferWrites = false; xtermState.pendingWrites = []; xtermState.proposedDimensions = { cols: 80, rows: 24 }; + xtermState.proposeFor = null; + testState.sessionsByEnvironment.clear(); xtermState.loadWebglShouldThrow = false; webglState.instances = []; webglState.importCount = 0; @@ -1291,6 +1350,8 @@ beforeEach(() => { testState.resetInputCommand.mockReset().mockResolvedValue(AsyncResult.success(undefined)); testState.reattachCommand.mockReset(); testState.resizeCommand.mockReset().mockResolvedValue(AsyncResult.success(undefined)); + // A read in flight: a size the server publishes settles the claim first. + testState.readSizeCommand.mockReset().mockReturnValue(new Promise(() => undefined)); testState.restartCommand.mockReset().mockResolvedValue(AsyncResult.success(undefined)); testState.stageImagePasteCommand .mockReset() @@ -1321,6 +1382,10 @@ beforeEach(() => { componentTimerCallbacks.clear(); animationFrames.clear(); nextAnimationFrame = 1; + settleClockOffset = 0; + settleNewRenderers = true; + const realPerformanceNow = performance.now.bind(performance); + vi.spyOn(performance, "now").mockImplementation(() => realPerformanceNow() + settleClockOffset); Object.defineProperty(document, "visibilityState", { configurable: true, value: "visible", @@ -1753,7 +1818,13 @@ describe("TerminalViewport mounted lifecycle", () => { expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); expect(testState.resizeCommand).toHaveBeenCalledWith({ environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, terminalId: "term-1", cols: 81, rows: 24 }, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 81, + rows: 24, + structuredResult: true, + }, }); }, ); @@ -1767,7 +1838,13 @@ describe("TerminalViewport mounted lifecycle", () => { expect(view.resizeSpy).toHaveBeenCalledOnce(); expect(view.resizeSpy).toHaveBeenLastCalledWith({ environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, terminalId: "term-1", cols: 80, rows: 24 }, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 24, + structuredResult: true, + }, }); await view.resizeContainer(); @@ -1806,7 +1883,13 @@ describe("TerminalViewport mounted lifecycle", () => { expect(view.resizeSpy).toHaveBeenCalledOnce(); expect(view.resizeSpy).toHaveBeenLastCalledWith({ environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, terminalId: "term-1", cols: 80, rows: 35 }, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 35, + structuredResult: true, + }, }); }); @@ -1831,6 +1914,25 @@ describe("TerminalViewport mounted lifecycle", () => { expect(attached.terminal.rows).toBeUndefined(); }); + it("attaches without dimensions when the mount is created with no area", async () => { + // #348: a center terminal created behind a maximized right panel has zero width. + const style = document.createElement("style"); + style.textContent = "[data-terminal-xterm-mount] { width: 0px; }"; + document.head.append(style); + try { + xtermState.proposedDimensions = { cols: 2, rows: 24 }; + const view = await mountViewport({ publishRuntime: false }); + const attached = testState.attachedSessionInputs.at(-1) as { + terminal: { cols?: number; rows?: number }; + }; + expect(attached.terminal.cols).toBeUndefined(); + expect(xtermState.fitAddons.at(-1)!.fit).not.toHaveBeenCalled(); + expect(view.fakeTerminal).not.toBeNull(); + } finally { + style.remove(); + } + }); + it("resets input modes after replaying an exited shell, not a running one", async () => { const view = await mountViewport(); const history = "\x1b[?1000h\x1b[?1004h\x1b[?2004h$ exit\r\n"; @@ -1887,6 +1989,7 @@ describe("TerminalViewport mounted lifecycle", () => { cols: 80, rows: 24, sizeClaim: expect.any(String), + structuredResult: true, }, }); }); @@ -1922,7 +2025,14 @@ describe("TerminalViewport mounted lifecycle", () => { await view.flushFrame(); expect(view.resizeSpy).toHaveBeenCalledWith({ environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, terminalId: "term-1", cols: 91, rows: 42, sizeClaim: claim }, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 91, + rows: 42, + sizeClaim: claim, + structuredResult: true, + }, }); await act(async () => view.runtime.ingest({ @@ -1943,7 +2053,7 @@ describe("TerminalViewport mounted lifecycle", () => { "claims an equal-size mirror on terminal %s and deduplicates while the resize RPC is pending", async (trigger) => { testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; - vi.spyOn(document, "hasFocus").mockReturnValue(false); + const hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(false); const view = await mountViewport(); await act(async () => view.runtime.ingest({ @@ -1954,6 +2064,8 @@ describe("TerminalViewport mounted lifecycle", () => { }, }), ); + // A person focusing the terminal focuses its page. + if (trigger === "focus") hasFocus.mockReturnValue(true); const mount = view.mounted.container.querySelector( "[data-terminal-xterm-mount]", )!; @@ -2049,6 +2161,46 @@ describe("TerminalViewport mounted lifecycle", () => { expect(view.resizeSpy).toHaveBeenCalledTimes(2); }); + it("waits for use of a size a handing-over server released, but claims a restart's (#348)", async () => { + testState.serverConfig!.environment.capabilities = { + terminalSizeOwnership: true, + terminalUserInputClaim: true, + }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot("prompt"), size: { cols: 151, rows: 50, sizeClaim: "o" } }, + }), + ); + view.resizeSpy.mockClear(); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 151, rows: 50, sizeClaim: null }, + }), + ); + await view.flushFrame(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + expect(view.mounted.container.textContent).toContain("Fit to this window"); + await act(async () => + view.runtime.ingest({ + type: "restarted", + threadId: THREAD_ID, + terminalId: "term-1", + snapshot: { + ...terminalSnapshot(""), + pid: 2, + size: { cols: 151, rows: 50, sizeClaim: null }, + }, + }), + ); + expect(view.resizeSpy).toHaveBeenCalledTimes(1); + }); + it("clears a failed claim slot and restores the different-size mirror action", async () => { testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; vi.spyOn(document, "hasFocus").mockReturnValue(false); @@ -2070,6 +2222,7 @@ describe("TerminalViewport mounted lifecycle", () => { }), ); const mount = view.mounted.container.querySelector("[data-terminal-xterm-mount]")!; + vi.mocked(document.hasFocus).mockReturnValue(true); await act(async () => mount.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))); expect(view.mounted.container.textContent).not.toContain("Fit to this window"); await act(async () => @@ -2159,7 +2312,7 @@ describe("TerminalViewport mounted lifecycle", () => { ); }); - it("releases a successful claim without waiting for an echo and keeps a pending RPC through an echo", async () => { + it("does not re-send an older server's answered claim and keeps a pending RPC through an echo", async () => { testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; vi.spyOn(document, "hasFocus").mockReturnValue(false); const view = await mountViewport(); @@ -2185,9 +2338,10 @@ describe("TerminalViewport mounted lifecycle", () => { await act(async () => keypress()); expect(view.resizeSpy).toHaveBeenCalledTimes(1); await act(async () => settle(AsyncResult.success(undefined))); + // An older server acknowledges nothing; the same geometry waits for its state to change. await act(async () => keypress()); - expect(view.resizeSpy).toHaveBeenCalledTimes(2); - const claim = view.resizeSpy.mock.calls[1]![0].input.sizeClaim as string; + expect(view.resizeSpy).toHaveBeenCalledTimes(1); + const claim = view.resizeSpy.mock.calls[0]![0].input.sizeClaim as string; await act(async () => view.runtime.ingest({ type: "resized", @@ -2200,11 +2354,24 @@ describe("TerminalViewport mounted lifecycle", () => { await view.resizeContainer(); await view.flushFrame(); expect(view.resizeSpy).toHaveBeenCalledTimes(2); + // Its echo arrives and the layout changes again while the RPC is pending. + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 91, rows: 42, sizeClaim: claim }, + }), + ); + xtermState.proposedDimensions = { cols: 92, rows: 43 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledTimes(2); await act(async () => settle(AsyncResult.success(undefined))); expect(view.resizeSpy).toHaveBeenCalledTimes(3); expect(view.resizeSpy.mock.calls[2]![0].input).toMatchObject({ - cols: 91, - rows: 42, + cols: 92, + rows: 43, sizeClaim: claim, }); }); @@ -2252,7 +2419,7 @@ describe("TerminalViewport mounted lifecycle", () => { }); it.each(["size", "owner", "generation"] as const)( - "clears no-echo claim notice suppression on the next %s change", + "ends no-echo claim notice suppression once the claim settles and keeps it off after a %s change", async (change) => { testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; vi.spyOn(document, "hasFocus").mockReturnValue(false); @@ -2270,7 +2437,8 @@ describe("TerminalViewport mounted lifecycle", () => { view.runtime.ingest({ type: "resized", threadId: THREAD_ID, terminalId: "term-1", size }), ); await flushTerminalWrites(); - expect(view.mounted.container.textContent).not.toContain("Sized for another window."); + // The other window still owns a different size: suppression ended with the claim (#348). + expect(view.mounted.container.textContent).toContain("Sized for another window."); await act(async () => view.runtime.ingest( change === "generation" @@ -2395,30 +2563,187 @@ describe("TerminalViewport mounted lifecycle", () => { expect(dismiss()).not.toBeNull(); }); - it("skips measuring and resize requests for owner keypresses at the applied size", async () => { + /** + * A manager-equivalent resize: it answers with the size it then holds, and publishes a + * `resized` at the answer's sequence, for an exact size and owner no-op too (#348). Held + * publications model a stream behind backpressure; output queued with them keeps its order. + */ + const serveResizes = ( + view: Awaited>, + initial: { cols: number; rows: number; sizeClaim: string | null }, + options: { + readonly hold?: boolean; + readonly outcome?: TerminalResizeResult["outcome"]; + /** The requester's attach stream, which the acknowledgement arrives on. */ + readonly source?: { readonly sizeClaim?: string }; + } = {}, + ) => { + let server = initial; + let sequence = 10; + const stream: Array = []; + view.resizeSpy.mockImplementation( + async ({ input }: { input: { cols: number; rows: number; sizeClaim?: string } }) => { + if (options.outcome !== undefined) { + return AsyncResult.success({ + outcome: options.outcome, + size: server, + generation: null, + sequence, + } satisfies TerminalResizeResult); + } + const sizeClaim = input.sizeClaim ?? null; + const unchanged = + input.cols === server.cols && + input.rows === server.rows && + sizeClaim === server.sizeClaim; + const size = { cols: input.cols, rows: input.rows, sizeClaim }; + server = size; + sequence += 1; + const event = { + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + sequence, + size, + } satisfies TerminalAttachStreamEvent; + if (options.hold) stream.push(event); + else queueMicrotask(() => view.runtime.ingest(event, options.source)); + return AsyncResult.success({ + outcome: unchanged ? "unchanged" : "applied", + size, + generation: null, + sequence, + } satisfies TerminalResizeResult); + }, + ); + return { + /** Output the server printed before the next held publication. */ + print(data: string) { + sequence += 1; + stream.push({ type: "output", threadId: THREAD_ID, terminalId: "term-1", sequence, data }); + }, + async release() { + await act(async () => { + for (const event of stream.splice(0)) view.runtime.ingest(event, options.source); + }); + }, + }; + }; + + it("repairs rows kept above the fit when the server already holds the claimed size (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 20, sizeClaim: claim }; + serveResizes(view, held); + await act(async () => + view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), size: held } }), + ); + expect(view.fakeTerminal!.rows).toBe(20); + // A stale size reaches this renderer (a missed or misordered event): 30 rows where 20 fit. + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 30, sizeClaim: claim }, + }), + ); + await view.flushFrame(); + // The owner asks for its fit; the server already holds it and acknowledges it in order. + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 20, + sizeClaim: claim, + structuredResult: true, + }, + }); + await act(async () => Promise.resolve()); + expect(view.fakeTerminal!.rows).toBe(20); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + expect(view.mounted.container.textContent).not.toContain("Sized for another window."); + // A width change still produces an echo. + xtermState.proposedDimensions = { cols: 81, rows: 20 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.fakeTerminal!.cols).toBe(81); + expect(view.fakeTerminal!.rows).toBe(20); + }); + + it("does not ask again on every key for a size the server's PTY kept refusing (#348)", async () => { testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 100, rows: 30 }; const view = await mountViewport(); const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) .terminal.sizeClaim; + // The PTY kept 80x24 after refusing 100x30 on every retry, and the server published it. + const kept = { cols: 80, rows: 24, sizeClaim: claim }; + view.resizeSpy.mockResolvedValue( + AsyncResult.success({ + outcome: "unchanged", + size: kept, + generation: null, + sequence: 12, + } satisfies TerminalResizeResult), + ); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot(""), sequence: 12, size: kept }, + }), + ); + await view.flushFrame(); + const asked = view.resizeSpy.mock.calls.length; + expect(asked).toBeGreaterThanOrEqual(1); + for (const key of ["a", "b", "c"]) { + await act(async () => view.fakeTerminal!.keyHandler?.(new KeyboardEvent("keydown", { key }))); + await view.flushFrame(); + } + expect(view.resizeSpy).toHaveBeenCalledTimes(asked); + expect(view.fakeTerminal!.rows).toBe(24); + // Another fit is asked for again. + xtermState.proposedDimensions = { cols: 90, rows: 30 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledTimes(asked + 1); + }); + + it("shows the size notice when a takeover claim succeeds without changing the owner (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); await act(async () => view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), - size: { cols: 80, rows: 24, sizeClaim: claim }, + size: { cols: 151, rows: 50, sizeClaim: "other" }, }, }), ); - xtermState.fitAddons[0]!.proposeDimensions.mockClear(); - await act(async () => { - for (let index = 0; index < 100; index += 1) - view.fakeTerminal!.keyHandler?.(new KeyboardEvent("keydown", { key: "a" })); - }); - expect(xtermState.fitAddons[0]!.proposeDimensions).not.toHaveBeenCalled(); - expect(view.resizeSpy).not.toHaveBeenCalled(); + expect(view.mounted.container.textContent).toContain("Sized for another window."); + // The server applies nothing: this renderer's claim is not registered there. + serveResizes( + view, + { cols: 151, rows: 50, sizeClaim: "other" }, + { outcome: "claimNotRegistered" }, + ); + await act(async () => view.pointerDownOnSurface()); + expect(view.resizeSpy).toHaveBeenCalledTimes(1); + await view.flushFrame(); + expect(view.fakeTerminal!.cols).toBe(151); + expect(view.mounted.container.textContent).toContain("Sized for another window."); }); - it("answers startup DSR and DA immediately when its attach snapshot names this renderer", async () => { + it("keeps the old size until its echo when an owner's claim answer arrives first (#348)", async () => { testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; vi.spyOn(document, "hasFocus").mockReturnValue(false); const view = await mountViewport(); @@ -2430,68 +2755,997 @@ describe("TerminalViewport mounted lifecycle", () => { snapshot: { ...terminalSnapshot(""), size: { cols: 80, rows: 24, sizeClaim: claim } }, }), ); - const terminal = view.fakeTerminal!; - expect(terminal.csiHandlers.get("n")!([6])).toBe(false); - expect(terminal.csiHandlers.get("c")!([0])).toBe(false); - await act(async () => { - terminal.dataHandler!("\x1b[1;1R"); - terminal.dataHandler!("\x1b[?1;2c"); - }); await view.flushFrame(); - expect(testState.writeCommand.mock.calls.map(([request]) => request.input.data).join("")).toBe( - "\x1b[1;1R\x1b[?1;2c", + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledTimes(1); + // The answer is in; output printed before the resize and the echo are still on the stream. + expect(view.fakeTerminal!.rows).toBe(24); + await act(async () => view.emitOutput("printed at 24 rows")); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 20, sizeClaim: claim }, + }), ); - expect(view.resizeSpy).not.toHaveBeenCalled(); - expect(view.mounted.container.textContent).not.toContain("Fit to this window"); + await view.flushFrame(); + expect(view.fakeTerminal!.rows).toBe(20); }); - it.each([true, false])( - "uses snapshot OSC responder activity with ownership capability %s", - async (enabled) => { - testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: enabled }; - const view = await mountViewport(); - const claim = - (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim?: string } }).terminal - .sizeClaim ?? "legacy"; - for (const active of [true, false]) { - await act(async () => - view.runtime.ingest({ - type: "snapshot", - snapshot: { - ...terminalSnapshot(""), - oscColorResponderActive: active, - size: { cols: 80, rows: 24, sizeClaim: claim }, - }, - }), - ); - for (const code of [10, 11, 12]) - expect(view.fakeTerminal!.oscHandlers.get(code)!("?")).toBe(active); - expect(view.fakeTerminal!.csiHandlers.get("?n")!([996])).toBe(true); - } - }, - ); - - it("refits when the terminal element changes size without a window resize", async () => { - const view = await mountViewport({ visible: true }); + it("does not flash the notice when a takeover answer arrives before its echo (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot(""), size: { cols: 151, rows: 50, sizeClaim: "other" } }, + }), + ); + await act(async () => view.pointerDownOnSurface()); + const claim = view.resizeSpy.mock.calls.at(-1)![0].input.sizeClaim as string; await view.flushFrame(); - await view.flushMountFit(); - view.resizeSpy.mockClear(); + expect(view.mounted.container.textContent).not.toContain("Sized for another window."); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: claim }, + }), + ); + expect(view.fakeTerminal!.cols).toBe(80); + expect(view.mounted.container.textContent).not.toContain("Sized for another window."); + }); - const observer = resizeObserverInstances[0]!; - expect(observer.observe).toHaveBeenCalledOnce(); - view.setFitDimensions(120, 40); - observer.callback([], observer as never); + it("keeps an exited terminal's size when its container shrinks (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 80, rows: 30 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 30, sizeClaim: claim }; + serveResizes(view, held, { outcome: "generationInvalidated" }); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot("", "exited"), pid: null, size: held }, + }), + ); await view.flushFrame(); - - expect(view.resizeSpy).toHaveBeenCalledWith({ - environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, terminalId: "term-1", cols: 120, rows: 40 }, - }); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + await act(async () => vi.advanceTimersByTimeAsync(5_000)); + vi.useRealTimers(); + // The server holds 30 rows for the exited process, so xterm keeps them; nothing retries. + expect(view.fakeTerminal!.rows).toBe(30); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + expect(testState.reattachCommand).not.toHaveBeenCalled(); }); - it("performs one trailing renderer refresh after element resize settles", async () => { - const view = await mountViewport({ visible: true }); - await view.flushFrame(); + it.each(["sessionMissing", "processAbsent"] as const)( + "changes nothing and does not re-attach when the answer is %s (#348)", + async (outcome) => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 80, rows: 30 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 30, sizeClaim: claim }; + serveResizes(view, held, { outcome }); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot(""), size: held }, + }), + ); + await view.flushFrame(); + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + expect(view.fakeTerminal!.rows).toBe(30); + expect(testState.reattachCommand).not.toHaveBeenCalled(); + }, + ); + + it("claims again once its stream attaches when the server did not know the claim (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 80, rows: 30 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 30, sizeClaim: claim }; + serveResizes(view, held, { outcome: "claimNotRegistered" }); + await act(async () => + view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), size: held } }), + ); + await view.flushFrame(); + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + // The same declined geometry is not sent again until the server's state changes. + await act(async () => view.pointerDownOnSurface()); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + // This renderer's stream attaches at the state already applied: it claims the fit again. + await act(async () => + view.runtime.ingest( + { type: "snapshot", snapshot: { ...terminalSnapshot(""), size: held } }, + { sizeClaim: claim }, + ), + ); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledTimes(2); + expect(view.fakeTerminal!.rows).toBe(30); + // Re-attaching could recreate a closed terminal; waiting for the stream is enough. + expect(testState.reattachCommand).not.toHaveBeenCalled(); + }); + + it("takes the size on a focused remount whose claim reached the server before its stream (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(true); + const view = await mountViewport(); + const first = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 151, rows: 50, sizeClaim: first }; + await act(async () => + view.runtime.ingest( + { type: "snapshot", snapshot: { ...terminalSnapshot(""), sequence: 2, size: held } }, + { sizeClaim: first }, + ), + ); + await unmount(view.mounted); + testState.resizeCommand.mockReset(); + const remounted = await mountViewport({ publishRuntime: false }); + serveResizes({ ...remounted, runtime: view.runtime }, held, { outcome: "claimNotRegistered" }); + await remounted.setTranscriptRuntime(view.runtime); + await remounted.flushFrame(); + // The cached transcript hydrates the new renderer, which claims before its stream exists. + expect(remounted.resizeSpy).toHaveBeenCalledOnce(); + const second = remounted.resizeSpy.mock.calls[0]![0].input.sizeClaim as string; + expect(second).not.toBe(first); + const stream = { sizeClaim: second }; + // The cached runtime is the first mount's, which the remount shares. + serveResizes({ ...remounted, runtime: view.runtime }, held, { source: stream }); + // Its stream attaches with the same sequence the cache already holds. + await act(async () => + view.runtime.ingest( + { type: "snapshot", snapshot: { ...terminalSnapshot(""), sequence: 2, size: held } }, + stream, + ), + ); + await remounted.flushFrame(); + await act(async () => Promise.resolve()); + expect(remounted.resizeSpy).toHaveBeenCalledTimes(2); + expect(remounted.fakeTerminal!.cols).toBe(80); + expect(remounted.mounted.container.textContent).not.toContain("Sized for another window."); + }); + + it("parses output held behind backpressure at the old size before the claimed size (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 20, sizeClaim: claim }; + const server = serveResizes(view, held, { hold: true }); + await act(async () => + view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), size: held } }), + ); + // Output printed at 20 rows is still in transit when a stale 30-row size reaches this + // renderer, which claims its fit at once. + server.print("printed before the claim"); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 30, sizeClaim: claim }, + }), + ); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + await act(async () => vi.advanceTimersByTimeAsync(1_000)); + vi.useRealTimers(); + // The answer is in, but nothing ordered has arrived: xterm keeps the size it parses at. + expect(view.fakeTerminal!.rows).toBe(30); + // The fake terminal's interface declares only what tests read; its write is a method. + const terminal = view.fakeTerminal! as FakeTerminalInstance & { + write(data: string, callback?: () => void): void; + }; + const order: Array = []; + const write = terminal.write.bind(terminal); + terminal.write = (data: string, callback?: () => void) => { + if (data.length > 0) order.push(`write ${terminal.rows}`); + write(data, callback); + }; + await server.release(); + await view.flushFrame(); + expect(order).toEqual(["write 30"]); + expect(view.fakeTerminal!.rows).toBe(20); + }); + + it("applies only ordered sizes when a newer claim completes before an older one's acknowledgement (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.deferWrites = true; + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 20, sizeClaim: claim }; + const server = serveResizes(view, held, { hold: true }); + await act(async () => + view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), size: held } }), + ); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 30, sizeClaim: claim }, + }), + ); + await flushTerminalWrites(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledTimes(1); + await act(async () => vi.advanceTimersByTimeAsync(300)); + // Slow parsing holds anything queued for xterm; a newer fit is claimed and answered. + await act(async () => view.emitOutput("slow")); + xtermState.proposedDimensions = { cols: 80, rows: 22 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledTimes(2); + await act(async () => vi.advanceTimersByTimeAsync(300)); + vi.useRealTimers(); + await flushTerminalWrites(); + // Neither claim's size was applied before its ordered acknowledgement. + expect(view.fakeTerminal!.rows).toBe(30); + await server.release(); + await flushTerminalWrites(); + expect(view.fakeTerminal!.rows).toBe(22); + }); + + it("sends one claim for a hundred keys while its acknowledgement is in transit (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 20, sizeClaim: claim }; + const server = serveResizes(view, held, { hold: true }); + await act(async () => + view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), size: held } }), + ); + await view.flushFrame(); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 30, sizeClaim: claim }, + }), + ); + // The stale size's own layout claim is answered first; typing follows. + await view.flushFrame(); + view.resizeSpy.mockClear(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + for (let key = 0; key < 100; key += 1) { + await act(async () => + view.fakeTerminal!.keyHandler?.(new KeyboardEvent("keydown", { key: "a" })), + ); + await view.flushFrame(); + await act(async () => vi.advanceTimersByTimeAsync(30)); + } + vi.useRealTimers(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + await server.release(); + await view.flushFrame(); + expect(view.fakeTerminal!.rows).toBe(20); + }); + + it("does not wait for an acknowledgement an older server never sends (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 20, sizeClaim: claim }; + // An older server answers every resize with null and publishes nothing for a no-op. + view.resizeSpy.mockResolvedValue(AsyncResult.success(null)); + let answerRead: (size: typeof held) => void = () => undefined; + testState.readSizeCommand.mockReturnValue( + new Promise((resolve) => { + answerRead = (size) => resolve(AsyncResult.success(size)); + }), + ); + await act(async () => + view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), size: held } }), + ); + await view.flushFrame(); + // A stale size reaches this renderer; the server already holds the fit. + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { ...held, rows: 30 }, + }), + ); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + for (let key = 0; key < 100; key += 1) { + await act(async () => + view.fakeTerminal!.keyHandler?.(new KeyboardEvent("keydown", { key: "a" })), + ); + await view.flushFrame(); + await act(async () => vi.advanceTimersByTimeAsync(30)); + } + vi.useRealTimers(); + // The same geometry is not sent again while nothing changed on the server. + expect(view.resizeSpy).toHaveBeenCalledOnce(); + // One read of the existing session, without a working directory or a claim, repairs it. + expect(testState.readSizeCommand).toHaveBeenCalledOnce(); + expect(testState.readSizeCommand).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { threadId: THREAD_ID, terminalId: "term-1" }, + }); + await act(async () => answerRead(held)); + await view.flushFrame(); + expect(view.fakeTerminal!.rows).toBe(20); + expect(view.mounted.container.textContent).not.toContain("Sized for another window."); + // A size the server does publish still settles the terminal. + xtermState.proposedDimensions = { cols: 80, rows: 22 }; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 22, sizeClaim: claim }, + }), + ); + await view.flushFrame(); + expect(view.fakeTerminal!.rows).toBe(22); + }); + + it("shows the notice again when an older server silently ignores a takeover (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); + const other = { cols: 151, rows: 50, sizeClaim: "other" }; + await act(async () => + view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), size: other } }), + ); + // An older server answers null for a claim it did not know and publishes nothing. + view.resizeSpy.mockResolvedValue(AsyncResult.success(null)); + testState.readSizeCommand.mockResolvedValue(AsyncResult.success(other)); + await act(async () => view.pointerDownOnSurface()); + await view.flushFrame(); + await act(async () => Promise.resolve()); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + expect(testState.readSizeCommand).toHaveBeenCalledOnce(); + expect(view.fakeTerminal!.cols).toBe(151); + expect(view.mounted.container.textContent).toContain("Sized for another window."); + await act(async () => view.pointerDownOnSurface()); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + }); + + it("repairs rows from the requester's resync when its acknowledgement was lost (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 20, sizeClaim: claim }; + const requester = { sizeClaim: claim }; + const other = { sizeClaim: "other-viewport" }; + // The acknowledgement is held and never released: the requester's stream loses it. + serveResizes(view, held, { hold: true, source: requester }); + const attached = { + type: "snapshot", + snapshot: { ...terminalSnapshot(""), sequence: 10, size: held }, + } satisfies TerminalAttachStreamEvent; + await act(async () => { + view.runtime.ingest(attached, requester); + view.runtime.ingest(attached, other); + }); + await act(async () => + view.runtime.ingest( + { type: "resized", threadId: THREAD_ID, terminalId: "term-1", size: { ...held, rows: 30 } }, + requester, + ), + ); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + expect(view.fakeTerminal!.rows).toBe(30); + // Another viewport's stream delivers the next output; the requester's catch-up follows. + await act(async () => { + view.runtime.ingest( + { type: "output", threadId: THREAD_ID, terminalId: "term-1", sequence: 12, data: "!" }, + other, + ); + view.runtime.ingest( + { + type: "snapshot", + resync: true, + snapshot: { ...terminalSnapshot("!"), sequence: 12, size: held }, + }, + requester, + ); + }); + await view.flushFrame(); + expect(view.fakeTerminal!.rows).toBe(20); + }); + + it("does not flash the notice for an older publication parsed after a takeover's answer (#348)", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); + const other = { cols: 151, rows: 50, sizeClaim: "other" }; + await act(async () => + view.runtime.ingest({ type: "snapshot", snapshot: { ...terminalSnapshot(""), size: other } }), + ); + expect(view.mounted.container.textContent).toContain("Sized for another window."); + const server = serveResizes(view, other, { hold: true }); + await act(async () => view.pointerDownOnSurface()); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + // The previous owner's unchanged size, published before the takeover, parses after it. + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + sequence: 5, + size: other, + }), + ); + expect(view.mounted.container.textContent).not.toContain("Sized for another window."); + await server.release(); + expect(view.fakeTerminal!.cols).toBe(80); + expect(view.mounted.container.textContent).not.toContain("Sized for another window."); + }); + + it.each(["keypress", "pointer", "focus"] as const)( + "re-measures on %s after the mount changed size without a layout notification (#348)", + async (trigger) => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const held = { cols: 80, rows: 24, sizeClaim: claim }; + serveResizes(view, held); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot(""), size: held }, + }), + ); + await view.flushFrame(); + await view.flushMountFit(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + // The box shrinks, and no ResizeObserver callback reaches the fit. + xtermState.proposedDimensions = { cols: 80, rows: 20 }; + const mount = view.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + await act(async () => { + if (trigger === "keypress") + view.fakeTerminal!.keyHandler?.(new KeyboardEvent("keydown", { key: "a" })); + if (trigger === "pointer") view.pointerDownOnSurface(); + if (trigger === "focus") mount.dispatchEvent(new FocusEvent("focusin", { bubbles: true })); + }); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 20, + sizeClaim: claim, + structuredResult: true, + }, + }); + expect(view.fakeTerminal!.rows).toBe(20); + }, + ); + + it.each(["idle", "pending claim", "replay", "remount"] as const)( + "shrinks an owned AI terminal to the rows left when its Activity panel appears (%s) (#348)", + async (scenario) => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + useCenterPanelStore.setState({ byThreadKey: {}, activityPanes: {} }); + useCenterPanelStore.getState().openTerminalPanel(THREAD_REF, "terminal-codex"); + useCenterPanelStore + .getState() + .setActivityPaneExpanded(THREAD_REF, "terminal:terminal-codex", true, "subagents"); + await publishTerminalAgentActivitySetting(ENVIRONMENT_ID, true); + testState.activityState = { + snapshot: Option.none(), + status: "synchronizing", + error: Option.none(), + recentEntries: new Map(), + }; + const view = await mountViewport({ + terminalId: "terminal-codex", + command: observedCommand(), + }); + const claim = () => + (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }).terminal + .sizeClaim; + const held = { cols: 80, rows: 24, sizeClaim: claim() }; + serveResizes(view, held); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot(""), size: held }, + }), + ); + await view.flushFrame(); + await view.flushMountFit(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + const host = () => + view.mounted.container.querySelector( + '[data-provider-terminal-activity-host="terminal-codex"]', + ); + expect(host()?.querySelector("[data-activity-pane]")).toBeNull(); + + let releasePending: (() => void) | null = null; + if (scenario === "pending claim") { + // A claim for a slightly smaller box is still in flight. + const serve = view.resizeSpy.getMockImplementation()!; + view.resizeSpy.mockImplementationOnce( + (request) => + new Promise((resolve) => { + releasePending = () => resolve(serve(request)); + }), + ); + xtermState.proposedDimensions = { cols: 80, rows: 22 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledTimes(1); + } + // The panel appears once observation is confirmed, and the browser reports the + // smaller terminal box. + await publishActivitySnapshot(activitySnapshot("terminal-codex")); + expect(host()?.querySelector('[data-activity-pane-expanded="true"]')).not.toBeNull(); + xtermState.proposedDimensions = { cols: 80, rows: 18 }; + if (scenario === "remount") { + await act(async () => { + view.mounted.root.render(<>); + }); + await view.setVisible(true); + await view.flushFrame(); + await view.flushMountFit(); + // The new renderer attaches with a new claim and the old owner's snapshot; dropping + // the old attachment then publishes a null owner. + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot(""), size: held }, + }), + ); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "terminal-codex", + size: { ...held, sizeClaim: null }, + }), + ); + } else { + await view.resizeContainer(); + if (scenario === "replay") { + // A catch-up snapshot taken before the claim replays the old size. + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot("replayed"), size: held }, + resync: true, + }), + ); + } + await view.flushFrame(); + } + if (releasePending) { + await act(async () => releasePending!()); + } + await view.flushFrame(); + await view.flushMountFit(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenLastCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "terminal-codex", + cols: 80, + rows: 18, + sizeClaim: claim(), + structuredResult: true, + }, + }); + expect(view.fakeTerminal!.rows).toBe(18); + }, + ); + + it("measures once per frame and sends no resize for owner keypresses at the applied size", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot(""), + size: { cols: 80, rows: 24, sizeClaim: claim }, + }, + }), + ); + xtermState.fitAddons[0]!.proposeDimensions.mockClear(); + await act(async () => { + for (let index = 0; index < 100; index += 1) + view.fakeTerminal!.keyHandler?.(new KeyboardEvent("keydown", { key: "a" })); + }); + expect(xtermState.fitAddons[0]!.proposeDimensions).toHaveBeenCalledOnce(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + }); + + it("answers startup DSR and DA immediately when its attach snapshot names this renderer", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot(""), size: { cols: 80, rows: 24, sizeClaim: claim } }, + }), + ); + const terminal = view.fakeTerminal!; + expect(terminal.csiHandlers.get("n")!([6])).toBe(false); + expect(terminal.csiHandlers.get("c")!([0])).toBe(false); + await act(async () => { + terminal.dataHandler!("\x1b[1;1R"); + terminal.dataHandler!("\x1b[?1;2c"); + }); + await view.flushFrame(); + expect(testState.writeCommand.mock.calls.map(([request]) => request.input.data).join("")).toBe( + "\x1b[1;1R\x1b[?1;2c", + ); + expect(view.resizeSpy).not.toHaveBeenCalled(); + expect(view.mounted.container.textContent).not.toContain("Fit to this window"); + }); + + it.each([true, false])( + "uses snapshot OSC responder activity with ownership capability %s", + async (enabled) => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: enabled }; + const view = await mountViewport(); + const claim = + (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim?: string } }).terminal + .sizeClaim ?? "legacy"; + for (const active of [true, false]) { + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot(""), + oscColorResponderActive: active, + size: { cols: 80, rows: 24, sizeClaim: claim }, + }, + }), + ); + for (const code of [10, 11, 12]) + expect(view.fakeTerminal!.oscHandlers.get(code)!("?")).toBe(active); + expect(view.fakeTerminal!.csiHandlers.get("?n")!([996])).toBe(true); + } + }, + ); + + it("refits when the terminal element changes size without a window resize", async () => { + const view = await mountViewport({ visible: true }); + await view.flushFrame(); + await view.flushMountFit(); + view.resizeSpy.mockClear(); + + const observer = resizeObserverInstances[0]!; + expect(observer.observe).toHaveBeenCalledOnce(); + view.setFitDimensions(120, 40); + observer.callback([], observer as never); + await view.flushFrame(); + + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 120, + rows: 40, + structuredResult: true, + }, + }); + }); + + it.each([false, true])( + "never sizes the PTY from a mount with no area and refits when it returns (ownership %s)", + async (ownership) => { + // #348: maximizing the right panel collapses the center column to zero width. + if (ownership) { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + } + vi.spyOn(document, "hasFocus").mockReturnValue(true); + const view = await mountViewport({ visible: true }); + const terminal = view.fakeTerminal!; + xtermState.fitAddons.at(-1)!.fit.mockImplementation(() => { + if (xtermState.proposedDimensions) { + terminal.cols = xtermState.proposedDimensions.cols; + terminal.rows = xtermState.proposedDimensions.rows; + } + }); + await view.flushFrame(); + await view.flushMountFit(); + let claim: string | undefined; + if (ownership) { + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot("prompt"), + size: { cols: 80, rows: 24, sizeClaim: null }, + }, + }), + ); + claim = view.resizeSpy.mock.calls.at(-1)![0].input.sizeClaim as string; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: claim! }, + }), + ); + } + view.resizeSpy.mockClear(); + const mount = view.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + + // What the real fit addon proposes for a zero-width host (terminalSizing.test.ts). + mount.style.width = "0px"; + xtermState.proposedDimensions = { cols: 2, rows: 24 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + expect(terminal.cols).toBe(80); + + mount.style.width = ""; + xtermState.proposedDimensions = { cols: 80, rows: 24 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).not.toHaveBeenCalled(); + expect(terminal.cols).toBe(80); + + xtermState.proposedDimensions = { cols: 80, rows: 30 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 30, + ...(ownership ? { sizeClaim: claim } : {}), + structuredResult: true, + }, + }); + }, + ); + + it.each(["legacy", "owner", "mirror"] as const)( + "refits when the device pixel ratio changes under an unchanged mount (%s)", + async (mode) => { + // #348: xterm rounds each row's CSS height from device pixels, so moving the + // window to a display with another scale changes how many rows fit while the + // mount keeps its box and the ResizeObserver stays silent. + const queries: Array<{ query: string; listeners: Set<() => void> }> = []; + vi.stubGlobal("devicePixelRatio", 2); + vi.stubGlobal( + "matchMedia", + vi.fn((query: string) => { + const entry = { query, listeners: new Set<() => void>() }; + queries.push(entry); + return { + matches: true, + media: query, + addEventListener: (_type: string, listener: () => void) => + entry.listeners.add(listener), + removeEventListener: (_type: string, listener: () => void) => + entry.listeners.delete(listener), + }; + }), + ); + if (mode !== "legacy") { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + } + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport({ visible: true }); + const terminal = view.fakeTerminal!; + const fit = xtermState.fitAddons.at(-1)!.fit; + // The fake grid changes only when fit runs, as xterm's does. + fit.mockImplementation(() => { + if (xtermState.proposedDimensions) { + terminal.cols = xtermState.proposedDimensions.cols; + terminal.rows = xtermState.proposedDimensions.rows; + } + }); + await view.flushFrame(); + await view.flushMountFit(); + let claim: string | undefined; + if (mode !== "legacy") { + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot("prompt"), + size: { cols: 80, rows: 24, sizeClaim: mode === "owner" ? null : "other-window" }, + }, + }), + ); + if (mode === "owner") { + claim = view.resizeSpy.mock.calls.at(-1)![0].input.sizeClaim as string; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: claim! }, + }), + ); + } + } + expect(terminal.rows).toBe(24); + view.resizeSpy.mockClear(); + fit.mockClear(); + terminal.refresh.mockClear(); + const watched = () => queries.filter((entry) => entry.listeners.size > 0); + expect(watched().map((entry) => entry.query)).toEqual(["(resolution: 2dppx)"]); + + vi.stubGlobal("devicePixelRatio", 1); + xtermState.proposedDimensions = { cols: 80, rows: 23 }; + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + await act(async () => { + for (const listener of watched()[0]!.listeners) listener(); + }); + await view.flushFrame(); + if (mode === "legacy") { + expect(fit).toHaveBeenCalledOnce(); + expect(terminal.rows).toBe(23); + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 23, + structuredResult: true, + }, + }); + } else if (mode === "owner") { + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 23, + sizeClaim: claim, + structuredResult: true, + }, + }); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 23, sizeClaim: claim! }, + }), + ); + expect(terminal.rows).toBe(23); + } else { + // A mirror keeps the owner's size and never claims on a scale change. + expect(view.resizeSpy).not.toHaveBeenCalled(); + expect(terminal.rows).toBe(24); + } + await act(async () => vi.advanceTimersByTime(50)); + expect(terminal.refresh).toHaveBeenCalledWith(0, terminal.rows - 1); + } finally { + vi.useRealTimers(); + } + // The next change is watched at the new ratio. + expect(watched().map((entry) => entry.query)).toEqual(["(resolution: 1dppx)"]); + + await view.setVisible(false); + expect(watched()).toEqual([]); + }, + ); + + it("sends a height-only change of the mount to the PTY as the size owner", async () => { + // #348: an Activity card appearing below an AI terminal shrinks only its height. + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport({ visible: true }); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { ...terminalSnapshot("prompt"), size: { cols: 80, rows: 24, sizeClaim: null } }, + }), + ); + const claim = view.resizeSpy.mock.calls.at(-1)![0].input.sizeClaim as string; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: claim }, + }), + ); + view.resizeSpy.mockClear(); + + xtermState.proposedDimensions = { cols: 80, rows: 19 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + expect(view.resizeSpy).toHaveBeenCalledWith({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 19, + sizeClaim: claim, + structuredResult: true, + }, + }); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 19, sizeClaim: claim }, + }), + ); + expect(view.fakeTerminal?.rows).toBe(19); + }); + + it("performs one trailing renderer refresh after element resize settles", async () => { + const view = await mountViewport({ visible: true }); + await view.flushFrame(); await view.flushMountFit(); const terminal = view.fakeTerminal!; terminal.clearTextureAtlas.mockClear(); @@ -2535,13 +3789,179 @@ describe("TerminalViewport mounted lifecycle", () => { await view.flushFrame(); expect(view.resizeSpy.mock.calls.map(([request]) => request.input)).toEqual([ - { threadId: THREAD_ID, terminalId: "term-1", cols: 80, rows: 24 }, - { threadId: THREAD_ID, terminalId: "term-1", cols: 120, rows: 24 }, - { threadId: THREAD_ID, terminalId: "term-1", cols: 120, rows: 40 }, - { threadId: THREAD_ID, terminalId: "term-1", cols: 80, rows: 24 }, + { threadId: THREAD_ID, terminalId: "term-1", cols: 80, rows: 24, structuredResult: true }, + { threadId: THREAD_ID, terminalId: "term-1", cols: 120, rows: 24, structuredResult: true }, + { threadId: THREAD_ID, terminalId: "term-1", cols: 120, rows: 40, structuredResult: true }, + { threadId: THREAD_ID, terminalId: "term-1", cols: 80, rows: 24, structuredResult: true }, ]); }); + it.each(["legacy", "owner"] as const)( + "retries a failed final resize with no further layout event, a bounded number of times (%s)", + async (mode) => { + // #348: the Activity panel shrinks the mount from 24 to 19 rows and that + // resize fails; without a retry the PTY keeps 24 rows and the footer is cut off. + if (mode === "owner") { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + } + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport({ visible: true }); + const terminal = view.fakeTerminal!; + xtermState.fitAddons.at(-1)!.fit.mockImplementation(() => { + if (xtermState.proposedDimensions) { + terminal.cols = xtermState.proposedDimensions.cols; + terminal.rows = xtermState.proposedDimensions.rows; + } + }); + await view.flushFrame(); + await view.flushMountFit(); + let claim: string | undefined; + if (mode === "owner") { + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot("prompt"), + size: { cols: 80, rows: 24, sizeClaim: null }, + }, + }), + ); + claim = view.resizeSpy.mock.calls.at(-1)![0].input.sizeClaim as string; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: claim! }, + }), + ); + } + view.resizeSpy.mockClear(); + const failure = AsyncResult.failure(Cause.fail(new Error("resize rejected"))); + const sent = () => view.resizeSpy.mock.calls.map(([request]) => request.input.rows); + const expected = (rows: number) => ({ + environmentId: ENVIRONMENT_ID, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows, + ...(claim ? { sizeClaim: claim } : {}), + structuredResult: true, + }, + }); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + view.resizeSpy.mockResolvedValueOnce(failure); + xtermState.proposedDimensions = { cols: 80, rows: 19 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(sent()).toEqual([19]); + // No further resize event: only a retry can deliver the size. + await act(async () => vi.advanceTimersByTimeAsync(10_000)); + expect(sent()).toEqual([19, 19]); + expect(view.resizeSpy).toHaveBeenLastCalledWith(expected(19)); + + // A newer desired size replaces the one a pending retry would send. + view.resizeSpy.mockClear(); + view.resizeSpy.mockResolvedValueOnce(failure); + xtermState.proposedDimensions = { cols: 80, rows: 18 }; + await view.resizeContainer(); + await view.flushFrame(); + xtermState.proposedDimensions = { cols: 80, rows: 17 }; + if (mode === "owner") { + // The echo re-measures and claims the newer size at once. + view.resizeSpy.mockClear(); + view.resizeSpy.mockResolvedValueOnce(failure); + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 19, sizeClaim: claim! }, + }), + ); + await view.flushFrame(); + expect(sent()).toEqual([17]); + } + await act(async () => vi.advanceTimersByTimeAsync(10_000)); + expect(view.resizeSpy).toHaveBeenLastCalledWith(expected(17)); + + // Retries are bounded when every attempt fails. + view.resizeSpy.mockClear(); + view.resizeSpy.mockResolvedValue(failure); + xtermState.proposedDimensions = { cols: 80, rows: 16 }; + await view.resizeContainer(); + await view.flushFrame(); + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(sent()).toEqual([16, 16, 16, 16]); + + // A success restores the retry budget; an unmounted renderer never retries. + view.resizeSpy.mockReset(); + view.resizeSpy.mockResolvedValue(AsyncResult.success(undefined)); + xtermState.proposedDimensions = { cols: 80, rows: 15 }; + await view.resizeContainer(); + await view.flushFrame(); + if (mode === "owner") { + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 15, sizeClaim: claim! }, + }), + ); + } + view.resizeSpy.mockResolvedValueOnce(failure); + xtermState.proposedDimensions = { cols: 80, rows: 14 }; + await view.resizeContainer(); + await view.flushFrame(); + expect(sent()).toEqual([15, 14]); + await view.setVisible(false); + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(sent()).toEqual([15, 14]); + } finally { + vi.useRealTimers(); + view.resizeSpy.mockReset(); + view.resizeSpy.mockResolvedValue(AsyncResult.success(undefined)); + } + }, + ); + + it("never retries a failed claim taken from another window's size", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport(); + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot("prompt"), + size: { cols: 151, rows: 50, sizeClaim: "other" }, + }, + }), + ); + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + try { + view.resizeSpy.mockResolvedValueOnce( + AsyncResult.failure(Cause.fail(new Error("resize rejected"))), + ); + const mount = view.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + await act(async () => + mount.dispatchEvent(new PointerEvent("pointerdown", { button: 0, bubbles: true })), + ); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + await act(async () => vi.advanceTimersByTimeAsync(120_000)); + expect(view.resizeSpy).toHaveBeenCalledOnce(); + expect(view.fakeTerminal?.rows).toBe(50); + } finally { + vi.useRealTimers(); + } + }); + it("allows the same exact geometry to retry after a known resize failure", async () => { testState.resizeCommand .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("resize rejected")))) @@ -2607,6 +4027,85 @@ describe("TerminalViewport mounted lifecycle", () => { expect(view.fakeTerminal).toBeNull(); }); + it.each([ + ["pane hidden", "legacy"], + ["pane hidden", "owner"], + ["document hidden", "legacy"], + ["document hidden", "owner"], + ] as const)( + "sends the smaller box after a height shrink while %s, with no resize event on return (%s)", + async (hiddenBy, mode) => { + // #348: several rows go missing at once if the mount shrank while the + // terminal was not visible and the PTY kept its old row count. + xtermState.fitAppliesProposal = true; + if (mode === "owner") { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + } + vi.spyOn(document, "hasFocus").mockReturnValue(false); + const view = await mountViewport({ visible: true }); + await view.flushFrame(); + await view.flushMountFit(); + if (mode === "owner") { + await act(async () => + view.runtime.ingest({ + type: "snapshot", + snapshot: { + ...terminalSnapshot("prompt"), + size: { cols: 80, rows: 24, sizeClaim: null }, + }, + }), + ); + const claim = view.resizeSpy.mock.calls.at(-1)![0].input.sizeClaim as string; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: claim }, + }), + ); + } + view.resizeSpy.mockClear(); + + if (hiddenBy === "pane hidden") await view.setVisible(false); + else await view.setDocumentVisible(false); + expect(view.fakeTerminal).toBeNull(); + xtermState.proposedDimensions = { cols: 80, rows: 19 }; + if (hiddenBy === "pane hidden") await view.setVisible(true); + else await view.setDocumentVisible(true); + await view.flushFrame(); + await view.flushMountFit(); + if (mode === "owner") { + // The server drops the hidden renderer's claim when its attachment ends. + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 24, sizeClaim: null }, + }), + ); + await view.flushFrame(); + } + + const last = view.resizeSpy.mock.calls.at(-1)?.[0].input; + expect(last).toMatchObject({ cols: 80, rows: 19 }); + if (mode === "owner") { + const claim = last!.sizeClaim as string; + await act(async () => + view.runtime.ingest({ + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + size: { cols: 80, rows: 19, sizeClaim: claim }, + }), + ); + } + expect(view.fakeTerminal?.rows).toBe(19); + expect(view.mounted.container.textContent).not.toContain("Fit to this window"); + }, + ); + it("resets the exact-size guard when teardown mounts a replacement renderer", async () => { const view = await mountViewport({ visible: true }); await view.flushFrame(); @@ -3826,9 +5325,97 @@ describe("TerminalViewport mounted lifecycle", () => { environmentId: ENVIRONMENT_ID, input: { threadId: THREAD_ID, terminalId: "term-gated" }, }); - expect(testState.resetInputCommand.mock.invocationCallOrder[0]).toBeLessThan( - testState.prepareInputCommand.mock.invocationCallOrder[0]!, + expect(testState.resetInputCommand.mock.invocationCallOrder[0]).toBeLessThan( + testState.prepareInputCommand.mock.invocationCallOrder[0]!, + ); + }); + + it.each([true, false])( + "marks a user's input with its claim and the terminal's own replies with null (server marks: %s) (#348)", + async (serverMarks) => { + testState.serverConfig!.environment.capabilities = { + terminalOrderedInput: true, + terminalSizeOwnership: true, + ...(serverMarks ? { terminalUserInputClaim: true } : {}), + }; + xtermState.answerQueriesOnWrite = true; + let now = 10_000; + vi.spyOn(performance, "now").mockImplementation(() => now); + const view = await mountViewport(); + const terminal = view.fakeTerminal!; + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const mount = view.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + await view.flushFrame(); + testState.writeCommand.mockClear(); + // A program queries the terminal with nobody at this window. + await act(async () => view.emitOutput("\x1b[c")); + await view.flushFrame(); + // A key, and the program querying the terminal as it echoes it: the reply is not typing. + await act(async () => { + mount.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true })); + terminal.dataHandler?.("a"); + }); + await act(async () => view.emitOutput("\x1b[6n")); + await view.flushFrame(); + // A focus report from a window losing focus, long after any input here. + now += 5_000; + await act(async () => terminal.dataHandler?.("\x1b[O")); + // A mouse report on release, after the press. + await act(async () => { + mount.dispatchEvent(new PointerEvent("pointerdown", { button: 0, bubbles: true })); + now += 100; + terminal.dataHandler?.("\x1b[M !!"); + }); + const writes = testState.writeCommand.mock.calls.map( + ([request]) => request.input as { data: string; userInputClaim?: string | null }, + ); + expect(writes.map(({ data }) => data)).toEqual([ + "\x1b[?1;2c", + "a", + "\x1b[1;1R", + "\x1b[O", + "\x1b[M !!", + ]); + expect(writes.map((write) => write.userInputClaim)).toEqual( + serverMarks ? [null, claim, null, null, claim] : Array(5).fill(undefined), + ); + if (!serverMarks) + for (const write of writes) expect(write).not.toHaveProperty("userInputClaim"); + }, + ); + + it("marks focus reports as the terminal's own even right after a key (#348)", async () => { + testState.serverConfig!.environment.capabilities = { + terminalOrderedInput: true, + terminalSizeOwnership: true, + terminalUserInputClaim: true, + }; + const view = await mountViewport(); + const terminal = view.fakeTerminal!; + const claim = (testState.attachedSessionInputs.at(-1) as { terminal: { sizeClaim: string } }) + .terminal.sizeClaim; + const mount = view.mounted.container.querySelector("[data-terminal-xterm-mount]")!; + await view.flushFrame(); + testState.writeCommand.mockClear(); + // xterm answers `CSI ?1004h` and each focus change with a report through onData. + await act(async () => { + mount.dispatchEvent(new KeyboardEvent("keydown", { key: "a", bubbles: true })); + terminal.dataHandler?.("a"); + terminal.dataHandler?.("\x1b[I"); + terminal.dataHandler?.("\x1b[O"); + }); + await view.flushFrame(); + const writes = testState.writeCommand.mock.calls.map( + ([request]) => request.input as { data: string; userInputClaim?: string | null }, ); + // Input of one origin shares a frame. + expect(writes.map(({ data, userInputClaim }) => [data, userInputClaim])).toEqual([ + ["a", claim], + ["\x1b[I\x1b[O", null], + ]); }); it("pipelines interactive and programmatic input on an ordered server before acknowledgement", async () => { @@ -5118,7 +6705,13 @@ describe("TerminalViewport mounted lifecycle", () => { expect(xtermState.fitAddons[0]?.fit).toHaveBeenCalled(); expect(testState.resizeCommand).toHaveBeenCalledWith({ environmentId: ENVIRONMENT_ID, - input: { threadId: THREAD_ID, terminalId: "term-1", cols: 80, rows: 24 }, + input: { + threadId: THREAD_ID, + terminalId: "term-1", + cols: 80, + rows: 24, + structuredResult: true, + }, }); }); @@ -6574,3 +8167,1114 @@ describe("ThreadTerminalPanel mounted controls", () => { expect(testState.writeCommand).not.toHaveBeenCalled(); }); }); + +/** + * Two windows on two machines showing one terminal (#348): the server host's own desktop + * window and a remote client window. Each has its own transcript runtime, as each machine + * has its own client. The fake manager follows `apps/server/src/terminal/manager.rs`: + * attach takes ownership only when unowned and publishes it (3379-3396); a resize from an + * unregistered claim and an exact no-op succeed without an echo (3621-3663); dropping a + * stream unregisters its claim only for that attachment and, if it held the size, publishes + * a null owner with the size kept (3666-3690, 4346-4365); `resized` reaches only streams + * that carried a claim (4316), which every renderer here does. + */ +describe("TerminalViewport shared by windows on two machines (#348)", () => { + const SERVER_HOST = EnvironmentId.make("server-host-window"); + const CLIENT = EnvironmentId.make("remote-client-window"); + const THIRD = EnvironmentId.make("third-window"); + const FOURTH = EnvironmentId.make("fourth-window"); + + interface Stream { + readonly claim: string; + readonly runtime: TerminalTranscriptRuntime; + readonly source: { readonly sizeClaim: string }; + } + + /** + * The ownership rules of `TerminalManager` in apps/server/src/terminal/manager.rs: attach, + * `resize_acknowledging`, `release_size_claim`, `note_input` and the restart carry-over in + * `create_session`, with the expectations of its tests + * `a_returning_owner_takes_the_size_back_from_an_unused_window`, + * `the_size_goes_to_the_remaining_viewer_typed_in_last`, + * `an_attach_does_not_take_a_size_left_unowned_until_used` and + * `one_viewer_answers_queries_while_the_size_is_unowned` (#348). + */ + class FakeTerminalManager { + cols = 80; + rows = 24; + owner: { claim: string; attachment: number } | null = null; + /** The released rule (v0.15.3): every release leaves the size un-owned, naming no replier. */ + releasesToNobody = false; + /** `Session::handover_replier`: set while a hand-over waits for a window to be used. */ + replier: string | null = null; + private departedOwner: string | null = null; + private ownerUnusedSinceHandoff = false; + private readonly typedAt = new Map(); + private inputClock = 0; + private readonly claims = new Map(); + private readonly streams = new Map(); + private readonly outbox: Array<() => void> = []; + private nextAttachment = 1; + private sequence = 1; + private pid = 1; + + /** `Session::size`: `replyClaim` only while the size is un-owned by a pending hand-over. */ + size(): TerminalSize { + return { + cols: this.cols, + rows: this.rows, + sizeClaim: this.owner?.claim ?? null, + ...(this.replier !== null && this.owner === null ? { replyClaim: this.replier } : {}), + }; + } + + /** `handover_replier`: the least remaining claim. */ + private leastClaim(): string | null { + return [...this.claims.keys()].toSorted()[0] ?? null; + } + + private snapshot() { + return { ...terminalSnapshot(""), pid: this.pid, sequence: this.sequence, size: this.size() }; + } + + attach(claim: string, runtime: TerminalTranscriptRuntime): number { + const id = this.nextAttachment++; + this.claims.set(claim, id); + // `attach`: a size left un-owned until used is not taken by attaching, except by the + // owner that left it. + const handoverPending = this.replier !== null && this.departedOwner !== claim; + if (this.owner === null && !handoverPending) { + this.owner = { claim, attachment: id }; + this.replier = null; + this.departedOwner = null; + this.publish(); + } else if (this.owner?.claim === claim) { + this.owner.attachment = id; + } else if (this.ownerUnusedSinceHandoff && this.departedOwner === claim) { + // The window last used came back before anyone used the one the size went to. + this.owner = { claim, attachment: id }; + this.departedOwner = null; + this.ownerUnusedSinceHandoff = false; + this.publish(); + } + const stream = { claim, runtime, source: { sizeClaim: claim } }; + this.streams.set(id, stream); + const snapshot = this.snapshot(); + this.outbox.push(() => runtime.ingest({ type: "snapshot", snapshot }, stream.source)); + return id; + } + + /** `resize_acknowledging`: `acknowledge` is a caller that reads `structuredResult`. */ + resize( + claim: string | undefined, + cols: number, + rows: number, + acknowledge = false, + ): TerminalResizeResult["outcome"] { + if (claim === undefined) throw new Error("every renderer here negotiates ownership"); + const attachment = this.claims.get(claim); + if (attachment === undefined) return "claimNotRegistered"; + const sizeChanged = this.cols !== cols || this.rows !== rows; + const ownerChanged = this.owner?.claim !== claim; + if (!sizeChanged && !ownerChanged) { + // Acknowledged on the requester's stream alone, in order with its output. + if (acknowledge) this.publish(attachment); + return "unchanged"; + } + if (ownerChanged) { + this.departedOwner = null; + this.ownerUnusedSinceHandoff = false; + } + this.cols = cols; + this.rows = rows; + // `resize_acknowledging`: an applied claim ends a pending hand-over. + this.replier = null; + this.owner = { claim, attachment }; + this.publish(); + return "applied"; + } + + /** `note_input`: `null` is the terminal's own reply, `undefined` an older client's input. */ + input(userInputClaim: string | null | undefined): void { + if (userInputClaim === null) return; + this.departedOwner = null; + this.ownerUnusedSinceHandoff = false; + if (userInputClaim !== undefined) this.typedAt.set(userInputClaim, ++this.inputClock); + } + + drop(id: number): void { + const stream = this.streams.get(id); + if (!stream) return; + this.streams.delete(id); + stream.runtime.releaseSource(stream.source); + if (this.claims.get(stream.claim) === id) this.claims.delete(stream.claim); + // `release_size_claim` prunes departed claims from the typing record on every release. + for (const claim of this.typedAt.keys()) + if (!this.claims.has(claim)) this.typedAt.delete(claim); + if (this.owner?.attachment !== id) { + // `release_size_claim`, a non-owner leaving a pending hand-over: a lone viewer left + // takes the size, and a replier that left is replaced. + if (this.replier === null) return; + const left = this.claims.size; + if (left > 1 && this.claims.has(this.replier)) return; + this.replier = left > 1 ? this.leastClaim() : null; + if (left === 0) return; + const lone = left === 1 ? [...this.claims][0]! : null; + this.ownerUnusedSinceHandoff = lone !== null; + this.owner = lone ? { claim: lone[0], attachment: lone[1] } : null; + this.publish(); + return; + } + // `release_size_claim`, the owner leaving: a lone remaining viewer takes the size at + // once; among several, the one typed in last; with none typed in, it stays un-owned and + // the least claim answers the terminal's queries. + const remaining = [...this.claims]; + const next = this.releasesToNobody + ? undefined + : remaining.length === 1 + ? remaining[0] + : remaining + .filter(([claim]) => this.typedAt.has(claim)) + .sort(([a], [b]) => this.typedAt.get(b)! - this.typedAt.get(a)!)[0]; + this.replier = next === undefined && !this.releasesToNobody ? this.leastClaim() : null; + this.departedOwner = this.owner.claim; + this.ownerUnusedSinceHandoff = next !== undefined; + this.owner = next ? { claim: next[0], attachment: next[1] } : null; + this.publish(); + } + + /** + * A restart: `create_session` starts a new session that keeps only the owner whose + * attachment survived (`SessionCarryOver::size_owner`), and sends `restarted`. + */ + restart(): void { + const kept = + this.owner !== null && this.claims.get(this.owner.claim) === this.owner.attachment; + if (!kept) this.owner = null; + this.replier = null; + this.departedOwner = null; + this.ownerUnusedSinceHandoff = false; + this.typedAt.clear(); + this.pid += 1; + this.sequence += 1; + const snapshot = this.snapshot(); + for (const stream of this.streams.values()) { + this.outbox.push(() => + stream.runtime.ingest( + { + type: "restarted", + threadId: THREAD_ID, + terminalId: "term-1", + sequence: snapshot.sequence, + snapshot, + }, + stream.source, + ), + ); + } + } + + /** The program writes `data`, such as a query, to every stream. */ + output(data: string): void { + this.sequence += 1; + const event = { ...outputEvent(data), sequence: this.sequence }; + for (const stream of this.streams.values()) + this.outbox.push(() => stream.runtime.ingest(event, stream.source)); + } + + /** `resize` answered as a server that reads `structuredResult` does. */ + answer(claim: string | undefined, cols: number, rows: number): TerminalResizeResult { + const outcome = this.resize(claim, cols, rows, true); + return { outcome, size: this.size(), generation: null, sequence: this.sequence }; + } + + /** Publishes the held size to every stream, or as an acknowledgement to one attachment. */ + private publish(onlyFor?: number): void { + this.sequence += 1; + const event: TerminalAttachStreamEvent = { + type: "resized", + threadId: THREAD_ID, + terminalId: "term-1", + sequence: this.sequence, + size: this.size(), + }; + for (const [id, stream] of this.streams) { + if (onlyFor !== undefined && id !== onlyFor) continue; + this.outbox.push(() => stream.runtime.ingest(event, stream.source)); + } + } + + /** Delivers queued stream events until no renderer reacts with another request. */ + async deliver(): Promise { + for (let round = 0; round < 20; round += 1) { + await act(async () => { + while (this.outbox.length > 0) this.outbox.shift()!(); + for (let tick = 0; tick < 6; tick += 1) await Promise.resolve(); + }); + await flushAnimationFrames(); + if (this.outbox.length === 0) return; + } + throw new Error("terminal size requests did not settle"); + } + } + + interface Machine { + readonly environmentId: EnvironmentId; + fit: { cols: number; rows: number }; + readonly runtime: TerminalTranscriptRuntime; + mounted: MountedTree; + attachment: number | null; + props: ViewportProps; + } + + let server: FakeTerminalManager; + let machines: Machine[]; + let hasFocus: { mockReturnValue(value: boolean): unknown }; + + beforeEach(() => { + testState.serverConfig!.environment.capabilities = { + terminalSizeOwnership: true, + terminalUserInputClaim: true, + }; + server = new FakeTerminalManager(); + machines = []; + hasFocus = vi.spyOn(document, "hasFocus").mockReturnValue(false); + xtermState.proposeFor = (terminal) => { + const parent = (terminal as FakeTerminalInstance).open.mock.calls[0]?.[0]; + return ( + machines.find( + (machine) => parent instanceof Node && machine.mounted.container.contains(parent), + )?.fit ?? null + ); + }; + testState.resizeCommand.mockImplementation( + async ({ input }: { input: { cols: number; rows: number; sizeClaim?: string } }) => { + server.resize(input.sizeClaim, input.cols, input.rows); + return AsyncResult.success(undefined); + }, + ); + testState.writeCommand.mockImplementation( + async ({ input }: { input: { userInputClaim?: string | null } }) => { + server.input(input.userInputClaim); + return AsyncResult.success(undefined); + }, + ); + }); + + afterEach(() => { + for (const environmentId of [SERVER_HOST, CLIENT, THIRD, FOURTH]) { + releaseTerminalUiResources(environmentId, THREAD_ID, "term-1"); + } + }); + + const claimOf = (machine: Machine) => + ( + testState.attachedSessionInputs.findLast( + (input) => + (input as { environmentId: string }).environmentId === machine.environmentId && + (input as { attach: boolean }).attach, + ) as { terminal: { sizeClaim: string } } + ).terminal.sizeClaim; + + const xtermOf = (machine: Machine) => + xtermState.terminals.findLast( + (terminal) => + terminal.dispose.mock.calls.length === 0 && + terminal.open.mock.calls.some( + ([element]) => element instanceof Node && machine.mounted.container.contains(element), + ), + )!; + + /** Opens the window's attach stream, as the client runtime does once its renderer is ready. */ + const connect = async (machine: Machine) => { + machine.attachment = server.attach(claimOf(machine), machine.runtime); + await server.deliver(); + }; + + /** Ends the window's attach stream: a network drop, suspend, or renderer teardown. */ + const disconnect = async (machine: Machine) => { + if (machine.attachment !== null) server.drop(machine.attachment); + machine.attachment = null; + await server.deliver(); + }; + + const openWindow = async ( + environmentId: EnvironmentId, + fit: { cols: number; rows: number }, + options: { + readonly beforeAttach?: (machine: Machine) => Promise; + /** False keeps the fresh mount unsettled, as a real one is for 100 ms. */ + readonly settled?: boolean; + } = {}, + ): Promise => { + const runtime = createTerminalTranscriptRuntime(); + testState.sessionsByEnvironment.set(environmentId, { + ...testState.session, + generation: runtime.metadata().generation, + transcriptRuntime: runtime, + }); + const props = viewportProps({ threadRef: scopeThreadRef(environmentId, THREAD_ID) }); + const machine: Machine = { + environmentId, + fit, + runtime, + mounted: null as unknown as MountedTree, + attachment: null, + props, + }; + machines.push(machine); + if (options.settled === false) settleNewRenderers = false; + machine.mounted = await mount(); + await flushAnimationFrames(); + await options.beforeAttach?.(machine); + await connect(machine); + return machine; + }; + + /** Hiding the window tears its renderer down, which ends its attach stream. */ + const setWindowVisible = async (machine: Machine, visible: boolean) => { + machine.props = { ...machine.props, visible }; + await act(async () => machine.mounted.root.render()); + if (!visible) await disconnect(machine); + else { + elapseMountSettle(); + await flushAnimationFrames(); + await connect(machine); + } + }; + + const relayout = async (machine: Machine, fit: { cols: number; rows: number }) => { + machine.fit = fit; + await resizeTerminalMounts(machine.mounted.container); + await flushAnimationFrames(); + await server.deliver(); + }; + + const noticeShown = (machine: Machine) => + machine.mounted.container.textContent?.includes("Sized for another window.") ?? false; + + /** What one window shows: its grid against its fit, the owner, and the notice. */ + const view = (machine: Machine) => { + const terminal = xtermOf(machine); + const scale = Number( + /scale\(([\d.]+)\)/.exec(terminal.element?.style.transform ?? "")?.[1] ?? 1, + ); + return { + grid: `${terminal.cols}x${terminal.rows}`, + fit: `${machine.fit.cols}x${machine.fit.rows}`, + scale, + clipped: terminal.rows * scale > machine.fit.rows || terminal.cols * scale > machine.fit.cols, + owner: + server.owner === null + ? "nobody" + : server.owner.claim === claimOf(machine) + ? "this window" + : "the other window", + notice: noticeShown(machine), + }; + }; + + /** The server host window owns a taller grid; the client mirrors it. */ + const serverHostOwnsTallerGrid = async () => { + hasFocus.mockReturnValue(true); + const host = await openWindow(SERVER_HOST, { cols: 120, rows: 50 }); + hasFocus.mockReturnValue(false); + const client = await openWindow(CLIENT, { cols: 100, rows: 30 }); + return { host, client }; + }; + + const setUserActive = (isActive: boolean) => + Object.defineProperty(navigator, "userActivation", { + configurable: true, + value: { isActive, hasBeenActive: isActive }, + }); + + afterEach(() => { + Reflect.deleteProperty(navigator, "userActivation"); + }); + + it("shows a taller owner's whole grid on the mirror, scaled to fit, bottom row in view", async () => { + const { client } = await serverHostOwnsTallerGrid(); + expect(view(client)).toEqual({ + grid: "120x50", + fit: "100x30", + scale: 0.6, + clipped: false, + owner: "the other window", + notice: true, + }); + expect(client.mounted.container.textContent).toContain("60%"); + }); + + it("follows the owner's grid and the mirror's own box without sending a claim", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + await relayout(host, { cols: 120, rows: 40 }); + expect(view(client)).toMatchObject({ grid: "120x40", scale: 0.75, clipped: false }); + const requests = testState.resizeCommand.mock.calls.length; + await relayout(client, { cols: 100, rows: 20 }); + expect(view(client)).toMatchObject({ scale: 0.5, clipped: false }); + await relayout(client, { cols: 60, rows: 40 }); + expect(view(client)).toMatchObject({ scale: 0.5, clipped: false }); + await relayout(client, { cols: 150, rows: 60 }); + expect(view(client)).toMatchObject({ grid: "120x40", scale: 1, clipped: false }); + expect(testState.resizeCommand.mock.calls.length).toBe(requests); + }); + + it("stops at the readability floor and pins the bottom rows in a scrollable box", async () => { + const { client } = await serverHostOwnsTallerGrid(); + await relayout(client, { cols: 40, rows: 20 }); + expect(view(client)).toMatchObject({ scale: 0.5 }); + const mount = client.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + expect(mount.style.overflow).toBe("auto"); + }); + + it("returns to full size when the mirror takes the size by a click, which xterm never maps while scaled", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + const mount = client.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + // The browser sends a scaled mirror's pointer input to the mount, not to xterm's cells. + expect(xtermOf(client).element!.style.pointerEvents).toBe("none"); + await act(async () => + mount.dispatchEvent(new PointerEvent("pointerdown", { button: 0, bubbles: true })), + ); + await server.deliver(); + expect(view(client)).toMatchObject({ grid: "100x30", scale: 1, owner: "this window" }); + expect(xtermOf(client).element!.style.pointerEvents).toBe(""); + expect(view(host)).toMatchObject({ grid: "100x30", scale: 1, clipped: false }); + }); + + /** xterm's focus target, attached so `document.activeElement` shows where keys go. */ + const focusableXterm = (machine: Machine) => { + const terminal = xtermOf(machine); + terminal.element!.append(terminal.textarea); + terminal.focus.mockImplementation(() => terminal.textarea.focus()); + return terminal; + }; + + /** + * A browser click. jsdom skips mousedown's default action, which moves focus to the + * target's nearest focusable ancestor, or away from everything when it has none. + */ + const browserClick = async (target: HTMLElement, init: MouseEventInit = {}) => { + await act(async () => { + const options = { ...init, button: 0, bubbles: true, cancelable: true }; + target.dispatchEvent(new PointerEvent("pointerdown", options)); + const mousedown = new MouseEvent("mousedown", options); + target.dispatchEvent(mousedown); + if (!mousedown.defaultPrevented) { + const focusable = target.closest("button, input, textarea, [tabindex]"); + if (focusable) focusable.focus(); + else (document.activeElement as HTMLElement | null)?.blur(); + } + target.dispatchEvent(new MouseEvent("click", options)); + }); + await server.deliver(); + }; + + const fitButton = (machine: Machine) => + Array.from(machine.mounted.container.querySelectorAll("button")).find( + (button) => button.textContent === "Fit to this window", + ); + + const typedReachesProgram = async (terminal: FakeTerminalInstance) => { + const before = testState.writeCommand.mock.calls.length; + await act(async () => terminal.dataHandler!("x")); + await flushAnimationFrames(); + return testState.writeCommand.mock.calls + .slice(before) + .map(([request]) => (request as { input: { data: string } }).input.data) + .join(""); + }; + + it.each([ + ["a click", {}], + ["a Shift-click", { shiftKey: true }], + ] as const)( + "leaves the scaled mirror focused after %s takes the size, so the next key reaches the program", + async (_click, init) => { + const { client } = await serverHostOwnsTallerGrid(); + const terminal = focusableXterm(client); + const mount = client.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + expect(terminal.element!.style.pointerEvents).toBe("none"); + await browserClick(mount, init); + expect(view(client)).toMatchObject({ grid: "100x30", scale: 1, owner: "this window" }); + expect(document.activeElement).toBe(terminal.textarea); + expect(await typedReachesProgram(terminal)).toBe("x"); + }, + ); + + it("leaves the terminal focused after Fit to this window takes the size", async () => { + const { client } = await serverHostOwnsTallerGrid(); + const terminal = focusableXterm(client); + await browserClick(fitButton(client)!); + expect(view(client)).toMatchObject({ grid: "100x30", scale: 1, owner: "this window" }); + expect(document.activeElement).toBe(terminal.textarea); + expect(await typedReachesProgram(terminal)).toBe("x"); + }); + + it("keeps focus on Fit to this window when no claim is sent", async () => { + const { client } = await serverHostOwnsTallerGrid(); + const terminal = focusableXterm(client); + testState.resizeCommand.mockImplementation(async () => + AsyncResult.success({ + outcome: "claimNotRegistered", + size: server.size(), + generation: null, + sequence: null, + } satisfies TerminalResizeResult), + ); + // The server declines the first claim, so the same geometry is not sent again. + await browserClick(fitButton(client)!); + expect(view(client)).toMatchObject({ scale: 0.6, notice: true }); + // A sent claim hid the button that held focus, so the terminal kept it through the answer. + expect(document.activeElement).toBe(terminal.textarea); + const requests = testState.resizeCommand.mock.calls.length; + const fit = fitButton(client)!; + await browserClick(fit); + expect(testState.resizeCommand.mock.calls.length).toBe(requests); + expect(document.activeElement).toBe(fit); + expect(document.activeElement).not.toBe(terminal.textarea); + }); + + // Real-pair case 5 on builds before the authoritative protocol: after the client took the + // size and the host took it back by a keypress, a click on the client did not take it back. + it.each([ + ["a taller host, scaled mirror", { cols: 120, rows: 50 }, "null answers"], + ["a taller host, scaled mirror", { cols: 120, rows: 50 }, "structured answers"], + ["an equal host fit", { cols: 100, rows: 30 }, "null answers"], + ["an equal host fit", { cols: 100, rows: 30 }, "structured answers"], + ] as const)( + "takes the size back with a click after the host took it by typing (%s, %s)", + async (_shape, hostFit, answers) => { + if (answers === "structured answers") { + testState.resizeCommand.mockImplementation( + async ({ input }: { input: { cols: number; rows: number; sizeClaim?: string } }) => + AsyncResult.success(server.answer(input.sizeClaim, input.cols, input.rows)), + ); + } + hasFocus.mockReturnValue(true); + const host = await openWindow(SERVER_HOST, hostFit); + hasFocus.mockReturnValue(false); + const client = await openWindow(CLIENT, { cols: 100, rows: 30 }); + const mount = client.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + // A scaled mirror takes no pointer input itself, so the browser hands a click to the + // mount; an unscaled one gets it on xterm's element, from which it bubbles. + const clickClient = async () => { + const element = xtermOf(client).element!; + const target = element.style.pointerEvents === "none" ? mount : element; + await act(async () => + target.dispatchEvent(new PointerEvent("pointerdown", { button: 0, bubbles: true })), + ); + await server.deliver(); + }; + const typeInHost = async () => { + await act(async () => + xtermOf(host).keyHandler?.(new KeyboardEvent("keydown", { key: "a" })), + ); + await server.deliver(); + }; + for (let round = 0; round < 2; round += 1) { + await clickClient(); + expect(view(client)).toMatchObject({ grid: "100x30", scale: 1, owner: "this window" }); + await typeInHost(); + expect(view(host)).toMatchObject({ owner: "this window", scale: 1 }); + if (hostFit.rows > 30) { + expect(view(client)).toMatchObject({ grid: "120x50", scale: 0.6, clipped: false }); + // The browser sends a scaled mirror's pointer input to the mount, not to xterm. + expect(xtermOf(client).element!.style.pointerEvents).toBe("none"); + } else expect(view(client)).toMatchObject({ grid: "100x30", owner: "the other window" }); + } + const requests = testState.resizeCommand.mock.calls.length; + await clickClient(); + expect(testState.resizeCommand.mock.calls.length).toBeGreaterThan(requests); + expect(view(client)).toMatchObject({ grid: "100x30", scale: 1, owner: "this window" }); + }, + ); + + it.each([ + ["wait", null], + ["height-only layout", { cols: 100, rows: 29 }], + ["width layout", { cols: 99, rows: 30 }], + ] as const)( + "gives the size back to the client when its own stream returns before anyone typed (%s)", + async (_action, fit) => { + hasFocus.mockReturnValue(true); + const client = await openWindow(CLIENT, { cols: 100, rows: 30 }); + hasFocus.mockReturnValue(false); + const host = await openWindow(SERVER_HOST, { cols: 120, rows: 50 }); + await disconnect(client); + // The lone idle host inherits the size and fits it to its own box, which is not use. + expect(view(host)).toMatchObject({ grid: "120x50", owner: "this window" }); + await connect(client); + if (fit) await relayout(client, fit); + const clientFit = fit ?? { cols: 100, rows: 30 }; + expect(view(client)).toMatchObject({ + grid: `${clientFit.cols}x${clientFit.rows}`, + clipped: false, + owner: "this window", + notice: false, + }); + expect(view(host)).toMatchObject({ owner: "the other window", clipped: false }); + }, + ); + + it("keeps the size with the host the client's absence left it to once someone typed there", async () => { + hasFocus.mockReturnValue(true); + const client = await openWindow(CLIENT, { cols: 100, rows: 30 }); + hasFocus.mockReturnValue(false); + const host = await openWindow(SERVER_HOST, { cols: 120, rows: 50 }); + await disconnect(client); + const hostMount = host.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + await act(async () => { + hostMount.dispatchEvent(new KeyboardEvent("keydown", { key: "x", bubbles: true })); + xtermOf(host).dataHandler?.("x"); + }); + await server.deliver(); + await connect(client); + expect(view(client)).toMatchObject({ grid: "120x50", owner: "the other window" }); + }); + + it.each([false, true])("scales the mirror the same with WebGL %s", async (webgl) => { + testState.webglEnabled = webgl; + const { client } = await serverHostOwnsTallerGrid(); + await settleWebgl(); + expect(view(client)).toMatchObject({ scale: 0.6, clipped: false }); + }); + + it("does not let an unattended window take the size when the page regains focus by itself", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + await act(async () => xtermOf(client).keyHandler?.(new KeyboardEvent("keydown", { key: "a" }))); + await server.deliver(); + expect(view(client).owner).toBe("this window"); + const hostMount = host.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + hasFocus.mockReturnValue(true); + setUserActive(false); + await act(async () => hostMount.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))); + await act(async () => window.dispatchEvent(new FocusEvent("focus"))); + await server.deliver(); + expect(view(client).owner).toBe("this window"); + setUserActive(true); + await act(async () => hostMount.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))); + await server.deliver(); + expect(view(host).owner).toBe("this window"); + }); + + it("lets the visible client claim its fit as soon as the server host window releases the size", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + await setWindowVisible(host, false); + expect(server.size()).toEqual({ cols: 100, rows: 30, sizeClaim: claimOf(client) }); + expect(view(client)).toEqual({ + grid: "100x30", + fit: "100x30", + scale: 1, + clipped: false, + owner: "this window", + notice: false, + }); + }); + + it("leaves a released size alone while the mirror is hidden and claims it once shown", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + await setWindowVisible(client, false); + const requests = testState.resizeCommand.mock.calls.length; + await disconnect(host); + expect(server.size()).toEqual({ cols: 120, rows: 50, sizeClaim: null }); + expect(testState.resizeCommand.mock.calls.length).toBe(requests); + await setWindowVisible(client, true); + expect(view(client)).toMatchObject({ grid: "100x30", scale: 1, owner: "this window" }); + }); + + const mountOf = (machine: Machine) => + machine.mounted.container.querySelector("[data-terminal-xterm-mount]")!; + + /** A reload: the page's renderer goes away with its stream, and a new one attaches. */ + const reload = async (machine: Machine) => { + await unmount(machine.mounted); + await disconnect(machine); + machine.mounted = await mount(); + await flushAnimationFrames(); + await connect(machine); + }; + + /** The terminal's answers to a device-attributes query, by the window that sent each. */ + const queryReplies = async () => { + testState.writeCommand.mockClear(); + server.output("\x1b[c"); + await server.deliver(); + return testState.writeCommand.mock.calls + .filter(([request]) => request.input.data === "\x1b[?1;2c") + .map(([request]) => request.environmentId as EnvironmentId); + }; + + /** The owner leaves three idle windows: the size stays un-owned until one is used. */ + const ownerLeavesThreeIdleWindows = async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + const third = await openWindow(THIRD, { cols: 90, rows: 25 }); + const fourth = await openWindow(FOURTH, { cols: 110, rows: 40 }); + await disconnect(host); + return { client, third, fourth }; + }; + + it.each(["click", "typing", "Fit"] as const)( + "leaves a handed-over size un-owned through a reload and a hide and show, until a %s takes it", + async (use) => { + const { client, third, fourth } = await ownerLeavesThreeIdleWindows(); + expect(server.size()).toMatchObject({ cols: 120, rows: 50, sizeClaim: null }); + expect(server.size().replyClaim).toBeDefined(); + const requests = testState.resizeCommand.mock.calls.length; + await relayout(client, { cols: 100, rows: 29 }); + hasFocus.mockReturnValue(true); + await act(async () => window.dispatchEvent(new FocusEvent("focus"))); + await server.deliver(); + hasFocus.mockReturnValue(false); + await setWindowVisible(third, false); + await setWindowVisible(third, true); + await reload(fourth); + expect(testState.resizeCommand.mock.calls.length).toBe(requests); + // No window is sized for the grid, so each shows all of it and says so. + for (const machine of [client, third, fourth]) + expect(view(machine)).toMatchObject({ + grid: "120x50", + owner: "nobody", + clipped: false, + notice: true, + }); + if (use === "click") + await act(async () => + mountOf(client).dispatchEvent( + new PointerEvent("pointerdown", { button: 0, bubbles: true }), + ), + ); + else if (use === "typing") + await act(async () => { + xtermOf(client).keyHandler?.(new KeyboardEvent("keydown", { key: "k" })); + xtermOf(client).dataHandler?.("k"); + }); + else { + const fit = [...client.mounted.container.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Fit to this window", + )!; + await click(fit); + } + await server.deliver(); + expect(view(client)).toMatchObject({ grid: "100x29", scale: 1, owner: "this window" }); + expect(view(third)).toMatchObject({ grid: "100x29", owner: "the other window" }); + expect(view(fourth)).toMatchObject({ grid: "100x29", owner: "the other window" }); + }, + ); + + it("lets only the named window answer the terminal's queries while the size is un-owned", async () => { + xtermState.answerQueriesOnWrite = true; + const windows = Object.values(await ownerLeavesThreeIdleWindows()); + const replierOf = () => windows.find((machine) => claimOf(machine) === server.replier)!; + const first = replierOf(); + expect(server.replier).toBe(windows.map(claimOf).toSorted()[0]); + expect(await queryReplies()).toEqual([first.environmentId]); + // The named window leaves: the server names another, which answers the next query. + await setWindowVisible(first, false); + const next = replierOf(); + expect(next).not.toBe(first); + expect(server.owner).toBeNull(); + expect(await queryReplies()).toEqual([next.environmentId]); + }); + + it("keeps an older server's rule: every window claims a released size and answers meanwhile", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + server.releasesToNobody = true; + xtermState.answerQueriesOnWrite = true; + const { host, client } = await serverHostOwnsTallerGrid(); + const third = await openWindow(THIRD, { cols: 90, rows: 25 }); + // Claims are held unanswered, so the size stays un-owned while the query is parsed. + const held: Array<() => void> = []; + testState.resizeCommand.mockImplementation( + ({ input }: { input: { cols: number; rows: number; sizeClaim?: string } }) => + new Promise((resolve) => + held.push(() => { + server.resize(input.sizeClaim, input.cols, input.rows); + resolve(AsyncResult.success(undefined)); + }), + ), + ); + testState.resizeCommand.mockClear(); + await disconnect(host); + expect(server.size()).toEqual({ cols: 120, rows: 50, sizeClaim: null }); + const claimants = testState.resizeCommand.mock.calls.map( + ([request]) => (request as { environmentId: EnvironmentId }).environmentId, + ); + expect(claimants.toSorted()).toEqual([CLIENT, THIRD].toSorted()); + expect((await queryReplies()).toSorted()).toEqual([CLIENT, THIRD].toSorted()); + await act(async () => { + for (const answer of held) answer(); + }); + await server.deliver(); + expect(server.owner).not.toBeNull(); + expect([view(client).owner, view(third).owner].toSorted()).toEqual([ + "the other window", + "this window", + ]); + }); + + it("keeps the owner across a restart while its window stays attached; the mirror still mirrors", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + // An unfocused page: a focused, active one may take the size on any attach. + hasFocus.mockReturnValue(false); + const requests = testState.resizeCommand.mock.calls.length; + server.restart(); + await server.deliver(); + expect(server.size()).toEqual({ cols: 120, rows: 50, sizeClaim: claimOf(host) }); + expect(testState.resizeCommand.mock.calls.length).toBe(requests); + expect(view(host)).toMatchObject({ grid: "120x50", owner: "this window", notice: false }); + expect(view(client)).toMatchObject({ + grid: "120x50", + scale: 0.6, + owner: "the other window", + notice: true, + }); + }); + + it("gives the size to the remaining window typed in last when the owner leaves", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + const third = await openWindow(THIRD, { cols: 90, rows: 28 }); + const typeIn = async (machine: Machine) => { + const mount = machine.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + await act(async () => { + mount.dispatchEvent(new KeyboardEvent("keydown", { key: "k", bubbles: true })); + xtermOf(machine).dataHandler?.("k"); + }); + await server.deliver(); + }; + await typeIn(third); + await typeIn(client); + await typeIn(host); + await disconnect(host); + expect(view(client)).toMatchObject({ grid: "100x30", owner: "this window", scale: 1 }); + expect(view(third)).toMatchObject({ owner: "the other window", clipped: false }); + }); + + it("lets the lone window claim at once on a server that releases every size to nobody", async () => { + testState.serverConfig!.environment.capabilities = { terminalSizeOwnership: true }; + server.releasesToNobody = true; + const { host, client } = await serverHostOwnsTallerGrid(); + await disconnect(host); + expect(view(client)).toMatchObject({ grid: "100x30", owner: "this window", scale: 1 }); + }); + + it("gives the size back to the owner window when it is hidden and shown before anyone typed", async () => { + hasFocus.mockReturnValue(true); + const client = await openWindow(CLIENT, { cols: 100, rows: 30 }); + hasFocus.mockReturnValue(false); + setUserActive(false); + const host = await openWindow(SERVER_HOST, { cols: 120, rows: 50 }); + await setWindowVisible(client, false); + expect(view(host)).toMatchObject({ grid: "120x50", owner: "this window" }); + await setWindowVisible(client, true); + expect(view(client)).toMatchObject({ grid: "100x30", owner: "this window", notice: false }); + expect(view(host)).toMatchObject({ owner: "the other window", clipped: false }); + }); + + it("does not take the size by focus while the page itself is not focused", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + const clientMount = client.mounted.container.querySelector( + "[data-terminal-xterm-mount]", + )!; + hasFocus.mockReturnValue(false); + setUserActive(true); + await act(async () => clientMount.dispatchEvent(new FocusEvent("focusin", { bubbles: true }))); + await server.deliver(); + expect(view(host).owner).toBe("this window"); + }); + + describe("automatic claims wait for a settled mount (#348)", () => { + // happy-dom lays nothing out: the mounts report the size a test gives them. + const mountSizes = new WeakMap(); + /** The size a mount without its own reports, such as one not created yet. */ + let nextMountSize: { width: number; height: number } | null = null; + let restoreSize: () => void = () => undefined; + beforeEach(() => { + const width = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientWidth"); + const height = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "clientHeight"); + const sized = (axis: "width" | "height") => + function (this: HTMLElement) { + return mountSizes.get(this)?.[axis] ?? nextMountSize?.[axis] ?? 0; + }; + Object.defineProperty(HTMLElement.prototype, "clientWidth", { + configurable: true, + get: sized("width"), + }); + Object.defineProperty(HTMLElement.prototype, "clientHeight", { + configurable: true, + get: sized("height"), + }); + restoreSize = () => { + for (const [name, descriptor] of [ + ["clientWidth", width], + ["clientHeight", height], + ] as const) { + if (descriptor) Object.defineProperty(HTMLElement.prototype, name, descriptor); + else Reflect.deleteProperty(HTMLElement.prototype, name); + } + }; + }); + afterEach(() => { + restoreSize(); + nextMountSize = null; + }); + const mountOf = (machine: Machine) => + machine.mounted.container.querySelector("[data-terminal-xterm-mount]")!; + const resizeMount = async ( + machine: Machine, + size: { width: number; height: number }, + fit: { cols: number; rows: number }, + ) => { + mountSizes.set(mountOf(machine), size); + await relayout(machine, fit); + }; + const settle = async () => { + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS + 40); + }), + ); + await server.deliver(); + }; + const claimsOf = (machine: Machine) => + testState.resizeCommand.mock.calls + .map( + ([request]) => + request as { environmentId: string; input: { cols: number; rows: number } }, + ) + .filter((request) => request.environmentId === machine.environmentId) + .map((request) => `${request.input.cols}x${request.input.rows}`); + + it("sends one claim at the final size after a mount passes through a few pixels", async () => { + hasFocus.mockReturnValue(true); + const owner = await openWindow(CLIENT, { cols: 85, rows: 26 }); + await settle(); + expect(view(owner)).toMatchObject({ grid: "85x26", owner: "this window" }); + const before = claimsOf(owner).length; + + // A divider dragged open from nothing: 4 px, 52 px, then its final width. + await resizeMount(owner, { width: 4, height: 400 }, { cols: 2, rows: 26 }); + await resizeMount(owner, { width: 52, height: 400 }, { cols: 5, rows: 26 }); + await resizeMount(owner, { width: 860, height: 400 }, { cols: 121, rows: 26 }); + expect(claimsOf(owner).slice(before)).toEqual([]); + await settle(); + expect(claimsOf(owner).slice(before)).toEqual(["121x26"]); + expect(view(owner)).toMatchObject({ grid: "121x26", owner: "this window" }); + }); + + it("does not claim a 4 px mount whose stream attaches before its size first changes", async () => { + hasFocus.mockReturnValue(true); + nextMountSize = { width: 4, height: 400 }; + // The attach answer arrives while the mount still has the size it was created with. + const owner = await openWindow(CLIENT, { cols: 2, rows: 26 }, { settled: false }); + expect(claimsOf(owner)).toEqual([]); + await resizeMount(owner, { width: 860, height: 400 }, { cols: 121, rows: 26 }); + await settle(); + expect(claimsOf(owner).filter((size) => size === "2x26")).toEqual([]); + expect(view(owner)).toMatchObject({ grid: "121x26", owner: "this window" }); + }); + + it("still claims at least twice a second while a mount keeps changing size", async () => { + hasFocus.mockReturnValue(true); + const owner = await openWindow(CLIENT, { cols: 85, rows: 26 }); + await settle(); + const before = claimsOf(owner).length; + // A slow continuous drag: a new height every 50 ms for 1.2 s, never holding still. + for (let step = 1; step <= 24; step += 1) { + await resizeMount( + owner, + { width: 600, height: 400 - step * 5 }, + { cols: 85, rows: 26 - Math.floor(step / 2) }, + ); + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 50); + }), + ); + } + expect(claimsOf(owner).length - before).toBeGreaterThanOrEqual(2); + await settle(); + expect(claimsOf(owner).at(-1)).toBe("85x14"); + }); + + it("does not claim the sizing minimum at the maximum wait while a mount keeps moving", async () => { + hasFocus.mockReturnValue(true); + const owner = await openWindow(CLIENT, { cols: 85, rows: 26 }); + await settle(); + const before = claimsOf(owner).length; + // A drag that stays a few pixels wide past the 500 ms mark: the fit is 2 columns. + for (let step = 1; step <= 14; step += 1) { + await resizeMount(owner, { width: 3 + (step % 3), height: 400 }, { cols: 2, rows: 26 }); + await act( + async () => + new Promise((resolve) => { + setTimeout(resolve, 50); + }), + ); + } + expect(claimsOf(owner).slice(before)).toEqual([]); + await resizeMount(owner, { width: 860, height: 400 }, { cols: 121, rows: 26 }); + await settle(); + expect(claimsOf(owner).slice(before)).toEqual(["121x26"]); + }); + + it("claims a small owner panel that holds still", async () => { + hasFocus.mockReturnValue(true); + const owner = await openWindow(CLIENT, { cols: 85, rows: 26 }); + await settle(); + const before = claimsOf(owner).length; + await resizeMount(owner, { width: 130, height: 60 }, { cols: 18, rows: 4 }); + await settle(); + expect(claimsOf(owner).slice(before)).toEqual(["18x4"]); + expect(view(owner)).toMatchObject({ grid: "18x4", owner: "this window", scale: 1 }); + await settle(); + expect(claimsOf(owner).slice(before)).toEqual(["18x4"]); + }); + + it("does not claim the size a mount had while 4 px wide when its stream attached", async () => { + hasFocus.mockReturnValue(true); + nextMountSize = { width: 4, height: 400 }; + // The panel keeps opening while the attach answer is on its way. + const owner = await openWindow( + CLIENT, + { cols: 2, rows: 26 }, + { + settled: false, + beforeAttach: (machine) => + resizeMount(machine, { width: 52, height: 400 }, { cols: 5, rows: 26 }), + }, + ); + await resizeMount(owner, { width: 860, height: 400 }, { cols: 121, rows: 26 }); + await settle(); + expect(claimsOf(owner).filter((size) => size === "2x26" || size === "5x26")).toEqual([]); + expect(view(owner)).toMatchObject({ grid: "121x26", owner: "this window" }); + }); + + it("still scales a small mirror at once while its own box changes", async () => { + const { host, client } = await serverHostOwnsTallerGrid(); + await settle(); + const requests = claimsOf(client).length; + await resizeMount(client, { width: 300, height: 200 }, { cols: 60, rows: 25 }); + expect(view(client)).toMatchObject({ grid: "120x50", scale: 0.5, owner: "the other window" }); + await settle(); + expect(claimsOf(client).length).toBe(requests); + expect(view(host).owner).toBe("this window"); + }); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalPanel.test.ts b/apps/web/src/components/ThreadTerminalPanel.test.ts index fa613ab892..138c5b9e23 100644 --- a/apps/web/src/components/ThreadTerminalPanel.test.ts +++ b/apps/web/src/components/ThreadTerminalPanel.test.ts @@ -8,8 +8,11 @@ import { resolveTerminalDocumentVisibility, resolveTerminalSelectionActionPosition, runtimeEnvSignature, + terminalThemeFromApp, } from "./ThreadTerminalPanel"; +import { resolveTerminalPalette } from "./terminalTheme"; import { writeTerminalBuffer } from "./terminalReplyGuard"; +import { BUILTIN_THEMES } from "../themes/builtinThemes"; afterEach(() => { vi.unstubAllGlobals(); @@ -214,3 +217,48 @@ describe("quoteTerminalPastePath", () => { expect(quoteTerminalPastePath(path, os)).toBe(expected); }); }); + +describe("terminal theme from the app (#290, R51)", () => { + const tokyoNight = BUILTIN_THEMES.find((theme) => theme.id === "tokyo-night")!; + // A dark document painted with Tokyo Night surfaces. + function stubDarkDocument() { + vi.stubGlobal("document", { + documentElement: { classList: { contains: (name: string) => name === "dark" } }, + querySelector: () => null, + body: {}, + }); + vi.stubGlobal("getComputedStyle", () => ({ + backgroundColor: "rgb(26, 27, 38)", + color: "rgb(169, 177, 214)", + })); + } + + it("a running session launched under Tokyo Night keeps its palette after the app turns light", () => { + stubDarkDocument(); + const launched = resolveTerminalPalette("app", tokyoNight); + const theme = terminalThemeFromApp("light", null, launched, false); + expect(theme.background).toBe("#1a1b26"); + expect(theme.green).toBe("#9ece6a"); + expect(theme.scrollbarSliderBackground).toBe("rgba(255, 255, 255, 0.1)"); + }); + + it("the Dark preference under a custom app theme paints the built-in dark palette, not the app surfaces", () => { + stubDarkDocument(); + const theme = terminalThemeFromApp( + "dark", + null, + resolveTerminalPalette("dark", tokyoNight), + false, + ); + expect(theme.background).toBe("rgb(14, 18, 24)"); + expect(theme.foreground).toBe("rgb(237, 241, 247)"); + }); + + it("under built-in Dark the terminal keeps today's app surface colors", () => { + stubDarkDocument(); + const dark = BUILTIN_THEMES.find((theme) => theme.id === "dark")!; + const theme = terminalThemeFromApp("dark", null, resolveTerminalPalette("app", dark), true); + expect(theme.background).toBe("rgb(26, 27, 38)"); + expect(theme.foreground).toBe("rgb(169, 177, 214)"); + }); +}); diff --git a/apps/web/src/components/ThreadTerminalPanel.tsx b/apps/web/src/components/ThreadTerminalPanel.tsx index 4466904f90..a190400dd3 100644 --- a/apps/web/src/components/ThreadTerminalPanel.tsx +++ b/apps/web/src/components/ThreadTerminalPanel.tsx @@ -10,8 +10,10 @@ import { createTerminalInputScheduler, createTerminalInputSchedulerRegistry, type TerminalInputScheduler, + type TerminalInputSchedulerOrigin, type TerminalInputSendResult, type TerminalRenderSignal, + type TerminalSizePosition, terminalInputKey, } from "@bibcode/client-runtime/state/terminal"; import { @@ -28,6 +30,7 @@ import { type TerminalLaunchCommand, type TerminalSize, type TerminalResizeInput, + type TerminalResizeResult, type ThreadId, } from "@bibcode/contracts"; import { getTerminalLabel } from "@bibcode/shared/terminalLabels"; @@ -86,14 +89,28 @@ import { showFileOutsideWorkspaceNotice, showPreviewFailedNotice } from "~/brows import { openFileInPreview } from "~/browser/openFileInPreview"; import { canPreviewAnyUrlInRuntime } from "~/previewStateStore"; import { createTerminalOutputSink } from "./terminalOutputSink"; +import { + createTerminalFollowOutput, + observeTerminalDrawing, + observeTerminalUserScroll, + syncXtermScrollArea, +} from "./terminalFollowOutput"; import { installTerminalReplyGuard } from "./terminalReplyGuard"; -import { proposeTerminalDimensions, registerTerminalSizeReader } from "./terminalSizing"; import { + isTerminalSizingMinimum, + proposeTerminalDimensions, + registerTerminalSizeReader, + terminalHostHasArea, +} from "./terminalSizing"; +import { + answersTerminalQueries, + isUnownedUntilUsed, shouldClaimTerminalSize, terminalDimensionsEqual, terminalSizesEqual, hasForeignTerminalSizeOwner, shouldShowTerminalSizeNotice, + terminalMirrorScale, type TerminalDimensions, type TerminalSizeTrigger, } from "./terminalSizePolicy"; @@ -108,15 +125,19 @@ import { import { useAtomCommand } from "../state/use-atom-command"; import { useEnvironmentSettings, usePrimarySettings } from "../hooks/useSettings"; import { useTheme } from "../hooks/useTheme"; +import { useThemeLibrary } from "../themes/customThemes"; import { ensureBundledTerminalFontLoaded, resolveTerminalFontFamily } from "../lib/terminalFont"; import { mergeTerminalSpawnEnv, resolveTerminalThemeMode, + isBuiltinTerminalPalette, + resolveTerminalPalette, retainTerminalLaunchTheme, terminalAnsiColors, terminalExtendedAnsiPalette, usesPersistentWindowsConsoleTheme, type TerminalLaunchThemeState, + type TerminalPalette, type TerminalThemeMode, } from "./terminalTheme"; import { ProviderTerminalActivityDock } from "./activity/ProviderTerminalActivityDock"; @@ -134,7 +155,13 @@ interface TerminalInputBinding { readonly terminal: Terminal; readonly onInputError: (message: string | null) => void; } | null; - write: ((data: string, fallbackError: string) => Promise) | null; + write: + | (( + data: string, + fallbackError: string, + origin: TerminalInputSchedulerOrigin, + ) => Promise) + | null; ordered: boolean; generation: number; attachment: { readonly runtime: object; readonly generation: number } | null; @@ -278,14 +305,20 @@ function createTerminalInputWriter(input: { readonly threadId: ThreadId; readonly terminalId: string; readonly data: string; + readonly userInputClaim?: string | null; }; }) => Promise>; }): NonNullable { const { environmentId, threadId, terminalId, run } = input; - return async (data, fallbackError) => { + return async (data, fallbackError, origin) => { const result = await run({ environmentId, - input: { threadId, terminalId, data }, + input: { + threadId, + terminalId, + data, + ...(origin === undefined ? {} : { userInputClaim: origin }), + }, }); return atomCommandTerminalInputResult(result, fallbackError); }; @@ -311,8 +344,11 @@ export function enqueueTerminalInput(input: { } return; } - binding.write = async (data, fallbackError) => - atomCommandTerminalInputResult(await input.write(data), fallbackError); + // A mounted renderer's writer keeps marking who produced its input (#348). + if (binding.renderer === null) { + binding.write = async (data, fallbackError) => + atomCommandTerminalInputResult(await input.write(data), fallbackError); + } binding.pendingFallbacks.push({ remaining: input.data.length, message: input.fallbackError, @@ -458,7 +494,7 @@ function acquireTerminalInputBinding(inputKey: string): { maxPendingBytes: 1024 * 1024, stopOnError: () => keyedBinding.ordered, onReset: () => keyedBinding.resetInput?.(), - send: async (data) => { + send: async (data, origin) => { const generation = keyedBinding.generation; let remaining = data.length; let fallbackError = "Terminal write failed"; @@ -504,7 +540,7 @@ function acquireTerminalInputBinding(inputKey: string): { } let result: TerminalInputSendResult; try { - result = await write(data, fallbackError); + result = await write(data, fallbackError, origin); } catch (error) { notifyFallbackFailures(error); throw error; @@ -541,8 +577,8 @@ function acquireTerminalInputBinding(inputKey: string): { }); return { - enqueue: (data) => rawScheduler.enqueue(data), - reserve: () => rawScheduler.reserve(), + enqueue: (data, origin) => rawScheduler.enqueue(data, origin), + reserve: (origin) => rawScheduler.reserve(origin), reset: () => { keyedBinding.generation += 1; keyedBinding.error = null; @@ -558,6 +594,67 @@ function acquireTerminalInputBinding(inputKey: string): { return { binding: keyedBinding, scheduler }; } +/** Events on a terminal's mount that only a user produces; their input counts as use (#348). */ +const TERMINAL_USER_INPUT_EVENTS = [ + "keydown", + "paste", + "compositionstart", + "compositionupdate", + "compositionend", + "input", + "pointerdown", + "pointerup", + "wheel", +] as const; +/** How long after a user event the terminal's input still counts as that user's. */ +const TERMINAL_USER_INPUT_WINDOW_MS = 500; +const TERMINAL_FOCUS_IN_REPORT = "\u001b[I"; +const TERMINAL_FOCUS_OUT_REPORT = "\u001b[O"; + +/** + * How long a mount must hold its size before an automatic size claim (layout, attach) is + * sent. A panel opening, collapsing or being dragged passes through a few pixels on the way, + * which would size the program to 2 columns; a small panel that holds still is claimed (#348). + */ +export const TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS = 100; +/** + * The longest an automatic claim waits for a mount that keeps changing size. An owner is not + * scaled, so its box cuts off the rows it no longer fits until a claim lands (#348). + */ +export const TERMINAL_AUTOMATIC_CLAIM_MAX_WAIT_MS = 500; + +/** Backoff for retrying a failed final resize; bounded so a dead link cannot spin. */ +const TERMINAL_RESIZE_RETRY_DELAYS_MS = [250, 1_000, 4_000] as const; + +/** A claim the server applied or already held, waiting for its ordered `resized` (#348). */ +interface TerminalClaimAcknowledgement { + readonly request: TerminalSize; + readonly sequence: number; + readonly generation: TerminalResizeResult["generation"]; +} + +/** + * Whether an applied size is the acknowledgement, or later: the server publishes it at the + * answer's sequence, behind the output queued before it. A size of another process ends the + * wait, as does one without a position, which an older server sends. + */ +function sizePositionReaches( + position: TerminalSizePosition, + acknowledgement: TerminalClaimAcknowledgement, +): boolean { + if (position.sequence === undefined) return true; + const { generation } = acknowledgement; + if ( + generation !== null && + position.generation !== undefined && + (generation.boot !== position.generation.boot || + generation.process !== position.generation.process) + ) { + return true; + } + return position.sequence >= acknowledgement.sequence; +} + export function fitTerminalSafely(fitAddon: FitAddon): boolean { try { fitAddon.fit(); @@ -634,19 +731,49 @@ function terminalThemesEqual(left: ITheme, right: ITheme): boolean { function repaintTerminalTheme( terminal: Terminal, resolvedTheme: TerminalThemeMode, - mountElement?: HTMLElement | null, + mountElement: HTMLElement | null, + palette: TerminalPalette | undefined, + appThemeIsBuiltin: boolean, ): void { - const nextTheme = terminalThemeFromApp(resolvedTheme, mountElement); + const nextTheme = terminalThemeFromApp(resolvedTheme, mountElement, palette, appThemeIsBuiltin); const currentTheme = terminal.options.theme; if (currentTheme && terminalThemesEqual(currentTheme, nextTheme)) return; terminal.options.theme = nextTheme; terminal.refresh(0, terminal.rows - 1); } -function terminalThemeFromApp( +/** + * `appThemeIsBuiltin` is true while the app shows built-in Light or Dark. Only then does a + * built-in palette take the app's panel surfaces, as it always has; under a custom app theme it + * keeps the built-in colors the shell is told about (R51). + */ +export function terminalThemeFromApp( resolvedTheme: TerminalThemeMode, - mountElement?: HTMLElement | null, + mountElement: HTMLElement | null | undefined, + palette: TerminalPalette | undefined, + appThemeIsBuiltin: boolean, ): ITheme { + // A theme's own terminal colors (preference "Follow app theme"), as the session launched them. + // A running session keeps them in their own mode whatever the app shows now (R51). + if (palette && !isBuiltinTerminalPalette(palette)) { + const paletteDark = palette.mode === "dark"; + return { + background: palette.background, + foreground: palette.foreground, + extendedAnsi: terminalExtendedAnsiPalette(palette.mode), + cursor: palette.cursor, + selectionBackground: palette.selectionBackground, + selectionForeground: palette.selectionForeground, + scrollbarSliderBackground: paletteDark ? "rgba(255, 255, 255, 0.1)" : "rgba(0, 0, 0, 0.15)", + scrollbarSliderHoverBackground: paletteDark + ? "rgba(255, 255, 255, 0.18)" + : "rgba(0, 0, 0, 0.25)", + scrollbarSliderActiveBackground: paletteDark + ? "rgba(255, 255, 255, 0.22)" + : "rgba(0, 0, 0, 0.3)", + ...palette.ansi, + }; + } const isDark = resolvedTheme === "dark"; const fallbackBackground = isDark ? "rgb(14, 18, 24)" : "rgb(255, 255, 255)"; const fallbackForeground = isDark ? "rgb(237, 241, 247)" : "rgb(28, 33, 41)"; @@ -657,7 +784,7 @@ function terminalThemeFromApp( const panelStyles = getComputedStyle(panelSurface); const bodyStyles = getComputedStyle(document.body); const requestedThemeMatchesDocument = - document.documentElement.classList.contains("dark") === isDark; + appThemeIsBuiltin && document.documentElement.classList.contains("dark") === isDark; const background = requestedThemeMatchesDocument ? normalizeComputedColor( panelStyles.backgroundColor, @@ -815,7 +942,7 @@ export function TerminalViewport({ keybindings, workspaceUnavailable = null, }: TerminalViewportProps) { - const { resolvedTheme: appTheme } = useTheme(); + const { resolvedTheme: appTheme, resolvedThemeId } = useTheme(); const terminalThemePreference = usePrimarySettings( (settings) => settings.terminalThemePreference, ); @@ -825,6 +952,15 @@ export function TerminalViewport({ // Everything downstream — the xterm theme and the OSC colours the PTY reports // to providers — is derived from this, never from the app theme directly. const resolvedTheme = resolveTerminalThemeMode(terminalThemePreference, appTheme); + // The library snapshot changes when a theme with the same id is saved with new colors. + const { themes } = useThemeLibrary(); + const activeTheme = + themes.find((theme) => theme.id === resolvedThemeId) ?? + themes.find((theme) => theme.id === appTheme)!; + const terminalPalette = useMemo( + () => resolveTerminalPalette(terminalThemePreference, activeTheme), + [activeTheme, terminalThemePreference], + ); const containerRef = useRef(null); const terminalRef = useRef(null); const fitAddonRef = useRef(null); @@ -832,7 +968,14 @@ export function TerminalViewport({ const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); const orderedInput = serverConfig?.environment.capabilities?.terminalOrderedInput === true; const sizeOwnership = serverConfig?.environment.capabilities?.terminalSizeOwnership === true; + // The server tells a user's input from the terminal's own replies (#348). + const userInputClaim = + sizeOwnership && serverConfig?.environment.capabilities?.terminalUserInputClaim === true; const terminalTargetKey = terminalInputKey(environmentId, threadId, terminalId); + // One claim for this viewport's renderers: a renderer recreated when the window is hidden + // and shown is the same window to the server, which gives a returning owner its size back. + // Another viewport of the terminal in this window has its own claim (#348). + const [viewportSizeClaim] = useState(randomUUID); const [rendererAttachmentInput, setRendererAttachmentInput] = useState<{ readonly target: string; readonly sizeClaim: string; @@ -844,10 +987,12 @@ export function TerminalViewport({ rendererAttachmentInput.sizeOwnership === sizeOwnership ? rendererAttachmentInput : null; - const [mirroringSize, setMirroringSize] = useState(false); + // The size notice's scale (1 when the owner's grid fits unscaled), or null while hidden. + const [mirroringSize, setMirroringSize] = useState(null); const rendererRef = useRef<{ readonly sizeClaim: string; dismissSizeNotice(): void; + fit(): void; render(signal: TerminalRenderSignal, claimOnAttach: boolean, exited: boolean): void; size(trigger: TerminalSizeTrigger): void; } | null>(null); @@ -918,6 +1063,9 @@ export function TerminalViewport({ const runTerminalResize = useAtomCommand(terminalEnvironment.resize, { reportFailure: false, }); + const runReadTerminalSize = useAtomCommand(terminalEnvironment.readSize, { + reportFailure: false, + }); const runTerminalRestart = useAtomCommand(terminalEnvironment.restart, { reportFailure: false, }); @@ -996,10 +1144,12 @@ export function TerminalViewport({ const [attachmentLaunchThemeState, setAttachmentLaunchThemeState] = useState<{ readonly targetKey: string; readonly theme: TerminalThemeMode; + readonly palette: TerminalPalette; readonly pinned: boolean; }>(() => ({ targetKey: terminalTargetKey, theme: resolvedTheme, + palette: terminalPalette, pinned: shouldPinAttachmentTheme, })); let attachmentLaunchThemeEntry = attachmentLaunchThemeState; @@ -1010,10 +1160,12 @@ export function TerminalViewport({ attachmentLaunchThemeEntry = { targetKey: terminalTargetKey, theme: resolvedTheme, + palette: terminalPalette, pinned: shouldPinAttachmentTheme, }; } const attachmentLaunchTheme = attachmentLaunchThemeEntry.theme; + const attachmentLaunchPalette = attachmentLaunchThemeEntry.palette; // Launch-command env supplies provider defaults and the thread's runtime env // wins ordinary conflicts. Theme colors and the Codex-only Windows console // theme are authoritative because the PTY consumes them during spawn. @@ -1022,10 +1174,17 @@ export function TerminalViewport({ commandEnv: command?.env, runtimeEnv, resolvedTheme: attachmentLaunchTheme, + palette: attachmentLaunchPalette, windowsConsoleTheme: hasPersistentWindowsConsoleTheme, }); return Object.keys(merged).length > 0 ? merged : undefined; - }, [attachmentLaunchTheme, command, hasPersistentWindowsConsoleTheme, runtimeEnv]); + }, [ + attachmentLaunchPalette, + attachmentLaunchTheme, + command, + hasPersistentWindowsConsoleTheme, + runtimeEnv, + ]); const terminalSession = useAttachedTerminalSession({ environmentId, terminal: { @@ -1064,6 +1223,7 @@ export function TerminalViewport({ resolvedTheme: hasPersistentWindowsConsoleTheme ? attachmentLaunchTheme : resolvedTheme, authoritativeTheme: terminalSession.consoleTheme, restartRequest: themeRestartRequest, + palette: terminalPalette, }; const [launchThemeState, setLaunchThemeState] = useState(() => retainTerminalLaunchTheme(null, launchThemeInput), @@ -1071,7 +1231,8 @@ export function TerminalViewport({ const launchTheme = retainTerminalLaunchTheme(launchThemeState, launchThemeInput); if ( launchTheme.generation !== launchThemeState.generation || - launchTheme.theme !== launchThemeState.theme + launchTheme.theme !== launchThemeState.theme || + launchTheme.palette !== launchThemeState.palette ) { setLaunchThemeState(launchTheme); } @@ -1090,6 +1251,7 @@ export function TerminalViewport({ if ( attachmentLaunchThemeEntry.targetKey !== attachmentLaunchThemeState.targetKey || attachmentLaunchThemeEntry.theme !== attachmentLaunchThemeState.theme || + attachmentLaunchThemeEntry.palette.key !== attachmentLaunchThemeState.palette.key || attachmentLaunchThemeEntry.pinned !== attachmentLaunchThemeState.pinned ) { setAttachmentLaunchThemeState(attachmentLaunchThemeEntry); @@ -1098,6 +1260,10 @@ export function TerminalViewport({ ? launchTheme.theme : resolvedTheme; const readEffectiveTerminalTheme = useEffectEvent(() => effectiveTerminalTheme); + const readLaunchPalette = useEffectEvent(() => launchTheme.palette); + const readAppThemeIsBuiltin = useEffectEvent( + () => resolvedThemeId === "light" || resolvedThemeId === "dark", + ); const nativeThemeMismatch = hasPersistentWindowsConsoleTheme && terminalStatus === "running" && @@ -1262,7 +1428,12 @@ export function TerminalViewport({ fontSize: 12, scrollback: 5_000, fontFamily: readTerminalFontFamily(), - theme: terminalThemeFromApp(readEffectiveTerminalTheme(), mount), + theme: terminalThemeFromApp( + readEffectiveTerminalTheme(), + mount, + readLaunchPalette(), + readAppThemeIsBuiltin(), + ), // OSC 8 activations bypass the link provider, so apply the same activation guard here. linkHandler: { activate: (event, uri, range) => { @@ -1317,30 +1488,135 @@ export function TerminalViewport({ }); terminal.loadAddon(fitAddon); terminal.open(mount); - fitTerminalSafely(fitAddon); - const sizeClaim = randomUUID(); - let fittedDimensions = proposeTerminalDimensions(fitAddon); - const updateFittedDimensions = () => (fittedDimensions = proposeTerminalDimensions(fitAddon)); + if (terminalHostHasArea(mount)) fitTerminalSafely(fitAddon); + // A paused renderer can move the view by itself; the position it had when drawing + // stopped decides whether a resize keeps following the output (#348). + const followScreen = mount.querySelector(".xterm-screen"); + const followOutput = followScreen + ? createTerminalFollowOutput(terminal, { + observeDrawing: observeTerminalDrawing(followScreen), + syncScrollArea: () => syncXtermScrollArea(terminal), + observeUserScroll: observeTerminalUserScroll(mount), + }) + : null; + const followsOutput = () => + followOutput?.atBottom() ?? terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + const sizeClaim = viewportSizeClaim; + let fittedDimensions = proposeTerminalDimensions(fitAddon, mount); + const updateFittedDimensions = () => + (fittedDimensions = proposeTerminalDimensions(fitAddon, mount)); setRendererAttachmentInput({ target: terminalTargetKey, sizeClaim, sizeOwnership, dimensions: fittedDimensions, }); - let mirroringSizeVisible = false; - const updateMirroringSize = (next: boolean) => { + let mirroringSizeVisible: number | null = null; + const updateMirroringSize = (next: number | null) => { if (mirroringSizeVisible === next) return; mirroringSizeVisible = next; setMirroringSize(next); }; + let mirrorScale = 1; + // A mirror never clips the owner's grid: it scales the whole xterm element down, which + // keeps the grid's cells and both renderers intact. A negative bottom margin gives the + // element its scaled height for scrolling, so below the floor the box scrolls from the + // bottom rows. xterm maps the mouse to cells at full size, so a scaled mirror takes no + // pointer input itself: a click reaches the mount, whose pointerdown takes the size. + const applyMirrorScale = (fitted: TerminalDimensions | null) => { + const element = terminal.element; + if (!element) return; + const scale = sizeOwnership + ? terminalMirrorScale(sizeClaim, appliedSize, fitted, unownedUntilUsed) + : 1; + if (scale === 1 && mirrorScale === 1) return; + const overflows = + scale < 1 && + appliedSize !== null && + fitted !== null && + (appliedSize.rows * scale > fitted.rows || appliedSize.cols * scale > fitted.cols); + mirrorScale = scale; + element.style.transformOrigin = scale < 1 ? "0 0" : ""; + element.style.transform = scale < 1 ? `scale(${scale})` : ""; + element.style.marginBottom = scale < 1 ? `${(scale - 1) * element.offsetHeight}px` : ""; + element.style.pointerEvents = scale < 1 ? "none" : ""; + mount.style.overflow = overflows ? "auto" : ""; + if (overflows) mount.scrollTop = mount.scrollHeight; + }; let appliedSize: TerminalSize | null = null; + // A server that hands the size over published it un-owned: it waits for use (#348). + let unownedUntilUsed = false; let claimInFlight: TerminalSize | null = null; let layoutChangedWhileClaimPending = false; + // Hides the notice only while a claim is pending or waits for its acknowledgement. let claimNoticeSuppressed = false; + // Automatic claims wait for the mount to hold its size, from its first measurement: a + // panel opening creates its terminal mid-transition, and its stream can attach before + // the size changes again. + let settledMountSize = { width: mount.clientWidth, height: mount.clientHeight }; + let mountSizeChangedAt = performance.now(); + // When the mount started changing without a claim since; null while settled. + let mountUnsettledSince: number | null = mountSizeChangedAt; + let deferredClaimTimer: number | null = null; + // An attach claim may take another window's size; a later layout change keeps it pending. + let deferredTrigger: "layout" | "attach" | null = null; + const mountSettleRemaining = () => { + const width = mount.clientWidth; + const height = mount.clientHeight; + const now = performance.now(); + if (width !== settledMountSize.width || height !== settledMountSize.height) { + settledMountSize = { width, height }; + mountSizeChangedAt = now; + mountUnsettledSince ??= now; + } + if (mountUnsettledSince === null) return 0; + const quiet = mountSizeChangedAt + TERMINAL_AUTOMATIC_CLAIM_SETTLE_MS - now; + let remaining = Math.min( + quiet, + mountUnsettledSince + TERMINAL_AUTOMATIC_CLAIM_MAX_WAIT_MS - now, + ); + // The maximum wait never claims the sizing minimum; only holding still does. + if (remaining <= 0 && quiet > 0) { + const fitted = proposeTerminalDimensions(fitAddon, mount); + if (fitted === null || isTerminalSizingMinimum(fitted)) remaining = quiet; + } + if (remaining <= 0) mountUnsettledSince = null; + return remaining; + }; + let keypressMeasureFrame: number | null = null; + // Geometry changes only through ordered size events; the answer names the one to wait for. + let awaitedAcknowledgement: TerminalClaimAcknowledgement | null = null; + let appliedPosition: TerminalSizePosition = {}; + // Geometry the server declined, or an older server answered without an acknowledgement, + // is not sent again until its state changes. + let declinedRequest: TerminalSize | null = null; + // The geometry whose held size an older server was last asked for; a published size since + // makes the read stale and allows another. + let legacyRead: TerminalSize | null = null; let oscColorResponderActive = false; let disposed = false; + let resizeRetryTimer: number | null = null; + let resizeRetries = 0; + // A failed final resize would otherwise wait for another layout or input + // event, leaving the PTY at its old size (#348). + const retryFailedResize = () => { + if (disposed || resizeRetryTimer !== null) return; + const delay = TERMINAL_RESIZE_RETRY_DELAYS_MS[resizeRetries]; + if (delay === undefined) return; + resizeRetries += 1; + resizeRetryTimer = window.setTimeout(() => { + resizeRetryTimer = null; + // Re-measures, so a newer size replaces the failed one, and the ownership + // policy decides whether this renderer may still claim. + handleSizeTrigger("layout"); + }, delay); + }; + // An older server ignores `structuredResult` and answers null, which `completeClaim` handles. const resizeTerminal = (size: Pick) => - runTerminalResize({ environmentId, input: { threadId, terminalId, ...size } }); + runTerminalResize({ + environmentId, + input: { threadId, terminalId, ...size, structuredResult: true }, + }); const requestTerminalResize = (rendererGeneration: number, dimensions: TerminalDimensions) => { if (resizeRendererGenerationRef.current !== rendererGeneration) return; const current = lastRequestedSizeRef.current; @@ -1353,12 +1629,16 @@ export function TerminalViewport({ lastRequestedSizeRef.current = request; void resizeTerminal(dimensions).then((result) => { if ( - result._tag === "Failure" && - resizeRendererGenerationRef.current === rendererGeneration && - lastRequestedSizeRef.current === request - ) { - lastRequestedSizeRef.current = null; + resizeRendererGenerationRef.current !== rendererGeneration || + lastRequestedSizeRef.current !== request + ) + return; + if (result._tag === "Success") { + resizeRetries = 0; + return; } + lastRequestedSizeRef.current = null; + retryFailedResize(); }); }; @@ -1394,30 +1674,130 @@ export function TerminalViewport({ write: (data, onParsed) => terminal.write(data, onParsed), }); let outputSink = createOutputSink(); + // xterm answers a query inside the parse that read it; nothing a user does runs there. + let replying = false; const replyGuard = installTerminalReplyGuard( terminal, - () => !sizeOwnership || !hasForeignTerminalSizeOwner(appliedSize, sizeClaim), + () => !sizeOwnership || answersTerminalQueries(appliedSize, sizeClaim), () => oscColorResponderActive, + () => { + if (replying) return; + replying = true; + queueMicrotask(() => { + replying = false; + }); + }, ); const updateSizeNotice = (fitted = updateFittedDimensions()) => { + applyMirrorScale(fitted); updateMirroringSize( !dismissedTerminalSizes.has(terminalTargetKey) && !claimNoticeSuppressed && + deferredTrigger !== "attach" && sizeOwnership && shouldShowTerminalSizeNotice({ sizeClaim, applied: appliedSize, fitted, claimInFlight: claimInFlight !== null, - }), + unownedUntilUsed, + }) + ? mirrorScale + : null, ); }; - const handleSizeTrigger = (trigger: TerminalSizeTrigger) => { + // The answer says what the server holds. An applied or unchanged claim is acknowledged + // by a `resized` on this renderer's stream, behind the output printed before it, and the + // ordered size path applies it, so a stale applied size is repaired without a timer + // (#348). Any other outcome changed nothing, so xterm keeps the size it has. + const completeClaim = (request: TerminalSize, result: TerminalResizeResult | null) => { + const accepted = result?.outcome === "applied" || result?.outcome === "unchanged"; + if (accepted && result.sequence !== null) { + // The server kept another size: its PTY refused this one on every retry. The same + // geometry is not sent again until the server's state changes (#348). + if (result.size !== null && !terminalDimensionsEqual(result.size, request)) + declinedRequest = request; + const acknowledgement = { + request, + sequence: result.sequence, + generation: result.generation, + }; + if (sizePositionReaches(appliedPosition, acknowledgement)) claimNoticeSuppressed = false; + else awaitedAcknowledgement = acknowledgement; + return; + } + // Not sent again until the server's state changes. An unregistered claim (a remount + // claiming before its stream attached, or a stream reconnecting) is claimed again when + // this renderer's stream attaches; a missing session or an ended process is shown by + // its own events. + declinedRequest = request; + // An older server answers null and acknowledges nothing: the size it publishes next + // settles the claim. It publishes nothing for an owner's claim of the size it already + // holds, nor for a claim it ignored, so one read of the size it holds settles those. + if ((result === null || accepted) && !terminalSizesEqual(appliedSize, request)) { + reconcileLegacyClaim(request, appliedSize?.sizeClaim === sizeClaim); + return; + } + claimNoticeSuppressed = false; + }; + const endLegacyClaim = () => { + claimNoticeSuppressed = false; + updateSizeNotice(); + }; + const reconcileLegacyClaim = (request: TerminalSize, owned: boolean) => { + if (terminalDimensionsEqual(legacyRead, request)) { + endLegacyClaim(); + return; + } + const read = request; + legacyRead = read; + void runReadTerminalSize({ environmentId, input: { threadId, terminalId } }).then( + (result) => { + if (disposed || legacyRead !== read) return; + const held = result._tag === "Success" ? result.value : null; + outputSink.barrier(() => { + if (disposed || legacyRead !== read) return; + // A takeover the server applied publishes its owner change, which settles it. + if (held?.sizeClaim === sizeClaim && !owned) return; + if (held !== null && owned) { + applySize(held); + // The read's own size does not allow another read of the same geometry. + legacyRead = read; + } else endLegacyClaim(); + }); + }, + ); + }; + const handleSizeTrigger = (requestedTrigger: TerminalSizeTrigger) => { + let trigger = requestedTrigger; if (disposed || resizeRendererGenerationRef.current !== resizeRendererGeneration) return; if (trigger === "layout" && isCenterPanelResizing(mount)) return; + // An automatic claim of a mount still changing size waits until it holds still, then + // runs once; the mirror scale still follows every change. + const settleRemaining = + trigger === "layout" || trigger === "attach" ? mountSettleRemaining() : 0; + if ((trigger === "layout" || trigger === "attach") && settleRemaining > 0) { + deferredTrigger = deferredTrigger === "attach" ? "attach" : trigger; + if (deferredClaimTimer !== null) window.clearTimeout(deferredClaimTimer); + deferredClaimTimer = window.setTimeout(() => { + const pending = deferredTrigger ?? "layout"; + deferredClaimTimer = null; + deferredTrigger = null; + handleSizeTrigger(pending); + }, settleRemaining); + if (sizeOwnership) updateSizeNotice(); + return; + } + // A settled automatic trigger runs a deferred one now; an attach claim keeps its rule. + if ((trigger === "layout" || trigger === "attach") && deferredClaimTimer !== null) { + window.clearTimeout(deferredClaimTimer); + deferredClaimTimer = null; + if (deferredTrigger === "attach") trigger = "attach"; + deferredTrigger = null; + } if (!sizeOwnership) { - if (trigger !== "layout") return; - const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + if (trigger !== "layout" || !terminalHostHasArea(mount)) return; + const wasAtBottom = followsOutput(); fitTerminalSafely(fitAddon); if (wasAtBottom) terminal.scrollToBottom(); requestTerminalResize(resizeRendererGeneration, { @@ -1426,19 +1806,35 @@ export function TerminalViewport({ }); return; } + // A claim already answered for this geometry waits for its acknowledgement; sending it + // again would only restart that wait. const shouldClaim = (fitted: TerminalDimensions | null) => shouldClaimTerminalSize({ sizeClaim, applied: appliedSize, fitted, + unownedUntilUsed, trigger, documentFocused: document.hasFocus(), - claimInFlight: claimInFlight !== null, + // Engines without the API keep the earlier focus-only rule. + userActive: navigator.userActivation?.isActive ?? true, + claimInFlight: + claimInFlight !== null || + terminalDimensionsEqual(declinedRequest, fitted) || + (awaitedAcknowledgement !== null && + terminalDimensionsEqual(awaitedAcknowledgement.request, fitted)), }); - // Use the same policy with cached geometry before measuring on keydown. - if (trigger === "keypress" && fittedDimensions !== null && !shouldClaim(fittedDimensions)) - return; - const fitted = updateFittedDimensions(); + // Typing measures at most once per frame; other keydowns in that frame reuse the fit. + let fitted: TerminalDimensions | null; + if (trigger === "keypress" && keypressMeasureFrame !== null) fitted = fittedDimensions; + else { + if (trigger === "keypress") { + keypressMeasureFrame = window.requestAnimationFrame(() => { + keypressMeasureFrame = null; + }); + } + fitted = updateFittedDimensions(); + } if ( trigger === "layout" && claimInFlight !== null && @@ -1450,8 +1846,9 @@ export function TerminalViewport({ if (fitted && shouldClaim(fitted)) { const request = { ...fitted, sizeClaim }; claimInFlight = request; + awaitedAcknowledgement = null; claimNoticeSuppressed = true; - const finishClaim = (succeeded: boolean) => { + const finishClaim = (succeeded: boolean, result: TerminalResizeResult | null = null) => { if ( disposed || resizeRendererGenerationRef.current !== resizeRendererGeneration || @@ -1459,32 +1856,58 @@ export function TerminalViewport({ ) return; claimInFlight = null; - if (!succeeded) claimNoticeSuppressed = false; + if (succeeded) { + resizeRetries = 0; + completeClaim(request, result); + } else claimNoticeSuppressed = false; const refit = layoutChangedWhileClaimPending; layoutChangedWhileClaimPending = false; if (refit) handleSizeTrigger("layout"); - else updateSizeNotice(); + else { + updateSizeNotice(); + // A claim taken from another window's size is not retried. + if (!succeeded && !hasForeignTerminalSizeOwner(appliedSize, sizeClaim)) { + retryFailedResize(); + } + } }; void resizeTerminal(request).then( - (result) => finishClaim(result._tag === "Success"), + (result) => + result._tag === "Success" + ? finishClaim(true, result.value ?? null) + : finishClaim(false), () => finishClaim(false), ); } updateSizeNotice(fitted); }; - const applySize = (size: TerminalSize | undefined, claimOnAttach = false) => { + const applySize = ( + size: TerminalSize | undefined, + claimOnAttach = false, + position: TerminalSizePosition = {}, + ) => { if (disposed) return; - if (!terminalSizesEqual(appliedSize, size)) claimNoticeSuppressed = false; + appliedPosition = position; + declinedRequest = null; + legacyRead = null; + if (awaitedAcknowledgement !== null && sizePositionReaches(position, awaitedAcknowledgement)) + awaitedAcknowledgement = null; + // A claim's outcome is its acknowledgement: an older publication parsed after the answer + // keeps the notice suppressed (#348). + if (claimInFlight === null && awaitedAcknowledgement === null) claimNoticeSuppressed = false; const dismissed = dismissedTerminalSizes.get(terminalTargetKey); if (size && dismissed && !terminalSizesEqual(dismissed, size)) dismissedTerminalSizes.delete(terminalTargetKey); appliedSize = size ?? null; + if (size?.sizeClaim != null) unownedUntilUsed = false; + // A window attaching to a size the server left un-owned waits to be used as well. + else if (isUnownedUntilUsed(size)) unownedUntilUsed = true; if (size && !terminalDimensionsEqual(terminal, size)) { - const wasAtBottom = terminal.buffer.active.viewportY >= terminal.buffer.active.baseY; + const wasAtBottom = followsOutput(); terminal.resize(size.cols, size.rows); if (wasAtBottom) terminal.scrollToBottom(); } - if (size?.sizeClaim === null) handleSizeTrigger("attach"); + if (size?.sizeClaim === null) handleSizeTrigger(unownedUntilUsed ? "layout" : "attach"); else if (claimOnAttach) handleSizeTrigger(size?.sizeClaim === sizeClaim ? "layout" : "attach"); else if (size?.sizeClaim === sizeClaim) handleSizeTrigger("layout"); @@ -1497,7 +1920,27 @@ export function TerminalViewport({ return; } if (signal.type === "resized") { - if (sizeOwnership) outputSink.barrier(() => applySize(signal.size)); + if (sizeOwnership) { + outputSink.barrier(() => { + // An older server leaves every released size un-owned; one that hands it over does + // so only when no remaining window is known to be in use. + unownedUntilUsed = userInputClaim && signal.size.sizeClaim === null; + applySize(signal.size, false, signal); + }); + } + return; + } + if (signal.type === "attached") { + // This renderer's own stream attached: its claim is registered, so it may claim now. + if (sizeOwnership) { + outputSink.barrier(() => { + claimInFlight = null; + awaitedAcknowledgement = null; + declinedRequest = null; + layoutChangedWhileClaimPending = false; + applySize(signal.size, true, signal); + }); + } return; } @@ -1510,11 +1953,15 @@ export function TerminalViewport({ // Its late RPC completion is ignored by the request-identity guard. if (claimOnAttach) { claimInFlight = null; + awaitedAcknowledgement = null; + declinedRequest = null; layoutChangedWhileClaimPending = false; claimNoticeSuppressed = false; + // A restart ends a pending hand-over: the size the event carries says who owns it now. + unownedUntilUsed = false; } oscColorResponderActive = signal.oscColorResponderActive ?? false; - if (sizeOwnership) applySize(signal.size, claimOnAttach); + if (sizeOwnership) applySize(signal.size, claimOnAttach, signal); else { lastRequestedSizeRef.current = null; handleSizeTrigger("layout"); @@ -1533,6 +1980,12 @@ export function TerminalViewport({ sizeClaim, render, size: handleSizeTrigger, + fit() { + const pending = claimInFlight; + handleSizeTrigger("fit"); + // The sent claim hides the notice and its button, so focus moves where the keys go. + if (claimInFlight !== null && claimInFlight !== pending) terminal.focus(); + }, dismissSizeNotice() { if (appliedSize) dismissedTerminalSizes.set(terminalTargetKey, appliedSize); updateSizeNotice(); @@ -1645,11 +2098,29 @@ export function TerminalViewport({ Boolean(readWorkspaceUnavailable()) || (inputBinding.ordered && inputBinding.error !== null) || inputBinding.renderer?.owner !== rendererOwner; + // Input a user produced here carries this renderer's claim and the terminal's own replies + // carry null, so an idle window answering a program is not a window in use (#348). xterm's + // onData does not say which is which: a query answer is marked by the reply guard, and + // other input is the user's only within a moment of a user event on this mount (composed + // text arrives from a timer after compositionend, a mouse report on release), so a focus + // report from a window losing focus is not. + let lastUserInputAt = Number.NEGATIVE_INFINITY; + const noteUserInput = () => { + lastUserInputAt = performance.now(); + }; + const inputOrigin = (data?: string): TerminalInputSchedulerOrigin => { + if (!userInputClaim) return undefined; + // xterm reports focus changes, and answers enabling the reports, through onData. + if (data === TERMINAL_FOCUS_IN_REPORT || data === TERMINAL_FOCUS_OUT_REPORT) return null; + return !replying && performance.now() - lastUserInputAt <= TERMINAL_USER_INPUT_WINDOW_MS + ? sizeClaim + : null; + }; const sendTerminalInput = (data: string, fallbackError: string) => { if (!replyGuard.acceptInput()) return; if (inputBlocked() || data.length === 0) return; inputBinding.pendingFallbacks.push({ remaining: data.length, message: fallbackError }); - inputScheduler.enqueue(data); + inputScheduler.enqueue(data, inputOrigin(data)); }; terminal.attachCustomKeyEventHandler((event) => { @@ -1842,6 +2313,10 @@ export function TerminalViewport({ }; let invertingGesture = false; const handleMouseDown = (event: MouseEvent) => { + // A click outside xterm's element (a scaled mirror takes no pointer input) lands on the + // unfocusable mount, whose default would take back the focus pointerdown gave xterm. + if (!(event.target instanceof Node && terminal.element?.contains(event.target))) + event.preventDefault(); const tracking = terminal.modes.mouseTrackingMode !== "none"; if (tracking && event.button === 2 && terminal.hasSelection()) { Object.defineProperty(event, forceSelectionKey, { value: true }); @@ -1901,7 +2376,7 @@ export function TerminalViewport({ const bracketedAtPaste = terminal.modes.bracketedPasteMode; const fallback = { remaining: 0, message: "Terminal write failed" }; inputBinding.pendingFallbacks.push(fallback); - const fill = inputScheduler.reserve(); + const fill = inputScheduler.reserve(inputOrigin()); pasteAborts.add(abort); setImagePastesPending((count) => count + 1); void staging.then((result) => { @@ -1933,6 +2408,8 @@ export function TerminalViewport({ }); }; const ownerDocument = mount.ownerDocument; + for (const type of TERMINAL_USER_INPUT_EVENTS) + mount.addEventListener(type, noteUserInput, { capture: true, passive: true }); mount.addEventListener("paste", handlePaste, true); mount.addEventListener("mousedown", handleMouseDown, true); const screenElement = mount.querySelector(".xterm-screen"); @@ -1954,7 +2431,13 @@ export function TerminalViewport({ // class/style churn (tooltips, scroll locks) fires this observer // far more often than the theme changes; a repaint rebuilds the glyph // atlas, so skip it unless the resolved theme actually differs. - repaintTerminalTheme(activeTerminal, readEffectiveTerminalTheme(), containerRef.current); + repaintTerminalTheme( + activeTerminal, + readEffectiveTerminalTheme(), + containerRef.current, + readLaunchPalette(), + readAppThemeIsBuiltin(), + ); }); themeObserver.observe(document.documentElement, { attributes: true, @@ -1971,11 +2454,14 @@ export function TerminalViewport({ return () => { if (disposed) return; disposed = true; - updateMirroringSize(false); + updateMirroringSize(null); resizeRendererGenerationRef.current += 1; lastRequestedSizeRef.current = null; window.clearTimeout(fitTimer); + if (resizeRetryTimer !== null) window.clearTimeout(resizeRetryTimer); + if (deferredClaimTimer !== null) window.clearTimeout(deferredClaimTimer); + if (keypressMeasureFrame !== null) window.cancelAnimationFrame(keypressMeasureFrame); outputSink.dispose(); replyGuard.dispose(); if (rendererRef.current === renderer) rendererRef.current = null; @@ -1987,6 +2473,8 @@ export function TerminalViewport({ clearSelectionAction(); cancelImagePastesRef.current(); cancelImagePastesRef.current = () => undefined; + for (const type of TERMINAL_USER_INPUT_EVENTS) + mount.removeEventListener(type, noteUserInput, true); mount.removeEventListener("paste", handlePaste, true); mount.removeEventListener("mousedown", handleMouseDown, true); screenElement?.removeEventListener("mousemove", handleHover); @@ -1997,6 +2485,7 @@ export function TerminalViewport({ mount.removeEventListener("focusin", handleFocus); window.removeEventListener("focus", handleWindowFocus); themeObserver.disconnect(); + followOutput?.dispose(); if (inputBinding.renderer?.owner === rendererOwner) { inputBinding.renderer = null; } @@ -2023,6 +2512,7 @@ export function TerminalViewport({ }, [ terminalTargetKey, runTerminalResize, + runReadTerminalSize, runResetInput, runTerminalWrite, canAttachTerminal, @@ -2033,6 +2523,8 @@ export function TerminalViewport({ threadId, sizeOwnership, orderedInput, + userInputClaim, + viewportSizeClaim, ]); useEffect(() => { @@ -2058,7 +2550,13 @@ export function TerminalViewport({ useEffect(() => { const activeTerminal = terminalRef.current; if (!activeTerminal) return; - repaintTerminalTheme(activeTerminal, effectiveTerminalTheme, containerRef.current); + repaintTerminalTheme( + activeTerminal, + effectiveTerminalTheme, + containerRef.current, + readLaunchPalette(), + readAppThemeIsBuiltin(), + ); }, [effectiveTerminalTheme]); useEffect(() => { @@ -2353,8 +2851,22 @@ export function TerminalViewport({ observer.observe(container); const workspace = container.closest("[data-center-panel-workspace]"); workspace?.addEventListener(CENTER_PANEL_RESIZE_END_EVENT, scheduleResize); + // xterm rounds row heights in device pixels, so a display scale change alters + // how many rows fit an unchanged box, which the ResizeObserver never reports. + let pixelRatioQuery: MediaQueryList | null = null; + const watchPixelRatio = () => { + pixelRatioQuery?.removeEventListener("change", handlePixelRatioChange); + pixelRatioQuery = window.matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`); + pixelRatioQuery.addEventListener("change", handlePixelRatioChange); + }; + function handlePixelRatioChange() { + watchPixelRatio(); + scheduleResize(); + } + watchPixelRatio(); return () => { + pixelRatioQuery?.removeEventListener("change", handlePixelRatioChange); observer.disconnect(); workspace?.removeEventListener(CENTER_PANEL_RESIZE_END_EVENT, scheduleResize); if (frame !== null) { @@ -2394,14 +2906,20 @@ export function TerminalViewport({ className="min-h-0 w-full flex-1 overflow-hidden" data-terminal-xterm-mount={terminalId} /> - {shouldRender && sizeOwnership && mirroringSize ? ( -
- Sized for another window. + {shouldRender && sizeOwnership && mirroringSize !== null ? ( +
+ + Sized for another window. + {mirroringSize < 1 ? ` Shown at ${Math.floor(mirroringSize * 100)}%.` : ""} + @@ -3044,7 +3562,7 @@ export default function ThreadTerminalPanel({ type="button" className={`flex w-full items-center rounded px-1 py-0.5 text-xs uppercase tracking-[0.08em] ${ isGroupActive - ? "bg-accent/70 text-foreground" + ? "bg-selected/70 text-selected-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" }`} onClick={() => onActiveTerminalChange(groupActiveTerminalId)} @@ -3066,7 +3584,7 @@ export default function ThreadTerminalPanel({ key={terminalId} className={`group flex items-center gap-1 rounded px-1 py-0.5 text-xs ${ isActive - ? "bg-accent text-foreground" + ? "bg-selected text-selected-foreground" : "text-muted-foreground hover:bg-accent/50 hover:text-foreground" }`} > diff --git a/apps/web/src/components/WorktreeDiscoverySection.test.tsx b/apps/web/src/components/WorktreeDiscoverySection.test.tsx index d13d7086e3..b5ec539848 100644 --- a/apps/web/src/components/WorktreeDiscoverySection.test.tsx +++ b/apps/web/src/components/WorktreeDiscoverySection.test.tsx @@ -85,6 +85,7 @@ vi.mock("./ui/toast", () => ({ stackedThreadToast: (toast: unknown) => toast, })); +import { textColorUtilities } from "./sidebar/sidebarLevels.testSupport"; import { WorktreeDiscoverySection } from "./WorktreeDiscoverySection"; const ENVIRONMENT_ID = EnvironmentId.make("environment-main"); @@ -137,6 +138,7 @@ function project( repositoryIdentity: null, defaultModelSelection: createModelSelection(ProviderInstanceId.make("codex"), "gpt-5"), scripts: [], + codeReviewPrompt: "", worktreeDiscovery: { visibility, initialPromptDismissedAt, baselinePaths }, createdAt: "2026-08-09T00:00:00.000Z", updatedAt: "2026-08-09T00:00:00.000Z", @@ -823,6 +825,12 @@ describe("WorktreeDiscoverySection", () => { }, }); expect(document.body.textContent).toContain("Hiding 1 discovered worktree"); + // The hidden-worktrees row is chat level, painted by the level rule alone (R63). + const hiding = button("Hiding 1 discovered worktree"); + expect(hiding.className.split(/\s+/)).not.toContain("text-muted-foreground"); + const text = hiding.querySelector('[data-sidebar-level="chat"]'); + expect(text?.textContent).toBe("Hiding 1 discovered worktree"); + expect(textColorUtilities(text?.getAttribute("class") ?? "")).toEqual([]); await act(async () => button("Hiding 1 discovered worktree").click()); expect(document.body.textContent).toContain("Discovered worktrees"); diff --git a/apps/web/src/components/WorktreeDiscoverySection.tsx b/apps/web/src/components/WorktreeDiscoverySection.tsx index e0674a9123..7fe34374f4 100644 --- a/apps/web/src/components/WorktreeDiscoverySection.tsx +++ b/apps/web/src/components/WorktreeDiscoverySection.tsx @@ -516,13 +516,16 @@ function PhysicalWorktreeDiscoverySection(props: { {showCollapsedLine ? ( ) : null} diff --git a/apps/web/src/components/add-project/useAddProjectWorkflow.ts b/apps/web/src/components/add-project/useAddProjectWorkflow.ts index 1b95861121..f87103c80c 100644 --- a/apps/web/src/components/add-project/useAddProjectWorkflow.ts +++ b/apps/web/src/components/add-project/useAddProjectWorkflow.ts @@ -43,6 +43,7 @@ import { resolveProviderSessionSelectionForInstance } from "~/providerSessionSel import { resolveEnvironmentOptionLabel } from "../BranchToolbar.logic"; import { buildProviderAgentActions, + resolveDefaultAgentInstanceId, resolveEffectiveProviderAgentAction, } from "../chat/providerAgentActions"; import { stackedThreadToast, toastManager } from "../ui/toast"; @@ -1020,15 +1021,12 @@ export function useAddProjectWorkflow(input: { ); const serverConfig = environment?.serverConfig; const settings = serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; - const defaultAgent = getDefaultAgent(); - const selectedAction = resolveEffectiveProviderAgentAction( - buildProviderAgentActions(serverConfig?.providers ?? [], settings), - defaultAgent, - ); - // Adding a project never waits for an agent: before sign-in, or while the server's - // agents are unknown, Main still opens on the Default Agent's provider. const resolution = resolveProviderSessionSelectionForInstance({ - instanceId: selectedAction?.entry.instanceId ?? defaultAgent.instanceId, + instanceId: resolveDefaultAgentInstanceId( + serverConfig?.providers ?? [], + settings, + getDefaultAgent(), + ), providers: serverConfig?.providers ?? [], settings, }); diff --git a/apps/web/src/components/agents/AgentsRow.tsx b/apps/web/src/components/agents/AgentsRow.tsx index 4d1927b7c6..8e9ca66519 100644 --- a/apps/web/src/components/agents/AgentsRow.tsx +++ b/apps/web/src/components/agents/AgentsRow.tsx @@ -105,7 +105,7 @@ export const AgentsRow = memo( size="lg" isActive={selected} className={cn( - "h-auto items-stretch gap-0 rounded-md px-3 py-2.5 hover:bg-accent/60 data-[active=true]:bg-accent data-[active=true]:font-normal", + "h-auto items-stretch gap-0 rounded-md px-3 py-2.5 hover:bg-accent/60 data-[active=true]:bg-selected data-[active=true]:text-selected-foreground data-[active=true]:font-normal", // Room for the Jump to workspace action, only where the host shows it. onJumpToWorkspace !== undefined && "pr-9", )} diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx index d0a8a65191..49431d6840 100644 --- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx +++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx @@ -305,6 +305,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
); diff --git a/apps/web/src/components/chat/ProposedPlanCard.tsx b/apps/web/src/components/chat/ProposedPlanCard.tsx index 6f6abcc79d..d2425f2b51 100644 --- a/apps/web/src/components/chat/ProposedPlanCard.tsx +++ b/apps/web/src/components/chat/ProposedPlanCard.tsx @@ -45,6 +45,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ onExpandedChange, detailsOpen, onDetailsOpenChange, + isCodeReview = false, }: { planMarkdown: string; environmentId: EnvironmentId; @@ -57,6 +58,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ /** The plan body's `
` blocks, when the caller owns their state (see `ChatMarkdown`). */ detailsOpen?: ReadonlyMap | undefined; onDetailsOpenChange?: ((ordinal: number, open: boolean) => void) | undefined; + /** A review thread's findings: the card names them so, whatever heading the agent wrote. */ + isCodeReview?: boolean | undefined; }) { const [localExpanded, setLocalExpanded] = useState(false); const expanded = controlledExpanded ?? localExpanded; @@ -86,8 +89,8 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({ }, }); const savePathInputId = useId(); - const authoredTitle = proposedPlanTitle(planMarkdown); - const title = authoredTitle ?? "Proposed plan"; + const authoredTitle = isCodeReview ? null : proposedPlanTitle(planMarkdown); + const title = isCodeReview ? "Review findings" : (authoredTitle ?? "Proposed plan"); const canCollapse = proposedPlanCanCollapse(planMarkdown); const displayedPlanMarkdown = stripDisplayedPlanMarkdown(planMarkdown); const collapsedPreview = canCollapse @@ -169,7 +172,7 @@ export const ProposedPlanCard = memo(function ProposedPlanCard({
- Plan + {isCodeReview ? "Review" : "Plan"}

, + settings: ProviderAgentSettings, + defaultAgent: DefaultAgentSelection, +): ProviderInstanceEntry["instanceId"] { + return ( + resolveEffectiveProviderAgentAction( + buildProviderAgentActions(providers, settings), + defaultAgent, + )?.entry.instanceId ?? defaultAgent.instanceId + ); +} diff --git a/apps/web/src/components/chat/providerTerminalActions.test.ts b/apps/web/src/components/chat/providerTerminalActions.test.ts index e4d0568094..363097c44e 100644 --- a/apps/web/src/components/chat/providerTerminalActions.test.ts +++ b/apps/web/src/components/chat/providerTerminalActions.test.ts @@ -14,8 +14,14 @@ import { import { describe, expect, it } from "vite-plus/test"; import type { ProviderInstanceEntry } from "~/providerInstances"; +import { resolveCodeReviewPrompt } from "~/codeReview"; import { decodeTerminalLaunchCommand } from "~/lib/terminalLaunchCommand"; -import { resolveProviderTerminalAction } from "./providerTerminalActions"; +import { + CODE_REVIEW_DRIVERS_WITH_TERMINALS, + WINDOWS_TERMINAL_REVIEWS_ENABLED, + resolveProviderReviewTerminalAction, + resolveProviderTerminalAction, +} from "./providerTerminalActions"; const CODEX_DRIVER = ProviderDriverKind.make("codex"); const CLAUDE_DRIVER = ProviderDriverKind.make("claudeAgent"); @@ -727,3 +733,152 @@ describe("resolveProviderTerminalAction", () => { } }); }); + +describe("resolveProviderReviewTerminalAction", () => { + const PROMPT = + "Code review requested from BiBCode. Do not edit files. Scope: the staged, unstaged and untracked changes in the working tree.\n\nReview."; + const claude = entry("claudeAgent", "claudeAgent", "Claude", [ + model("opus", []), + model("sonnet", []), + ]); + const codex = entry("codex", "codex", "Codex", [model("gpt-5", []), model("gpt-5.4", [])]); + const review = ( + target: ProviderInstanceEntry, + input: Partial[2]> = {}, + ) => + resolveProviderReviewTerminalAction(target, DEFAULT_SERVER_SETTINGS, { + prompt: PROMPT, + platformOs: "darwin", + modelSelection: null, + ...input, + }); + + it("starts Claude in plan mode with the prompt last, after -- (R97)", () => { + const action = review(claude, { + modelSelection: { instanceId: ProviderInstanceId.make("claudeAgent"), model: "opus" }, + }); + expect(action?.disabledReason).toBeNull(); + expect(action?.label).toBe("Claude Review"); + expect(action?.command?.executable).toBe("claude"); + expect(action?.command?.args).toEqual([ + "--permission-mode", + "plan", + "--model", + "opus", + "--", + PROMPT, + ]); + expect(action?.command?.activity).toEqual({ + driverKind: CLAUDE_DRIVER, + providerInstanceId: ProviderInstanceId.make("claudeAgent"), + }); + }); + + it("starts Codex in the read-only sandbox with the prompt last, after -- (R97)", () => { + const args = review(codex)?.command?.args ?? []; + expect(args.slice(0, 4)).toEqual(["--sandbox", "read-only", "--ask-for-approval", "never"]); + expect(args.slice(-2)).toEqual(["--", PROMPT]); + }); + + it("never carries a bypass flag", () => { + for (const target of [claude, codex]) { + const args = review(target)?.command?.args ?? []; + expect(args.length).toBeGreaterThan(0); + for (const flag of [ + "--dangerously-skip-permissions", + "--allow-dangerously-skip-permissions", + "--dangerously-bypass-approvals-and-sandbox", + ]) { + expect(args).not.toContain(flag); + } + } + }); + + it("uses the review model only for its own instance", () => { + const foreign = review(claude, { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "sonnet" }, + }); + expect(foreign?.command?.args.slice(2, 4)).toEqual(["--model", "opus"]); + const own = review(codex, { + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + }); + expect(own?.command?.args.slice(4, 6)).toEqual(["--model", "gpt-5.4"]); + }); + + it("offers no review terminal for other drivers", () => { + expect(review(entry("cursor"))).toBeNull(); + expect(review(entry("opencode"))).toBeNull(); + expect(review(entry("grok"))).toBeNull(); + expect(CODE_REVIEW_DRIVERS_WITH_TERMINALS).toEqual(new Set([CLAUDE_DRIVER, CODEX_DRIVER])); + }); + + it("refuses a prompt over 8,192 characters", () => { + expect(review(claude, { prompt: "x".repeat(8_192) })?.disabledReason).toBeNull(); + expect(review(claude, { prompt: "x".repeat(8_193) })).toMatchObject({ + command: null, + disabledReason: + "Review prompt is longer than 8,192 characters. Shorten it in Settings → Agents.", + }); + }); + + it("refuses control characters other than tab and line breaks", () => { + expect(review(codex, { prompt: "a\tb\r\nc" })?.disabledReason).toBeNull(); + expect(review(codex, { prompt: "a b" })?.disabledReason).toBeNull(); + for (const prompt of [ + "a\u0000b", + "a\u001bb", + "a\u007fb", + "a\u0080b", + "a\u0085b", + "a\u009bb", + "a\u009fb", + ]) { + expect(review(codex, { prompt })).toMatchObject({ + command: null, + disabledReason: + "Review prompt holds a control character other than tab or a line break. Remove it in Settings → Agents.", + }); + } + }); + + it("keeps a prompt that starts with a dash after --", () => { + const args = review(claude, { prompt: "--dangerously-skip-permissions" })?.command?.args ?? []; + expect(args.at(-2)).toBe("--"); + expect(args.indexOf("--dangerously-skip-permissions")).toBe(args.length - 1); + }); + + it("is on on Windows, with line breaks flattened (R98)", () => { + expect(WINDOWS_TERMINAL_REVIEWS_ENABLED).toBe(true); + for (const target of [claude, codex]) { + const action = review(target, { prompt: "a\nb\r\nc\rd", platformOs: "windows" }); + expect(action?.disabledReason).toBeNull(); + expect(action?.command?.args.at(-1)).toBe("a b c d"); + for (const platformOs of ["darwin", "linux", null]) { + expect(review(target, { platformOs })?.disabledReason).toBeNull(); + } + } + }); + + it("flattens line breaks on Windows only", () => { + const prompt = "a\nb\r\nc\rd"; + expect(review(codex, { prompt, platformOs: "windows" })?.command?.args.at(-1)).toBe("a b c d"); + expect(review(codex, { prompt, platformOs: "linux" })?.command?.args.at(-1)).toBe(prompt); + }); + + it("ends with one-off instructions, flattened only on Windows (R105)", () => { + const prompt = resolveCodeReviewPrompt({ + project: "Rules", + user: "", + scope: { kind: "uncommitted", base: "origin/main" }, + extra: "Focus on the parser.", + }); + for (const platformOs of ["darwin", "linux"]) { + expect(review(claude, { prompt, platformOs })?.command?.args.at(-1)).toMatch( + /Rules\n\nFocus on the parser\.$/, + ); + } + expect(review(claude, { prompt, platformOs: "windows" })?.command?.args.at(-1)).toMatch( + /Rules {2}Focus on the parser\.$/, + ); + }); +}); diff --git a/apps/web/src/components/chat/providerTerminalActions.ts b/apps/web/src/components/chat/providerTerminalActions.ts index 9be4f32ca7..550f89488d 100644 --- a/apps/web/src/components/chat/providerTerminalActions.ts +++ b/apps/web/src/components/chat/providerTerminalActions.ts @@ -1,5 +1,7 @@ import { ProviderDriverKind, + TERMINAL_LAUNCH_ARGUMENT_MAX_LENGTH, + type ModelSelection, type ServerSettings, type TerminalLaunchCommand, } from "@bibcode/contracts"; @@ -156,31 +158,47 @@ function providerArguments( return []; } -export function resolveProviderTerminalAction( +function resolveTerminalLaunch( entry: ProviderInstanceEntry, settings: ProviderTerminalSettings, -): ProviderTerminalActionItem | null { - const definition = DEFINITIONS[entry.driverKind]; - if (!definition) return null; + definition: ProviderTerminalDefinition, + explicitSelection: ModelSelection | null, +) { const instance = settings.providerInstances?.[entry.instanceId]; const legacy = (settings.providers as Readonly>)[entry.driverKind]; - const executable = binaryPath(instance?.config) ?? binaryPath(legacy) ?? definition.executable; - const label = `${entry.displayName} Terminal`; - // Every AI Terminal names its provider: the server observes the ones it can (Codex, Claude, - // OpenCode on macOS and Linux) and reports the rest as status unavailable (#187). - const activity = { - driverKind: entry.driverKind, - providerInstanceId: entry.instanceId, - }; const resolution = resolveProviderSessionDefault({ driver: entry.driverKind, instanceId: entry.instanceId, models: entry.models, configuredDefault: settings.providerSessionDefaults[entry.driverKind] ?? null, + explicitSelection, }); + return { + executable: binaryPath(instance?.config) ?? binaryPath(legacy) ?? definition.executable, + // Every AI Terminal names its provider: the server observes the ones it can (Codex, Claude, + // OpenCode on macOS and Linux) and reports the rest as status unavailable (#187). + activity: { driverKind: entry.driverKind, providerInstanceId: entry.instanceId }, + resolution, + modelArguments: providerArguments(entry.driverKind, resolution, entry.models), + }; +} + +export function resolveProviderTerminalAction( + entry: ProviderInstanceEntry, + settings: ProviderTerminalSettings, +): ProviderTerminalActionItem | null { + const definition = DEFINITIONS[entry.driverKind]; + if (!definition) return null; + const { executable, activity, resolution, modelArguments } = resolveTerminalLaunch( + entry, + settings, + definition, + null, + ); + const label = `${entry.displayName} Terminal`; const command = decodeTerminalLaunchCommand({ executable, - args: [...definition.args, ...providerArguments(entry.driverKind, resolution, entry.models)], + args: [...definition.args, ...modelArguments], label, ...(definition.env ? { env: { ...definition.env } } : {}), activity, @@ -201,3 +219,87 @@ export function resolveProviderTerminalAction( ...(resolution.fallback ? { fallback: resolution.fallback } : {}), }; } + +// On since the native Windows batch argument tests ran green on real Windows (D44, Task 12.17). +export const WINDOWS_TERMINAL_REVIEWS_ENABLED = true; + +/** Read-only flags replace `DEFINITIONS`' bypass flags; other CLIs' read-only flags are unverified. */ +const REVIEW_ARGUMENTS: Partial>> = { + [ProviderDriverKind.make("claudeAgent")]: ["--permission-mode", "plan"], + [ProviderDriverKind.make("codex")]: ["--sandbox", "read-only", "--ask-for-approval", "never"], +}; + +export const CODE_REVIEW_DRIVERS_WITH_TERMINALS: ReadonlySet = new Set( + Object.keys(REVIEW_ARGUMENTS) as ProviderDriverKind[], +); + +const WINDOWS_REVIEWS_OFF_REASON = + "Terminal reviews are off on Windows for now. Pick a chat reviewer in Settings → Agents."; +const REVIEW_PROMPT_TOO_LONG_REASON = `Review prompt is longer than ${TERMINAL_LAUNCH_ARGUMENT_MAX_LENGTH.toLocaleString("en-US")} characters. Shorten it in Settings → Agents.`; +const REVIEW_PROMPT_CONTROL_REASON = + "Review prompt holds a control character other than tab or a line break. Remove it in Settings → Agents."; +// oxlint-disable-next-line no-control-regex -- the point is to find control characters. +const FORBIDDEN_CONTROL_CHARACTER = /[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f-\u009f]/; + +/** The refusal of a terminal review prompt, worded for the one-off instructions that caused it. */ +export function describeInstructionsRefusal(disabledReason: string | null): string | null { + if (disabledReason === REVIEW_PROMPT_TOO_LONG_REASON) { + return `These instructions make the review prompt longer than ${TERMINAL_LAUNCH_ARGUMENT_MAX_LENGTH.toLocaleString("en-US")} characters. Shorten them to start the review.`; + } + if (disabledReason === REVIEW_PROMPT_CONTROL_REASON) { + return "These instructions hold a control character other than tab or a line break. Remove it to start the review."; + } + return disabledReason; +} + +export function resolveProviderReviewTerminalAction( + entry: ProviderInstanceEntry, + settings: ProviderTerminalSettings, + input: { + readonly prompt: string; + readonly platformOs: string | null; + readonly modelSelection: ModelSelection | null; + }, +): ProviderTerminalActionItem | null { + const reviewArguments = REVIEW_ARGUMENTS[entry.driverKind]; + const definition = DEFINITIONS[entry.driverKind]; + if (!reviewArguments || !definition) return null; + const label = `${entry.displayName} Review`; + const disabled = (disabledReason: string): DisabledProviderTerminalAction => ({ + entry, + label, + command: null, + disabledReason, + }); + const isWindows = input.platformOs === "windows"; + if (isWindows && !WINDOWS_TERMINAL_REVIEWS_ENABLED) { + return disabled(WINDOWS_REVIEWS_OFF_REASON); + } + // cmd.exe cannot carry a line break inside an argument; the web cannot see whether the + // resolved executable is a batch file, so every Windows launch is flattened. + const prompt = isWindows ? input.prompt.replace(/\r\n|\r|\n/g, " ") : input.prompt; + if (prompt.length > TERMINAL_LAUNCH_ARGUMENT_MAX_LENGTH) { + return disabled(REVIEW_PROMPT_TOO_LONG_REASON); + } + if (FORBIDDEN_CONTROL_CHARACTER.test(prompt)) return disabled(REVIEW_PROMPT_CONTROL_REASON); + const { executable, activity, resolution, modelArguments } = resolveTerminalLaunch( + entry, + settings, + definition, + input.modelSelection, + ); + const command = decodeTerminalLaunchCommand({ + executable, + args: [...reviewArguments, ...modelArguments, "--", prompt], + label, + activity, + }); + if (command === null) return disabled(COMMAND_BOUNDS_REASON); + return { + entry, + label, + command, + disabledReason: null, + ...(resolution.fallback ? { fallback: resolution.fallback } : {}), + }; +} diff --git a/apps/web/src/components/desktop/UpdateProtectionDialog.leaveFocus.test.tsx b/apps/web/src/components/desktop/UpdateProtectionDialog.leaveFocus.test.tsx new file mode 100644 index 0000000000..1f638d4bac --- /dev/null +++ b/apps/web/src/components/desktop/UpdateProtectionDialog.leaveFocus.test.tsx @@ -0,0 +1,133 @@ +// @vitest-environment happy-dom + +import type { DesktopBridge, DesktopUpdateState } from "@bibcode/contracts"; +import { AsyncResult } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +// The real update dialog and the real leave dialog: only the shell state and toasts are stubbed. +vi.mock("../../state/shell", () => ({ environmentAvailabilityCommands: { retry: {} } })); +vi.mock("../ui/toast", () => ({ + toastManager: { add: vi.fn(), close: vi.fn() }, + stackedThreadToast: (toast: unknown) => toast, +})); + +import { FileEditingSession } from "../files/fileEditingSession"; +import { FileEditingSessionRegistry } from "../files/fileEditingSessionRegistry"; +import { UnsavedChangesCloseHost } from "../files/UnsavedChangesCloseDialog"; +import { UpdateProtectionDialog } from "./UpdateProtectionDialog"; + +const state: DesktopUpdateState = { + enabled: true, + status: "downloaded", + currentVersion: "1.0.0", + hostArch: "x64", + appArch: "x64", + runningUnderArm64Translation: false, + availableVersion: "1.1.0", + downloadedVersion: "1.1.0", + downloadPercent: 100, + checkedAt: null, + message: null, + errorContext: null, + canRetry: false, + phase: "failed", + protection: [], +}; + +type Session = FileEditingSession; + +let container: HTMLDivElement; +let root: Root; +let registry: FileEditingSessionRegistry; +let originalGetAnimations: PropertyDescriptor | undefined; + +const buttons = () => [...document.querySelectorAll("button")]; +const byText = (text: string) => buttons().find((entry) => entry.textContent?.trim() === text); +const settle = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + }); + +beforeEach(async () => { + originalGetAnimations = Object.getOwnPropertyDescriptor(Element.prototype, "getAnimations"); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + window.desktopBridge = { + restartApp: vi.fn(), + getUpdateState: vi.fn(), + } as unknown as DesktopBridge; + registry = new FileEditingSessionRegistry(); + registry + .getOrCreate( + "notes.md", + () => + new FileEditingSession({ + cwd: "/repo", + relativePath: "notes.md", + debounceMs: 500, + base: { version: "v1", contents: "old\n" }, + persist: vi.fn(async () => AsyncResult.success({ version: "v2" })), + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + onConflictChange: vi.fn(), + }), + ) + .changeFromRichEditor("my edit"); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => + root.render( + <> + + undefined} + installUpdate={vi.fn()} + onDiagnostics={() => undefined} + /> + , + ), + ); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + await registry.dispose(); + container.remove(); + document.body.replaceChildren(); + if (originalGetAnimations) { + Object.defineProperty(Element.prototype, "getAnimations", originalGetAnimations); + } else { + Reflect.deleteProperty(Element.prototype, "getAnimations"); + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; +}); + +describe("leave question opened from the update dialog", () => { + it("returns focus to the install button after Cancel, and keeps it enabled meanwhile", async () => { + const install = byText("Retry installation")!; + install.focus(); + await act(async () => install.click()); + await settle(); + + expect(byText("Discard")).toBeDefined(); + // Nearest observable fact: the opener must not be disabled while the question is open. + expect(install.disabled).toBe(false); + + const leaveDialog = document.querySelector('[data-slot="alert-dialog-popup"]')!; + const cancel = [...leaveDialog.querySelectorAll("button")].find( + (entry) => entry.textContent?.trim() === "Cancel", + )!; + await act(async () => cancel.click()); + await settle(); + + expect(document.activeElement).toBe(install); + }); +}); diff --git a/apps/web/src/components/desktop/UpdateProtectionDialog.test.tsx b/apps/web/src/components/desktop/UpdateProtectionDialog.test.tsx index a8c3197568..c81b79c838 100644 --- a/apps/web/src/components/desktop/UpdateProtectionDialog.test.tsx +++ b/apps/web/src/components/desktop/UpdateProtectionDialog.test.tsx @@ -17,6 +17,13 @@ const harness = vi.hoisted(() => ({ toastAdd: vi.fn(), toastClose: vi.fn(), onDismiss: (_open: boolean) => undefined as void, + askBeforeLeaving: vi.fn(), + releaseLeaveHolds: vi.fn(), +})); + +vi.mock("../files/unsavedFileEdits", () => ({ + askBeforeLeaving: harness.askBeforeLeaving, + releaseLeaveHolds: harness.releaseLeaveHolds, })); vi.mock("../../state/shell", () => ({ @@ -142,6 +149,7 @@ beforeEach(() => { vi.clearAllMocks(); harness.retryProjectData.mockReset().mockResolvedValue(undefined); harness.restartApp.mockReset().mockResolvedValue(undefined); + harness.askBeforeLeaving.mockReset().mockResolvedValue("proceed"); harness.retryEnvironment.mockReset().mockResolvedValue(AsyncResult.success(undefined)); harness.retryRegistries = []; harness.getUpdateState.mockReset().mockResolvedValue(baseState); @@ -169,6 +177,36 @@ afterEach(async () => { }); describe("UpdateProtectionDialog", () => { + describe("restart and update install ask first", () => { + it("installing waits for the answer and does nothing when the person stays", async () => { + harness.askBeforeLeaving.mockResolvedValue("stay"); + const installUpdate = await render(baseState); + await act(async () => button("Retry installation").click()); + expect(harness.askBeforeLeaving).toHaveBeenCalledWith("update"); + expect(installUpdate).not.toHaveBeenCalled(); + }); + + it("installing proceeds after the answer and lets saving run again when it did not complete", async () => { + const installUpdate = vi + .fn() + .mockResolvedValue({ accepted: false, completed: false, state: baseState }); + await render(baseState, installUpdate); + await act(async () => button("Retry installation").click()); + expect(installUpdate).toHaveBeenCalledOnce(); + expect(harness.releaseLeaveHolds).toHaveBeenCalledOnce(); + }); + + it("restarting BiBCode does nothing when the person stays", async () => { + harness.retryProjectData.mockRejectedValue("BiBCode's local server couldn't restart"); + await render(recoveryState); + await act(async () => button("Restart server").click()); + harness.askBeforeLeaving.mockResolvedValue("stay"); + await act(async () => button("Restart BiBCode").click()); + expect(harness.askBeforeLeaving).toHaveBeenCalledWith("restart"); + expect(harness.restartApp).not.toHaveBeenCalled(); + }); + }); + it("shows recovery actions and explains why installation cannot be retried", async () => { const onOpenChange = vi.fn(); await render( diff --git a/apps/web/src/components/desktop/UpdateProtectionDialog.tsx b/apps/web/src/components/desktop/UpdateProtectionDialog.tsx index 640d4cde9b..1603613e24 100644 --- a/apps/web/src/components/desktop/UpdateProtectionDialog.tsx +++ b/apps/web/src/components/desktop/UpdateProtectionDialog.tsx @@ -15,6 +15,7 @@ import { getDesktopUpdateErrorMessage, } from "../desktopUpdate.logic"; import { Button } from "../ui/button"; +import { askBeforeLeaving, releaseLeaveHolds } from "../files/unsavedFileEdits"; import { Checkbox } from "../ui/checkbox"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { @@ -47,10 +48,14 @@ function addRecoveryToast(toast: Parameters[0]): void { async function runDesktopUpdateAction( setBusy: (busy: boolean) => void, action: () => Promise, + leaveReason?: Parameters[0], ): Promise { if (desktopUpdateActionInFlight) return; desktopUpdateActionInFlight = true; try { + // Asked before the busy state: disabled buttons cannot take focus back after "stay", and the + // in-flight flag already turns away a second click. + if (leaveReason && (await askBeforeLeaving(leaveReason)) === "stay") return; setBusy(true); await action(); } finally { @@ -204,6 +209,7 @@ export function UpdateProtectionDialog({ try { await restartApp(); } catch (error) { + releaseLeaveHolds(); const message = getDesktopUpdateErrorMessage( error, "BiBCode couldn't restart. Try again.", @@ -220,6 +226,7 @@ export function UpdateProtectionDialog({ } } }, + "restart", ); }; @@ -280,6 +287,8 @@ export function UpdateProtectionDialog({ const handleInstallResult = (result: DesktopUpdateActionResult) => { if (result.completed) return; + // The install did not restart the app, so edits held after Discard may be saved again. + releaseLeaveHolds(); if (result.state.backendRecovery?.length) { showRecoveryToast(result.state.backendRecovery); } else if (result.state.message) { @@ -289,35 +298,45 @@ export function UpdateProtectionDialog({ const startInstall = async () => { if (working || recovering || (failedSecondaries.length > 0 && !exclusionsComplete)) return; - await runDesktopUpdateAction(setSubmitting, async () => { - setRestartError(null); - setAppRestartError(null); - setDetailsOpen(false); - try { - const exclusions = [...excludedEnvironmentIds]; - const result = await installUpdate( - exclusions.length > 0 ? { excludedEnvironmentIds: exclusions } : undefined, - ); - handleInstallResult(result); - } catch (error) { - onError?.(error instanceof Error ? error.message : "An unexpected error occurred."); - } - }); + await runDesktopUpdateAction( + setSubmitting, + async () => { + setRestartError(null); + setAppRestartError(null); + setDetailsOpen(false); + try { + const exclusions = [...excludedEnvironmentIds]; + const result = await installUpdate( + exclusions.length > 0 ? { excludedEnvironmentIds: exclusions } : undefined, + ); + handleInstallResult(result); + } catch (error) { + releaseLeaveHolds(); + onError?.(error instanceof Error ? error.message : "An unexpected error occurred."); + } + }, + "update", + ); }; const startUnprotectedInstall = async () => { if (working || recovering || !hasProtectionFailure || !skipProtectionAcknowledged) return; - await runDesktopUpdateAction(setSubmitting, async () => { - setRestartError(null); - setAppRestartError(null); - setDetailsOpen(false); - try { - const result = await installUpdate({ skipProtection: true }); - handleInstallResult(result); - } catch (error) { - onError?.(error instanceof Error ? error.message : "An unexpected error occurred."); - } - }); + await runDesktopUpdateAction( + setSubmitting, + async () => { + setRestartError(null); + setAppRestartError(null); + setDetailsOpen(false); + try { + const result = await installUpdate({ skipProtection: true }); + handleInstallResult(result); + } catch (error) { + releaseLeaveHolds(); + onError?.(error instanceof Error ? error.message : "An unexpected error occurred."); + } + }, + "update", + ); }; return ( diff --git a/apps/web/src/components/diffs/DiffFileList.tsx b/apps/web/src/components/diffs/DiffFileList.tsx index 2f43596158..0732cf3364 100644 --- a/apps/web/src/components/diffs/DiffFileList.tsx +++ b/apps/web/src/components/diffs/DiffFileList.tsx @@ -257,7 +257,7 @@ function DiffFileListRow({ title={row.previousPath ? `${row.previousPath} → ${row.path}` : row.path} className={cn( "flex h-full cursor-pointer select-none items-center gap-2 px-2 text-xs", - selected ? "bg-accent text-accent-foreground" : "text-foreground/80 hover:bg-muted/60", + selected ? "bg-selected text-selected-foreground" : "text-foreground/80 hover:bg-muted/60", )} onClick={() => onSelect(row)} > diff --git a/apps/web/src/components/files/FileBrowserPanel.realTree.test.tsx b/apps/web/src/components/files/FileBrowserPanel.realTree.test.tsx index d4b105b89b..986691c63a 100644 --- a/apps/web/src/components/files/FileBrowserPanel.realTree.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.realTree.test.tsx @@ -28,6 +28,9 @@ vi.mock("~/browser/openFileInPreview", () => ({ })); vi.mock("~/editorPreferences", () => ({ usePreferredEditor: () => [null] })); vi.mock("~/hooks/useTheme", () => ({ useTheme: () => ({ resolvedTheme: "dark" as const }) })); +vi.mock("~/hooks/useSettings", () => ({ + usePrimaryDefaultAgent: () => ({ kind: "chat", instanceId: "codex" }), +})); vi.mock("~/previewStateStore", () => ({ canPreviewAnyUrlInRuntime: () => false })); vi.mock("~/state/assets", () => ({ assetEnvironment: { createUrl: { label: "createUrl" } } })); vi.mock("~/state/environments", () => ({ diff --git a/apps/web/src/components/files/FileBrowserPanel.test.tsx b/apps/web/src/components/files/FileBrowserPanel.test.tsx index 1c042ced2e..839bf102d6 100644 --- a/apps/web/src/components/files/FileBrowserPanel.test.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.test.tsx @@ -101,6 +101,7 @@ const testState = vi.hoisted(() => ({ error?: unknown; }>, commandCalls: [] as Array<{ label: string; input: unknown }>, + defaultAgent: { kind: "chat", instanceId: "codex" } as { kind: string; instanceId: string }, commandResults: {} as Record, toastAdd: (() => {}) as (toast: unknown) => void, fileTree: { @@ -230,6 +231,10 @@ vi.mock("~/state/environments", () => ({ }), })); +vi.mock("~/hooks/useSettings", () => ({ + usePrimaryDefaultAgent: () => testState.defaultAgent, +})); + vi.mock("~/state/preview", () => ({ previewEnvironment: { open: { label: "openPreview" } }, })); @@ -492,6 +497,7 @@ beforeEach(() => { testState.vcsStatus = null; testState.openFileInPreview = vi.fn(async () => ({ _tag: "Success" })); testState.commandCalls = []; + testState.defaultAgent = { kind: "chat", instanceId: "codex" }; testState.fileTreeEntriesLive = []; testState.commandResults = {}; testState.toastAdd = vi.fn(); @@ -1915,6 +1921,67 @@ describe("add as project", () => { ); }); + function readyProvider(instanceId: string, driver: string, model: string) { + return { + instanceId, + driver, + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + auth: { status: "authenticated" }, + checkedAt: "2026-11-11T00:00:00.000Z", + models: [{ slug: model, name: model, isCustom: false, capabilities: null }], + slashCommands: [], + skills: [], + agents: [], + }; + } + + function addedProjectInstanceId(): unknown { + const call = testState.commandCalls.find((entry) => entry.label === "create"); + return (call?.input as { input: { defaultModelSelection: { instanceId: string } } } | undefined) + ?.input.defaultModelSelection.instanceId; + } + + it("opens the new project's Main on the Default Agent (#347)", async () => { + testState.defaultAgent = { kind: "chat", instanceId: "claudeAgent" }; + testState.environment = { + serverConfig: { + providers: [ + readyProvider("codex", "codex", "gpt-5.4"), + readyProvider("claudeAgent", "claudeAgent", "claude-opus-4-6"), + ], + settings: DEFAULT_SERVER_SETTINGS, + }, + }; + setEntries([entry("packages/app", "directory")]); + renderPanel(); + rowActionsFor("packages/app", "directory").onAddAsProject(); + await flushPromises(); + + expect(addedProjectInstanceId()).toBe("claudeAgent"); + }); + + it("falls back to the first ready chat when the Default Agent is unavailable, as Add Project does", async () => { + testState.defaultAgent = { kind: "chat", instanceId: "claudeAgent" }; + testState.environment = { + serverConfig: { + providers: [ + readyProvider("codex", "codex", "gpt-5.4"), + { ...readyProvider("claudeAgent", "claudeAgent", "claude-opus-4-6"), status: "error" }, + ], + settings: DEFAULT_SERVER_SETTINGS, + }, + }; + setEntries([entry("packages/app", "directory")]); + renderPanel(); + rowActionsFor("packages/app", "directory").onAddAsProject(); + await flushPromises(); + + expect(addedProjectInstanceId()).toBe("codex"); + }); + it("reports failures adding a directory as a project", async () => { setEntries([entry("packages/app", "directory")]); testState.commandResults["create"] = { _tag: "Failure", error: new Error("exists") }; diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 8a64ed75e8..ee7f913a83 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -1,5 +1,5 @@ import { scopedThreadKey } from "@bibcode/client-runtime/environment"; -import { DEFAULT_SERVER_SETTINGS, ProviderInstanceId } from "@bibcode/contracts"; +import { DEFAULT_SERVER_SETTINGS } from "@bibcode/contracts"; import type { EditorId, EnvironmentId, @@ -21,6 +21,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { isBrowserPreviewFile, openFileInPreview } from "~/browser/openFileInPreview"; import { sanitizeFrontendLogText } from "~/diagnostics/frontendLogCapture"; import { usePreferredEditor } from "~/editorPreferences"; +import { usePrimaryDefaultAgent } from "~/hooks/useSettings"; import { useTheme } from "~/hooks/useTheme"; import { inferProjectTitleFromPath } from "~/lib/projectPaths"; import { cn, newProjectId } from "~/lib/utils"; @@ -43,6 +44,7 @@ import { useAtomQueryRunner } from "~/state/use-atom-query-runner"; import { vcsEnvironment } from "~/state/vcs"; import { resolveProviderSessionSelectionForInstance } from "~/providerSessionSelection"; +import { resolveDefaultAgentInstanceId } from "../chat/providerAgentActions"; import { RetryButton } from "../ui/retry-button"; import { stackedThreadToast, toastManager } from "../ui/toast"; import FileEntryDialog, { type FileEntryDialogRequest } from "./FileEntryDialog"; @@ -284,6 +286,7 @@ export default function FileBrowserPanel({ const hasWorkspaceRoot = cwd.length > 0; const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const defaultAgent = usePrimaryDefaultAgent(); const createEntry = useAtomCommand(projectEnvironment.createEntry, { reportFailure: false }); const refreshServerEntries = useAtomCommand(projectEnvironment.refreshEntries, { reportFailure: false, @@ -418,10 +421,12 @@ export default function FileBrowserPanel({ (workspaceRoot: string) => { void (async () => { const serverConfig = environment?.serverConfig; + const providers = serverConfig?.providers ?? []; + const settings = serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS; const resolution = resolveProviderSessionSelectionForInstance({ - instanceId: ProviderInstanceId.make("codex"), - providers: serverConfig?.providers ?? [], - settings: serverConfig?.settings ?? DEFAULT_SERVER_SETTINGS, + instanceId: resolveDefaultAgentInstanceId(providers, settings, defaultAgent), + providers, + settings, }); if (resolution.fallback) { console.warn("Provider session default fallback", resolution.fallback); @@ -446,7 +451,7 @@ export default function FileBrowserPanel({ } })(); }, - [createProject, environment?.serverConfig, environmentId, showMutationError], + [createProject, defaultAgent, environment?.serverConfig, environmentId, showMutationError], ); const createChildEntry = useCallback( diff --git a/apps/web/src/components/files/FileEditorToolbar.test.tsx b/apps/web/src/components/files/FileEditorToolbar.test.tsx index b261a91117..b234e772a4 100644 --- a/apps/web/src/components/files/FileEditorToolbar.test.tsx +++ b/apps/web/src/components/files/FileEditorToolbar.test.tsx @@ -273,4 +273,49 @@ describe("FileEditorToolbar", () => { expect(undo!.className.split(/\s+/)).not.toContain("text-muted-foreground"); expect(redo!.className.split(/\s+/)).toContain("text-muted-foreground"); }); + + const renderWithFormatting = (formatting?: React.ReactNode) => + act(() => { + root.render( + , + ); + }); + + it("the formatting group sits between Redo and the view toggle", () => { + renderWithFormatting( +

+
, + ); + + const labels = [...container.querySelectorAll("button, [role='group']")].map((element) => + element.getAttribute("aria-label"), + ); + expect(labels).toEqual([ + "Save file", + "Undo", + "Redo", + "Formatting", + "Bold", + "Show markdown source", + ]); + }); + + it("no formatting group is rendered without the prop", () => { + renderWithFormatting(); + + expect(container.querySelector("[role='group']")).toBeNull(); + }); }); diff --git a/apps/web/src/components/files/FileEditorToolbar.tsx b/apps/web/src/components/files/FileEditorToolbar.tsx index b1581f7856..7f7caf34c1 100644 --- a/apps/web/src/components/files/FileEditorToolbar.tsx +++ b/apps/web/src/components/files/FileEditorToolbar.tsx @@ -31,6 +31,8 @@ export interface FileEditorToolbarProps { */ readonly saveBlockedReason?: string | null; readonly markdownView?: FileEditorMarkdownView | undefined; + /** Controls shown between Redo and the view toggle, such as the Markdown formatting group. */ + readonly formatting?: ReactNode; readonly onSave: () => void; readonly onUndo: () => void; readonly onRedo: () => void; @@ -87,6 +89,7 @@ export function FileEditorToolbar({ cleanStatus, saveBlockedReason = null, markdownView, + formatting, onSave, onUndo, onRedo, @@ -181,6 +184,16 @@ export function FileEditorToolbar({ > + {formatting ? ( + <> + + {formatting} + + ) : null} {markdownView ? ( <> ({ status: "loading" }); useEffect(() => { const controller = new AbortController(); diff --git a/apps/web/src/components/files/FilePreviewPanel.tabSwitch.integration.test.tsx b/apps/web/src/components/files/FilePreviewPanel.tabSwitch.integration.test.tsx index d886fb99b7..b3c36eb8b7 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tabSwitch.integration.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tabSwitch.integration.test.tsx @@ -26,11 +26,19 @@ interface FakeRead { const fake = vi.hoisted(() => { const state = { + path: "pierre-edit.ts", disk: { contents: "", version: "v0" }, query: null as FakeRead | null, overlay: null as FakeRead | null, listeners: new Set<() => void>(), - toolbar: null as null | { canUndo?: boolean; savePhase?: string }, + toolbar: null as null | { + canUndo?: boolean; + savePhase?: string; + markdownView?: { onRenderedChange: (rendered: boolean) => void }; + }, + markdown: null as null | { + onTaskListChange?: (input: { markerOffset: number; checked: boolean }) => void; + }, /** Resolves a held write; null while writes complete at once. */ heldWrite: null as null | (() => void), holdWrites: false, @@ -39,7 +47,7 @@ const fake = vi.hoisted(() => { }, readDisk(): FakeRead { return { - relativePath: "pierre-edit.ts", + relativePath: state.path, contents: state.disk.contents, byteLength: state.disk.contents.length, truncated: false, @@ -54,7 +62,12 @@ vi.mock("~/browser/openFileInPreview", () => ({ isBrowserPreviewFile: () => false, openFileInPreview: vi.fn(), })); -vi.mock("~/components/ChatMarkdown", () => ({ default: () => null })); +vi.mock("~/components/ChatMarkdown", () => ({ + default: (props: NonNullable) => { + fake.markdown = props; + return null; + }, +})); vi.mock("~/components/chat/OpenInPicker", () => ({ OpenInPicker: () => null })); vi.mock("~/hooks/useSettings", () => ({ useClientSettings: (selector: (settings: { wordWrap: boolean }) => unknown) => @@ -109,7 +122,7 @@ const rpc = vi.hoisted(() => ({ return AsyncResult.failure({ failure: "file_changed_on_disk" } as never); } fake.disk = { contents: input.contents, version: `v:${input.contents}` }; - return AsyncResult.success({ relativePath: "pierre-edit.ts", version: fake.disk.version }); + return AsyncResult.success({ relativePath: fake.path, version: fake.disk.version }); }, getFileVersion: async () => { const { AsyncResult } = await import("effect/reactivity"); @@ -127,7 +140,7 @@ vi.mock("~/state/use-atom-command", () => ({ vi.mock("~/state/use-atom-query-runner", () => ({ useAtomQueryRunner: () => vi.fn() })); vi.mock("./FileBrowserPanel", () => ({ default: () => null })); vi.mock("./FileEditorToolbar", () => ({ - FileEditorToolbar: (props: { canUndo?: boolean; savePhase?: string }) => { + FileEditorToolbar: (props: NonNullable) => { fake.toolbar = props; return null; }, @@ -250,7 +263,7 @@ class InThreadDiffsWorker { }; const highlighter = await getSharedHighlighter({ themes: [options.theme as never], - langs: ["typescript"], + langs: ["typescript", "markdown"], }); return { type: "success", @@ -319,10 +332,12 @@ describe("FilePreviewPanel across a right-panel tab switch", () => { container = document.createElement("div"); document.body.append(container); root = createRoot(container); + fake.path = "pierre-edit.ts"; fake.disk = { contents: ORIGINAL, version: "v0" }; fake.query = fake.readDisk(); fake.overlay = null; fake.toolbar = null; + fake.markdown = null; fake.heldWrite = null; fake.holdWrites = false; InThreadDiffsWorker.heldHighlights = []; @@ -443,4 +458,88 @@ describe("FilePreviewPanel across a right-panel tab switch", () => { expect(editorText()).toContain("original packaged text"); }, ); + + it.each(["the view toggle", "a line link"])( + "ticking a checkbox in a read-only Markdown file keeps the source Undo history (back through %s)", + async (way) => { + // A footnote keeps the file read-only in rendered view. + const original = "- [ ] task\n\nA claim.[^1]\n\n[^1]: The source.\n"; + fake.path = "notes.md"; + fake.disk = { contents: original, version: "v0" }; + fake.query = fake.readDisk(); + const registry = new FileEditingSessionRegistry< + FileEditingSession + >(); + const render = async (revealLine: number | null = null) => { + await act(async () => { + root.render( + new InThreadDiffsWorker() as never, poolSize: 1 }} + highlighterOptions={{ theme: "pierre-dark", tokenizeMaxLineLength: 1_000 }} + > + + , + ); + registry.setActivePath("notes.md"); + }); + }; + const settle = async (until: () => boolean) => { + for (let attempt = 0; attempt < 100 && !until(); attempt += 1) { + releaseHighlights(); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 10)); + }); + } + expect(until()).toBe(true); + }; + const toggle = async (rendered: boolean) => { + await act(async () => fake.toolbar!.markdownView!.onRenderedChange(rendered)); + }; + + await render(); + expect(fake.markdown?.onTaskListChange).toBeTypeOf("function"); + await toggle(false); + const session = registry.get("notes.md")!; + await settle(() => session.editor.getFile() !== undefined); + const insert = (character: number, newText: string) => + session.editor.applyEdits( + [{ range: { start: { line: 0, character }, end: { line: 0, character } }, newText }], + true, + true, + ); + await act(async () => { + insert(10, " one"); + insert(14, " two"); + }); + expect(session.editor.getText()).toBe(original.replace("task", "task one two")); + + await toggle(true); + await act(async () => fake.markdown!.onTaskListChange!({ markerOffset: 2, checked: true })); + if (way === "the view toggle") await toggle(false); + else await render(2); + await settle(() => session.editor.getText().startsWith("- [x] task one two\n")); + + await act(async () => session.undo()); + expect(session.editor.getText()).toBe(original.replace("task", "task one two")); + await act(async () => session.undo()); + expect(session.editor.getText()).toBe(original.replace("task", "task one")); + await act(async () => session.undo()); + expect(session.editor.getText()).toBe(original); + }, + ); }); diff --git a/apps/web/src/components/files/FilePreviewPanel.test.tsx b/apps/web/src/components/files/FilePreviewPanel.test.tsx index 6f6bbab10f..971769721f 100644 --- a/apps/web/src/components/files/FilePreviewPanel.test.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.test.tsx @@ -98,7 +98,7 @@ const testState = vi.hoisted(() => { undo: ReturnType; redo: ReturnType; setEditorChangeHandler: ReturnType; - changeOutsideEditor: ReturnType; + changeFromRichEditor: ReturnType; }, editingSessions: null as unknown as { get: ReturnType; @@ -201,7 +201,7 @@ const testState = vi.hoisted(() => { session.undo = vi.fn(session.undo.bind(session)); session.redo = vi.fn(session.redo.bind(session)); session.setEditorChangeHandler = vi.fn(session.setEditorChangeHandler.bind(session)); - session.changeOutsideEditor = vi.fn(session.changeOutsideEditor.bind(session)); + session.changeFromRichEditor = vi.fn(session.changeFromRichEditor.bind(session)); sessions.set(relativePath, session); state.session = session; return session; @@ -372,6 +372,13 @@ vi.mock("~/components/ChatMarkdown", () => ({ }, })); +vi.mock("./richMarkdown/RichMarkdownSurface", () => ({ + default: (props: Record) => { + ui.record("RichMarkdownSurface", props); + return
; + }, +})); + vi.mock("~/components/chat/OpenInPicker", () => ({ OpenInPicker: (props: Record) => { ui.record("OpenInPicker", props); @@ -569,6 +576,7 @@ vi.mock("./fileSaveCoordinator", () => ({ }; readonly getSnapshot = () => testState.sessionSnapshot.save; readonly holdForConflict = vi.fn(); + readonly setAutosaveEnabled = vi.fn(); readonly releaseConflict = vi.fn(); readonly adopt = vi.fn(); readonly discardPendingSave = vi.fn(); @@ -613,7 +621,9 @@ vi.mock("./projectFilesQueryState", () => ({ import { resolveDiffThemeName } from "~/lib/diffRendering"; import type { FileEditingSession } from "./fileEditingSession"; +import { FileEditingSessionRegistry } from "./fileEditingSessionRegistry"; import FilePreviewPanel from "./FilePreviewPanel"; +import type { RichMarkdownEditor } from "./richMarkdown/richMarkdownExtensions"; const environmentId = EnvironmentId.make("environment-1"); const otherEnvironmentId = EnvironmentId.make("environment-2"); @@ -1008,7 +1018,7 @@ describe("file explorer toggle", () => { describe("markdown preview", () => { it("renders markdown formatted by default and offers the source toggle", () => { setFileData("# Title\n"); - const markup = renderPanel(baseProps({ relativePath: "README.md" })); + const markup = renderPanel(baseProps({ relativePath: "README.mdx" })); expect(markup).not.toContain('aria-label="Show markdown source"'); expect(ui.filter("ChatMarkdown")).toHaveLength(1); @@ -1023,7 +1033,7 @@ describe("markdown preview", () => { (call) => typeof call.next === "object" && call.next !== null && - (call.next as { path: string | null }).path === "README.md", + (call.next as { path: string | null }).path === "README.mdx", ), ).toBe(true); @@ -1045,7 +1055,7 @@ describe("markdown preview", () => { canRedo: true, }; setFileData("- [ ] first\n- [x] second\n"); - const markup = renderPanel(baseProps({ relativePath: "README.md" })); + const markup = renderPanel(baseProps({ relativePath: "README.mdx" })); expect(markup).toContain("data-chat-markdown"); expect(ui.find("FileEditorToolbar")).toMatchObject({ canSave: true, @@ -1065,10 +1075,10 @@ describe("markdown preview", () => { expect(testState.setProjectFileQueryData).toHaveBeenCalledWith( environmentId, "/workspace/demo", - "README.md", + "README.mdx", "- [x] first\n- [x] second\n", ); - expect(testState.session!.changeOutsideEditor).toHaveBeenCalledWith( + expect(testState.session!.changeFromRichEditor).toHaveBeenCalledWith( "- [x] first\n- [x] second\n", ); const coordinator = testState.coordinators[testState.coordinators.length - 1]!; @@ -1089,7 +1099,7 @@ describe("markdown preview", () => { expect(testState.setProjectFileQueryData).toHaveBeenCalledWith( environmentId, "/workspace/demo", - "README.md", + "README.mdx", "- [ ] first\n- [x] second\n", ); await flushPromises(); @@ -1112,15 +1122,15 @@ describe("markdown preview", () => { initial !== null && "path" in initial && "revealRequestId" in initial, - { path: "README.md", revealRequestId: 1 }, + { path: "README.mdx", revealRequestId: 1 }, ); - renderPanel(baseProps({ relativePath: "README.md" })); + renderPanel(baseProps({ relativePath: "README.mdx" })); const session = testState.session!; testState.pendingWork = options.unsaved; testState.editors.at(-1)!.options.onChange({ contents: edited }); if (options.changedOnDisk !== undefined) setFileData(options.changedOnDisk); - renderPanel(baseProps({ relativePath: "README.md" })); + renderPanel(baseProps({ relativePath: "README.mdx" })); expect(testState.session).toBe(session); const chatMarkdown = ui.find("ChatMarkdown"); return { @@ -1139,11 +1149,11 @@ describe("markdown preview", () => { expect(text).toBe("- [ ] first\n- [ ] typed\n"); toggleTask({ markerOffset: 2, checked: true }); - expect(session.changeOutsideEditor).toHaveBeenCalledWith("- [x] first\n- [ ] typed\n"); + expect(session.changeFromRichEditor).toHaveBeenCalledWith("- [x] first\n- [ ] typed\n"); expect(testState.setProjectFileQueryData).toHaveBeenCalledWith( environmentId, "/workspace/demo", - "README.md", + "README.mdx", "- [x] first\n- [ ] typed\n", ); }); @@ -1157,7 +1167,7 @@ describe("markdown preview", () => { expect(text).toBe("- [ ] first\n- [ ] typed\n"); toggleTask({ markerOffset: 2, checked: true }); - expect(session.changeOutsideEditor).toHaveBeenCalledWith("- [x] first\n- [ ] typed\n"); + expect(session.changeFromRichEditor).toHaveBeenCalledWith("- [x] first\n- [ ] typed\n"); }); it("renders and toggles the file on disk once the source edits are saved", () => { @@ -1170,7 +1180,7 @@ describe("markdown preview", () => { expect(text).toBe("- [ ] first\n- [ ] typed\n- [ ] agent\n"); toggleTask({ markerOffset: 2, checked: true }); - expect(session.changeOutsideEditor).toHaveBeenCalledWith( + expect(session.changeFromRichEditor).toHaveBeenCalledWith( "- [x] first\n- [ ] typed\n- [ ] agent\n", ); }); @@ -2310,3 +2320,250 @@ describe("effects wiring", () => { expect(testState.editingSessions.getOrCreate).not.toHaveBeenCalled(); }); }); + +describe("rich markdown editing", () => { + const readOnlyNotice = + "This file uses Markdown that the editor cannot change safely. Edit it in source view."; + + function fakeRichEditor(overrides: Partial = {}): RichMarkdownEditor { + return { + canUndo: false, + canRedo: false, + undo: vi.fn(), + redo: vi.fn(), + isActive: () => false, + run: vi.fn(), + subscribe: () => () => {}, + serialize: () => "", + destroy: vi.fn(), + ...overrides, + }; + } + + /** Renders README.md once to create its session, then again with the surface's report. */ + async function renderRich(contents: string, editor: RichMarkdownEditor | null | undefined) { + setFileData(contents, { version: "v1" }); + renderPanel(baseProps({ relativePath: "README.md" })); + // The surface is loaded lazily; once loaded it renders in place of the fallback. + await vi.dynamicImportSettled(); + const session = testState.session as unknown as FileEditingSession; + if (editor !== undefined) { + harness.seedState( + (initial) => typeof initial === "object" && initial !== null && "editor" in initial, + { key: session.cacheKey, editor, opened: contents }, + ); + } + const markup = renderPanel(baseProps({ relativePath: "README.md" })); + return { markup, session }; + } + + it("a markdown file opens editable in rendered view with undo enabled after an edit", async () => { + const editor = fakeRichEditor({ canUndo: true }); + const { markup } = await renderRich("# Title\n", editor); + + expect(markup).toContain("data-rich-markdown-surface"); + expect(ui.find("RichMarkdownSurface")).toMatchObject({ contents: "# Title\n" }); + expect(markup).not.toContain(readOnlyNotice); + const toolbar = ui.find("FileEditorToolbar"); + expect(toolbar).toMatchObject({ canUndo: true, canRedo: false }); + (toolbar.onUndo as () => void)(); + expect(editor.undo).toHaveBeenCalledOnce(); + }); + + it("a file the editor cannot map block for block opens read-only with the same notice", async () => { + const { markup } = await renderRich("# Title\n", null); + + expect(ui.filter("RichMarkdownSurface")).toHaveLength(0); + expect(ui.filter("ChatMarkdown")).toHaveLength(1); + expect(markup).toContain(readOnlyNotice); + }); + + it("a file with a footnote renders read-only with a notice and its task checkboxes still work", async () => { + const contents = "- [ ] task\n\nA claim.[^1]\n\n[^1]: The source.\n"; + const { markup, session } = await renderRich(contents, undefined); + const sourceEditor = session.editor; + const cacheKey = session.cacheKey; + + expect(ui.filter("RichMarkdownSurface")).toHaveLength(0); + expect(markup).toContain(readOnlyNotice); + const onTaskListChange = ui.find("ChatMarkdown").onTaskListChange as (input: { + markerOffset: number; + checked: boolean; + }) => void; + onTaskListChange({ markerOffset: 2, checked: true }); + + expect(session.documentContents).toBe("- [x] task\n\nA claim.[^1]\n\n[^1]: The source.\n"); + // The source editor, and with it its Undo history, survives the tick. + expect(session.editor).toBe(sourceEditor); + expect(session.cacheKey).toBe(cacheKey); + }); + + it("a Markdown file of 129 KiB renders read-only with the size notice and creates no rich editor", async () => { + const { markup } = await renderRich(`# Big\n\n${"a".repeat(129 * 1024)}\n`, undefined); + + expect(ui.filter("RichMarkdownSurface")).toHaveLength(0); + expect(ui.filter("ChatMarkdown")).toHaveLength(1); + expect(markup).toContain( + "This file is too large to edit in rendered view. Edit it in source view.", + ); + expect(markup).not.toContain(readOnlyNotice); + }); + + it("a file the editor refused opens editable again once a source edit repairs it", async () => { + testState.sessionSnapshot = { + save: { phase: "pending", canSave: true, confirmedRevision: 0 }, + canUndo: true, + canRedo: false, + }; + testState.pendingWork = true; + const { session } = await renderRich("©\n", undefined); + const refused = { key: session.cacheKey, editor: null, opened: "©\n" }; + const isRichState = (initial: unknown) => + typeof initial === "object" && initial !== null && "editor" in initial; + const enterRendered = () => { + harness.seedState(isRichState, refused); + renderPanel(baseProps({ relativePath: "README.md" })); + ( + ui.find("FileEditorToolbar").markdownView as { + onRenderedChange: (rendered: boolean) => void; + } + ).onRenderedChange(true); + harness.seedState(isRichState, refused); + renderPanel(baseProps({ relativePath: "README.md" })); + }; + + enterRendered(); + expect(ui.filter("RichMarkdownSurface")).toHaveLength(0); + + testState.session!.editor.options.onChange({ contents: "(c)\n" }); + enterRendered(); + + expect(ui.find("RichMarkdownSurface")).toMatchObject({ contents: "(c)\n" }); + }); + + it("a refused file repaired in source view opens editable when a line link is cleared", async () => { + testState.sessionSnapshot = { + save: { phase: "pending", canSave: true, confirmedRevision: 0 }, + canUndo: true, + canRedo: false, + }; + testState.pendingWork = true; + const { session } = await renderRich("©\n", undefined); + const refused = { key: session.cacheKey, editor: null, opened: "©\n" }; + const isRichState = (initial: unknown) => + typeof initial === "object" && initial !== null && "editor" in initial; + + harness.seedState(isRichState, refused); + renderPanel(baseProps({ relativePath: "README.md", revealLine: 1, revealRequestId: 2 })); + expect(ui.filter("RichMarkdownSurface")).toHaveLength(0); + testState.session!.editor.options.onChange({ contents: "(c)\n" }); + harness.seedState(isRichState, refused); + renderPanel(baseProps({ relativePath: "README.md", revealLine: null, revealRequestId: 2 })); + + expect(ui.find("RichMarkdownSurface")).toMatchObject({ contents: "(c)\n" }); + }); + + it("switching to source after a rich edit commits it as one step", async () => { + const { session } = await renderRich("# Title\n", fakeRichEditor()); + const commitRichEdits = vi.spyOn(session, "commitRichEdits"); + const toolbar = ui.find("FileEditorToolbar"); + + (toolbar.onSave as () => void)(); + expect(testState.session!.flush).toHaveBeenCalledTimes(1); + (toolbar.markdownView as { onRenderedChange: (rendered: boolean) => void }).onRenderedChange( + false, + ); + expect(commitRichEdits).toHaveBeenCalledOnce(); + + harness.seedState( + (initial) => typeof initial === "object" && initial !== null && "revealRequestId" in initial, + { path: "README.md", revealRequestId: 1 }, + ); + renderPanel(baseProps({ relativePath: "README.md" })); + (ui.find("FileEditorToolbar").onSave as () => void)(); + expect(testState.session!.flush).toHaveBeenCalledTimes(2); + }); + + it("an unsaved source edit is shown after switching to rendered view and Undo there starts empty", async () => { + testState.sessionSnapshot = { + save: { phase: "pending", canSave: true, confirmedRevision: 0 }, + canUndo: true, + canRedo: false, + }; + testState.pendingWork = true; + setFileData("# Title\n", { version: "v1" }); + renderPanel(baseProps({ relativePath: "README.md" })); + testState.session!.editor.options.onChange({ contents: "# Title edited\n" }); + await vi.dynamicImportSettled(); + + renderPanel(baseProps({ relativePath: "README.md" })); + + expect(ui.find("RichMarkdownSurface")).toMatchObject({ contents: "# Title edited\n" }); + expect(ui.find("FileEditorToolbar")).toMatchObject({ canUndo: false, canRedo: false }); + }); + + it("a rendered edit on a file that changed on disk blocks the save and shows the reload-or-overwrite choice", async () => { + const { session } = await renderRich("Mine\n", fakeRichEditor()); + testState.pendingWork = true; + session.changeFromRichEditor("Mine edited\n"); + const token = session.beginDiskCheck()!; + expect(session.reconcileVersion(token, { _tag: "present", version: "v2" })).toBe("read"); + session.reconcileRead(token, { version: "v2", contents: "Theirs\n" }); + + renderPanel(baseProps({ relativePath: "README.md" })); + + expect(ui.find("FileDiskConflictBar")).toMatchObject({ + conflict: { reason: "changed", diskKnown: true, unreadable: null }, + confirmReload: true, + }); + expect(ui.find("FileEditorToolbar").saveBlockedReason).toBe( + "Changed on disk. Choose Reload from disk or Overwrite disk.", + ); + }); + + it("Save, Reload and Overwrite explain in a toast that an undelivered edit refused them", async () => { + const { session } = await renderRich("Mine\n", fakeRichEditor()); + testState.pendingWork = true; + session.changeFromRichEditor("Mine edited\n"); + const token = session.beginDiskCheck()!; + session.reconcileVersion(token, { _tag: "present", version: "v2" }); + session.reconcileRead(token, { version: "v2", contents: "Theirs\n" }); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + session.setPendingEditFlusher(() => { + throw new Error("reconcile failed"); + }); + renderPanel(baseProps({ relativePath: "README.md" })); + const refused = { + stacked: true, + type: "error", + title: "Your last edit could not be applied to the file.", + description: "Switch to source view to keep it.", + }; + + (ui.find("FileEditorToolbar").onSave as () => void)(); + (ui.find("FileDiskConflictBar").onReload as () => void)(); + (ui.find("FileDiskConflictBar").onOverwrite as () => void)(); + reportError.mockRestore(); + + expect(testState.toastAdd.mock.calls).toEqual([[refused], [refused], [refused]]); + expect(session.documentContents).toBe("Mine edited\n"); + testState.toastAdd.mockReset(); + session.setPendingEditFlusher(() => session.changeFromRichEditor("Mine edited again\n")); + (ui.find("FileEditorToolbar").onSave as () => void)(); + expect(testState.toastAdd).not.toHaveBeenCalled(); + }); + + it("leaving the file autosaves a rendered edit", async () => { + const { session } = await renderRich("Mine\n", fakeRichEditor()); + const registry = new FileEditingSessionRegistry>(); + registry.getOrCreate("README.md", () => session); + registry.setActivePath("README.md"); + session.setPendingEditFlusher(() => session.changeFromRichEditor("Mine edited\n")); + const coordinator = testState.coordinators.at(-1)!; + + registry.setActivePath("other.md"); + + expect(coordinator.change).toHaveBeenCalledWith("Mine edited\n"); + expect(coordinator.flush).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 0e7aee37ce..49c2a48d27 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -19,7 +19,6 @@ import { import { FolderSearch, FolderTree, Globe2, LoaderCircle } from "lucide-react"; import * as Schema from "effect/Schema"; import { - type RefObject, useCallback, useEffect, useLayoutEffect, @@ -78,8 +77,19 @@ import { FileEditingSessionRegistry } from "./fileEditingSessionRegistry"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; +import { RichMarkdownFormatGroup } from "./richMarkdown/RichMarkdownFormatGroup"; +import { RichMarkdownLinkPopover } from "./richMarkdown/RichMarkdownLinkPopover"; +import { useInsertImage } from "./richMarkdown/useInsertImage"; +import type { RichMarkdownEditor } from "./richMarkdown/richMarkdownExtensions"; import { FileMediaPreview } from "./FileMediaPreview"; -import { filePreviewKind, isMarkdownPreviewFile, setMarkdownTaskChecked } from "./filePreviewMode"; +import { RichMarkdownView } from "./RichMarkdownView"; +import { explainUndeliveredEdit, saveFile, useFileSaveShortcut } from "./useFileSaveShortcut"; +import { + filePreviewKind, + isMarkdownPreviewFile, + richMarkdownMode, + setMarkdownTaskChecked, +} from "./filePreviewMode"; import { clearProjectFileQueryData, confirmProjectFileQueryData, @@ -119,6 +129,7 @@ const UNAVAILABLE_SESSION_SNAPSHOT: FileEditingSessionSnapshot = { canRedo: false, conflict: null, deletedOnDisk: false, + editPending: false, }; const subscribeUnavailable = (): (() => void) => () => {}; @@ -414,32 +425,6 @@ function useFileDiskWatch({ }, [diskCheckSignal, reconnectSignal, session]); } -/** - * Wire an explicit Ctrl+S / Cmd+S save onto the file-surface container. The chord - * flushes the pending debounced write immediately and always preventDefaults so - * the browser's own save dialog can never open. The listener is scoped to the - * container (capture phase, so it wins before the Pierre editor's own handlers) - * and only fires while focus lives inside the surface. - */ -function useFileSaveShortcut( - containerRef: RefObject, - session: { flush(): Promise } | null, -): void { - useEffect(() => { - const container = containerRef.current; - if (!container || !session) return; - const handleKeyDown = (event: KeyboardEvent) => { - const isSaveChord = - (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "s"; - if (!isSaveChord) return; - event.preventDefault(); - void session.flush(); - }; - container.addEventListener("keydown", handleKeyDown, true); - return () => container.removeEventListener("keydown", handleKeyDown, true); - }, [containerRef, session]); -} - /** * Drops the worker pool's highlighted lines for a session's `cacheKey` when its editable surface * goes away. Pierre caches those lines by `cacheKey` alone, and a highlight still in the worker @@ -714,27 +699,67 @@ function EditableFileSurface({ ); } +/** The formatting group with its image picker, which needs the file's folder. */ +function RichMarkdownFormatting({ + cwd, + environmentId, + markdownPath, + editor, + onLink, +}: { + cwd: string; + environmentId: EnvironmentId; + markdownPath: string; + editor: RichMarkdownEditor | null; + onLink: () => void; +}) { + const insertImage = useInsertImage({ cwd, environmentId, markdownPath, editor }); + return ( + <> + + + + ); +} + +const READ_ONLY_MARKDOWN_NOTICE = + "This file uses Markdown that the editor cannot change safely. Edit it in source view."; +const TOO_LARGE_MARKDOWN_NOTICE = + "This file is too large to edit in rendered view. Edit it in source view."; + function RenderedMarkdownSurface({ session, environmentId, cwd, contents, threadRef, + notice = null, }: { session: FileEditingSession; environmentId: EnvironmentId; cwd: string; contents: string; threadRef: ScopedThreadRef; + notice?: string | null; }) { const relativePath = session.relativePath; const surfaceRef = useRef(null); useFileSaveShortcut(surfaceRef, session); + // A line link opens source view without the toggle, so a ticked checkbox closes its visit here. + useEffect(() => () => session.commitRichEdits(), [session]); // Unsaved source edits win over the file; once saved, the file wins, so a // change made on disk after the save shows here and a task toggle keeps it. return ( + {notice ? ( +

+ {notice} +

+ ) : null}
@@ -834,6 +859,53 @@ export default function FilePreviewPanel({ session?.getSnapshot ?? readUnavailable, session?.getSnapshot ?? readUnavailable, ); + const renderedText = + renderMarkdown && session && file.data ? (session.unsavedContents ?? file.data.contents) : null; + // Decided when the rendered view opens a document; the rich editor's own saves do not reopen it. + const richMode = useMemo( + () => + relativePath && renderedText !== null ? richMarkdownMode(relativePath, renderedText) : null, + // oxlint-disable-next-line react-hooks/exhaustive-deps + [relativePath, session?.cacheKey, renderedText === null], + ); + // What the rich surface reported for this document: its editor, or null when it cannot edit + // the text it opened. + const [richEditorState, setRichEditorState] = useState<{ + key: string | null; + editor: RichMarkdownEditor | null; + opened: string | null; + }>({ key: null, editor: null, opened: null }); + const richEditorKey = session?.cacheKey ?? ""; + // A refusal holds only for the text it was made on, so a source edit that repairs the file + // reopens it editable however rendered view returns (the toggle or a cleared line link). + const richEditor = + richEditorState.key === richEditorKey && + (richEditorState.editor !== null || richEditorState.opened === renderedText) + ? richEditorState.editor + : undefined; + const onRichEditorChange = useCallback( + (editor: RichMarkdownEditor | null, opened: string) => + setRichEditorState({ key: richEditorKey, editor, opened }), + [richEditorKey], + ); + const richEditable = richMode?.editable === true && richEditor !== null; + const activeRichEditor = renderMarkdown && richEditable ? (richEditor ?? null) : null; + const [linkDialogOpen, setLinkDialogOpen] = useState(false); + const richMarkdownWorkspace = useMemo( + () => ({ + environmentId, + threadId: threadRef.threadId, + cwd, + markdownPath: relativePath ?? "", + }), + [cwd, environmentId, relativePath, threadRef.threadId], + ); + + useSyncExternalStore( + activeRichEditor?.subscribe ?? subscribeUnavailable, + () => (activeRichEditor ? `${activeRichEditor.canUndo}:${activeRichEditor.canRedo}` : ""), + () => "", + ); const diskConflict = sessionSnapshot.conflict; const deletedOnDisk = sessionSnapshot.deletedOnDisk; const canOpenInBrowser = @@ -980,22 +1052,45 @@ export default function FilePreviewPanel({ ) : null} {relativePath !== null && mediaKind === null ? ( setLinkDialogOpen(true)} + /> + ) : undefined + } markdownView={ isMarkdown ? { rendered: renderMarkdown, onRenderedChange: (rendered) => { + // The rendered visit reaches the source editor's history as one step. + if (!rendered) session?.commitRichEdits(); setSourceView({ path: rendered ? null : relativePath, revealRequestId: rendered ? null : revealRequestId, @@ -1019,12 +1114,15 @@ export default function FilePreviewPanel({ : undefined } onSave={() => { - if (session) void session.flush(); + if (session) saveFile(session); }} - onUndo={() => session?.undo()} - onRedo={() => session?.redo()} + onUndo={() => (activeRichEditor ? activeRichEditor.undo() : session?.undo())} + onRedo={() => (activeRichEditor ? activeRichEditor.redo() : session?.redo())} /> ) : null} + {activeRichEditor && linkDialogOpen ? ( + + ) : null} {session && diskConflict ? ( { + session.overwriteDisk(); + explainUndeliveredEdit(session); }} - onOverwrite={() => session.overwriteDisk()} /> ) : null} {relativePath && file.data && truncatedPreview ? ( @@ -1094,13 +1196,37 @@ export default function FilePreviewPanel({
) : relativePath && file.data ? ( isMarkdown && renderMarkdown && session ? ( - + richEditable ? ( + + } + /> + ) : ( + + ) ) : truncatedPreview ? ( { + let release!: () => void; + const loaded = new Promise((resolve) => { + release = resolve; + }); + return { loaded, release: () => release() }; +}); + +// The editor chunk loads only when the test releases it, as a first lazy load does. +vi.mock("./richMarkdown/RichMarkdownSurface", async () => { + await surfaceModule.loaded; + return { default: () =>
}; +}); + +import { RichMarkdownView } from "./RichMarkdownView"; + +let container: HTMLDivElement; +let root: Root; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe("RichMarkdownView", () => { + it("saves on the save shortcut once the editor's first lazy load resolves", async () => { + const session = { + flush: vi.fn(async () => "saved"), + getSnapshot: () => ({ editPending: false }), + changeFromRichEditor: vi.fn(), + setPendingEditFlusher: vi.fn(), + commitRichEdits: vi.fn(), + setComposing: vi.fn(), + }; + await act(async () => { + root.render( + } + />, + ); + }); + expect(container.querySelector("[data-fallback]")).not.toBeNull(); + + await act(async () => { + surfaceModule.release(); + await vi.dynamicImportSettled(); + }); + const surface = container.querySelector("[data-rich-surface]"); + expect(surface).not.toBeNull(); + + const chord = new KeyboardEvent("keydown", { + key: "s", + ctrlKey: true, + bubbles: true, + cancelable: true, + }); + surface!.dispatchEvent(chord); + + expect(session.flush).toHaveBeenCalledOnce(); + expect(chord.defaultPrevented).toBe(true); + }); +}); diff --git a/apps/web/src/components/files/RichMarkdownView.tsx b/apps/web/src/components/files/RichMarkdownView.tsx new file mode 100644 index 0000000000..6e290da6bd --- /dev/null +++ b/apps/web/src/components/files/RichMarkdownView.tsx @@ -0,0 +1,38 @@ +import { lazy, type ReactNode, Suspense, useRef } from "react"; + +import { ScrollArea } from "~/components/ui/scroll-area"; + +import type { RichMarkdownSurfaceProps } from "./richMarkdown/RichMarkdownSurface"; +import { type SavableSession, useFileSaveShortcut } from "./useFileSaveShortcut"; + +const RichMarkdownSurface = lazy(() => import("./richMarkdown/RichMarkdownSurface")); + +type RichMarkdownViewSession = RichMarkdownSurfaceProps["session"] & SavableSession; + +type LoadedRichMarkdownProps = Pick< + RichMarkdownSurfaceProps, + "contents" | "onEditorChange" | "workspace" +> & { session: RichMarkdownViewSession }; + +/** The editable rendered view; until its editor loads, the read-only view shows the text. */ +export function RichMarkdownView({ + fallback, + ...props +}: LoadedRichMarkdownProps & { fallback: ReactNode }) { + return ( + + + + ); +} + +/** Inside the boundary, so the save shortcut's effect first runs once the loaded editor commits. */ +function LoadedRichMarkdown({ session, ...props }: LoadedRichMarkdownProps) { + const surfaceRef = useRef(null); + useFileSaveShortcut(surfaceRef, session); + return ( + + + + ); +} diff --git a/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx b/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx new file mode 100644 index 0000000000..1830fbbace --- /dev/null +++ b/apps/web/src/components/files/UnsavedChangesCloseDialog.test.tsx @@ -0,0 +1,485 @@ +// @vitest-environment happy-dom +import type { + DesktopBridge, + DesktopCloseRequest, + ProjectWritePrecondition, +} from "@bibcode/contracts"; +import type { AtomCommandResult } from "@bibcode/client-runtime/state/runtime"; +import * as Cause from "effect/Cause"; +import { AsyncResult } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const { toastAdd } = vi.hoisted(() => ({ toastAdd: vi.fn() })); +vi.mock("../ui/toast", async (importOriginal) => ({ + ...(await importOriginal()), + toastManager: { add: toastAdd }, +})); + +import { FileEditingSession } from "./fileEditingSession"; +import { FileEditingSessionRegistry } from "./fileEditingSessionRegistry"; +import { UnsavedChangesCloseHost } from "./UnsavedChangesCloseDialog"; +import { askBeforeLeaving } from "./unsavedFileEdits"; + +type WriteResult = AtomCommandResult<{ readonly version?: string }, unknown>; +type Session = FileEditingSession; +type Persist = ( + relativePath: string, + contents: string, + precondition: ProjectWritePrecondition | null, +) => Promise; + +const saved = (): WriteResult => AsyncResult.success({ version: "v2" }); +const changedOnDisk = (): WriteResult => + AsyncResult.failure( + Cause.fail({ + _tag: "ProjectWriteFileError", + failure: "file_changed_on_disk", + message: "file_changed_on_disk", + }), + ); + +let container: HTMLDivElement; +let root: Root; +let registries: FileEditingSessionRegistry[] = []; +let originalGetAnimations: PropertyDescriptor | undefined; + +function openFile( + registry: FileEditingSessionRegistry, + relativePath: string, + persist: Persist = vi.fn(async () => saved()), +) { + return registry.getOrCreate( + relativePath, + () => + new FileEditingSession({ + cwd: "/repo", + relativePath, + debounceMs: 500, + base: { version: "v1", contents: "old\n" }, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + onConflictChange: vi.fn(), + }), + ); +} + +function newRegistry() { + const registry = new FileEditingSessionRegistry(); + registries.push(registry); + return registry; +} + +const dialogText = () => document.querySelector('[data-slot="alert-dialog-popup"]')?.textContent; +function button(label: string): HTMLButtonElement { + const match = [...document.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + if (!match) throw new Error(`Missing button: ${label}`); + return match; +} +async function click(label: string) { + await act(async () => button(label).click()); +} +async function leave(reason: Parameters[0] = "close") { + let answer: Promise<"proceed" | "stay">; + await act(async () => { + answer = askBeforeLeaving(reason); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + // Wrapped: an async function would otherwise wait for the answer itself. + return [answer!] as const; +} + +beforeEach(async () => { + originalGetAnimations = Object.getOwnPropertyDescriptor(Element.prototype, "getAnimations"); + Object.defineProperty(Element.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => root.render()); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + await Promise.all(registries.map((registry) => registry.dispose())); + registries = []; + container.remove(); + document.body.replaceChildren(); + toastAdd.mockReset(); + if (originalGetAnimations) { + Object.defineProperty(Element.prototype, "getAnimations", originalGetAnimations); + } else { + Reflect.deleteProperty(Element.prototype, "getAnimations"); + } + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; +}); + +describe("UnsavedChangesCloseHost", () => { + it("Save all writes a rendered and a source edit and proceeds", async () => { + const persist = vi.fn(async () => saved()); + const registry = newRegistry(); + const rendered = openFile(registry, "notes.md", persist); + const source = openFile(registry, "src/app.ts", persist); + rendered.setPendingEditFlusher(() => rendered.changeFromRichEditor("rendered edit")); + source.changeFromRichEditor("source edit"); + + const [answer] = await leave(); + expect(dialogText()).toContain("notes.md"); + expect(dialogText()).toContain("src/app.ts"); + await click("Save all"); + + await expect(answer).resolves.toBe("proceed"); + expect(persist).toHaveBeenCalledWith("notes.md", "rendered edit", expect.anything()); + expect(persist).toHaveBeenCalledWith("src/app.ts", "source edit", expect.anything()); + }); + + it("Save all with a file changed on disk stays and shows the reload-or-overwrite choice", async () => { + const persist = vi.fn(async () => changedOnDisk()); + const session = openFile(newRegistry(), "notes.md", persist); + session.changeFromRichEditor("my edit"); + + const [answer] = await leave(); + await click("Save all"); + + await expect(answer).resolves.toBe("stay"); + expect(session.getSnapshot().conflict).not.toBeNull(); + expect(session.unsavedContents).toBe("my edit"); + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", description: expect.stringContaining("notes.md") }), + ); + expect(dialogText()).toBeUndefined(); + }); + + it("a save that fails for another reason stays and shows the error", async () => { + const persist = vi.fn(async () => AsyncResult.failure(Cause.fail("disk full"))); + const session = openFile(newRegistry(), "notes.md", persist); + session.changeFromRichEditor("my edit"); + + const [answer] = await leave(); + await click("Save all"); + + await expect(answer).resolves.toBe("stay"); + expect(session.getSnapshot().save.phase).toBe("failed"); + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", description: expect.stringContaining("notes.md") }), + ); + }); + + it("Save all keeps the dialog open and shows that it is saving until the writes end", async () => { + let finishWrite!: (result: WriteResult) => void; + const persist = vi.fn( + () => + new Promise((resolve) => { + finishWrite = resolve; + }), + ); + const session = openFile(newRegistry(), "notes.md", persist); + session.changeFromRichEditor("my edit"); + + const [answer] = await leave(); + await click("Save all"); + expect(button("Saving…").disabled).toBe(true); + expect(button("Discard").disabled).toBe(true); + expect(button("Cancel").disabled).toBe(true); + + await act(async () => finishWrite(saved())); + await expect(answer).resolves.toBe("proceed"); + expect(dialogText()).toBeUndefined(); + }); + + it("Discard proceeds without writing", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + const persist = vi.fn(async () => saved()); + const session = openFile(newRegistry(), "notes.md", persist); + session.changeFromRichEditor("my edit"); + + const [answer] = await leave(); + await click("Discard"); + await expect(answer).resolves.toBe("proceed"); + await vi.advanceTimersByTimeAsync(5_000); + expect(persist).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("Cancel stays and keeps the edits", async () => { + const persist = vi.fn(async () => saved()); + const session = openFile(newRegistry(), "notes.md", persist); + session.changeFromRichEditor("my edit"); + + const [answer] = await leave(); + await click("Cancel"); + + await expect(answer).resolves.toBe("stay"); + expect(session.unsavedContents).toBe("my edit"); + expect(dialogText()).toBeUndefined(); + }); + + it("nothing unsaved proceeds at once without a dialog", async () => { + openFile(newRegistry(), "notes.md"); + const [answer] = await leave(); + await expect(answer).resolves.toBe("proceed"); + expect(dialogText()).toBeUndefined(); + }); + + it("typing in rendered view and leaving at once shows the dialog", async () => { + const session = openFile(newRegistry(), "notes.md"); + // The rendered view registers its flusher on the first keystroke and waits 300 ms. + session.setPendingEditFlusher(() => session.changeFromRichEditor("typed")); + + const [answer] = await leave(); + expect(dialogText()).toContain("notes.md"); + await click("Cancel"); + await expect(answer).resolves.toBe("stay"); + }); + + it("the guard works while the Files view is not mounted", async () => { + // No FilePreviewPanel is rendered: the registry alone holds the edit, as when another + // surface is shown in the right panel. + const session = openFile(newRegistry(), "notes.md"); + session.changeFromRichEditor("kept while hidden"); + expect(container.querySelector("[data-file-preview]")).toBeNull(); + + const [answer] = await leave("quit"); + expect(dialogText()).toContain("notes.md"); + await click("Cancel"); + await expect(answer).resolves.toBe("stay"); + }); + + it("Cancel returns focus to the element that had it", async () => { + const session = openFile(newRegistry(), "notes.md"); + session.changeFromRichEditor("my edit"); + const editor = document.createElement("textarea"); + document.body.append(editor); + editor.focus(); + + const [answer] = await leave(); + await click("Cancel"); + await expect(answer).resolves.toBe("stay"); + await vi.waitFor(() => expect(document.activeElement).toBe(editor)); + }); + + it("focus lands on Save all and Escape cancels", async () => { + const session = openFile(newRegistry(), "notes.md"); + session.changeFromRichEditor("my edit"); + + const [answer] = await leave(); + await vi.waitFor(() => expect(document.activeElement).toBe(button("Save all"))); + await act(async () => { + document.activeElement!.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + await expect(answer).resolves.toBe("stay"); + expect(session.unsavedContents).toBe("my edit"); + }); + + describe("a remote update install the window allowed", () => { + let send: (request: DesktopCloseRequest) => void; + let endInstall: () => void; + let replies: [number, string][]; + + beforeEach(async () => { + await act(async () => root.unmount()); + replies = []; + window.desktopBridge = { + onCloseRequest: (listener: (request: DesktopCloseRequest) => void) => { + send = listener; + return () => {}; + }, + replyCloseRequest: async (requestId: number, reply: string) => { + replies.push([requestId, reply]); + }, + onUpdateInstallEnded: (listener: () => void) => { + endInstall = listener; + return () => {}; + }, + } as unknown as DesktopBridge; + root = createRoot(container); + await act(async () => root.render()); + }); + + afterEach(async () => { + // Unblocks whatever a test left blocked, so the next test starts clean. + await act(async () => endInstall()); + delete window.desktopBridge; + }); + + async function requestInstall(requestId: number) { + await act(async () => send({ requestId, reason: "update" })); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + } + + // Longer than the 500 ms autosave delay, so a write that is not held would have started. + const afterTheSaveDelay = () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve, 700)); + }); + + function editorOutsideTheDialog() { + const editor = document.createElement("textarea"); + container.append(editor); + return editor; + } + + it("Discard, then the install fails: saving works again", async () => { + const persist = vi.fn(async () => saved()); + const session = openFile(newRegistry(), "notes.md", persist); + session.changeFromRichEditor("kept edit"); + + await requestInstall(4); + await click("Discard"); + await vi.waitFor(() => expect(replies.at(-1)).toEqual([4, "allow"])); + await afterTheSaveDelay(); + expect(persist).not.toHaveBeenCalled(); + + await act(async () => endInstall()); + await expect(session.flush()).resolves.toBe("saved"); + expect(persist).toHaveBeenCalledWith("notes.md", "kept edit", expect.anything()); + }); + + it("Save all, then an edit before the restart is blocked", async () => { + const persist = vi.fn(async () => saved()); + const session = openFile(newRegistry(), "notes.md", persist); + session.changeFromRichEditor("saved edit"); + const editor = editorOutsideTheDialog(); + + await requestInstall(5); + await click("Save all"); + await vi.waitFor(() => expect(replies.at(-1)).toEqual([5, "allow"])); + expect(dialogText()).toContain("Installing an update"); + // The modal dialog takes the editors out of reach. + await vi.waitFor(() => + expect(editor.closest("[inert], [aria-hidden='true']")).not.toBeNull(), + ); + await act(async () => { + document.activeElement!.dispatchEvent( + new KeyboardEvent("keydown", { key: "Escape", bubbles: true }), + ); + }); + expect(dialogText()).toContain("Installing an update"); + // An edit that still reaches the session is not written before the restart. + session.changeFromRichEditor("late edit"); + await afterTheSaveDelay(); + expect(persist).not.toHaveBeenCalledWith("notes.md", "late edit", expect.anything()); + }); + + it("the install fails after Save all: editing and saving run again", async () => { + const persist = vi.fn(async () => saved()); + const session = openFile(newRegistry(), "notes.md", persist); + session.changeFromRichEditor("saved edit"); + const editor = editorOutsideTheDialog(); + + await requestInstall(6); + await click("Save all"); + await vi.waitFor(() => expect(dialogText()).toContain("Installing an update")); + + await act(async () => endInstall()); + expect(dialogText()).toBeUndefined(); + await vi.waitFor(() => expect(editor.closest("[inert], [aria-hidden='true']")).toBeNull()); + expect(toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ + type: "error", + title: "The update did not install", + description: + "BiBCode did not restart. Files can be edited again. If saving fails, open the Update not installed dialog and choose Restart server.", + }), + ); + session.changeFromRichEditor("next edit"); + await expect(session.flush()).resolves.toBe("saved"); + expect(persist).toHaveBeenCalledWith("notes.md", "next edit", expect.anything()); + }); + + it("with nothing unsaved, an edit started during the install is not written", async () => { + const persist = vi.fn(async () => saved()); + const session = openFile(newRegistry(), "notes.md", persist); + + await requestInstall(7); + await vi.waitFor(() => expect(replies.at(-1)).toEqual([7, "allow"])); + expect(dialogText()).toContain("Installing an update"); + session.changeFromRichEditor("late edit"); + await afterTheSaveDelay(); + expect(persist).not.toHaveBeenCalled(); + }); + + it("asks about the update by name", async () => { + openFile(newRegistry(), "notes.md").changeFromRichEditor("my edit"); + await requestInstall(9); + expect(dialogText()).toContain("Save your changes before updating?"); + }); + + it("an end signal while the question is open drops the request", async () => { + openFile(newRegistry(), "notes.md").changeFromRichEditor("my edit"); + await requestInstall(10); + expect(dialogText()).toContain("notes.md"); + + // The person forced the install from the native prompt, and it failed. + await act(async () => endInstall()); + expect(dialogText()).toBeUndefined(); + await afterTheSaveDelay(); + expect(dialogText()).toBeUndefined(); + expect(replies).not.toContainEqual([10, "allow"]); + }); + + it("cancelling another leave during the install keeps it blocked", async () => { + const session = openFile(newRegistry(), "notes.md"); + session.changeFromRichEditor("kept edit"); + + await requestInstall(8); + await click("Discard"); + await vi.waitFor(() => expect(dialogText()).toContain("Installing an update")); + const [answer] = await leave("quit"); + expect(dialogText()).toContain("notes.md"); + await click("Cancel"); + await expect(answer).resolves.toBe("stay"); + expect(dialogText()).toContain("Installing an update"); + }); + }); + + it("answers a desktop close request through the bridge", async () => { + await act(async () => root.unmount()); + const replies: [number, string][] = []; + let send: ((request: DesktopCloseRequest) => void) | null = null; + window.desktopBridge = { + onCloseRequest: (listener: (request: DesktopCloseRequest) => void) => { + send = listener; + return () => { + send = null; + }; + }, + replyCloseRequest: async (requestId: number, reply: string) => { + replies.push([requestId, reply]); + }, + } as unknown as DesktopBridge; + try { + root = createRoot(container); + await act(async () => root.render()); + const session = openFile(newRegistry(), "notes.md"); + session.changeFromRichEditor("my edit"); + + await act(async () => send!({ requestId: 9, reason: "close" })); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + expect(dialogText()).toContain("notes.md"); + await click("Save all"); + await vi.waitFor(() => expect(replies.at(-1)).toEqual([9, "allow"])); + expect(replies[0]).toEqual([9, "acknowledge"]); + } finally { + delete window.desktopBridge; + } + }); +}); diff --git a/apps/web/src/components/files/UnsavedChangesCloseDialog.tsx b/apps/web/src/components/files/UnsavedChangesCloseDialog.tsx new file mode 100644 index 0000000000..0533e258db --- /dev/null +++ b/apps/web/src/components/files/UnsavedChangesCloseDialog.tsx @@ -0,0 +1,138 @@ +import { useEffect, useRef, useSyncExternalStore } from "react"; + +import { + AlertDialog, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Button } from "../ui/button"; +import { Spinner } from "../ui/spinner"; +import { + attachLeaveQuestionHost, + getLeaveQuestion, + isUpdateInstalling, + respondToDesktopCloseRequests, + subscribeLeaveQuestion, + type LeaveReason, +} from "./unsavedFileEdits"; + +const TITLE: Record = { + close: "Save your changes before leaving?", + quit: "Save your changes before leaving?", + restart: "Save your changes before restarting?", + update: "Save your changes before updating?", +}; + +export interface UnsavedChangesCloseDialogProps { + reason?: LeaveReason; + files: readonly string[]; + /** Save all is running: every choice waits for it. */ + saving?: boolean; + onSaveAll(): void; + onDiscard(): void; + onCancel(): void; +} + +export function UnsavedChangesCloseDialog({ + reason = "close", + files, + saving = false, + onSaveAll, + onDiscard, + onCancel, +}: UnsavedChangesCloseDialogProps) { + const saveAllRef = useRef(null); + return ( + { + if (!open && !saving) onCancel(); + }} + > + + + {TITLE[reason]} + + {files.length === 1 + ? "This file has changes that are not saved. Discard loses them." + : `These ${files.length} files have changes that are not saved. Discard loses them.`} + + +
    + {files.map((file) => ( +
  • + {file} +
  • + ))} +
+ + + + + +
+
+ ); +} + +/** Shown while an allowed update install runs, so no edit is made that the restart would lose. */ +function UpdateInstallingDialog() { + return ( + + + + + + Installing an update + + + Another device started this update. BiBCode restarts when it finishes, and you can work + again after that. + + + + + ); +} + +/** The window's one place that shows the leave question. Mount it once on every route. */ +export function UnsavedChangesCloseHost() { + const question = useSyncExternalStore(subscribeLeaveQuestion, getLeaveQuestion, getLeaveQuestion); + const installing = useSyncExternalStore( + subscribeLeaveQuestion, + isUpdateInstalling, + isUpdateInstalling, + ); + useEffect(() => { + const detach = attachLeaveQuestionHost(); + const bridge = window.desktopBridge; + const stop = bridge ? respondToDesktopCloseRequests(bridge) : undefined; + return () => { + stop?.(); + detach(); + }; + }, []); + if (question === null) return installing ? : null; + return ( + question.answer("save")} + onDiscard={() => question.answer("discard")} + onCancel={() => question.answer("cancel")} + /> + ); +} diff --git a/apps/web/src/components/files/fileEditingSession.disk.test.ts b/apps/web/src/components/files/fileEditingSession.disk.test.ts index 10cf8e326c..b173b2352e 100644 --- a/apps/web/src/components/files/fileEditingSession.disk.test.ts +++ b/apps/web/src/components/files/fileEditingSession.disk.test.ts @@ -100,7 +100,7 @@ describe("FileEditingSession disk reconciliation", () => { expect(session.watchesDisk).toBe(false); expect(session.beginDiskCheck()).toBeNull(); - session.changeOutsideEditor("edited\n"); + session.changeFromRichEditor("edited\n"); await expect(session.flush()).resolves.toBe("saved"); expect(persist).toHaveBeenCalledWith("src/app.ts", "edited\n", null); }); @@ -108,9 +108,9 @@ describe("FileEditingSession disk reconciliation", () => { it("guards each save with the last version it saw and confirms the committed one", async () => { const { session, persist, onConfirmed } = createSession(); - session.changeOutsideEditor("one\n"); + session.changeFromRichEditor("one\n"); await session.flush(); - session.changeOutsideEditor("two\n"); + session.changeFromRichEditor("two\n"); await session.flush(); expect(persist.mock.calls.map((call) => call[2])).toEqual([ @@ -136,7 +136,7 @@ describe("FileEditingSession disk reconciliation", () => { expect(session.getSnapshot()).toMatchObject({ conflict: null, deletedOnDisk: false }); expect(persist).not.toHaveBeenCalled(); - session.changeOutsideEditor("newer\n"); + session.changeFromRichEditor("newer\n"); await session.flush(); expect(persist).toHaveBeenCalledWith("src/app.ts", "newer\n", { _tag: "version", @@ -171,7 +171,7 @@ describe("FileEditingSession disk reconciliation", () => { // The composition ends with its text committed: the deletion is now a conflict. session.setComposing(false); - session.changeOutsideEditor("composed\n"); + session.changeFromRichEditor("composed\n"); check(session, { _tag: "missing" }); expect(session.getSnapshot()).toMatchObject({ conflict: { reason: "deleted", diskKnown: true }, @@ -182,7 +182,7 @@ describe("FileEditingSession disk reconciliation", () => { it("does not read the file again for a conflict whose disk version it already shows", () => { const { session } = createSession(); - session.changeOutsideEditor("mine\n"); + session.changeFromRichEditor("mine\n"); check(session, { _tag: "present", version: "v2" }, { version: "v2", contents: "agent\n" }); const conflict = session.getSnapshot().conflict; @@ -190,19 +190,19 @@ describe("FileEditingSession disk reconciliation", () => { expect(session.getSnapshot().conflict).toBe(conflict); // Edits that come to match the disk text settle the conflict without a read. - session.changeOutsideEditor("agent\n"); + session.changeFromRichEditor("agent\n"); expect(check(session, { _tag: "present", version: "v2" })).toBe("settled"); expect(session.getSnapshot()).toMatchObject({ conflict: null, save: { phase: "clean" } }); // A newer disk version is read as before. - session.changeOutsideEditor("mine again\n"); + session.changeFromRichEditor("mine again\n"); expect(check(session, { _tag: "present", version: "v3" })).toBe("read"); }); it("adopts a disk version whose text already matches the document", async () => { vi.useFakeTimers(); const { session, persist } = createSession(); - session.changeOutsideEditor("same edit\n"); + session.changeFromRichEditor("same edit\n"); check(session, { _tag: "present", version: "v2" }, { version: "v2", contents: "same edit\n" }); @@ -220,7 +220,7 @@ describe("FileEditingSession disk reconciliation", () => { const registry = new FileEditingSessionRegistry(); registry.getOrCreate("src/app.ts", () => session); registry.setActivePath("src/app.ts"); - session.changeOutsideEditor("mine\n"); + session.changeFromRichEditor("mine\n"); const cacheKey = session.cacheKey; check(session, { _tag: "present", version: "v2" }, { version: "v2", contents: "agent\n" }); @@ -241,7 +241,7 @@ describe("FileEditingSession disk reconciliation", () => { registry.setActivePath("src/other.ts"); session.pauseSaving(); session.resumeSaving(); - session.changeOutsideEditor("still mine\n"); + session.changeFromRichEditor("still mine\n"); await vi.runAllTimersAsync(); // Closing the tab keeps the session, so reopening shows the edits and the bar. await registry.reconcile([]); @@ -253,7 +253,7 @@ describe("FileEditingSession disk reconciliation", () => { it("overwrites the disk version the conflict showed, then saves from what it wrote", async () => { const { session, persist, onConflictChange } = createSession(); - session.changeOutsideEditor("mine\n"); + session.changeFromRichEditor("mine\n"); check(session, { _tag: "present", version: "v2" }, { version: "v2", contents: "agent\n" }); session.overwriteDisk(); @@ -266,7 +266,7 @@ describe("FileEditingSession disk reconciliation", () => { await vi.waitFor(() => expect(session.getSnapshot().save.phase).toBe("clean")); expect(session.getSnapshot().conflict).toBeNull(); expect(onConflictChange).toHaveBeenLastCalledWith("src/app.ts", false); - session.changeOutsideEditor("later\n"); + session.changeFromRichEditor("later\n"); await session.flush(); expect(persist).toHaveBeenLastCalledWith("src/app.ts", "later\n", { _tag: "version", @@ -291,7 +291,7 @@ describe("FileEditingSession disk reconciliation", () => { it("recreates a deleted file only with a missing precondition", async () => { const { session, persist } = createSession(); - session.changeOutsideEditor("mine\n"); + session.changeFromRichEditor("mine\n"); check(session, { _tag: "missing" }); expect(session.getSnapshot().conflict).toMatchObject({ reason: "deleted", diskKnown: true }); @@ -314,7 +314,7 @@ describe("FileEditingSession disk reconciliation", () => { it("discards edits for a deleted file when the user reloads from disk", () => { const { session, persist } = createSession(); - session.changeOutsideEditor("mine\n"); + session.changeFromRichEditor("mine\n"); check(session, { _tag: "missing" }); session.reloadFromDisk(); @@ -334,12 +334,12 @@ describe("FileEditingSession disk reconciliation", () => { persist.mockReturnValueOnce(write.promise); const tokenBeforeSave = session.beginDiskCheck()!; - session.changeOutsideEditor("one\n"); + session.changeFromRichEditor("one\n"); const flushed = session.flush(); await vi.waitFor(() => expect(persist).toHaveBeenCalledOnce()); expect(session.beginDiskCheck()).toBeNull(); // The user keeps typing while the save is in flight. - session.changeOutsideEditor("two\n"); + session.changeFromRichEditor("two\n"); write.resolve(saved("v-one")); await flushed; @@ -362,7 +362,7 @@ describe("FileEditingSession disk reconciliation", () => { const requested = vi.fn(); session.subscribeDiskCheckRequests(requested); persist.mockResolvedValueOnce(refused("file_changed_on_disk")); - session.changeOutsideEditor("mine\n"); + session.changeFromRichEditor("mine\n"); await expect(session.flush()).resolves.toBe("failed"); @@ -379,7 +379,7 @@ describe("FileEditingSession disk reconciliation", () => { it("releases a conflict when the disk returns to the version the edits were based on", async () => { const { session, persist } = createSession(); - session.changeOutsideEditor("mine\n"); + session.changeFromRichEditor("mine\n"); check(session, { _tag: "present", version: "v2" }, { version: "v2", contents: "agent\n" }); expect(check(session, { _tag: "present", version: "v1" })).toBe("settled"); @@ -391,4 +391,36 @@ describe("FileEditingSession disk reconciliation", () => { version: "v1", }); }); + + it("a rendered edit that starts waiting during a disk read makes a newer disk version a conflict", async () => { + const { session, persist } = createSession(); + const token = session.beginDiskCheck()!; + expect(session.reconcileVersion(token, { _tag: "present", version: "v2" })).toBe("read"); + session.setPendingEditFlusher(() => { + session.setPendingEditFlusher(null); + session.changeFromRichEditor("mine\n"); + }); + + session.reconcileRead(token, { version: "v2", contents: "theirs\n" }); + + expect(session.getSnapshot().conflict?.reason).toBe("changed"); + expect(session.unsavedContents).toBe("mine\n"); + await session.flush(); + expect(persist).not.toHaveBeenCalled(); + }); + + it("a rendered edit that starts waiting during a disk check makes a deletion a conflict", () => { + const { session } = createSession(); + const token = session.beginDiskCheck()!; + session.setPendingEditFlusher(() => { + session.setPendingEditFlusher(null); + session.changeFromRichEditor("mine\n"); + }); + + expect(session.reconcileVersion(token, { _tag: "missing" })).toBe("settled"); + + expect(session.getSnapshot()).toMatchObject({ deletedOnDisk: false }); + expect(session.getSnapshot().conflict?.reason).toBe("deleted"); + expect(session.unsavedContents).toBe("mine\n"); + }); }); diff --git a/apps/web/src/components/files/fileEditingSession.test.ts b/apps/web/src/components/files/fileEditingSession.test.ts index 7c68f8ba44..7fe6ad1c8a 100644 --- a/apps/web/src/components/files/fileEditingSession.test.ts +++ b/apps/web/src/components/files/fileEditingSession.test.ts @@ -61,7 +61,7 @@ describe("FileEditingSession", () => { }); expect(pierre.instances).toHaveLength(0); - session.changeOutsideEditor("- [x] rendered-only edit\n"); + session.changeFromRichEditor("- [x] rendered-only edit\n"); expect(pierre.instances).toHaveLength(0); const editor = session.editor; @@ -90,7 +90,7 @@ describe("FileEditingSession", () => { expect(editor.undo).toHaveBeenCalledOnce(); }); - it("retains its editor's edited document, and an outside change only until it is saved", async () => { + it("retains its editor's edited document after it is saved", async () => { const session = new FileEditingSession({ cwd: "/repo", relativePath: "README.md", @@ -108,13 +108,6 @@ describe("FileEditingSession", () => { expect(session.retainedContents).toBe("- [ ] typed\n"); await expect(session.flush()).resolves.toBe("saved"); expect(session.retainedContents).toBe("- [ ] typed\n"); - - // A rendered-view toggle replaces the editor document; once saved, a newer - // read of the file seeds the next one. - session.changeOutsideEditor("- [x] typed\n"); - expect(session.retainedContents).toBe("- [x] typed\n"); - await expect(session.flush()).resolves.toBe("saved"); - expect(session.retainedContents).toBeNull(); }); it("exposes its contents as unsaved until a save of them is confirmed", async () => { @@ -174,29 +167,6 @@ describe("FileEditingSession", () => { expect(persist).toHaveBeenCalledWith("src/new.ts", "renamed contents", null); }); - it("invalidates existing source history without eagerly constructing a replacement", () => { - const session = new FileEditingSession({ - cwd: "/repo", - relativePath: "README.md", - debounceMs: 500, - persist: vi.fn(async () => AsyncResult.success(undefined)), - onPendingChange: vi.fn(), - onConfirmed: vi.fn(), - }); - const firstEditor = session.editor; - const firstCacheKey = session.cacheKey; - - session.changeOutsideEditor("- [x] done\n"); - - expect(firstEditor.cleanUp).toHaveBeenCalledOnce(); - expect(pierre.instances).toEqual([firstEditor]); - expect(session.cacheKey).not.toBe(firstCacheKey); - expect(session.getSnapshot().save.phase).toBe("pending"); - expect(session.getSnapshot().canUndo).toBe(false); - expect(session.editor).not.toBe(firstEditor); - expect(pierre.instances).toHaveLength(2); - }); - it("migrates a pending marker from the old file path to the renamed path", () => { const onPendingChange = vi.fn(); const session = new FileEditingSession({ @@ -755,3 +725,307 @@ describe("FileEditingSession", () => { lease!.release(); }); }); + +interface FakeSourceEditor { + readonly editor: InstanceType; + type(text: string): void; + text(): string; + setAttached(attached: boolean): void; +} + +interface FakeTextEdit { + readonly range: { + readonly start: { line: number; character: number }; + readonly end: { line: number; character: number }; + }; + readonly newText: string; +} + +/** Gives the fake Pierre editor a document, an undo stack and an attach state. */ +function fakeSourceHistory(session: FileEditingSession, initial: string): FakeSourceEditor { + const editor = session.editor as unknown as InstanceType & + Record; + const onChange = editor.options["onChange"] as (file: { contents: string }) => void; + let text = initial; + let attached = true; + const history: string[] = []; + const offsetAt = ({ line, character }: { line: number; character: number }) => + text + .split("\n") + .slice(0, line) + .reduce((sum, current) => sum + current.length + 1, 0) + character; + editor["getText"] = () => (attached ? text : ""); + editor["getFile"] = () => (attached ? { name: "README.md", contents: text } : undefined); + editor["applyEdits"] = (edits: FakeTextEdit[], _history: boolean, undoBoundary: boolean) => { + if (!attached) throw new Error("Editor is not attached"); + expect(undoBoundary).toBe(true); + history.push(text); + const resolved = edits + .map((edit) => ({ + start: offsetAt(edit.range.start), + end: offsetAt(edit.range.end), + newText: edit.newText, + })) + .sort((a, b) => b.start - a.start); + for (const edit of resolved) { + text = text.slice(0, edit.start) + edit.newText + text.slice(edit.end); + } + onChange({ contents: text }); + }; + editor.undo.mockImplementation(() => { + const previous = history.pop(); + if (previous === undefined) return; + text = previous; + onChange({ contents: text }); + }); + editor.canUndo = true; + return { + editor, + type: (next) => { + history.push(text); + text = next; + onChange({ contents: text }); + }, + text: () => text, + setAttached: (next) => { + attached = next; + if (next) (editor.options["onAttach"] as () => void)(); + }, + }; +} + +describe("FileEditingSession rich edits", () => { + const createSession = (persist = vi.fn(async () => AsyncResult.success(undefined))) => + new FileEditingSession({ + cwd: "/repo", + relativePath: "README.md", + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + + beforeEach(() => { + vi.useRealTimers(); + pierre.instances.length = 0; + }); + + it("a rich edit keeps the source editor and its history", () => { + const session = createSession(); + const source = fakeSourceHistory(session, "one\n"); + source.type("one two\n"); + source.type("one two three\n"); + + session.changeFromRichEditor("# one two three\n"); + session.commitRichEdits(); + + expect(session.editor).toBe(source.editor); + expect(source.editor.cleanUp).not.toHaveBeenCalled(); + expect(session.unsavedContents).toBe("# one two three\n"); + expect(source.text()).toBe("# one two three\n"); + session.undo(); + expect(source.text()).toBe("one two three\n"); + session.undo(); + expect(source.text()).toBe("one two\n"); + session.undo(); + expect(source.text()).toBe("one\n"); + }); + + it("several rich edits in one visit are one undo step", () => { + const session = createSession(); + const source = fakeSourceHistory(session, "start\n"); + + session.changeFromRichEditor("start a\n"); + session.changeFromRichEditor("start ab\n"); + session.changeFromRichEditor("start abc\n"); + session.commitRichEdits(); + + expect(source.text()).toBe("start abc\n"); + session.undo(); + expect(source.text()).toBe("start\n"); + }); + + it("a commit on a detached editor lands on the next attach, one undo step per visit", () => { + const session = createSession(); + const source = fakeSourceHistory(session, "start\n"); + source.type("start typed\n"); + source.setAttached(false); + + session.changeFromRichEditor("first visit\n"); + session.commitRichEdits(); + session.changeFromRichEditor("second visit\n"); + session.commitRichEdits(); + expect(source.editor.cleanUp).not.toHaveBeenCalled(); + + source.setAttached(true); + expect(source.text()).toBe("second visit\n"); + expect(session.getSnapshot().save.phase).toBe("pending"); + session.undo(); + expect(source.text()).toBe("first visit\n"); + session.undo(); + expect(source.text()).toBe("start typed\n"); + }); + + it("a visit saved before the switch reaches the source editor without another save", async () => { + const persist = vi.fn(async () => AsyncResult.success(undefined)); + const session = createSession(persist); + const source = fakeSourceHistory(session, "start\n"); + source.setAttached(false); + + session.changeFromRichEditor("saved visit\n"); + await expect(session.flush()).resolves.toBe("saved"); + session.commitRichEdits(); + source.setAttached(true); + + expect(source.text()).toBe("saved visit\n"); + expect(session.getSnapshot().save.phase).toBe("clean"); + expect(persist).toHaveBeenCalledOnce(); + }); + + it("a visit the source editor refuses drops that editor instead of keeping its old text", () => { + const session = createSession(); + const source = fakeSourceHistory(session, "start\n"); + source.setAttached(false); + (source.editor as unknown as Record)["applyEdits"] = () => { + throw new Error("refused"); + }; + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + + session.changeFromRichEditor("rich text\n"); + session.commitRichEdits(); + source.setAttached(true); + reportError.mockRestore(); + + expect(source.editor.cleanUp).toHaveBeenCalledOnce(); + expect(session.editor).not.toBe(source.editor); + expect(session.documentContents).toBe("rich text\n"); + }); + + it("a rich edit marks the file as having unsaved changes and flush saves it", async () => { + const persist = vi.fn(async () => AsyncResult.success(undefined)); + const session = createSession(persist); + + session.changeFromRichEditor("rich text\n"); + + expect(session.getSnapshot().save.phase).toBe("pending"); + await expect(session.flush()).resolves.toBe("saved"); + expect(persist).toHaveBeenCalledWith("README.md", "rich text\n", null); + }); + + it("commitRichEdits without a source editor does nothing", () => { + const session = createSession(); + + session.changeFromRichEditor("rich only\n"); + session.commitRichEdits(); + + expect(pierre.instances).toHaveLength(0); + expect(session.documentContents).toBe("rich only\n"); + }); + + it("a delivery that throws is kept and retried, and flush and settle report it as failed", async () => { + const persist = vi.fn(async () => AsyncResult.success(undefined)); + const session = createSession(persist); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let failures = 2; + session.setPendingEditFlusher(() => { + if (failures-- > 0) throw new Error("delivery failed"); + session.changeFromRichEditor("retried\n"); + }); + + await expect(session.flush()).resolves.toBe("failed"); + await expect(session.settle()).resolves.toBe("failed"); + expect(session.getSnapshot().editPending).toBe(true); + expect(persist).not.toHaveBeenCalled(); + await expect(session.flush()).resolves.toBe("saved"); + reportError.mockRestore(); + + expect(persist).toHaveBeenCalledWith("README.md", "retried\n", null); + expect(session.getSnapshot().editPending).toBe(false); + }); + + it("disposing the registry retries a close whose delivery failed and writes the edit", async () => { + const persist = vi.fn(async () => AsyncResult.success(undefined)); + const registry = new FileEditingSessionRegistry>(); + const session = registry.getOrCreate("README.md", () => createSession(persist)); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + let failures = 1; + session.setPendingEditFlusher(() => { + if (failures-- > 0) throw new Error("delivery failed"); + session.changeFromRichEditor("kept\n"); + }); + + await registry.reconcile([]); + expect(persist).not.toHaveBeenCalled(); + await registry.dispose(); + reportError.mockRestore(); + + expect(persist).toHaveBeenCalledWith("README.md", "kept\n", null); + }); + + it("disposing the registry takes the whole serialization when the delivery keeps failing", async () => { + const persist = vi.fn(async () => AsyncResult.success(undefined)); + const registry = new FileEditingSessionRegistry>(); + const session = registry.getOrCreate("README.md", () => createSession(persist)); + const reportError = vi.spyOn(console, "error").mockImplementation(() => undefined); + session.setPendingEditFlusher((whole) => { + if (!whole) throw new Error("reconcile failed"); + session.changeFromRichEditor("whole\n"); + }); + + await registry.dispose(); + reportError.mockRestore(); + + expect(persist).toHaveBeenCalledWith("README.md", "whole\n", null); + }); + + it("the pending edit flusher runs before flush, settle, a view commit, a disk check, a file switch and a path mutation", async () => { + const persist = vi.fn(async () => AsyncResult.success(undefined)); + const registry = new FileEditingSessionRegistry>(); + const session = registry.getOrCreate("README.md", () => createSession(persist)); + registry.getOrCreate("other.md", () => createSession(persist)); + const waitWith = (text: string) => { + const flusher = vi.fn(() => { + session.setPendingEditFlusher(null); + session.changeFromRichEditor(text); + }); + session.setPendingEditFlusher(flusher); + return flusher; + }; + + const onFlush = waitWith("flushed\n"); + expect(session.getSnapshot().editPending).toBe(true); + await expect(session.flush()).resolves.toBe("saved"); + expect(onFlush).toHaveBeenCalledOnce(); + expect(session.getSnapshot().editPending).toBe(false); + expect(persist).toHaveBeenLastCalledWith("README.md", "flushed\n", null); + + waitWith("settled\n"); + await expect(session.settle()).resolves.toBe("saved"); + expect(persist).toHaveBeenLastCalledWith("README.md", "settled\n", null); + + waitWith("committed\n"); + session.commitRichEdits(); + expect(session.unsavedContents).toBe("committed\n"); + + waitWith("checked\n"); + session.beginDiskCheck(); + expect(session.unsavedContents).toBe("checked\n"); + + registry.setActivePath("README.md"); + waitWith("switched\n"); + registry.setActivePath("other.md"); + expect(session.unsavedContents).toBe("switched\n"); + await vi.waitFor(() => + expect(persist).toHaveBeenLastCalledWith("README.md", "switched\n", null), + ); + + waitWith("renamed\n"); + const lease = await registry.beginPathMutation({ + kind: "rename", + fromRelativePath: "README.md", + toRelativePath: "GUIDE.md", + }); + expect(persist).toHaveBeenLastCalledWith("README.md", "renamed\n", null); + lease?.release(); + }); +}); diff --git a/apps/web/src/components/files/fileEditingSession.ts b/apps/web/src/components/files/fileEditingSession.ts index 01cb553138..a8a100f88d 100644 --- a/apps/web/src/components/files/fileEditingSession.ts +++ b/apps/web/src/components/files/fileEditingSession.ts @@ -41,6 +41,8 @@ export interface FileEditingSessionSnapshot { readonly conflict: FileDiskConflict | null; /** The file is gone and nothing unsaved is left to show. */ readonly deletedOnDisk: boolean; + /** A rendered-view edit waits to be delivered: the file has unsaved changes. */ + readonly editPending: boolean; } /** The disk snapshot a session's file was read from. */ @@ -149,6 +151,14 @@ export class FileEditingSession { /** Set around a disk reload, whose editor change is adopted instead of recorded. */ private applyingDiskText = false; private composing = false; + /** Delivers a rendered-view edit that still waits on its debounce. */ + private pendingEditFlusher: ((whole: boolean) => void) | null = null; + /** Rendered-view edits not yet closed into the source editor's history. */ + private richEditsPending = false; + /** The text each closed rendered visit ended with, applied when the source editor attaches. */ + private closedRichVisits: string[] = []; + /** Set around applying closed visits, whose text the coordinator already holds. */ + private applyingRichText = false; constructor(private readonly options: FileEditingSessionOptions) { this.cwd = options.cwd; @@ -229,14 +239,17 @@ export class FileEditingSession { const editorOptions: EditorOptions = { persistState: true, onAttach: () => { - if (!this.disposed) this.publish(); + if (this.disposed) return; + this.applyClosedRichVisits(); + this.publish(); }, onChange: (file, lineAnnotations) => { if (this.disposed) return; this.editorChangedDocument = true; - // A reload's text is already on disk: adopt it rather than save it back. + // A reload's text is already on disk: adopt it rather than save it back. A closed + // rendered visit's text is already recorded, saved or not: leave the coordinator alone. if (this.applyingDiskText) this.coordinator.adopt(file.contents); - else this.coordinator.change(file.contents); + else if (!this.applyingRichText) this.coordinator.change(file.contents); this.editorChangeHandler?.(file, lineAnnotations); this.publish(); }, @@ -251,6 +264,7 @@ export class FileEditingSession { canRedo: this.editorInstance?.canRedo ?? false, conflict: this.conflict?.view ?? null, deletedOnDisk: this.deletedOnDisk, + editPending: this.pendingEditFlusher !== null, }; } @@ -261,7 +275,8 @@ export class FileEditingSession { next.canUndo === this.snapshot.canUndo && next.canRedo === this.snapshot.canRedo && next.conflict === this.snapshot.conflict && - next.deletedOnDisk === this.snapshot.deletedOnDisk + next.deletedOnDisk === this.snapshot.deletedOnDisk && + next.editPending === this.snapshot.editPending ) { return; } @@ -290,13 +305,99 @@ export class FileEditingSession { } async flush(): Promise { + // An edit that could not be delivered is not saved, so the caller must not treat it as such. + if (!this.deliverPendingEdit()) return "failed"; return this.coordinator.flush(); } - async settle(): Promise { + /** + * With `lastResort`, for a session about to be disposed, an edit that still cannot be + * reconciled is delivered whole rather than lost. + */ + async settle(lastResort = false): Promise { + if (!this.deliverPendingEdit() && !(lastResort && this.deliverPendingEdit(true))) { + return "failed"; + } return this.coordinator.settle(); } + /** + * Registers the rendered view's function that delivers an edit still waiting on its + * debounce, or clears it. Every operation that reads or replaces the document calls it first. + * Called with `whole`, it delivers the editor's whole serialization instead of a reconcile. + */ + setPendingEditFlusher(flush: ((whole: boolean) => void) | null): void { + if (this.disposed) return; + this.pendingEditFlusher = flush; + this.publish(); + } + + /** Records a rendered-view edit without touching the source editor or its history. */ + changeFromRichEditor(contents: string): void { + if (this.disposed) return; + this.coordinator.change(contents); + this.richEditsPending = true; + this.publish(); + } + + /** + * Closes a rendered visit: its edits reach the source editor as one undo step, now when the + * editor is attached, else when it next attaches. Without a source editor nothing is needed, + * because the next one seeds from the session's text. + */ + commitRichEdits(): void { + this.deliverPendingEdit(); + if (this.disposed || !this.richEditsPending) return; + this.richEditsPending = false; + if (this.editorInstance === null) return; + this.closedRichVisits.push(this.localText()); + this.applyClosedRichVisits(); + this.publish(); + } + + /** + * Delivers an edit still waiting on the rendered view's debounce. Returns false when the + * delivery threw: the flusher stays registered, so the edit stays unsaved and a later call + * retries it. + */ + private deliverPendingEdit(whole = false): boolean { + const flush = this.pendingEditFlusher; + if (flush === null || this.disposed) return true; + try { + flush(whole); + } catch (error) { + console.error("[file-editing-session] delivering a rendered edit failed", error); + return false; + } + if (this.pendingEditFlusher === flush) this.pendingEditFlusher = null; + this.publish(); + return true; + } + + private applyClosedRichVisits(): void { + const editor = this.editorInstance; + if (this.closedRichVisits.length === 0 || editor === null || editor.getFile() === undefined) { + return; + } + const visits = this.closedRichVisits; + this.closedRichVisits = []; + this.applyingRichText = true; + let applied = false; + try { + for (const text of visits) { + editor.applyEdits(diskReloadEdits(editor.getText(), text), true, true); + } + applied = editor.getText() === this.localText(); + } catch (error) { + console.error("[file-editing-session] applying rendered edits to the editor failed", error); + } finally { + this.applyingRichText = false; + } + // An editor still holding older text would save it over the rendered edits: lose its + // history instead, and the next attach seeds from the session's text. + if (!applied) this.discardEditor(); + } + setAutosaveEnabled(enabled: boolean): void { this.coordinator.setAutosaveEnabled(enabled); } @@ -326,6 +427,7 @@ export class FileEditingSession { } rename(relativePath: string): void { + this.deliverPendingEdit(); if (relativePath === this.currentRelativePath) return; const previousRelativePath = this.currentRelativePath; const pending = this.coordinator.hasPendingWork(); @@ -337,13 +439,6 @@ export class FileEditingSession { if (conflicted) this.options.onConflictChange?.(relativePath, true); } - changeOutsideEditor(contents: string): void { - if (this.disposed) return; - this.discardEditor(); - this.coordinator.change(contents); - this.publish(); - } - /** Asks the disk watcher, when one is attached, to check the file now. */ requestDiskCheck(): void { for (const listener of this.diskCheckListeners) listener(); @@ -362,6 +457,9 @@ export class FileEditingSession { * (no versions, a save in flight, or saving paused by a rename or delete in progress). */ beginDiskCheck(): number | null { + // An edit still waiting must count as unsaved, so a disk change becomes a conflict. One + // that cannot be delivered blocks the check, so no disk result replaces it. + if (!this.deliverPendingEdit()) return null; return this.canReconcile() ? this.saveGeneration : null; } @@ -371,7 +469,8 @@ export class FileEditingSession { * the base, and the next check re-reads. */ reconcileVersion(token: number, disk: ProjectFileVersion): "settled" | "read" | "stale" { - if (!this.isCurrentCheck(token)) return "stale"; + // An edit that started waiting after the check began must also count as unsaved. + if (!this.deliverPendingEdit() || !this.isCurrentCheck(token)) return "stale"; const base = this.base!; switch (disk._tag) { case "missing": @@ -412,7 +511,9 @@ export class FileEditingSession { /** Reconciles with a disk read made for the check that `token` started. */ reconcileRead(token: number, read: FileDiskRead): void { - if (!this.isCurrentCheck(token) || read.version === undefined) return; + if (!this.deliverPendingEdit() || !this.isCurrentCheck(token) || read.version === undefined) { + return; + } const disk = { kind: "present", version: read.version, contents: read.contents } as const; if (read.contents === this.localText()) { // Our own save, or the same edit made elsewhere. @@ -433,6 +534,7 @@ export class FileEditingSession { * source editor attached the reload is one undo step, so Undo brings the edits back. */ reloadFromDisk(): void { + if (!this.deliverPendingEdit()) return; const disk = this.conflict?.disk; if (this.disposed || disk == null) return; if (disk.kind === "present") this.applyDiskText(disk); @@ -441,6 +543,7 @@ export class FileEditingSession { /** Resolves a conflict by saving the document over the disk state the user was shown. */ overwriteDisk(): void { + if (!this.deliverPendingEdit()) return; const disk = this.conflict?.disk; if (this.disposed || this.conflict === null) return; if (disk == null) { @@ -606,6 +709,8 @@ export class FileEditingSession { this.editorInstance?.cleanUp(); this.editorInstance = null; this.editorChangedDocument = false; + this.closedRichVisits = []; + this.richEditsPending = false; this.currentCacheKey = createSessionCacheKey(this.cwd); } } diff --git a/apps/web/src/components/files/fileEditingSessionRegistry.test.ts b/apps/web/src/components/files/fileEditingSessionRegistry.test.ts index 941b800a15..ef5460b9e2 100644 --- a/apps/web/src/components/files/fileEditingSessionRegistry.test.ts +++ b/apps/web/src/components/files/fileEditingSessionRegistry.test.ts @@ -1,8 +1,10 @@ import { afterEach, describe, expect, it, vi } from "vite-plus/test"; +import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { FileEditingSession } from "./fileEditingSession"; import { FileEditingSessionRegistry } from "./fileEditingSessionRegistry"; +import { windowUnsavedFiles } from "./unsavedFileEdits"; function fakeSession(relativePath: string) { return { @@ -17,9 +19,25 @@ function fakeSession(relativePath: string) { this.relativePath = next; }), dispose: vi.fn(), + unsavedContents: null as string | null, + getSnapshot: () => ({ editPending: false }), }; } +function realSession( + relativePath: string, + persist = vi.fn(async () => AsyncResult.success(undefined)), +) { + return new FileEditingSession({ + cwd: "/repo", + relativePath, + debounceMs: 500, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); +} + function deferredResult() { let resolve!: (value: T) => void; const promise = new Promise((resolvePromise) => { @@ -155,7 +173,7 @@ describe("FileEditingSessionRegistry", () => { toRelativePath: "src/renamed.ts", }); - session.changeOutsideEditor("paused draft"); + session.changeFromRichEditor("paused draft"); registry.setActivePath(null); expect(persist).not.toHaveBeenCalled(); @@ -189,7 +207,7 @@ describe("FileEditingSessionRegistry", () => { toRelativePath: "src/renamed.ts", }); - session.changeOutsideEditor("paused draft"); + session.changeFromRichEditor("paused draft"); const flush = vi.spyOn(session, "flush"); registry.setActivePath(null); const pausedFlush = flush.mock.results[0]!.value; @@ -235,7 +253,7 @@ describe("FileEditingSessionRegistry", () => { toRelativePath: "src/renamed.ts", }); - session.changeOutsideEditor("paused draft"); + session.changeFromRichEditor("paused draft"); registry.setActivePath(null); lease!.release(); await vi.advanceTimersByTimeAsync(0); @@ -814,4 +832,112 @@ describe("FileEditingSessionRegistry", () => { reportError.mockRestore(); } }); + + describe("unsaved sessions", () => { + it("lists a source edit, an edit inside the rendered-view delay and an undelivered edit", () => { + vi.useFakeTimers(); + const registry = new FileEditingSessionRegistry>(); + const source = registry.getOrCreate("a.md", () => realSession("a.md")); + const waiting = registry.getOrCreate("b.md", () => realSession("b.md")); + const undelivered = registry.getOrCreate("c.md", () => realSession("c.md")); + registry.getOrCreate("clean.md", () => realSession("clean.md")); + + source.changeFromRichEditor("source edit"); + waiting.setPendingEditFlusher(() => {}); + undelivered.setPendingEditFlusher(() => { + throw new Error("the rendered view could not serialize"); + }); + vi.spyOn(console, "error").mockImplementation(() => {}); + void undelivered.flush(); + + expect(registry.unsavedSessions()).toEqual([ + { path: "a.md" }, + { path: "b.md" }, + { path: "c.md" }, + ]); + }); + + it("Save all writes every unsaved session and reports saved", async () => { + const persist = vi.fn(async () => AsyncResult.success(undefined)); + const registry = new FileEditingSessionRegistry>(); + const rendered = registry.getOrCreate("a.md", () => realSession("a.md", persist)); + const source = registry.getOrCreate("b.md", () => realSession("b.md", persist)); + rendered.setPendingEditFlusher(() => rendered.changeFromRichEditor("rendered edit")); + source.changeFromRichEditor("source edit"); + + await expect(registry.saveAll()).resolves.toBe("saved"); + + expect(persist).toHaveBeenCalledWith("a.md", "rendered edit", null); + expect(persist).toHaveBeenCalledWith("b.md", "source edit", null); + expect(registry.unsavedSessions()).toEqual([]); + }); + + it("Save all reports blocked and keeps the edit when a save fails", async () => { + const persist = vi.fn(async () => AsyncResult.failure(Cause.fail("disk full"))); + const registry = new FileEditingSessionRegistry>(); + const session = registry.getOrCreate("a.md", () => realSession("a.md", persist as never)); + session.changeFromRichEditor("edit"); + + await expect(registry.saveAll()).resolves.toBe("blocked"); + expect(registry.unsavedSessions()).toEqual([{ path: "a.md" }]); + }); + + it("a leave hold stops every write until it is released, even when saving is enabled again", async () => { + vi.useFakeTimers(); + const persist = vi.fn(async () => AsyncResult.success(undefined)); + const registry = new FileEditingSessionRegistry>(); + const session = registry.getOrCreate("a.md", () => realSession("a.md", persist)); + session.changeFromRichEditor("discarded edit"); + + const release = registry.holdSavingForLeave(); + registry.setSavingEnabled(false); + registry.setSavingEnabled(true); + const added = registry.getOrCreate("b.md", () => realSession("b.md", persist)); + added.changeFromRichEditor("another discarded edit"); + await vi.advanceTimersByTimeAsync(5_000); + expect(persist).not.toHaveBeenCalled(); + + release(); + await vi.advanceTimersByTimeAsync(5_000); + expect(persist).toHaveBeenCalledTimes(2); + }); + + it("joins the window-wide set with its first session and leaves it when its dispose finishes", async () => { + const registry = new FileEditingSessionRegistry>(); + expect(windowUnsavedFiles()).not.toContainEqual({ path: "joined.md" }); + const session = registry.getOrCreate("joined.md", () => realSession("joined.md")); + session.changeFromRichEditor("edit"); + expect(windowUnsavedFiles()).toContainEqual({ path: "joined.md" }); + + await registry.dispose(); + expect(windowUnsavedFiles()).not.toContainEqual({ path: "joined.md" }); + }); + + it("a disposing registry lists a session until its settle ends, and Save all waits for it", async () => { + const write = deferredResult>>(); + const persist = vi.fn(() => write.promise); + const registry = new FileEditingSessionRegistry>(); + const session = registry.getOrCreate("leaving.md", () => + realSession("leaving.md", persist as never), + ); + session.changeFromRichEditor("edit"); + + const disposed = registry.dispose(); + await vi.waitFor(() => expect(persist).toHaveBeenCalledOnce()); + expect(registry.unsavedSessions()).toEqual([{ path: "leaving.md" }]); + expect(windowUnsavedFiles()).toContainEqual({ path: "leaving.md" }); + + let saved: string | null = null; + void registry.saveAll().then((result) => { + saved = result; + }); + await Promise.resolve(); + expect(saved).toBeNull(); + + write.resolve(AsyncResult.success(undefined)); + await disposed; + await vi.waitFor(() => expect(saved).toBe("saved")); + expect(windowUnsavedFiles()).not.toContainEqual({ path: "leaving.md" }); + }); + }); }); diff --git a/apps/web/src/components/files/fileEditingSessionRegistry.ts b/apps/web/src/components/files/fileEditingSessionRegistry.ts index 444e0be831..77522dfdee 100644 --- a/apps/web/src/components/files/fileEditingSessionRegistry.ts +++ b/apps/web/src/components/files/fileEditingSessionRegistry.ts @@ -1,16 +1,23 @@ import type { FileSaveFlushResult, FileSaveSettleResult } from "./fileSaveCoordinator"; import type { FilePathMutationLease, FilePathMutationRequest } from "./filePathMutationLease"; +import { registerUnsavedSource } from "./unsavedFileEdits"; export interface ManagedFileEditingSession { relativePath: string; flush(): Promise; - settle(): Promise; + /** `lastResort` is for a session about to be disposed: it keeps an edit it cannot place. */ + settle(lastResort?: boolean): Promise; setAutosaveEnabled(enabled: boolean): void; pauseSaving(): void; resumeSaving(): void; discardPendingSave(): void; rename(relativePath: string): void; dispose(): void; + // Optional so session fakes that never edit stay valid; `FileEditingSession` has both. + /** Non-null while a write is pending, in flight, failed or paused. */ + readonly unsavedContents?: string | null; + /** `editPending` is true while a rendered-view edit waits on its delay or failed to deliver. */ + getSnapshot?(): { readonly editPending: boolean }; } function isPathAtOrUnder(candidate: string, parent: string): boolean { @@ -62,6 +69,13 @@ export class FileEditingSessionRegistry< private ownerCount = 0; private activeRelativePath: string | null = null; private savingEnabled = true; + /** Held while the window leaves after Discard, so no write lands before it is gone. */ + private leaveHolds = 0; + /** Leaves the window-wide set; set while this registry owns sessions. */ + private leaveWindowSet: (() => void) | null = null; + /** Sessions a dispose is still settling, by the path they had. */ + private readonly disposingSessions = new Map(); + private disposeSaveFailed = false; private ownershipGeneration = 0; private disposed = false; private disposePromise: Promise | null = null; @@ -79,7 +93,8 @@ export class FileEditingSessionRegistry< } const session = create(); this.sessions.set(relativePath, session); - if (!this.savingEnabled) session.pauseSaving(); + if (!this.disposed) this.leaveWindowSet ??= registerUnsavedSource(this); + if (!this.writesAllowed()) session.pauseSaving(); if (relativePath === this.activeRelativePath) session.setAutosaveEnabled(false); for (const mutation of this.activePathMutations) { if (!mutation.scopePaths.some((path) => isPathAtOrUnder(relativePath, path))) continue; @@ -111,8 +126,58 @@ export class FileEditingSessionRegistry< setSavingEnabled(enabled: boolean): void { if (this.disposed || this.savingEnabled === enabled) return; this.savingEnabled = enabled; + this.applySavingState(); + } + + /** The sessions holding an edit that is not on disk. */ + unsavedSessions(): { path: string }[] { + const unsaved = (session: Session) => + session.unsavedContents != null || session.getSnapshot?.().editPending === true; + return [ + ...[...this.sessions.entries()].filter(([, session]) => unsaved(session)), + ...[...this.disposingSessions.entries()] + .filter(([session]) => unsaved(session)) + .map(([session, path]) => [path, session] as const), + ].map(([path]) => ({ path })); + } + + /** Writes every session through the normal save path; "blocked" when any write failed. */ + async saveAll(): Promise<"saved" | "blocked"> { + const results = await Promise.all( + [...this.sessions.values()].map((session) => + session.settle().catch((error: unknown) => { + this.reportSessionCleanupError(error); + return "failed" as const; + }), + ), + ); + // A dispose already settles its sessions; wait for it instead of saving them twice. + if (this.disposePromise !== null) await this.disposePromise; + return results.includes("failed") || this.disposeSaveFailed ? "blocked" : "saved"; + } + + /** Pauses every write until the returned release runs, whatever else enables saving. */ + holdSavingForLeave(): () => void { + this.leaveHolds += 1; + this.applySavingState(); + let released = false; + return () => { + if (released) return; + released = true; + this.leaveHolds -= 1; + this.applySavingState(); + }; + } + + private writesAllowed(): boolean { + return this.savingEnabled && this.leaveHolds === 0; + } + + private applySavingState(): void { + if (this.disposed) return; + const allowed = this.writesAllowed(); for (const session of this.sessions.values()) { - if (enabled && !this.isSessionLeased(session)) session.resumeSaving(); + if (allowed && !this.isSessionLeased(session)) session.resumeSaving(); else session.pauseSaving(); } } @@ -244,22 +309,28 @@ export class FileEditingSessionRegistry< const sessions = [...this.sessions.entries()].filter( ([, session]) => !leasedSessions.has(session), ); - for (const [path] of sessions) this.sessions.delete(path); + for (const [path, session] of sessions) { + this.sessions.delete(path); + this.disposingSessions.set(session, path); + } await Promise.all(sessions.map(([, session]) => this.settleAndDisposeSession(session))); await Promise.all(activeMutations.map((mutation) => mutation.completion)); + this.leaveWindowSet?.(); } private async settleAndDisposeSession(session: Session): Promise { const closeState = this.sessionCloseStates.get(session); - if (closeState?.phase === "settling") { - await closeState.completion; - } else if (closeState === undefined) { + if (closeState?.phase === "settling") await closeState.completion; + // A close that failed still holds an edit, and nothing retries it after the dispose. + if (this.sessionCloseStates.get(session)?.phase !== "settled") { try { - await session.settle(); + if ((await session.settle(true)) === "failed") this.disposeSaveFailed = true; } catch (error) { + this.disposeSaveFailed = true; this.reportSessionCleanupError(error); } } + this.disposingSessions.delete(session); this.sessionCloseStates.delete(session); try { session.dispose(); @@ -465,7 +536,7 @@ export class FileEditingSessionRegistry< ): Promise { this.activePathMutations.delete(mutation); try { - if (resumeSaving && this.savingEnabled) { + if (resumeSaving && this.writesAllowed()) { for (const session of mutation.pausedSessions) { if (!this.isSessionLeased(session)) session.resumeSaving(); } diff --git a/apps/web/src/components/files/filePreviewMode.ts b/apps/web/src/components/files/filePreviewMode.ts index 743482ed74..fc15b63344 100644 --- a/apps/web/src/components/files/filePreviewMode.ts +++ b/apps/web/src/components/files/filePreviewMode.ts @@ -1,5 +1,30 @@ +import { + findUnsupportedConstruct, + splitFrontmatter, + type UnsupportedConstruct, +} from "./richMarkdown/markdownSupport"; + export const isMarkdownPreviewFile = (path: string): boolean => /\.(?:md|mdx)$/i.test(path); +/** Larger files stay read-only: each rendered edit reparses the whole file (R34). */ +const RICH_MARKDOWN_MAX_BYTES = 128 * 1024; + +/** Whether the rendered view of `path` may be edited, and why not when it may not (R34). */ +export function richMarkdownMode( + path: string, + source: string, +): + | { editable: true } + | { editable: false; reason: UnsupportedConstruct | "mdx" | "not-markdown" | "too-large" } { + if (!isMarkdownPreviewFile(path)) return { editable: false, reason: "not-markdown" }; + if (/\.mdx$/i.test(path)) return { editable: false, reason: "mdx" }; + if (new TextEncoder().encode(source).length > RICH_MARKDOWN_MAX_BYTES) { + return { editable: false, reason: "too-large" }; + } + const reason = findUnsupportedConstruct(splitFrontmatter(source).body); + return reason === null ? { editable: true } : { editable: false, reason }; +} + /** * How the Files panel shows a file, chosen from its extension before any text read. The server's * asset route serves exactly these types (images, SVG as an image, PDF, audio); anything else is diff --git a/apps/web/src/components/files/fileTransfers.test.ts b/apps/web/src/components/files/fileTransfers.test.ts index 8b3e16923a..53447f8085 100644 --- a/apps/web/src/components/files/fileTransfers.test.ts +++ b/apps/web/src/components/files/fileTransfers.test.ts @@ -4,6 +4,7 @@ import { describeByteLimit, downloadWithBridge, interpretUploadResponse, + nextFreeFileName, resolveTransferUrl, sendBrowserUpload, triggerBrowserDownload, @@ -178,3 +179,11 @@ describe("resolveTransferUrl", () => { expect(resolveTransferUrl("not a url", "/api/transfers/t.k")).toBeNull(); }); }); + +describe("nextFreeFileName", () => { + it("nextFreeFileName adds a numbered suffix before the extension", () => { + expect(nextFreeFileName("diagram.png", 1)).toBe("diagram-1.png"); + expect(nextFreeFileName("archive.tar.gz", 2)).toBe("archive.tar-2.gz"); + expect(nextFreeFileName("README", 1)).toBe("README-1"); + }); +}); diff --git a/apps/web/src/components/files/fileTransfers.ts b/apps/web/src/components/files/fileTransfers.ts index 2e3e50829d..ff44ae61c8 100644 --- a/apps/web/src/components/files/fileTransfers.ts +++ b/apps/web/src/components/files/fileTransfers.ts @@ -125,6 +125,12 @@ export async function sendBrowserUpload( return { status: response.status, body: await response.text() }; } +/** `name` with `-` before its last extension: `diagram.png`, 1 gives `diagram-1.png`. */ +export function nextFreeFileName(name: string, attempt: number): string { + const dot = name.lastIndexOf("."); + return dot > 0 ? `${name.slice(0, dot)}-${attempt}${name.slice(dot)}` : `${name}-${attempt}`; +} + /** A byte cap as a person reads it: "1 GiB", "1.5 MiB", "900 bytes". */ export function describeByteLimit(bytes: number): string { const units = [ diff --git a/apps/web/src/components/files/pierreEditProvider.integration.test.tsx b/apps/web/src/components/files/pierreEditProvider.integration.test.tsx index 3700c2427b..6fcdc68d79 100644 --- a/apps/web/src/components/files/pierreEditProvider.integration.test.tsx +++ b/apps/web/src/components/files/pierreEditProvider.integration.test.tsx @@ -250,6 +250,42 @@ describe("Pierre stable edit integration", () => { session.reconcileRead(token, { version, contents }); }; + it("two source edits, a rich visit, then three undos restore the original text", async () => { + const { session } = createSession("one\n"); + let editor = await renderSession(session, "one\n"); + const insert = (character: number, newText: string) => + editor.applyEdits( + [ + { + range: { start: { line: 0, character }, end: { line: 0, character } }, + newText, + }, + ], + true, + true, + ); + insert(3, " two"); + insert(7, " three"); + expect(editor.getText()).toBe("one two three\n"); + + // Rendered view replaces the source surface, which detaches the editor. + await act(async () => root.render(null)); + session.changeFromRichEditor("# one two three\n"); + session.changeFromRichEditor("# one two three!\n"); + session.commitRichEdits(); + + editor = await renderSession(session, session.retainedContents ?? "one\n"); + expect(editor).toBe(session.editor); + // Pierre reports the attach on its next render frame, which applies the visit. + await vi.waitFor(() => expect(editor.getText()).toBe("# one two three!\n")); + session.undo(); + expect(editor.getText()).toBe("one two three\n"); + session.undo(); + expect(editor.getText()).toBe("one two\n"); + session.undo(); + expect(editor.getText()).toBe("one\n"); + }); + it("reloads a clean editor in place, and Undo restores the old text as unsaved", async () => { const before = "one\ntwo words\nthree\n"; const { session, persist } = createSession(before); diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownFormatGroup.test.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownFormatGroup.test.tsx new file mode 100644 index 0000000000..c8822b7638 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownFormatGroup.test.tsx @@ -0,0 +1,213 @@ +// @vitest-environment happy-dom + +import type { Editor } from "@tiptap/core"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { TooltipProvider } from "../../ui/tooltip"; +import { RichMarkdownFormatGroup } from "./RichMarkdownFormatGroup"; +import { createRichMarkdownEditor, type RichMarkdownEditor } from "./richMarkdownExtensions"; + +let container: HTMLDivElement; +let root: Root; +const editors: RichMarkdownEditor[] = []; + +function openEditor(body: string) { + const element = document.createElement("div"); + document.body.append(element); + const editor = createRichMarkdownEditor({ body, element, onUpdate: vi.fn() }); + editors.push(editor); + const tiptap = (element.querySelector(".ProseMirror") as HTMLElement & { editor: Editor }).editor; + // Select the whole first block's text, as a person selecting the word would. + act(() => { + tiptap.commands.setTextSelection({ + from: 1, + to: 1 + tiptap.state.doc.firstChild!.content.size, + }); + }); + return { editor, tiptap }; +} + +function renderGroup( + editor: RichMarkdownEditor | null, + handlers = { onLink: vi.fn(), onImage: vi.fn() }, +) { + act(() => { + root.render( + + + , + ); + }); + return handlers; +} + +function control(label: string): HTMLElement { + const element = document.body.querySelector(`[aria-label="${label}"]`); + if (!element) throw new Error(`No control named ${label}`); + return element; +} + +function click(label: string) { + act(() => control(label).click()); +} + +async function openMore() { + await act(async () => control("More formatting").click()); +} + +function menuItem(name: string): HTMLElement { + const item = [...document.body.querySelectorAll("[role='menuitem']")].find( + (candidate) => candidate.textContent?.trim() === name, + ); + if (!item) throw new Error(`No menu item ${name}`); + return item; +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + for (const editor of editors.splice(0)) editor.destroy(); + document.body.replaceChildren(); +}); + +describe("RichMarkdownFormatGroup", () => { + it("every control has an accessible name", async () => { + const { editor } = openEditor("Item\n"); + renderGroup(editor); + + for (const label of [ + "Paragraph", + "Heading 1", + "Heading 2", + "Heading 3", + "Bold", + "Italic", + "Strikethrough", + "Bullet list", + "Numbered list", + "Task list", + "Quote", + "Link", + "Image", + "More formatting", + ]) { + expect(control(label).tagName).toBe("BUTTON"); + } + expect(control("Bold").closest("[role='group']")?.getAttribute("aria-label")).toBe( + "Formatting", + ); + await openMore(); + for (const name of ["Heading 4", "Heading 5", "Collapsible section"]) { + expect(menuItem(name)).toBeTruthy(); + } + }); + + it("controls are disabled with a reason when there is no editor", async () => { + const handlers = renderGroup(null); + + for (const label of ["Paragraph", "Bold", "Link", "Image", "More formatting"]) { + expect(control(label).getAttribute("aria-disabled")).toBe("true"); + } + click("Link"); + click("Image"); + expect(handlers.onLink).not.toHaveBeenCalled(); + expect(handlers.onImage).not.toHaveBeenCalled(); + await act(async () => { + control("Bold").dispatchEvent(new PointerEvent("pointerenter", { bubbles: true })); + control("Bold").focus(); + }); + await vi.waitFor(() => + expect(document.body.textContent).toContain("Formatting is available in rendered view"), + ); + }); + + it("heading 2 then bold serializes as a bold heading", () => { + const { editor } = openEditor("Title\n"); + renderGroup(editor); + + click("Heading 2"); + click("Bold"); + + expect(editor.serialize()).toBe("## **Title**"); + }); + + it.each([ + ["Paragraph", "Item"], + ["Heading 1", "# Item"], + ["Heading 2", "## Item"], + ["Heading 3", "### Item"], + ["Bold", "**Item**"], + ["Italic", "*Item*"], + ["Strikethrough", "~~Item~~"], + ["Bullet list", "- Item"], + ["Numbered list", "1. Item"], + ["Task list", "- [ ] Item"], + ["Quote", "> Item"], + ])("each format command serializes as expected: %s", (label, expected) => { + const { editor } = openEditor("Item\n"); + renderGroup(editor); + + click(label); + + expect(editor.serialize()).toBe(expected); + }); + + it.each([ + ["Heading 4", "#### Item"], + ["Heading 5", "##### Item"], + ])("the More menu applies %s", async (name, expected) => { + const { editor } = openEditor("Item\n"); + renderGroup(editor); + + await openMore(); + await act(async () => menuItem(name).click()); + + expect(editor.serialize()).toBe(expected); + }); + + it("an active format shows as pressed and toggles off", () => { + const { editor } = openEditor("- Item\n"); + renderGroup(editor); + + expect(control("Bullet list").getAttribute("aria-pressed")).toBe("true"); + expect(control("Numbered list").getAttribute("aria-pressed")).toBe("false"); + click("Bullet list"); + + expect(editor.serialize()).toBe("Item"); + expect(control("Bullet list").getAttribute("aria-pressed")).toBe("false"); + }); + + it("a collapsible section is saved as details with a summary and reopens editable", async () => { + const { editor, tiptap } = openEditor("Intro\n"); + renderGroup(editor); + act(() => { + tiptap.commands.setTextSelection(1 + tiptap.state.doc.firstChild!.content.size); + }); + + await openMore(); + await act(async () => menuItem("Collapsible section").click()); + act(() => { + tiptap.commands.insertContent("Text"); + }); + + const saved = editor.serialize(); + expect(saved).toContain("
"); + expect(saved).toContain("More"); + expect(saved).toContain("Text"); + const reopened = openEditor(saved); + let detailsNodes = 0; + reopened.tiptap.state.doc.descendants((node) => { + if (node.type.name === "details") detailsNodes += 1; + }); + expect(detailsNodes).toBe(1); + expect(reopened.tiptap.isEditable).toBe(true); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownFormatGroup.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownFormatGroup.tsx new file mode 100644 index 0000000000..3a219cf708 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownFormatGroup.tsx @@ -0,0 +1,180 @@ +/* + * The control set, order and commands follow Orca's formatting toolbar + * (https://github.com/stablyai/orca, src/renderer/src/components/editor/rich-markdown/commands/ + * RichMarkdownToolbar.tsx). Copyright (c) 2026 Lovecast Inc. Licensed under the MIT License (full + * text in detailsExtension.ts). Rebuilt on this repository's Button, Tooltip and Menu. + */ +import { + Bold, + Ellipsis, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Image, + Italic, + Link, + List, + ListCollapse, + ListOrdered, + ListTodo, + Pilcrow, + Strikethrough, + TextQuote, + type LucideIcon, +} from "lucide-react"; +import { useSyncExternalStore } from "react"; + +import { Button } from "~/components/ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; + +import type { FormatName, RichMarkdownEditor } from "./richMarkdownExtensions"; + +const UNAVAILABLE_REASON = "Formatting is available in rendered view"; + +const FORMATS: ReadonlyArray<{ name: FormatName; label: string; icon: LucideIcon }> = [ + { name: "paragraph", label: "Paragraph", icon: Pilcrow }, + { name: "heading1", label: "Heading 1", icon: Heading1 }, + { name: "heading2", label: "Heading 2", icon: Heading2 }, + { name: "heading3", label: "Heading 3", icon: Heading3 }, + { name: "bold", label: "Bold", icon: Bold }, + { name: "italic", label: "Italic", icon: Italic }, + { name: "strike", label: "Strikethrough", icon: Strikethrough }, + { name: "bulletList", label: "Bullet list", icon: List }, + { name: "orderedList", label: "Numbered list", icon: ListOrdered }, + { name: "taskList", label: "Task list", icon: ListTodo }, + { name: "blockquote", label: "Quote", icon: TextQuote }, +]; + +const MORE_FORMATS: ReadonlyArray<{ name: FormatName; label: string; icon: LucideIcon }> = [ + { name: "heading4", label: "Heading 4", icon: Heading4 }, + { name: "heading5", label: "Heading 5", icon: Heading5 }, + { name: "details", label: "Collapsible section", icon: ListCollapse }, +]; + +const PRESSABLE: readonly FormatName[] = [...FORMATS.map((format) => format.name), "link"]; + +const noSubscription = () => () => {}; + +function FormatButton({ + label, + icon: Icon, + pressed, + disabled, + onClick, +}: { + label: string; + icon: LucideIcon; + pressed?: boolean | undefined; + disabled: boolean; + onClick: () => void; +}) { + return ( + + event.preventDefault()} + onClick={disabled ? undefined : onClick} + > + + + } + /> + {disabled ? UNAVAILABLE_REASON : label} + + ); +} + +/** + * The Markdown formatting controls of the Files toolbar. Without an editor (source view, or a + * file the editor cannot change) every control stays focusable and says why it does nothing. + */ +export function RichMarkdownFormatGroup({ + editor, + onLink, + onImage, +}: { + editor: RichMarkdownEditor | null; + onLink(): void; + onImage(): void; +}) { + // One character per format, so the group renders again only when an active state changes. + const active = useSyncExternalStore( + editor?.subscribe ?? noSubscription, + () => PRESSABLE.map((name) => (editor?.isActive(name) ? "1" : "0")).join(""), + () => "", + ); + const disabled = editor === null; + const isPressed = (name: FormatName) => active[PRESSABLE.indexOf(name)] === "1"; + + return ( +
+ {FORMATS.map((format) => ( + editor?.run(format.name)} + /> + ))} + + + + + + } + > + + + } + /> + {disabled ? UNAVAILABLE_REASON : "More formatting"} + + + {MORE_FORMATS.map((format) => ( + editor?.run(format.name)}> + + ))} + + +
+ ); +} diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownImage.test.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownImage.test.tsx new file mode 100644 index 0000000000..ce585e95bc --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownImage.test.tsx @@ -0,0 +1,174 @@ +// @vitest-environment happy-dom + +import { EnvironmentId, ThreadId } from "@bibcode/contracts"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const assetState = vi.hoisted(() => ({ + result: { _tag: "Success", value: { relativeUrl: "/api/assets/tok/pic.png" } } as + | { _tag: "Success"; value: { relativeUrl: string } } + | { _tag: "Initial" } + | { _tag: "Failure"; cause: string }, + requests: [] as unknown[], +})); + +vi.mock("@effect/atom-react", () => ({ useAtomValue: () => assetState.result })); +vi.mock("~/state/assets", () => ({ + assetEnvironment: { + createUrl: (request: unknown) => { + assetState.requests.push(request); + return request; + }, + }, +})); +vi.mock("~/state/environments", () => ({ + useEnvironmentHttpBaseUrl: () => "https://server.example.test/", +})); + +import { RichMarkdownImage, type RichMarkdownWorkspace } from "./RichMarkdownImage"; + +const workspace: RichMarkdownWorkspace = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + cwd: "/workspace", + markdownPath: "docs/readme.md", +}; + +describe("RichMarkdownImage", () => { + let container: HTMLDivElement; + let root: Root; + const fetchMock = vi.fn<(url: string, init?: RequestInit) => Promise>(); + const createObjectUrl = vi.fn<(blob: Blob) => string>(); + const revokeObjectUrl = vi.fn<(url: string) => void>(); + + beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + assetState.result = { _tag: "Success", value: { relativeUrl: "/api/assets/tok/pic.png" } }; + assetState.requests.length = 0; + let blobCount = 0; + fetchMock.mockReset().mockImplementation(async () => ({ + ok: true, + status: 200, + headers: new Headers(), + blob: async () => new Blob(["png"]), + })); + createObjectUrl.mockReset().mockImplementation(() => `blob:fixture-${(blobCount += 1)}`); + revokeObjectUrl.mockReset(); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "URL", + Object.assign(URL, { createObjectURL: createObjectUrl, revokeObjectURL: revokeObjectUrl }), + ); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.unstubAllGlobals(); + }); + + async function render(src: string, alt = "A pixel", props = workspace) { + await act(async () => { + root.render(); + }); + await act(async () => {}); + } + + it("loads a relative image from the Markdown file's folder through the workspace", async () => { + await render("images/pic.png"); + + expect(assetState.requests).toEqual([ + { + environmentId: workspace.environmentId, + input: { + resource: { + _tag: "workspace-file", + threadId: workspace.threadId, + path: "/workspace/docs/images/pic.png", + }, + }, + }, + ]); + expect(fetchMock.mock.calls[0]?.[0]).toBe("https://server.example.test/api/assets/tok/pic.png"); + const image = container.querySelector("img"); + expect(image?.getAttribute("src")).toBe("blob:fixture-1"); + expect(image?.getAttribute("alt")).toBe("A pixel"); + + act(() => root.render(null)); + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:fixture-1"); + }); + + it("does not ask for an image outside the workspace and shows its text instead", async () => { + await render("../../outside.png"); + + expect(assetState.requests).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(container.querySelector("img")).toBeNull(); + const text = container.querySelector("[data-rich-markdown-image-text='failed']"); + expect(text?.textContent).toBe("A pixel"); + expect(text?.getAttribute("title")).toBe("Only images inside this project are shown."); + }); + + it("shows the file name when an image without text cannot be read", async () => { + fetchMock.mockImplementation(async () => ({ + ok: false, + status: 404, + headers: new Headers(), + blob: async () => new Blob(), + })); + await render("images/missing.png", ""); + + expect(container.querySelector("img")).toBeNull(); + const text = container.querySelector("[data-rich-markdown-image-text='failed']"); + expect(text?.textContent).toBe("missing.png"); + expect(text?.getAttribute("title")).toContain("Check that the file exists"); + }); + + it("says the file may be missing when the environment refuses its address", async () => { + assetState.result = { _tag: "Failure", cause: "not found" }; + await render("images/missing.png"); + + expect(fetchMock).not.toHaveBeenCalled(); + const text = container.querySelector("[data-rich-markdown-image-text='failed']"); + expect(text?.textContent).toBe("A pixel"); + expect(text?.getAttribute("title")).toContain("Check that the file exists"); + }); + + it("keeps the drawn image while its signed address is reissued", async () => { + await render("images/pic.png"); + expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:fixture-1"); + + const seen: (string | null)[] = []; + const observer = new MutationObserver(() => + seen.push(container.querySelector("img")?.getAttribute("src") ?? null), + ); + observer.observe(container, { subtree: true, childList: true, attributes: true }); + assetState.result = { _tag: "Success", value: { relativeUrl: "/api/assets/tok2/pic.png" } }; + // A copy re-renders past the compiler's memo, as the atom's own update would. + await render("images/pic.png", "A pixel", { ...workspace }); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + "https://server.example.test/api/assets/tok/pic.png", + "https://server.example.test/api/assets/tok2/pic.png", + ]); + await act(async () => {}); + observer.disconnect(); + + expect(seen).not.toContain(null); + expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:fixture-2"); + expect(revokeObjectUrl).toHaveBeenCalledWith("blob:fixture-1"); + }); + + it("leaves a web or data URL to the page", async () => { + await render("https://images.example.test/pic.png"); + + expect(assetState.requests).toEqual([]); + expect(fetchMock).not.toHaveBeenCalled(); + expect(container.querySelector("img")?.getAttribute("src")).toBe( + "https://images.example.test/pic.png", + ); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx new file mode 100644 index 0000000000..e00656a6ca --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownImage.tsx @@ -0,0 +1,118 @@ +import { useAtomValue } from "@effect/atom-react"; +import { resolveAssetUrl } from "@bibcode/client-runtime/state/assets"; +import type { EnvironmentId, ThreadId } from "@bibcode/contracts"; +import { ImageOff } from "lucide-react"; +import { useState } from "react"; + +import { assetEnvironment } from "~/state/assets"; +import { useEnvironmentHttpBaseUrl } from "~/state/environments"; + +import { useAssetBlobUrl } from "../FileMediaPreview"; +import { joinWorkspacePath } from "../FileTreeContextMenu.logic"; +import { resolveRichMarkdownImageSource } from "./richMarkdownImageSource"; + +/** The Markdown file the rendered view shows, and where its images are read from. */ +export interface RichMarkdownWorkspace { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly cwd: string; + /** The Markdown file, relative to `cwd`. */ + readonly markdownPath: string; +} + +interface ImageProps { + readonly src: string; + readonly alt: string; +} + +const unreadable = (src: string) => `Couldn't read ${src}. Check that the file exists.`; + +/** The image's own text, shown while it loads and when it can't be shown. */ +function ImageText({ src, alt, problem }: ImageProps & { problem: string | null }) { + const label = alt || src.slice(src.lastIndexOf("/") + 1) || src; + return ( + + {problem ? : null} + {label} + + ); +} + +function DrawnImage({ url, src, alt }: ImageProps & { url: string }) { + // Tied to the URL, so a reissued one gets another try. + const [failedUrl, setFailedUrl] = useState(null); + if (failedUrl === url) return ; + return ( + {alt} setFailedUrl(url)} /> + ); +} + +function BlobImage({ assetUrl, src, alt }: ImageProps & { assetUrl: string }) { + const blob = useAssetBlobUrl(assetUrl); + if (blob.status === "loading") return ; + if (blob.status === "failed") { + return ( + + ); + } + return ; +} + +function WorkspaceImage({ + workspace, + path, + src, + alt, +}: ImageProps & { workspace: RichMarkdownWorkspace; path: string }) { + const httpBaseUrl = useEnvironmentHttpBaseUrl(workspace.environmentId); + const asset = useAtomValue( + assetEnvironment.createUrl({ + environmentId: workspace.environmentId, + input: { + resource: { + _tag: "workspace-file", + threadId: workspace.threadId, + path: joinWorkspacePath(workspace.cwd, path), + }, + }, + }), + ); + if (asset._tag === "Failure") { + return ; + } + const assetUrl = + asset._tag === "Success" && httpBaseUrl !== null + ? resolveAssetUrl(httpBaseUrl, asset.value.relativeUrl) + : null; + if (assetUrl === null) return ; + // Not keyed by the URL: the signed URL is reissued over time, and the drawn image stays. + return ; +} + +/** + * An image in the rendered view. A relative `src` is read from the workspace, relative to the + * Markdown file, the way the Files preview reads media; other URLs load as they are. + */ +export function RichMarkdownImage({ + workspace, + src, + alt, +}: ImageProps & { workspace: RichMarkdownWorkspace }) { + const source = resolveRichMarkdownImageSource(workspace.markdownPath, src); + if (source.kind === "url") return ; + if (source.kind === "refused") { + return ; + } + return ; +} diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownLinkPopover.test.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownLinkPopover.test.tsx new file mode 100644 index 0000000000..7d8af64355 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownLinkPopover.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment happy-dom + +import type { Editor } from "@tiptap/core"; +import { act, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { RichMarkdownLinkPopover } from "./RichMarkdownLinkPopover"; +import { createRichMarkdownEditor, type RichMarkdownEditor } from "./richMarkdownExtensions"; + +let container: HTMLDivElement; +let root: Root; +const editors: RichMarkdownEditor[] = []; + +function openEditor(body: string, select: string) { + const element = document.createElement("div"); + document.body.append(element); + const editor = createRichMarkdownEditor({ body, element, onUpdate: vi.fn() }); + editors.push(editor); + const view = element.querySelector(".ProseMirror") as HTMLElement & { editor: Editor }; + const tiptap = view.editor; + const from = 1 + tiptap.state.doc.textContent.indexOf(select); + act(() => { + tiptap + .chain() + .focus() + .setTextSelection({ from, to: from + select.length }) + .run(); + }); + return { editor, view }; +} + +function Harness({ editor, onClosed }: { editor: RichMarkdownEditor; onClosed: () => void }) { + const [open, setOpen] = useState(true); + return ( + { + setOpen(next); + if (!next) onClosed(); + }} + /> + ); +} + +async function showPopover(editor: RichMarkdownEditor) { + const onClosed = vi.fn(); + await act(async () => root.render()); + const field = document.body.querySelector("input[aria-label='Link URL']"); + if (!field) throw new Error("The link field is not shown"); + return { field, onClosed }; +} + +async function enter(field: HTMLInputElement, value: string) { + await act(async () => { + const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")!.set!; + setter.call(field, value); + field.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function press(label: string) { + const button = [...document.body.querySelectorAll("button")].find( + (candidate) => candidate.textContent?.trim() === label, + ); + if (!button) throw new Error(`No ${label} button`); + await act(async () => button.click()); +} + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(() => { + act(() => root.unmount()); + for (const editor of editors.splice(0)) editor.destroy(); + document.body.replaceChildren(); +}); + +describe("RichMarkdownLinkPopover", () => { + it("linking selected text saves a Markdown link", async () => { + const { editor } = openEditor("Read the docs today\n", "docs"); + const { field, onClosed } = await showPopover(editor); + + await enter(field, "https://example.test/docs"); + await press("Save"); + + expect(editor.serialize()).toBe("Read the [docs](https://example.test/docs) today"); + expect(onClosed).toHaveBeenCalledOnce(); + }); + + it("an empty URL removes the link and keeps the text", async () => { + const { editor } = openEditor("Read the [docs](https://example.test/docs) today\n", "oc"); + const { field } = await showPopover(editor); + + await enter(field, ""); + await press("Save"); + + expect(editor.serialize()).toBe("Read the docs today"); + }); + + it("the field shows the current URL when the caret is in a link", async () => { + const { editor } = openEditor("Read the [docs](https://example.test/docs) today\n", "oc"); + const { field } = await showPopover(editor); + + expect(field.value).toBe("https://example.test/docs"); + expect( + [...document.body.querySelectorAll("button")].map((button) => button.textContent), + ).toContain("Remove link"); + }); + + it("Escape closes the dialog, changes nothing and returns focus to the editor", async () => { + const { editor, view } = openEditor("Read the docs today\n", "docs"); + const { field, onClosed } = await showPopover(editor); + await enter(field, "https://example.test/other"); + + await act(async () => { + field.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + }); + + expect(onClosed).toHaveBeenCalledOnce(); + expect(editor.serialize()).toBe("Read the docs today"); + await vi.waitFor(() => expect(view.contains(document.activeElement)).toBe(true)); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownLinkPopover.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownLinkPopover.tsx new file mode 100644 index 0000000000..fea18c033f --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownLinkPopover.tsx @@ -0,0 +1,86 @@ +/* + * The rules follow Orca's link bubble (https://github.com/stablyai/orca, + * src/renderer/src/components/editor/rich-markdown/links/useLinkBubble.ts): an empty URL removes + * the link, and an empty selection inserts the URL as linked text. Copyright (c) 2026 Lovecast Inc. + * Licensed under the MIT License (full text in detailsExtension.ts). + */ +import { type FormEvent, useState } from "react"; + +import { Button } from "~/components/ui/button"; +import { Input } from "~/components/ui/input"; +import { Popover, PopoverPopup } from "~/components/ui/popover"; + +import type { RichMarkdownEditor } from "./richMarkdownExtensions"; + +/** The selection's position on screen, captured when the dialog opens. */ +function selectionAnchor(): { getBoundingClientRect(): DOMRect } | undefined { + const selection = typeof window === "undefined" ? null : window.getSelection(); + if (!selection || selection.rangeCount === 0) return undefined; + const rect = selection.getRangeAt(0).getBoundingClientRect(); + return { getBoundingClientRect: () => rect }; +} + +function LinkForm({ editor, onDone }: { editor: RichMarkdownEditor; onDone: () => void }) { + const [current] = useState(() => editor.currentLink()); + const [href, setHref] = useState(current?.href ?? ""); + + const save = (event: FormEvent) => { + event.preventDefault(); + const trimmed = href.trim(); + if (trimmed) editor.setLink(trimmed); + else if (current) editor.removeLink(); + onDone(); + }; + + return ( +
+ setHref(event.currentTarget.value)} + /> +
+ {current ? ( + + ) : null} + +
+
+ ); +} + +/** Adds, changes or removes the link at the selection, anchored next to it (R27). */ +export function RichMarkdownLinkPopover({ + editor, + open, + onOpenChange, +}: { + editor: RichMarkdownEditor; + open: boolean; + onOpenChange(open: boolean): void; +}) { + const [anchor] = useState(selectionAnchor); + return ( + + + {open ? onOpenChange(false)} /> : null} + + + ); +} diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownMermaid.test.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownMermaid.test.tsx new file mode 100644 index 0000000000..7898824e07 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownMermaid.test.tsx @@ -0,0 +1,328 @@ +// @vitest-environment jsdom +// DOMPurify, which every drawn diagram goes through, does not run under happy-dom. +import { EnvironmentId, ThreadId } from "@bibcode/contracts"; +import type { Editor } from "@tiptap/core"; +import { AsyncResult } from "effect/reactivity"; +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@pierre/diffs/edit", () => ({ + Editor: class { + readonly cleanUp = vi.fn(); + readonly getFile = () => undefined; + }, +})); + +import { writeThemePreference } from "../../../hooks/useTheme"; +import { + __resetMermaidDiagramsForTests, + __setMermaidRendererLoaderForTests, +} from "../../markdown/MarkdownMermaidBlock"; +import { FileEditingSession } from "../fileEditingSession"; +import type { RichMarkdownEditor } from "./richMarkdownExtensions"; +import RichMarkdownSurface from "./RichMarkdownSurface"; + +type MermaidRenderer = typeof import("../../markdown/mermaidRenderer"); + +const FLOWCHART = "graph TD\n Write --> Draw"; +// A tilde fence: the editor writes backticks, so only the reconcile keeps these bytes. +const FENCE = `~~~mermaid\n${FLOWCHART}\n~~~`; +const FILE = `# Diagram\n\nBefore the diagram.\n\n${FENCE}\n\nAfter the diagram.\n`; + +let container: HTMLDivElement; +let root: Root; +let renderedSources: string[] = []; + +async function countingRenderer(): Promise { + const renderer = await import("../../markdown/mermaidRenderer"); + return { + ...renderer, + renderMermaidDiagram: (source, fonts, idPrefix) => { + renderedSources.push(source); + return renderer.renderMermaidDiagram(source, fonts, idPrefix); + }, + }; +} + +function createSession(contents: string) { + const session = new FileEditingSession({ + cwd: "/repo", + relativePath: "notes.md", + debounceMs: 60_000, + base: { version: "v1", contents }, + persist: vi.fn(async (path: string, saved: string) => + AsyncResult.success({ relativePath: path, version: `saved:${saved.length}` }), + ), + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + vi.spyOn(session, "changeFromRichEditor"); + return session; +} + +async function mount(session: FileEditingSession, contents: string) { + const onEditorChange = vi.fn<(editor: RichMarkdownEditor | null, opened: string) => void>(); + await act(async () => { + root.render( + , + ); + }); + const element = container.querySelector(".ProseMirror") as HTMLElement & { editor: Editor }; + return { onEditorChange, tiptap: element.editor }; +} + +async function settle(ms = 0): Promise { + await act(async () => new Promise((resolve) => setTimeout(resolve, ms))); +} + +async function waitFor(condition: () => boolean, message: string): Promise { + for (let attempt = 0; !condition(); attempt += 1) { + if (attempt > 400) throw new Error(message); + await settle(); + } +} + +/** Lets the 300 ms serialization deliver the last edit. */ +const deliver = () => settle(350); + +const svg = () => container.querySelector(".chat-markdown-mermaid svg"); +const errorText = () => container.querySelector(".chat-markdown-mermaid-error")?.textContent; + +/** Puts the caret at the end of the first text block that contains `marker`. */ +function caretAfter(tiptap: Editor, marker: string): void { + let position = -1; + tiptap.state.doc.descendants((node, pos) => { + if (position >= 0) return false; + if (node.isTextblock && node.textContent.includes(marker)) { + position = pos + 1 + node.content.size; + return false; + } + return true; + }); + expect(position, marker).toBeGreaterThan(0); + act(() => { + tiptap.commands.setTextSelection(position); + }); +} + +function caretBefore(tiptap: Editor, marker: string): void { + let position = -1; + tiptap.state.doc.descendants((node, pos) => { + if (position >= 0) return false; + if (node.isTextblock && node.textContent.includes(marker)) { + position = pos + 1; + return false; + } + return true; + }); + expect(position, marker).toBeGreaterThan(0); + act(() => { + tiptap.commands.setTextSelection(position); + }); +} + +function type(tiptap: Editor, text: string): void { + act(() => { + tiptap.commands.insertContent(text); + }); +} + +/** + * Presses `key` through the editor's key handlers, as a real key press reaches them. TipTap's + * `keyboardShortcut` command keeps only the steps of what the key did, not its selection. + */ +function press(tiptap: Editor, key: string): void { + act(() => { + const event = new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true }); + tiptap.view.someProp("handleKeyDown", (handle) => handle(tiptap.view, event)); + }); +} + +function selectDiagram(tiptap: Editor): void { + let position = -1; + tiptap.state.doc.descendants((node, pos) => { + if (node.type.name === "mermaidDiagram") position = pos; + return position < 0; + }); + expect(position).toBeGreaterThanOrEqual(0); + act(() => { + tiptap.commands.setNodeSelection(position); + }); +} + +beforeAll(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.stubGlobal( + "ResizeObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + }, + ); + window.matchMedia ??= ((query: string) => ({ + matches: false, + media: query, + onchange: null, + addEventListener() {}, + removeEventListener() {}, + addListener() {}, + removeListener() {}, + dispatchEvent: () => false, + })) as typeof window.matchMedia; + // ProseMirror measures the selection to scroll it into view; jsdom has no layout. + const emptyRects = () => Object.assign([], { item: () => null }) as unknown as DOMRectList; + Range.prototype.getClientRects ??= emptyRects; + Range.prototype.getBoundingClientRect ??= () => new DOMRect(); + Object.defineProperty(HTMLElement.prototype, "getAnimations", { + configurable: true, + value: () => [], + }); +}); + +beforeEach(() => { + __resetMermaidDiagramsForTests(); + __setMermaidRendererLoaderForTests(countingRenderer); + renderedSources = []; + writeThemePreference("light"); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + __setMermaidRendererLoaderForTests(); + vi.restoreAllMocks(); +}); + +describe("Mermaid diagrams in the rendered Markdown editor", () => { + it("draws a valid fence as the diagram, read-only, with its source in source view", async () => { + const session = createSession(FILE); + const { onEditorChange } = await mount(session, FILE); + expect(onEditorChange.mock.lastCall![0]).not.toBeNull(); + + await waitFor(() => svg() !== null, "the diagram never drew"); + const block = container.querySelector(".chat-markdown-codeblock")!; + expect(block.closest("[contenteditable='false']")).not.toBeNull(); + expect(block.parentElement?.textContent).toContain("Edit the diagram in source view"); + expect(renderedSources).toEqual([FLOWCHART]); + }); + + it("shows an invalid fence's refusal as chat does", async () => { + const invalid = "# Invalid\n\n```mermaid\ngraph TD\n A --> B\n B --> C]\n```\n"; + const session = createSession(invalid); + await mount(session, invalid); + + await waitFor(() => errorText() != null, "no error was shown"); + expect(errorText()).toBe("Can't draw this diagram: line 3, Unrecognized text."); + expect(container.querySelector(".chat-markdown-codeblock pre")?.textContent).toContain( + "B --> C]", + ); + }); + + it("opening the file and leaving it untouched changes no byte", async () => { + const session = createSession(FILE); + const { onEditorChange } = await mount(session, FILE); + await waitFor(() => svg() !== null, "the diagram never drew"); + await deliver(); + await act(async () => root.render(null)); + + expect(onEditorChange.mock.calls[0]![0]).not.toBeNull(); + expect(session.changeFromRichEditor).not.toHaveBeenCalled(); + expect(session.getSnapshot().save.phase).toBe("clean"); + }); + + it("typing before and after the diagram keeps the fence's bytes and its drawing", async () => { + const session = createSession(FILE); + const { tiptap } = await mount(session, FILE); + await waitFor(() => svg() !== null, "the diagram never drew"); + const drawn = svg(); + + caretAfter(tiptap, "Before the diagram."); + type(tiptap, " Typed before."); + caretBefore(tiptap, "After the diagram."); + type(tiptap, "Typed after. "); + await deliver(); + + expect(session.unsavedContents).toBe( + `# Diagram\n\nBefore the diagram. Typed before.\n\n${FENCE}\n\nTyped after. After the diagram.\n`, + ); + // Typing elsewhere neither draws the diagram again nor replaces it. + expect(svg()).toBe(drawn); + expect(renderedSources).toEqual([FLOWCHART]); + }); + + it("Enter on a selected last-block diagram opens a paragraph after it", async () => { + const last = `Intro.\n\n${FENCE}\n`; + const session = createSession(last); + const { tiptap } = await mount(session, last); + + selectDiagram(tiptap); + press(tiptap, "Enter"); + type(tiptap, "Written after."); + await deliver(); + + expect(session.unsavedContents).toBe(`Intro.\n\n${FENCE}\n\nWritten after.\n`); + }); + + it("Delete removes a selected diagram and exactly its fence", async () => { + const session = createSession(FILE); + const { tiptap } = await mount(session, FILE); + + selectDiagram(tiptap); + press(tiptap, "Delete"); + await deliver(); + + expect(session.unsavedContents).toBe( + "# Diagram\n\nBefore the diagram.\n\nAfter the diagram.\n", + ); + }); + + it("Backspace at the start of the next paragraph deletes exactly the fence, Undo restores it", async () => { + const session = createSession(FILE); + const { tiptap, onEditorChange } = await mount(session, FILE); + const editor = onEditorChange.mock.lastCall![0]!; + + caretBefore(tiptap, "After the diagram."); + // As before a horizontal rule: the block before the caret goes whole. + press(tiptap, "Backspace"); + await deliver(); + expect(session.unsavedContents).toBe( + "# Diagram\n\nBefore the diagram.\n\nAfter the diagram.\n", + ); + expect(container.querySelector(".chat-markdown-codeblock")).toBeNull(); + + act(() => editor.undo()); + await deliver(); + expect(session.changeFromRichEditor).toHaveBeenLastCalledWith(FILE); + await waitFor(() => svg() !== null, "the restored diagram never drew"); + }); + + it("a theme switch recolors the drawn diagram without drawing or replacing it", async () => { + const session = createSession(FILE); + await mount(session, FILE); + await waitFor(() => svg() !== null, "the diagram never drew"); + const drawn = svg(); + const card = container.querySelector(".chat-markdown-codeblock"); + + await act(async () => writeThemePreference("dark")); + await settle(); + + expect(container.querySelector(".chat-markdown-codeblock")).toBe(card); + expect(svg()).toBe(drawn); + expect(renderedSources).toEqual([FLOWCHART]); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownMermaid.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownMermaid.tsx new file mode 100644 index 0000000000..882a984191 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownMermaid.tsx @@ -0,0 +1,32 @@ +import { memo } from "react"; + +import { useTheme } from "../../../hooks/useTheme"; +import { MarkdownMermaidBlock } from "../../markdown/MarkdownMermaidBlock"; + +/** + * A Mermaid fence in the rendered editor, drawn by chat's Mermaid block. Memoized on its source, + * so typing elsewhere in the file renders nothing here; only a theme switch re-renders it, and + * the drawn SVG recolors through CSS without drawing again. + */ +export const RichMarkdownMermaid = memo(function RichMarkdownMermaid({ + source, +}: { + readonly source: string; +}) { + const { resolvedTheme } = useTheme(); + return ( + <> +

Edit the diagram in source view

+ +
+          {source}
+        
+
+ + ); +}); diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownSurface.test.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownSurface.test.tsx new file mode 100644 index 0000000000..34d9f83d55 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownSurface.test.tsx @@ -0,0 +1,489 @@ +// @vitest-environment happy-dom + +import type { Editor } from "@tiptap/core"; +import { AsyncResult } from "effect/reactivity"; +import { act, type ReactNode, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@pierre/diffs/edit", () => ({ + Editor: class { + readonly cleanUp = vi.fn(); + readonly getFile = () => undefined; + }, +})); + +const reconcile = vi.hoisted(() => ({ fail: false })); + +vi.mock("./markdownReconcile", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + reconcileMarkdown: (input: Parameters[0]) => { + if (reconcile.fail) throw new Error("reconcile failed"); + return actual.reconcileMarkdown(input); + }, + }; +}); + +// The image's loading is RichMarkdownImage's own test; here, where the surface draws it. +vi.mock("./RichMarkdownImage", () => ({ + RichMarkdownImage: (props: { src: string; alt: string; workspace: { markdownPath: string } }) => ( + + ), +})); + +import { EnvironmentId, ThreadId } from "@bibcode/contracts"; + +import { FileEditingSession } from "../fileEditingSession"; +import { FileEditingSessionRegistry } from "../fileEditingSessionRegistry"; +import type { RichMarkdownEditor } from "./richMarkdownExtensions"; +import RichMarkdownSurface from "./RichMarkdownSurface"; + +let container: HTMLDivElement; +let root: Root; + +const workspace = { + environmentId: EnvironmentId.make("environment-1"), + threadId: ThreadId.make("thread-1"), + cwd: "/repo", + markdownPath: "notes.md", +}; + +function createSession(contents: string, relativePath = "notes.md") { + const persist = vi.fn(async (path: string, saved: string) => + AsyncResult.success({ relativePath: path, version: `saved:${saved.length}` }), + ); + const session = new FileEditingSession({ + cwd: "/repo", + relativePath, + debounceMs: 500, + base: { version: "v1", contents }, + persist, + onPendingChange: vi.fn(), + onConfirmed: vi.fn(), + }); + vi.spyOn(session, "changeFromRichEditor"); + return { session, persist }; +} + +async function mount(session: FileEditingSession, contents: string) { + const onEditorChange = vi.fn<(editor: RichMarkdownEditor | null, opened: string) => void>(); + await act(async () => { + root.render( + , + ); + }); + const element = container.querySelector(".ProseMirror") as + | (HTMLElement & { editor: Editor }) + | null; + return { onEditorChange, tiptap: element?.editor ?? null }; +} + +/** Types `text` at the end of the first text block that contains `marker`. */ +function typeAfter(tiptap: Editor, marker: string, text: string) { + let position = -1; + tiptap.state.doc.descendants((node, pos) => { + if (position >= 0) return false; + if (node.isTextblock && node.textContent.includes(marker)) { + position = pos + 1 + node.content.size; + return false; + } + return true; + }); + expect(position, marker).toBeGreaterThan(0); + act(() => { + tiptap.chain().setTextSelection(position).insertContent(text).run(); + }); +} + +beforeEach(() => { + vi.useFakeTimers(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + reconcile.fail = false; + await act(async () => root.unmount()); + container.remove(); + vi.useRealTimers(); +}); + +describe("RichMarkdownSurface", () => { + it("typing in a paragraph updates the session text", async () => { + const { session } = createSession("Hello world\n"); + const { tiptap } = await mount(session, "Hello world\n"); + + typeAfter(tiptap!, "Hello world", " again"); + await act(async () => vi.advanceTimersByTime(300)); + + expect(session.unsavedContents).toBe("Hello world again\n"); + expect(session.getSnapshot().save.phase).toBe("pending"); + }); + + it("draws each image inside the editor for the file's workspace and keeps its source", async () => { + const original = "See ![A pixel](images/pic.png) here.\n"; + const { session } = createSession(original); + await mount(session, original); + await act(async () => {}); + + const stub = container.querySelector(".ProseMirror [data-image-stub]"); + expect(stub?.getAttribute("data-image-stub")).toBe("images/pic.png"); + expect(stub?.getAttribute("data-image-alt")).toBe("A pixel"); + expect(stub?.getAttribute("data-image-file")).toBe("notes.md"); + expect(container.querySelector("img")).toBeNull(); + await act(async () => vi.advanceTimersByTime(1_000)); + expect(session.changeFromRichEditor).not.toHaveBeenCalled(); + }); + + it("opening a file does not mark it changed", async () => { + const original = "Title\n=====\n\n* one\n* two\n"; + const { session } = createSession(original); + const { onEditorChange } = await mount(session, original); + await act(async () => vi.advanceTimersByTime(1_000)); + await act(async () => root.render(null)); + + expect(onEditorChange).toHaveBeenCalledWith( + expect.objectContaining({ canUndo: false }), + original, + ); + expect(session.changeFromRichEditor).not.toHaveBeenCalled(); + expect(session.getSnapshot().save.phase).toBe("clean"); + }); + + it("frontmatter is shown read-only and kept on edit", async () => { + const source = "---\ntitle: Spaced\n---\nBody text.\n"; + const { session } = createSession(source); + const { tiptap } = await mount(session, source); + + const block = container.querySelector("[data-rich-markdown-frontmatter]"); + expect(block?.textContent).toContain("Edit frontmatter in source view"); + expect(block?.textContent).toContain("title: Spaced"); + expect(block?.closest("[contenteditable='true']")).toBeNull(); + expect(tiptap!.getText()).not.toContain("Spaced"); + + typeAfter(tiptap!, "Body text.", " More."); + await act(async () => vi.advanceTimersByTime(300)); + + expect(session.unsavedContents).toBe("---\ntitle: Spaced\n---\nBody text. More.\n"); + }); + + it("undo and redo reverse a rich edit", async () => { + const { session } = createSession("Text\n"); + const { tiptap, onEditorChange } = await mount(session, "Text\n"); + const editor = onEditorChange.mock.lastCall![0]!; + + typeAfter(tiptap!, "Text", "!"); + expect(editor.canUndo).toBe(true); + act(() => editor.undo()); + expect(editor.serialize()).toBe("Text"); + act(() => editor.redo()); + expect(editor.serialize()).toBe("Text!"); + }); + + it("typing works in a list item, a block quote and a table cell", async () => { + const original = "* item\n\n> quoted\n\n| a | b |\n|---|---|\n| cell | 2 |\n"; + const { session } = createSession(original); + const { tiptap } = await mount(session, original); + + typeAfter(tiptap!, "item", " one"); + typeAfter(tiptap!, "quoted", " two"); + typeAfter(tiptap!, "cell", " three"); + await act(async () => vi.advanceTimersByTime(300)); + + const saved = session.unsavedContents!; + expect(saved).toContain("* item one\n"); + expect(saved).toContain("> quoted two\n"); + expect(saved).toMatch(/\| cell three \|\s*2\s*\|/); + expect(saved).toContain("| a | b |\n|---|---|\n"); + }); + + it("rapid typing serializes once after 300 ms", async () => { + const { session } = createSession("Fast\n"); + const { tiptap } = await mount(session, "Fast\n"); + + for (const letter of ["a", "b", "c"]) { + typeAfter(tiptap!, "Fast", letter); + await act(async () => vi.advanceTimersByTime(100)); + } + expect(session.changeFromRichEditor).not.toHaveBeenCalled(); + expect(session.getSnapshot().editPending).toBe(true); + await act(async () => vi.advanceTimersByTime(300)); + + expect(session.changeFromRichEditor).toHaveBeenCalledOnce(); + expect(session.changeFromRichEditor).toHaveBeenCalledWith("Fastabc\n"); + expect(session.getSnapshot().editPending).toBe(false); + }); + + it.each([ + ["Save", (session: FileEditingSession) => void session.flush()], + ["a view switch", (session: FileEditingSession) => session.commitRichEdits()], + [ + "a file switch", + (session: FileEditingSession) => { + const registry = new FileEditingSessionRegistry>(); + registry.getOrCreate("notes.md", () => session); + registry.setActivePath("notes.md"); + registry.setActivePath("other.md"); + }, + ], + ["a rename", (session: FileEditingSession) => session.rename("renamed.md")], + ])("%s delivers a pending edit without advancing the clock", async (_name, operation) => { + const { session } = createSession("Pending\n"); + const { tiptap } = await mount(session, "Pending\n"); + typeAfter(tiptap!, "Pending", " edit"); + + act(() => operation(session)); + + expect(session.changeFromRichEditor).toHaveBeenCalledWith("Pending edit\n"); + expect(session.documentContents).toBe("Pending edit\n"); + }); + + it("a disk change during the 300 ms wait shows the conflict choice and loses neither version", async () => { + const { session } = createSession("Mine\n"); + const { tiptap } = await mount(session, "Mine\n"); + typeAfter(tiptap!, "Mine", " edited"); + + const token = session.beginDiskCheck(); + expect(token).not.toBeNull(); + expect(session.reconcileVersion(token!, { _tag: "present", version: "v2" })).toBe("read"); + session.reconcileRead(token!, { version: "v2", contents: "Theirs\n" }); + + expect(session.getSnapshot().conflict).toMatchObject({ reason: "changed", diskKnown: true }); + expect(session.documentContents).toBe("Mine edited\n"); + act(() => session.reloadFromDisk()); + expect(session.documentContents).toBe("Theirs\n"); + }); + + it("an edit typed while a disk check is out makes a newer disk version a conflict", async () => { + const { session, persist } = createSession("Mine\n"); + const { tiptap } = await mount(session, "Mine\n"); + const token = session.beginDiskCheck()!; + + typeAfter(tiptap!, "Mine", " edited"); + expect(session.reconcileVersion(token, { _tag: "present", version: "v2" })).toBe("read"); + session.reconcileRead(token, { version: "v2", contents: "Theirs\n" }); + await act(async () => vi.advanceTimersByTime(300)); + await act(async () => root.render(null)); + await act(async () => session.flush()); + + expect(session.getSnapshot().conflict).toMatchObject({ reason: "changed" }); + expect(session.documentContents).toBe("Mine edited\n"); + expect(persist).not.toHaveBeenCalled(); + }); + + it.each([ + ["an entity", "Copyright © 2026.\n"], + ["a link around an image", "See [![logo](logo.png)](https://example.test) here.\n"], + ])("a file with %s opens without an editor", async (_name, original) => { + const { session } = createSession(original); + const { onEditorChange } = await mount(session, original); + + expect(onEditorChange).toHaveBeenCalledExactlyOnceWith(null, original); + }); + + it("an edit after an empty details block reaches the session and survives leaving", async () => { + const original = "
More
\n\nHello\n"; + const { session } = createSession(original); + const { tiptap, onEditorChange } = await mount(session, original); + expect(onEditorChange.mock.lastCall![0]).not.toBeNull(); + + typeAfter(tiptap!, "Hello", " world"); + await act(async () => vi.advanceTimersByTime(300)); + await act(async () => root.render(null)); + + expect(session.unsavedContents).toBe( + "
More
\n\nHello world\n", + ); + }); + + it("an edit the reconcile throws on stays pending and visible", async () => { + const { session } = createSession("Hello\n"); + const { tiptap } = await mount(session, "Hello\n"); + reconcile.fail = true; + + typeAfter(tiptap!, "Hello", " world"); + await act(async () => { + expect(() => vi.advanceTimersByTime(300)).toThrow("reconcile failed"); + }); + + expect(session.getSnapshot().editPending).toBe(true); + }); + + it("an edit whose delivery fails is still saved after Save, a fix and leaving the file", async () => { + const { session, persist } = createSession("Hello\n"); + const { tiptap } = await mount(session, "Hello\n"); + reconcile.fail = true; + typeAfter(tiptap!, "Hello", " world"); + await act(async () => { + expect(() => vi.advanceTimersByTime(300)).toThrow("reconcile failed"); + }); + + await act(async () => { + await expect(session.flush()).resolves.toBe("failed"); + }); + expect(session.getSnapshot().editPending).toBe(true); + reconcile.fail = false; + await act(async () => root.render(null)); + await act(async () => void (await session.flush())); + + expect(persist.mock.lastCall?.[1]).toBe("Hello world\n"); + }); + + it("leaving the file while the reconcile still throws keeps the editor's text", async () => { + const source = "---\r\na: 1\r\n---\r\nOne\r\n\r\nHello\r\n"; + const { session } = createSession(source); + const { tiptap } = await mount(session, source); + reconcile.fail = true; + typeAfter(tiptap!, "Hello", " world"); + + await act(async () => root.render(null)); + + expect(session.unsavedContents).toBe("---\r\na: 1\r\n---\r\nOne\r\n\r\nHello world\r\n"); + expect(session.getSnapshot().editPending).toBe(false); + }); + + it("disposing the registry while the reconcile still throws saves the editor's text", async () => { + const { session, persist } = createSession("One\r\n\r\nHello\r\n"); + const registry = new FileEditingSessionRegistry>(); + registry.getOrCreate("notes.md", () => session); + const { tiptap } = await mount(session, "One\r\n\r\nHello\r\n"); + reconcile.fail = true; + typeAfter(tiptap!, "Hello", " world"); + + await act(async () => { + await registry.reconcile([]); + await registry.dispose(); + }); + + expect(persist.mock.lastCall?.[1]).toBe("One\r\n\r\nHello world\r\n"); + }); + + it("unmounting the registry's owner with the surface inside saves a waiting edit", async () => { + const { session, persist } = createSession("Hello\n"); + const registry = new FileEditingSessionRegistry>(); + registry.getOrCreate("notes.md", () => session); + // The owner's cleanup runs before the surface's, as in ChatView around the Files panel. + function Owner({ children }: { children: ReactNode }) { + useEffect(() => registry.acquireOwnership(), []); + return children; + } + await act(async () => { + root.render( + + + , + ); + }); + const tiptap = (container.querySelector(".ProseMirror") as HTMLElement & { editor: Editor }) + .editor; + typeAfter(tiptap, "Hello", " world"); + + await act(async () => root.render(null)); + await act(async () => registry.dispose()); + + expect(persist.mock.lastCall?.[1]).toBe("Hello world\n"); + }); + + it.each([ + ["a table", "| a | b |\n|---|---|\n| 1 | 2 |\n"], + ["a paragraph", "Next paragraph.\n"], + ["a fence", "```js\nx\n```\n"], + ])( + "splitting a paragraph before %s keeps one blank line, visit after visit", + async (_name, tail) => { + let text = `first half second half\n\n${tail}`; + for (const [marker, expected] of [ + ["first half", `first half\n\nsecond half\n\n${tail}`], + ["second", `first half\n\nsecond\n\nhalf\n\n${tail}`], + ] as const) { + const { session } = createSession(text); + const { tiptap } = await mount(session, text); + let position = -1; + tiptap!.state.doc.descendants((node, pos) => { + if (position < 0 && node.isTextblock && node.textContent.startsWith(marker)) { + position = pos + 1 + marker.length; + } + }); + act(() => { + tiptap! + .chain() + .setTextSelection(position) + .deleteRange({ from: position, to: position + 1 }) + .splitBlock() + .run(); + }); + await act(async () => vi.advanceTimersByTime(300)); + await act(async () => root.render(null)); + + expect(session.unsavedContents).toBe(expected); + text = expected; + } + }, + ); + + it("Reload and Overwrite change nothing while a waiting edit cannot be delivered", async () => { + const { session, persist } = createSession("Mine\n"); + const { tiptap } = await mount(session, "Mine\n"); + typeAfter(tiptap!, "Mine", " edited"); + const token = session.beginDiskCheck()!; + session.reconcileVersion(token, { _tag: "present", version: "v2" }); + session.reconcileRead(token, { version: "v2", contents: "Theirs\n" }); + reconcile.fail = true; + typeAfter(tiptap!, "Mine edited", " more"); + + act(() => session.reloadFromDisk()); + act(() => session.overwriteDisk()); + + expect(session.getSnapshot().conflict).toMatchObject({ reason: "changed" }); + expect(session.documentContents).toBe("Mine edited\n"); + expect(session.getSnapshot().editPending).toBe(true); + expect(persist).not.toHaveBeenCalled(); + }); + + it("a disk check while delivery fails applies no result, so the newer disk version still blocks Save", async () => { + const { session, persist } = createSession("Mine\n"); + const { tiptap } = await mount(session, "Mine\n"); + const token = session.beginDiskCheck()!; + reconcile.fail = true; + typeAfter(tiptap!, "Mine", " edited"); + + expect(session.reconcileVersion(token, { _tag: "present", version: "v2" })).toBe("stale"); + session.reconcileRead(token, { version: "v2", contents: "Theirs\n" }); + expect(session.beginDiskCheck()).toBeNull(); + expect(session.documentContents).toBe("Mine\n"); + expect(session.getSnapshot().conflict).toBeNull(); + expect(session.getSnapshot().editPending).toBe(true); + reconcile.fail = false; + await act(async () => root.render(null)); + expect(session.documentContents).toBe("Mine edited\n"); + expect(session.getSnapshot().save.phase).toBe("pending"); + + // The save precondition is still v1, so the next check sees v2 as a change on disk. + const next = session.beginDiskCheck()!; + expect(session.reconcileVersion(next, { _tag: "present", version: "v2" })).toBe("read"); + session.reconcileRead(next, { version: "v2", contents: "Theirs\n" }); + await act(async () => void (await session.flush())); + + expect(session.getSnapshot().conflict).toMatchObject({ reason: "changed", diskKnown: true }); + expect(session.documentContents).toBe("Mine edited\n"); + expect(persist).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/RichMarkdownSurface.tsx b/apps/web/src/components/files/richMarkdown/RichMarkdownSurface.tsx new file mode 100644 index 0000000000..a4c37b2fe7 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/RichMarkdownSurface.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import type { FileEditingSession } from "../fileEditingSession"; +import { canReconcile, detectDominantEol, reconcileMarkdown } from "./markdownReconcile"; +import { splitFrontmatter } from "./markdownSupport"; +import type { MermaidDiagramView } from "./mermaidDiagramExtension"; +import { RichMarkdownImage, type RichMarkdownWorkspace } from "./RichMarkdownImage"; +import { RichMarkdownMermaid } from "./RichMarkdownMermaid"; +import { + createRichMarkdownEditor, + type RichMarkdownEditor, + type RichMarkdownImageView, +} from "./richMarkdownExtensions"; +import "./richMarkdown.css"; + +const SERIALIZE_DELAY_MS = 300; + +type RichEditingSession = Pick< + FileEditingSession, + "changeFromRichEditor" | "setPendingEditFlusher" | "commitRichEdits" | "setComposing" +>; + +export interface RichMarkdownSurfaceProps { + readonly session: RichEditingSession; + /** The text to open, read once: later changes come from this surface or remount it. */ + readonly contents: string; + /** + * The editor once it is ready, or null when the file cannot be edited block for block, with + * the text it opened. + */ + readonly onEditorChange: (editor: RichMarkdownEditor | null, opened: string) => void; + /** Where the file's relative images are read from. */ + readonly workspace: RichMarkdownWorkspace; +} + +/** + * The editable rendered view of a Markdown file. Edits reach the session 300 ms after the last + * change, reconciled so untouched blocks keep the file's own bytes, with the frontmatter kept + * verbatim and shown read-only above the editor. + */ +export default function RichMarkdownSurface({ + session, + contents, + onEditorChange, + workspace, +}: RichMarkdownSurfaceProps) { + const [opened] = useState(() => ({ source: contents, ...splitFrontmatter(contents) })); + const editorRef = useRef(null); + // Changes only when a diagram block is added or removed, never on typing. + const [diagrams, setDiagrams] = useState([]); + // Changes only when an image is added, removed or given another source. + const [images, setImages] = useState([]); + + useEffect(() => { + const element = editorRef.current; + if (!element) return; + let timer: ReturnType | null = null; + // True from an edit until it is delivered, so an edit whose delivery threw stays unsaved. + let waiting = false; + let delivered = opened.source; + let before = ""; + const send = (next: string) => { + waiting = false; + session.setPendingEditFlusher(null); + if (next === delivered) return; + delivered = next; + session.changeFromRichEditor(next); + }; + // `whole` is the last resort when the reconcile throws: the editor's whole serialization, + // with the body's line ending and final-newline state, rather than a lost edit. + const deliver = (whole = false) => { + if (timer !== null) clearTimeout(timer); + timer = null; + if (whole) { + const eol = detectDominantEol(opened.body); + const text = editor.serialize().trim().replaceAll("\n", eol); + send(`${opened.frontmatter}${text}${opened.body.slice(opened.body.trimEnd().length)}`); + return; + } + const body = reconcileMarkdown({ original: opened.body, before, after: editor.serialize() }); + send(`${opened.frontmatter}${body}`); + }; + const editor = createRichMarkdownEditor({ + body: opened.body, + element, + onUpdate: () => { + if (!waiting) session.setPendingEditFlusher(deliver); + waiting = true; + if (timer !== null) clearTimeout(timer); + timer = setTimeout(() => deliver(), SERIALIZE_DELAY_MS); + }, + onDiagramViewsChange: setDiagrams, + onImageViewsChange: setImages, + }); + before = editor.serialize(); + if (!canReconcile({ original: opened.body, before })) { + // Saving would rewrite blocks the person did not touch; the panel shows the read-only view. + editor.destroy(); + onEditorChange(null, opened.source); + return; + } + const startComposing = () => session.setComposing(true); + const endComposing = () => session.setComposing(false); + element.addEventListener("compositionstart", startComposing); + element.addEventListener("compositionend", endComposing); + onEditorChange(editor, opened.source); + return () => { + element.removeEventListener("compositionstart", startComposing); + element.removeEventListener("compositionend", endComposing); + session.setComposing(false); + if (waiting) { + try { + deliver(); + } catch (error) { + console.error("[rich-markdown] reconciling the last edit failed", error); + deliver(true); + } + } + session.commitRichEdits(); + editor.destroy(); + }; + }, [onEditorChange, opened, session]); + + return ( +
+ {opened.frontmatter ? ( +
+

Edit frontmatter in source view

+
{opened.frontmatter}
+
+ ) : null} + {/* ChatMarkdown's root classes: the diagram fonts are measured once per page from here. */} +
+ {diagrams.map((view) => + createPortal(, view.element, String(view.id)), + )} + {images.map((view) => + createPortal( + , + view.element, + String(view.id), + ), + )} +
+ ); +} diff --git a/apps/web/src/components/files/richMarkdown/detailsExtension.ts b/apps/web/src/components/files/richMarkdown/detailsExtension.ts new file mode 100644 index 0000000000..4dcc9f759b --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/detailsExtension.ts @@ -0,0 +1,185 @@ +/* + * Adapted from Orca (https://github.com/stablyai/orca), + * src/renderer/src/components/editor/rich-markdown/details/rich-markdown-details-extension.ts + * and details-markdown-html.ts. + * Copyright (c) 2026 Lovecast Inc. Licensed under the MIT License: + * Permission is hereby granted, free of charge, to any person obtaining a copy of this software + * and associated documentation files (the "Software"), to deal in the Software without + * restriction, including without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the + * Software is furnished to do so, subject to the following conditions: The above copyright + * notice and this permission notice shall be included in all copies or substantial portions of + * the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A + * PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE + * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR + * OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER + * DEALINGS IN THE SOFTWARE. + * + * Changed for BiBCode: only the `open` attribute is accepted and none is written, the toggle + * heading variants, placeholders and Orca's class are dropped, and the fence scan is inlined. + */ +import { decodeHtmlEntities, type AnyExtension, type MarkdownToken } from "@tiptap/core"; +import { Details, DetailsContent, DetailsSummary } from "@tiptap/extension-details"; + +type DetailsToken = MarkdownToken & { + summaryTokens?: MarkdownToken[]; + bodyTokens?: MarkdownToken[]; + open?: boolean; +}; + +const OPENING_TAG = /^/i; +const SUMMARY = /^\s*([^<]*)<\/summary>/i; +const TAG = /<\/?details\b[^>]*>/gi; + +/** Offsets of lines inside fenced code, where a `
` is text, not a tag. */ +function insideFence(content: string): (offset: number) => boolean { + const ranges: [number, number][] = []; + let fence: string | null = null; + let fenceStart = 0; + let offset = 0; + for (const line of content.split("\n")) { + const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]; + if (fence === null && marker) { + fence = marker; + fenceStart = offset; + } else if ( + fence !== null && + marker && + marker[0] === fence[0] && + marker.length >= fence.length + ) { + ranges.push([fenceStart, offset + line.length]); + fence = null; + } + offset += line.length + 1; + } + if (fence !== null) ranges.push([fenceStart, content.length]); + return (at) => ranges.some(([start, end]) => at > start && at <= end); +} + +/** A `
` block at the start of `content`, through its matching close, or null. */ +function matchDetailsBlock(content: string): { raw: string; inner: string } | null { + const opening = OPENING_TAG.exec(content); + if (!opening) return null; + const fenced = insideFence(content); + TAG.lastIndex = 0; + let depth = 0; + for (let tag = TAG.exec(content); tag !== null; tag = TAG.exec(content)) { + if (tag.index !== 0 && fenced(tag.index)) continue; + if (tag[0].startsWith("", ">") + .replaceAll('"', """); +} + +declare module "@tiptap/core" { + interface Commands { + filesDetails: { + /** Inserts an open collapsible section titled "More", with the caret in its body. */ + insertDetails: () => ReturnType; + }; + } +} + +const FilesDetails = Details.extend({ + addCommands() { + return { + ...this.parent?.(), + insertDetails: + () => + ({ state, chain }) => { + const at = state.selection.$from.after(1); + const section = { + type: "details", + attrs: { open: true }, + content: [ + { type: "detailsSummary", content: [{ type: "text", text: "More" }] }, + { type: "detailsContent", content: [{ type: "paragraph" }] }, + ], + }; + // Body paragraph start: details, summary text "More" and its close, content, paragraph. + return chain() + .insertContentAt(at, section) + .setTextSelection(at + 1 + 6 + 1 + 1) + .run(); + }, + }; + }, + markdownTokenizer: { + name: "details", + level: "block", + start: (source: string) => { + const match = /(?:^|\n) {0,3} { + const details = token as DetailsToken; + if (details.type !== "details") return []; + const summary = helpers.createNode( + "detailsSummary", + {}, + helpers.parseInline(details.summaryTokens ?? []), + ); + const body = helpers.parseChildren(details.bodyTokens ?? []); + const content = helpers.createNode( + "detailsContent", + {}, + body.length > 0 ? body : [helpers.createNode("paragraph")], + ); + return helpers.createNode("details", { open: details.open === true }, [summary, content]); + }, + renderMarkdown: (node, helpers) => { + const summary = node.content?.find((child) => child.type === "detailsSummary"); + const content = node.content?.find((child) => child.type === "detailsContent"); + const summaryText = escapeDetailsHtml( + decodeHtmlEntities(helpers.renderChildren(summary?.content ?? [], "")), + ); + const body = helpers.renderChildren(content?.content ?? [], "\n\n").trim(); + return `
\n${summaryText}\n\n${body}\n\n
`; + }, +}); + +// Inline images and links can sit in a summary; the upstream text-only content would reject them. +const FilesDetailsSummary = DetailsSummary.extend({ content: "inline*" }); + +export function createDetailsExtensions(): AnyExtension[] { + // The open state lives in the document so a new section opens, but renderMarkdown never writes + // it: toggling a section does not change the file, and a hand-written `open` stays as written. + return [FilesDetails.configure({ persist: true }), FilesDetailsSummary, DetailsContent]; +} diff --git a/apps/web/src/components/files/richMarkdown/fixtures/corpus.ts b/apps/web/src/components/files/richMarkdown/fixtures/corpus.ts new file mode 100644 index 0000000000..58526e31b6 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/fixtures/corpus.ts @@ -0,0 +1,63 @@ +/** Markdown inputs for the rich-editing round-trip tests. Each one is a shape a real file has. */ + +export const yamlFrontmatter = "---\ntitle: Guide\ntags: [a, b]\n---\n\n# Guide\n\nIntro text.\n"; + +export const tomlFrontmatterNoFinalNewline = '+++\ntitle = "Guide"\n+++'; + +export const alignedTable = + "| Name | Count | Note |\n|:-----|------:|:----:|\n| one | 1 | a |\n| two | 22 | b |\n\nAfter the table.\n"; + +export const fourBacktickFence = + "Before the fence.\n\n````js \nconst value = 1; \n```\nnot a close\n````\n\nThe last paragraph.\n"; + +export const mixedListMarkers = "* star one\n* star two\n\n- dash three\n- dash four\n"; + +export const listWithNestedFence = + "* first item\n* second item\n\n ````text\n nested code\n ````\n\n* third item\n"; + +export const blockquoteWithList = + "> Quoted intro.\n>\n> - quoted one\n> - quoted _two_\n\nOutside.\n"; + +export const setextHeadings = "Main title\n==========\n\nSection\n-------\n\nBody text.\n"; + +export const handWrittenDetails = + "Intro.\n\n
\nMore\n\nHidden text.\n\nSecond hidden paragraph with _underscores_.\n\n
\n\nOutro.\n"; + +export const crlfLineEndings = "# Title\r\n\r\nFirst paragraph.\r\n\r\nSecond paragraph.\r\n"; + +export const taskList = "- [ ] open task\n- [x] done task\n\nAfter tasks.\n"; + +export const referenceLinks = + "See [the docs][docs] for more.\n\n[docs]: https://example.test/docs\n"; + +export const footnote = "A claim.[^1]\n\n[^1]: The source.\n"; + +export const htmlComment = "Text.\n\n\n\nMore text.\n"; + +export const rawDiv = "Text.\n\n
raw block
\n\nMore text.\n"; + +export const inlineMath = "The area is $a^2$ here.\n"; + +export const blockMath = "Before.\n\n$$\nE = mc^2\n$$\n\nAfter.\n"; + +/** Every fixture the editor must keep editable (none of the read-only constructs). */ +/** One HTML block with no body: TipTap writes it back in the blank-line form. */ +export const emptyDetails = "
More
\n\nHello\n"; + +/** A tilde fence, which the editor would write with backticks, drawn as a diagram. */ +export const mermaidFence = + "Before the diagram.\n\n~~~mermaid\ngraph TD\n A --> B\n~~~\n\nAfter the diagram.\n"; + +export const supportedFixtures = { + mermaidFence, + alignedTable, + fourBacktickFence, + mixedListMarkers, + listWithNestedFence, + blockquoteWithList, + setextHeadings, + handWrittenDetails, + emptyDetails, + crlfLineEndings, + taskList, +} as const; diff --git a/apps/web/src/components/files/richMarkdown/markdownReconcile.test.ts b/apps/web/src/components/files/richMarkdown/markdownReconcile.test.ts new file mode 100644 index 0000000000..04eda02d45 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/markdownReconcile.test.ts @@ -0,0 +1,240 @@ +import { describe, expect, it, vi } from "vite-plus/test"; + +import * as corpus from "./fixtures/corpus"; +import { canReconcile, reconcileMarkdown } from "./markdownReconcile"; + +const allFixtures = Object.values(corpus).filter( + (value): value is Extract<(typeof corpus)[keyof typeof corpus], string> => + typeof value === "string", +); + +describe("reconcileMarkdown", () => { + it("returns the original unchanged when nothing was edited, for every corpus fixture", () => { + for (const original of allFixtures) { + const canonical = `${original.trim()} canonical`; + expect(reconcileMarkdown({ original, before: canonical, after: canonical })).toBe(original); + } + }); + + it("keeps every byte before an edited last paragraph, including a four-backtick fence", () => { + const original = corpus.fourBacktickFence; + const before = + "Before the fence.\n\n````js\nconst value = 1; \n```\nnot a close\n````\n\nThe last paragraph."; + const after = before.replace("The last paragraph.", "The last paragraph, edited."); + + expect(reconcileMarkdown({ original, before, after })).toBe( + original.replace("The last paragraph.", "The last paragraph, edited."), + ); + }); + + it("leaves the blocks before and after an inserted block byte-identical", () => { + const original = corpus.setextHeadings; + const before = "# Main title\n\n## Section\n\nBody text."; + const after = "# Main title\n\nInserted.\n\n## Section\n\nBody text."; + + expect(reconcileMarkdown({ original, before, after })).toBe( + "Main title\n==========\n\nInserted.\n\nSection\n-------\n\nBody text.\n", + ); + }); + + it("a block inserted between blocks the original wrote with no blank line keeps the rest's bytes", () => { + const original = "# Title\nPara\n\n* a\n* b\n"; + const before = "# Title\n\nPara\n\n* a\n* b"; + const after = "# Title\n\nnew\n\nPara\n\n* a\n* b"; + + expect(reconcileMarkdown({ original, before, after })).toBe( + "# Title\n\nnew\n\nPara\n\n* a\n* b\n", + ); + }); + + it("a paragraph inserted inside a block quote keeps the quote's `> ` prefix", () => { + const original = "> a\n>\n> b\n"; + const before = "> a\n>\n> b"; + const after = "> a\n>\n> new\n>\n> b"; + + expect(reconcileMarkdown({ original, before, after })).toBe("> a\n>\n> new\n>\n> b\n"); + }); + + it("writes a changed block with the original's CRLF line endings", () => { + const original = corpus.crlfLineEndings; + const before = "# Title\n\nFirst paragraph.\n\nSecond paragraph."; + const after = "# Title\n\nFirst paragraph changed.\nNext line.\n\nSecond paragraph."; + + expect(reconcileMarkdown({ original, before, after })).toBe( + "# Title\r\n\r\nFirst paragraph changed.\r\nNext line.\r\n\r\nSecond paragraph.\r\n", + ); + }); + + it("keeps a sibling list item's marker and nested fence when the first item is edited", () => { + const original = corpus.listWithNestedFence; + const before = "* first item\n* second item\n ```text\n nested code\n ```\n* third item"; + const after = before.replace("first item", "first item edited"); + + expect(reconcileMarkdown({ original, before, after })).toBe( + original.replace("first item", "first item edited"), + ); + }); + + it("keeps sibling bytes when an item inside a block quote is edited", () => { + const original = corpus.blockquoteWithList; + const before = "> Quoted intro.\n>\n> - quoted one\n> - quoted *two*\n\nOutside."; + const after = before.replace("quoted one", "quoted uno"); + + expect(reconcileMarkdown({ original, before, after })).toBe( + original.replace("quoted one", "quoted uno"), + ); + }); + + it("keeps sibling bytes when a paragraph inside a details body is edited", () => { + const original = corpus.handWrittenDetails; + const before = + "Intro.\n\n
\nMore\n\nHidden text.\n\nSecond hidden paragraph with *underscores*.\n\n
\n\nOutro."; + const after = before.replace("Hidden text.", "Hidden text, edited."); + + expect(reconcileMarkdown({ original, before, after })).toBe( + original.replace("Hidden text.", "Hidden text, edited."), + ); + }); + + it("takes an ambiguous region from the edit and keeps fenced code contents", () => { + const original = "Title\n=====\n\n# Title\n\n````\ncode\n````\n"; + const before = "# Title\n\n# Title\n\n```\ncode\n```"; + // The first heading was deleted; both serialize the same, so neither original is chosen. + const after = "# Title\n\n```\ncode\n```"; + + expect(reconcileMarkdown({ original, before, after })).toBe("# Title\n\n````\ncode\n````\n"); + }); + + it("does not copy a deleted block back through the separator", () => { + const original = "Alpha\n\nDelete me\n\n````\nkeep\n````\n\nOmega\n"; + const before = "Alpha\n\nDelete me\n\n```\nkeep\n```\n\nOmega"; + const after = "Alpha\n\n```\nkeep\n```\n\nOmega"; + + expect(reconcileMarkdown({ original, before, after })).toBe( + "Alpha\n\n````\nkeep\n````\n\nOmega\n", + ); + }); + + it("keeps mixed line endings inside an untouched fence when a block is deleted", () => { + const original = "````text\none\r\ntwo\r\n````\n\nAlpha\n\nDelete me\n\nOmega"; + const before = "```text\none\r\ntwo\r\n```\n\nAlpha\n\nDelete me\n\nOmega"; + const after = "```text\none\r\ntwo\r\n```\n\nAlpha\n\nOmega"; + + expect(reconcileMarkdown({ original, before, after })).toBe( + "````text\none\r\ntwo\r\n````\n\nAlpha\n\nOmega", + ); + }); + + it("takes a table from the edit when its rebuilt rows no longer read as the edit", () => { + const original = "Intro\n=====\n\n| a | b |\n|---|---|\n| 1 | 2 |\n"; + const before = "# Intro\n\n| a | b |\n| --- | --- |\n| 1 | 2 |"; + const after = "# Intro\n\n| a | b | c |\n| --- | --- | --- |\n| 1 | 2 | 3 |"; + + expect(reconcileMarkdown({ original, before, after })).toBe( + "Intro\n=====\n\n| a | b | c |\n| --- | --- | --- |\n| 1 | 2 | 3 |\n", + ); + }); + + it("changes only the checkbox character when a task is ticked", () => { + const original = "- [ ] Keep\n- [ ] Other\n"; + const before = "- [ ] Keep\n- [ ] Other"; + const after = "- [x] Keep\n- [ ] Other"; + + expect(reconcileMarkdown({ original, before, after })).toBe("- [x] Keep\n- [ ] Other\n"); + }); + + it("gives an inserted ordered item the list's delimiter and keeps the renumbered item", () => { + const original = "1) one\n2) two\n"; + const before = "1. one\n2. two"; + const after = "1. one\n2. new\n3. two"; + + expect(reconcileMarkdown({ original, before, after })).toBe("1) one\n2) new\n2) two\n"); + }); + + it("never changes the original's final-newline state", () => { + const edit = (original: string) => + reconcileMarkdown({ original, before: "A\n\nB", after: "A\n\nB2" }); + + expect(edit("A\n\nB")).toBe("A\n\nB2"); + expect(edit("A\n\nB\n")).toBe("A\n\nB2\n"); + expect(edit("A\n\nB\n\n\n")).toBe("A\n\nB2\n\n\n"); + }); +}); + +describe("an empty details block", () => { + // TipTap 3.31.4 writes `
More
` as the blank-line form. + const original = "
More
\n\nHello\n"; + const before = "
\nMore\n\n\n\n
\n\nHello"; + + it("opens editable and keeps its bytes when the paragraph after it is edited", () => { + expect(canReconcile({ original, before })).toBe(true); + expect(reconcileMarkdown({ original, before, after: `${before} world` })).toBe( + "
More
\n\nHello world\n", + ); + }); +}); + +describe("a reconcile whose blocks do not line up", () => { + it("saves the edit from the serialization instead of throwing", () => { + const failures = vi.spyOn(console, "error").mockImplementation(() => undefined); + expect(reconcileMarkdown({ original: "A\n", before: "A\n\nB", after: "A\n\nB x" })).toBe( + "A\n\nB x\n", + ); + expect(failures).toHaveBeenCalledOnce(); + failures.mockRestore(); + }); +}); + +describe("canReconcile", () => { + it("is true for every supported fixture with a faithful serialization", () => { + for (const original of Object.values(corpus.supportedFixtures)) { + expect(canReconcile({ original, before: original })).toBe(true); + } + expect( + canReconcile({ + original: corpus.setextHeadings, + before: "# Main title\n\n## Section\n\nBody text.", + }), + ).toBe(true); + expect( + canReconcile({ + original: corpus.alignedTable, + before: + "\n| Name | Count | Note |\n| :--- | ---: | :---: |\n| one | 1 | a |\n| two | 22 | b |\n\nAfter the table.", + }), + ).toBe(true); + expect( + canReconcile({ + original: corpus.handWrittenDetails, + before: + "Intro.\n\n
\nMore\n\nHidden text.\n\nSecond hidden paragraph with *underscores*.\n\n
\n\nOutro.", + }), + ).toBe(true); + }); + + it("treats a wrapped CRLF paragraph as the same text as its LF serialization", () => { + expect(canReconcile({ original: "first\r\nsecond\r\n", before: "first\nsecond" })).toBe(true); + }); + + it("is false when the serialization has one block fewer", () => { + expect(canReconcile({ original: "A\n\nB\n\nC\n", before: "A\n\nB" })).toBe(false); + }); + + it("is false when a nested list has a different number of items", () => { + expect( + canReconcile({ + original: "- one\n - a\n - b\n- two\n", + before: "- one\n - a\n- two", + }), + ).toBe(false); + }); + + it("is false when the serialization loses inline content", () => { + expect( + canReconcile({ + original: "[![a](b.png)](https://example.test)\n", + before: "![a](b.png)", + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/markdownReconcile.ts b/apps/web/src/components/files/richMarkdown/markdownReconcile.ts new file mode 100644 index 0000000000..77e2810c66 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/markdownReconcile.ts @@ -0,0 +1,656 @@ +/* + * detectDominantEol is adapted from Orca (https://github.com/stablyai/orca), + * src/renderer/src/components/editor/rich-markdown/document/rich-markdown-source-reconcile.ts. + * Copyright (c) 2026 Lovecast Inc. Licensed under the MIT License. + * The block-level reconcile below is not from Orca. + */ +import type { Nodes, Root, RootContent } from "mdast"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +/** + * Keeps the file's own spelling when a rendered-view edit is saved. `original` is the file body, + * `before` the editor's serialization when it loaded `original`, and `after` its serialization + * now. Blocks that did not change keep `original`'s bytes; changed and inserted blocks take + * `after`'s text. See docs/plans/2026-11-09-files-rich-markdown-toolbar-design.md. + */ + +interface Block { + readonly kind: string; + readonly start: number; + readonly end: number; + /** Null for a leaf: its text is emitted whole. */ + readonly children: readonly Block[] | null; + /** The block's content without its spelling: equal keys are the same block. */ + readonly key: string; +} + +interface Parsed { + readonly source: string; + readonly root: Root; + readonly blocks: readonly Block[]; +} + +const CONTAINER_KINDS = new Set(["list", "listItem", "blockquote", "table"]); +export const DETAILS_OPEN = + /^\s*([^<]*)<\/summary>\s*$/i; +export const DETAILS_WHOLE = + /^\s*([^<]*)<\/summary>([\s\S]*?)<\/details>\s*$/i; +export const DETAILS_CLOSE = /^<\/details>\s*$/i; + +const processor = unified().use(remarkParse).use(remarkGfm); +const parseCache = new Map(); + +/** + * Parses `source`. With `keep`, the tree stays cached: `original` and `before` are the same for + * a whole visit, so only `after` and the reconcile's own output are parsed on each edit. + */ +function parse(source: string, keep = false): Parsed { + const cached = parseCache.get(source); + if (cached) return cached; + const root = processor.parse(source) as Root; + const parsed = { source, root, blocks: toBlocks(root.children) }; + if (keep) { + // Two visits' worth: the open file's `original` and `before`, and the previous file's. + if (parseCache.size >= 4) parseCache.delete(parseCache.keys().next().value as string); + parseCache.set(source, parsed); + } + return parsed; +} + +function startOf(node: Nodes): number { + return node.position?.start.offset ?? 0; +} + +function endOf(node: Nodes): number { + return node.position?.end.offset ?? 0; +} + +function htmlValue(node: RootContent): string | null { + return node.type === "html" ? node.value : null; +} + +/** The index of the `
` node that closes the opener at `index`, or -1. */ +function detailsClose(nodes: readonly RootContent[], index: number): number { + let depth = 0; + for (let cursor = index; cursor < nodes.length; cursor += 1) { + const value = htmlValue(nodes[cursor]!); + if (value === null) continue; + if (DETAILS_OPEN.test(value)) depth += 1; + else if (DETAILS_CLOSE.test(value)) { + depth -= 1; + if (depth === 0) return cursor; + } + } + return -1; +} + +function toBlocks(nodes: readonly RootContent[]): Block[] { + const blocks: Block[] = []; + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index]!; + const value = htmlValue(node); + if (value !== null && DETAILS_OPEN.test(value)) { + const close = detailsClose(nodes, index); + if (close > index) { + // An empty body is a leaf, as normalizeNodes counts it: one block, not an opener and a closer. + blocks.push({ + kind: "details", + start: startOf(node), + end: endOf(nodes[close]!), + children: close > index + 1 ? toBlocks(nodes.slice(index + 1, close)) : null, + key: JSON.stringify(normalizeNodes(nodes.slice(index, close + 1))), + }); + index = close; + continue; + } + } + const children = + CONTAINER_KINDS.has(node.type) && "children" in node && node.children.length > 0 + ? toBlocks(node.children as RootContent[]) + : null; + blocks.push({ + kind: value !== null && DETAILS_WHOLE.test(value) ? "details" : node.type, + start: startOf(node), + end: endOf(node), + children, + key: JSON.stringify(normalizeNodes([node])), + }); + } + return blocks; +} + +function decodeSummary(text: string): string { + return text + .trim() + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll(""", '"') + .replaceAll("'", "'") + .replaceAll("&", "&"); +} + +type Normalized = { readonly [key: string]: unknown }; + +/** The document a tree describes, without positions or spelling-only details. */ +function normalizeNodes(nodes: readonly RootContent[]): Normalized[] { + const out: Normalized[] = []; + for (let index = 0; index < nodes.length; index += 1) { + const node = nodes[index]!; + const value = htmlValue(node); + if (value !== null) { + const whole = DETAILS_WHOLE.exec(value); + if (whole) { + const body = processor.parse(whole[2]!) as Root; + out.push({ + type: "details", + summary: decodeSummary(whole[1]!), + children: normalizeNodes(body.children), + }); + continue; + } + const open = DETAILS_OPEN.exec(value); + const close = open ? detailsClose(nodes, index) : -1; + if (open && close > index) { + out.push({ + type: "details", + summary: decodeSummary(open[1]!), + children: normalizeNodes(nodes.slice(index + 1, close)), + }); + index = close; + continue; + } + } + const { + position: _position, + data: _data, + ...fields + } = node as RootContent & { + spread?: unknown; + data?: unknown; + }; + const { spread: _spread, ...rest } = fields as typeof fields & { spread?: unknown }; + const normalized: Record = { ...rest }; + // A line break inside a paragraph means the same with either line ending. + if (typeof normalized["value"] === "string") { + normalized["value"] = (normalized["value"] as string).replaceAll("\r\n", "\n"); + } + if ("children" in node) { + normalized["children"] = normalizeNodes(node.children as RootContent[]); + } + const previous = out.at(-1); + if (node.type === "text" && previous?.["type"] === "text") { + out[out.length - 1] = { type: "text", value: `${previous["value"] as string}${node.value}` }; + continue; + } + out.push(normalized); + } + return out; +} + +function sameDocument(left: string, right: string): boolean { + return ( + JSON.stringify(normalizeNodes(parse(left).root.children)) === + JSON.stringify(normalizeNodes(parse(right).root.children)) + ); +} + +/** Same keys at every level, so each `before` block has the `original` block it stands for. */ +function sameBlocks(left: readonly Block[], right: readonly Block[]): boolean { + return ( + left.length === right.length && + left.every((block, index) => { + const other = right[index]!; + if (block.key !== other.key) return false; + // A leaf on either side is copied or replaced whole, never recursed into. + return block.children === null || other.children === null + ? true + : sameBlocks(block.children, other.children); + }) + ); +} + +/** + * True when `before` describes the same document as `original`, block for block, so edits can be + * placed. The keys are normalized trees, so this also compares inline content. + */ +export function canReconcile(input: { original: string; before: string }): boolean { + return sameBlocks(parse(input.original, true).blocks, parse(input.before, true).blocks); +} + +/** CRLF when CRLF lines are at least as many as bare LF lines (Orca's rule). */ +export function detectDominantEol(text: string): "\n" | "\r\n" { + if (!text.includes("\r")) return "\n"; + const totalLf = (text.match(/\n/g) ?? []).length; + const crlf = (text.match(/\r\n/g) ?? []).length; + return crlf > 0 && crlf >= totalLf - crlf ? "\r\n" : "\n"; +} + +function lineStart(source: string, offset: number): number { + return source.lastIndexOf("\n", offset - 1) + 1; +} + +/** The text that starts each continuation line at `offset`'s depth, with markers as spaces. */ +function continuationPrefix(source: string, offset: number): string { + return source + .slice(lineStart(source, offset), offset) + .replace(/\[[ xX]\]/g, " ") + .replace(/\d+[.)]|[-*+]/g, (marker) => " ".repeat(marker.length)); +} + +interface Level { + readonly original: Parsed; + readonly after: Parsed; + readonly eol: string; + /** Continuation prefixes of this level's children in `original` and in `after`. */ + readonly originalPrefix: string; + readonly afterPrefix: string; + /** The bullet character or ordered delimiter of the original list, for inserted items. */ + readonly listMarker: string | null; +} + +/** Text from `after`, moved into `original`'s container prefix and line endings. */ +function fromAfter(level: Level, text: string): string { + const { afterPrefix, originalPrefix } = level; + const lines = text.split("\n"); + const moved = lines.map((line, index) => { + if (index === 0) return line; + let rest = line; + if (rest.startsWith(afterPrefix)) rest = rest.slice(afterPrefix.length); + else if (rest.startsWith(afterPrefix.trimEnd())) + rest = rest.slice(afterPrefix.trimEnd().length); + // The last line leads into the next block's text, so it keeps the whole prefix. + return rest === "" && index < lines.length - 1 + ? originalPrefix.trimEnd() + : `${originalPrefix}${rest}`; + }); + return moved.join(level.eol); +} + +function textOf(parsed: Parsed, block: Block): string { + return parsed.source.slice(block.start, block.end); +} + +/** + * Blocks match on content, not spelling: a renumbered list item or a table row re-padded after + * an edit elsewhere in its table still matches. + */ +function keyOf(_parsed: Parsed, block: Block): string { + return block.key; +} + +type Step = + | { readonly kind: "match"; readonly before: number; readonly after: number } + | { readonly kind: "pair"; readonly before: number; readonly after: number } + | { readonly kind: "insert"; readonly after: number }; + +/** Longest common subsequence of keys, as steps over `after` (unlisted `before` are deleted). */ +function align(beforeKeys: readonly string[], afterKeys: readonly string[]): Step[] { + let prefix = 0; + while ( + prefix < beforeKeys.length && + prefix < afterKeys.length && + beforeKeys[prefix] === afterKeys[prefix] + ) { + prefix += 1; + } + let suffix = 0; + while ( + suffix < beforeKeys.length - prefix && + suffix < afterKeys.length - prefix && + beforeKeys[beforeKeys.length - 1 - suffix] === afterKeys[afterKeys.length - 1 - suffix] + ) { + suffix += 1; + } + const middleBefore = beforeKeys.slice(prefix, beforeKeys.length - suffix); + const middleAfter = afterKeys.slice(prefix, afterKeys.length - suffix); + const rows = middleBefore.length; + const columns = middleAfter.length; + const table = Array.from({ length: rows + 1 }, () => + Array.from({ length: columns + 1 }, () => 0), + ); + for (let row = rows - 1; row >= 0; row -= 1) { + for (let column = columns - 1; column >= 0; column -= 1) { + table[row]![column] = + middleBefore[row] === middleAfter[column] + ? table[row + 1]![column + 1]! + 1 + : Math.max(table[row + 1]![column]!, table[row]![column + 1]!); + } + } + const matches: [number, number][] = []; + for (let index = 0; index < prefix; index += 1) matches.push([index, index]); + let row = 0; + let column = 0; + while (row < rows && column < columns) { + if (middleBefore[row] === middleAfter[column]) { + matches.push([prefix + row, prefix + column]); + row += 1; + column += 1; + } else if (table[row + 1]![column]! >= table[row]![column + 1]!) row += 1; + else column += 1; + } + for (let index = 0; index < suffix; index += 1) { + matches.push([beforeKeys.length - suffix + index, afterKeys.length - suffix + index]); + } + + const steps: Step[] = []; + let nextBefore = 0; + let nextAfter = 0; + const flushGap = (beforeEnd: number, afterEnd: number) => { + // Unmatched blocks between two matches pair up in order; extra `after` blocks are inserts. + while (nextAfter < afterEnd) { + steps.push( + nextBefore < beforeEnd + ? { kind: "pair", before: nextBefore++, after: nextAfter } + : { kind: "insert", after: nextAfter }, + ); + nextAfter += 1; + } + nextBefore = beforeEnd; + }; + for (const [beforeIndex, afterIndex] of matches) { + flushGap(beforeIndex, afterIndex); + steps.push({ kind: "match", before: beforeIndex, after: afterIndex }); + nextBefore = beforeIndex + 1; + nextAfter = afterIndex + 1; + } + flushGap(beforeKeys.length, afterKeys.length); + return steps; +} + +interface Emitted { + readonly text: string; + /** The `original` sibling this block stands for, which decides its separators. */ + readonly original: number | null; + readonly after: number; + /** True when the block was rebuilt from its children rather than copied. */ + readonly rebuilt: boolean; +} + +function headOf(parsed: Parsed, block: Block): string { + return parsed.source.slice(block.start, block.children![0]!.start); +} + +function tailOf(parsed: Parsed, block: Block): string { + return parsed.source.slice(block.children!.at(-1)!.end, block.end); +} + +function listMarkerOf(source: string, list: Block): string | null { + const marker = /^\s*(?:([-*+])|\d+([.)]))/.exec(source.slice(list.start)); + return marker?.[1] ?? marker?.[2] ?? null; +} + +function withListMarker(text: string, marker: string | null): string { + if (marker === null) return text; + return text.replace(/^(\s*)(?:[-*+]|(\d+)[.)])/, (_match, indent: string, number?: string) => + number === undefined ? `${indent}${marker}` : `${indent}${number}${marker}`, + ); +} + +function rebuildHead( + level: Level, + original: Block, + before: Parsed, + beforeBlock: Block, + after: Block, +): string { + const originalHead = headOf(level.original, original); + const beforeHead = headOf(before, beforeBlock); + const afterHead = headOf(level.after, after); + if (beforeHead === afterHead) return originalHead; + if (original.kind === "listItem") { + const checkbox = /\[([ xX])\]/.exec(afterHead); + if (checkbox && /\[[ xX]\]/.test(originalHead)) { + return originalHead.replace(/\[[ xX]\]/, `[${checkbox[1]}]`); + } + return fromAfter(level, withListMarker(afterHead, level.listMarker)); + } + return fromAfter(level, afterHead); +} + +function reconcileChildren( + level: Level, + before: Parsed, + originalBlocks: readonly Block[], + beforeBlocks: readonly Block[], + afterBlocks: readonly Block[], +): Emitted[] { + const steps = align( + beforeBlocks.map((block) => keyOf(before, block)), + afterBlocks.map((block) => keyOf(level.after, block)), + ); + const matchedBefore = new Set( + steps.flatMap((step) => (step.kind === "insert" ? [] : [step.before])), + ); + const ambiguous = (index: number) => + [index - 1, index + 1].some( + (neighbour) => + neighbour >= 0 && + neighbour < beforeBlocks.length && + !matchedBefore.has(neighbour) && + keyOf(before, beforeBlocks[neighbour]!) === keyOf(before, beforeBlocks[index]!) && + textOf(level.original, originalBlocks[neighbour]!) !== + textOf(level.original, originalBlocks[index]!), + ); + + return steps.map((step): Emitted => { + const afterBlock = afterBlocks[step.after]!; + const afterText = textOf(level.after, afterBlock); + if (step.kind === "insert") { + const text = + afterBlock.kind === "listItem" ? withListMarker(afterText, level.listMarker) : afterText; + return { text: fromAfter(level, text), original: null, after: step.after, rebuilt: false }; + } + const original = originalBlocks[step.before]!; + const beforeBlock = beforeBlocks[step.before]!; + if (step.kind === "match") { + // Two blocks with one spelling in `before` but two in `original`: which survived is unknown. + if (ambiguous(step.before)) { + return { + text: fromAfter(level, afterText), + original: null, + after: step.after, + rebuilt: false, + }; + } + return { + text: textOf(level.original, original), + original: step.before, + after: step.after, + rebuilt: false, + }; + } + const canRecurse = + original.kind === afterBlock.kind && + original.children !== null && + beforeBlock.children !== null && + afterBlock.children !== null; + if (!canRecurse) { + return { + text: fromAfter(level, afterText), + original: step.before, + after: step.after, + rebuilt: false, + }; + } + const inner: Level = { + original: level.original, + after: level.after, + eol: level.eol, + originalPrefix: continuationPrefix(level.original.source, original.children![0]!.start), + afterPrefix: continuationPrefix(level.after.source, afterBlock.children![0]!.start), + listMarker: original.kind === "list" ? listMarkerOf(level.original.source, original) : null, + }; + const head = rebuildHead(level, original, before, beforeBlock, afterBlock); + const tail = + tailOf(before, beforeBlock) === tailOf(level.after, afterBlock) + ? tailOf(level.original, original) + : fromAfter(level, tailOf(level.after, afterBlock)); + const children = reconcileChildren( + inner, + before, + original.children!, + beforeBlock.children!, + afterBlock.children!, + ); + // An ordered list's first number is its start: after a deletion it must be `after`'s. + const start = /^\d+/.exec(textOf(level.after, afterBlock.children![0]!))?.[0]; + if (original.kind === "list" && start !== undefined && /^\d/.test(children[0]!.text)) { + children[0] = { ...children[0]!, text: children[0]!.text.replace(/^\d+/, start) }; + } + return { + text: `${head}${join(inner, original.children!, afterBlock.children!, children)}${tail}`, + original: step.before, + after: step.after, + rebuilt: true, + }; + }); +} + +/** Joins emitted siblings with `original`'s separators where both sides have an original. */ +function join( + level: Level, + originalBlocks: readonly Block[], + afterBlocks: readonly Block[], + emitted: readonly Emitted[], +): string { + const { eol, originalPrefix } = level; + const afterSeparator = (previous: Emitted, block: Emitted) => { + const from = afterBlocks[previous.after]!; + // A list in a block quote can end after a blank `>` line that `original`'s copy lacks. + const end = from.start + textOf(level.after, from).search(/(?:\r?\n[ \t>]*)*$/); + return fromAfter(level, level.after.source.slice(end, afterBlocks[block.after]!.start)); + }; + const rows = originalBlocks[0]?.kind === "tableRow"; + let text = ""; + emitted.forEach((block, index) => { + const previous = emitted[index - 1]; + if (previous && rows) { + // Exactly one separator holds the delimiter row: the one after the first emitted row. + if (index === 1) { + text += + originalBlocks.length > 1 + ? level.original.source.slice(originalBlocks[0]!.end, originalBlocks[1]!.start) + : afterSeparator(previous, block); + } else { + text += `${eol}${originalPrefix}`; + } + } else if (previous) { + if ( + previous.original !== null && + block.original !== null && + block.original > previous.original + ) { + // Up to the next original block only, so a deleted block is not copied back. + const from = originalBlocks[previous.original]!; + const to = originalBlocks[previous.original + 1]!; + text += level.original.source.slice(from.end, to.start); + } else { + const leading = + block.original !== null && block.original > 0 + ? level.original.source.slice( + originalBlocks[block.original - 1]!.end, + originalBlocks[block.original]!.start, + ) + : ""; + // After an inserted block, an original block keeps the blank lines that led into it. + // One without a blank line at this depth would join the inserted block to it. + const quote = originalPrefix.trimEnd(); + const blank = leading + .split(/\r?\n/) + .slice(1, -1) + .some((line) => line.startsWith(quote) && line.slice(quote.length).trim() === ""); + text += blank ? leading : afterSeparator(previous, block); + } + } + text += block.text; + }); + return text; +} + +interface CodeSpan { + readonly valueStart: number; + readonly valueEnd: number; + readonly value: string; +} + +/** Fenced code blocks at the start of a line, with the span their content occupies. */ +function topLevelFences(source: string): CodeSpan[] { + return parse(source) + .root.children.filter((node) => node.type === "code") + .flatMap((node) => { + const start = startOf(node); + const end = endOf(node); + if (!/^(?:`{3,}|~{3,})/.test(source.slice(start, start + 3))) return []; + const valueStart = source.indexOf("\n", start) + 1; + let valueEnd = source.lastIndexOf("\n", end - 1); + if (valueStart === 0 || valueEnd < valueStart) return []; + if (source[valueEnd - 1] === "\r") valueEnd -= 1; + return [{ valueStart, valueEnd, value: source.slice(valueStart, valueEnd) }]; + }); +} + +/** Puts back fenced code content that differs from the original only in line endings. */ +function restoreFenceContents(replacement: string, original: string): string { + const originalFences = topLevelFences(original); + const toLf = (text: string) => text.replaceAll("\r\n", "\n"); + let result = replacement; + for (const fence of topLevelFences(replacement).toReversed()) { + const source = originalFences.find( + (candidate) => candidate.value !== fence.value && toLf(candidate.value) === toLf(fence.value), + ); + if (source) { + result = `${result.slice(0, fence.valueStart)}${source.value}${result.slice(fence.valueEnd)}`; + } + } + return result; +} + +export function reconcileMarkdown(input: { + original: string; + before: string; + after: string; +}): string { + if (input.before === input.after) return input.original; + const original = parse(input.original, true); + const before = parse(input.before, true); + const after = parse(input.after); + const eol = detectDominantEol(input.original); + const level: Level = { + original, + after, + eol, + originalPrefix: "", + afterPrefix: "", + listMarker: null, + }; + const head = input.original.slice(0, original.blocks[0]?.start ?? input.original.length); + const tail = input.original.slice(original.blocks.at(-1)?.end ?? input.original.length); + const asAfter = (text: string) => restoreFenceContents(fromAfter(level, text), input.original); + + const fallback = () => `${head}${asAfter(input.after.trim())}${tail}`; + let result: string; + try { + const emitted = reconcileChildren( + level, + before, + original.blocks, + before.blocks, + after.blocks, + ).map((block) => { + // A rebuilt block that no longer reads as the edited one is taken from `after` alone. + const afterText = textOf(after, after.blocks[block.after]!); + return block.rebuilt && !sameDocument(block.text, afterText) + ? { ...block, text: asAfter(afterText), rebuilt: false } + : block; + }); + result = `${head}${join(level, original.blocks, after.blocks, emitted)}${tail}`; + } catch (error) { + // The edit must still reach the session: losing it is worse than rewriting the body. + console.error("[markdown-reconcile] placing the edit failed; saving the serialization", error); + return fallback(); + } + if (sameDocument(result, input.after)) return result; + return fallback(); +} diff --git a/apps/web/src/components/files/richMarkdown/markdownSupport.test.ts b/apps/web/src/components/files/richMarkdown/markdownSupport.test.ts new file mode 100644 index 0000000000..ebec1a6f62 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/markdownSupport.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { richMarkdownMode } from "../filePreviewMode"; +import * as corpus from "./fixtures/corpus"; +import { findUnsupportedConstruct, splitFrontmatter } from "./markdownSupport"; + +describe("splitFrontmatter", () => { + it("returns a YAML block byte for byte", () => { + const { frontmatter, body } = splitFrontmatter(corpus.yamlFrontmatter); + + expect(frontmatter).toBe("---\ntitle: Guide\ntags: [a, b]\n---\n"); + expect(body).toBe("\n# Guide\n\nIntro text.\n"); + }); + + it("returns a TOML block with no final newline byte for byte", () => { + expect(splitFrontmatter(corpus.tomlFrontmatterNoFinalNewline)).toEqual({ + frontmatter: '+++\ntitle = "Guide"\n+++', + body: "", + }); + }); + + it("returns no frontmatter for a file without one, or with a rule further down", () => { + expect(splitFrontmatter("# Title\n\nText.\n").frontmatter).toBe(""); + expect(splitFrontmatter("Intro.\n\n---\n\nMore.\n---\n").frontmatter).toBe(""); + }); + + it("always joins back into the source", () => { + for (const source of Object.values(corpus).filter((value) => typeof value === "string")) { + const { frontmatter, body } = splitFrontmatter(source as string); + expect(frontmatter + body).toBe(source); + } + const crlf = "---\r\ntitle: x\r\n---\r\nBody\r\n"; + expect(splitFrontmatter(crlf).frontmatter).toBe("---\r\ntitle: x\r\n---\r\n"); + }); +}); + +describe("findUnsupportedConstruct", () => { + it("finds each construct the editor cannot represent", () => { + expect(findUnsupportedConstruct(corpus.referenceLinks)).toBe("reference-link"); + expect(findUnsupportedConstruct(corpus.footnote)).toBe("footnote"); + expect(findUnsupportedConstruct(corpus.htmlComment)).toBe("html-comment"); + expect(findUnsupportedConstruct(corpus.rawDiv)).toBe("raw-html"); + expect(findUnsupportedConstruct(corpus.inlineMath)).toBe("math"); + expect(findUnsupportedConstruct(corpus.blockMath)).toBe("math"); + }); + + it("accepts the representable variants", () => { + for (const body of [ + corpus.setextHeadings, + corpus.alignedTable, + corpus.listWithNestedFence, + corpus.handWrittenDetails, + corpus.taskList, + "It costs $5 and $10 today.\n", + ]) { + expect(findUnsupportedConstruct(body)).toBeNull(); + } + }); + + it("ignores text that only looks like a construct inside code", () => { + const body = + "Text.\n\n```\n[docs]: https://example.test\n
x
\n\n$$\n```\n\nUse `$a$` and `[^1]`.\n"; + + expect(findUnsupportedConstruct(body)).toBeNull(); + }); + + it("treats a details block with other attributes or inner HTML as raw HTML", () => { + expect( + findUnsupportedConstruct( + '
\nMore\n\nText\n\n
\n', + ), + ).toBe("raw-html"); + expect( + findUnsupportedConstruct("
\nMore\n\nA
B\n\n
\n"), + ).toBe("raw-html"); + }); +}); + +describe("richMarkdownMode", () => { + it("decides from the extension and the content", () => { + expect(richMarkdownMode("notes.md", "# Notes\n\nText.\n")).toEqual({ editable: true }); + expect(richMarkdownMode("notes.mdx", "# Notes\n")).toEqual({ editable: false, reason: "mdx" }); + expect(richMarkdownMode("notes.md", corpus.footnote)).toEqual({ + editable: false, + reason: "footnote", + }); + expect(richMarkdownMode("notes.txt", "Text.\n")).toEqual({ + editable: false, + reason: "not-markdown", + }); + expect(richMarkdownMode("docs/GUIDE.MD", corpus.yamlFrontmatter)).toEqual({ editable: true }); + }); + + it("keeps a file over 128 KiB of UTF-8 read-only before looking at its Markdown", () => { + const limit = 128 * 1024; + expect(richMarkdownMode("notes.md", "a".repeat(limit))).toEqual({ editable: true }); + expect( + richMarkdownMode("notes.md", `${"a".repeat(129 * 1024 - 20)}\n\n${corpus.footnote}`), + ).toEqual({ + editable: false, + reason: "too-large", + }); + // 65,537 two-byte characters are 131,074 bytes, over the limit in fewer than 128 Ki characters. + expect(richMarkdownMode("notes.md", "\u00e9".repeat(limit / 2 + 1))).toEqual({ + editable: false, + reason: "too-large", + }); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/markdownSupport.ts b/apps/web/src/components/files/richMarkdown/markdownSupport.ts new file mode 100644 index 0000000000..7f5c5fed18 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/markdownSupport.ts @@ -0,0 +1,64 @@ +import type { Nodes, Root } from "mdast"; +import remarkGfm from "remark-gfm"; +import remarkParse from "remark-parse"; +import { unified } from "unified"; + +import { DETAILS_CLOSE, DETAILS_OPEN, DETAILS_WHOLE } from "./markdownReconcile"; + +/** Markdown the rich editor cannot write back faithfully, so the file stays read-only (R34). */ +export type UnsupportedConstruct = + | "reference-link" + | "footnote" + | "raw-html" + | "html-comment" + | "math"; + +const FRONTMATTER = /^(---|\+\+\+)[ \t]*\r?\n(?:[\s\S]*?\r?\n)?\1[ \t]*(?:\r?\n|$)/; +/** Pandoc's rule: no space after the opening `$`, none before the closing one, no digit after. */ +const INLINE_MATH = /(?:^|[^\\$])\$(?=\S)[^$\n]*?\S\$(?!\d)|\$\$/; + +const processor = unified().use(remarkParse).use(remarkGfm); + +/** A leading YAML (`---`) or TOML (`+++`) block, with its bytes exactly as written. */ +export function splitFrontmatter(source: string): { frontmatter: string; body: string } { + const frontmatter = FRONTMATTER.exec(source)?.[0] ?? ""; + return { frontmatter, body: source.slice(frontmatter.length) }; +} + +function htmlConstruct(value: string): UnsupportedConstruct | null { + if (value.trimStart().startsWith(" B\n```\n\nAfter the diagram.", + ); + // An unclosed fence stays a code block, as chat shows it as source. + expect(open("```mermaid\ngraph TD\n").tiptap.state.doc.child(0).type.name).toBe("codeBlock"); + }); + + it.each([ + [ + "a block quote", + "> Quoted.\n>\n> ```mermaid\n> graph TD\n>\n> A --> B\n> ```\n\nAfter.\n", + "Quoted.", + ], + ["a list item", "- item\n\n ```mermaid\n graph TD\n\n A --> B\n ```\n\n- next\n", "next"], + [ + "a details body", + "
\nMore\n\n```mermaid\ngraph TD\n\n A --> B\n```\n\n
\n\nAfter.\n", + "After.", + ], + [ + "an info string with a title", + 'Intro.\n\n```mermaid title="Flow"\ngraph TD\n A --> B\n```\n', + "Intro.", + ], + [ + "a CRLF file", + "Before.\r\n\r\n~~~mermaid\r\ngraph TD\r\n A --> B\r\n~~~\r\n\r\nAfter.\r\n", + "After.", + ], + ])( + "keeps a diagram in %s editable and its bytes through an edit elsewhere", + (_name, original, edited) => { + const { editor, tiptap } = open(original); + let diagrams = 0; + tiptap.state.doc.descendants((node) => { + if (node.type.name === "mermaidDiagram") diagrams += 1; + }); + expect(diagrams).toBe(1); + const before = editor.serialize(); + expect(canReconcile({ original, before })).toBe(true); + + typeAfter(tiptap, edited, " Edited."); + + expect(reconcileMarkdown({ original, before, after: editor.serialize() })).toBe( + original.replace(edited, `${edited} Edited.`), + ); + }, + ); + + it("publishes one diagram snapshot per change, however many diagrams it touches", async () => { + const body = Array.from( + { length: 200 }, + (_, index) => `\`\`\`mermaid\ngraph TD\n A${String(index)} --> B\n\`\`\``, + ).join("\n\n"); + const onDiagramViewsChange = vi.fn<(views: readonly unknown[]) => void>(); + const element = document.createElement("div"); + document.body.append(element); + const editor = createRichMarkdownEditor({ + body, + element, + onUpdate: vi.fn(), + onDiagramViewsChange, + }); + await Promise.resolve(); + expect(onDiagramViewsChange).toHaveBeenCalledOnce(); + expect(onDiagramViewsChange.mock.lastCall![0]).toHaveLength(200); + + const tiptap = (element.querySelector(".ProseMirror") as HTMLElement & { editor: Editor }) + .editor; + tiptap.chain().selectAll().deleteSelection().run(); + await Promise.resolve(); + expect(onDiagramViewsChange).toHaveBeenCalledTimes(2); + expect(onDiagramViewsChange.mock.lastCall![0]).toHaveLength(0); + + tiptap.commands.undo(); + editor.destroy(); + await Promise.resolve(); + // Teardown publishes nothing, so a destroyed editor never sets the surface's state. + expect(onDiagramViewsChange).toHaveBeenCalledTimes(2); + }); + + it.each([ + ["a diagram with a title", '~~~mermaid title="Flow"', "graph TD\n A --> B", "mermaidDiagram"], + [ + "a diagram with a backtick run", + "~~~~mermaid", + "graph TD\n %% ```\n A --> B", + "mermaidDiagram", + ], + ["a code block with a title", '~~~js title="x"', "const a = 1;", "codeBlock"], + ["a tilde code block", "~~~js", "const a = 1;", "codeBlock"], + ["a code block with a backtick run", "~~~~text", "a\n```\nb", "codeBlock"], + ])("cut and pasted, %s keeps its info string and code", (_name, opener, code, type) => { + const closer = opener.replace(/[^~]/g, ""); + const original = `Intro.\n\n${opener}\n${code}\n${closer}\n\nEnd.\n`; + const { editor, tiptap } = open(original); + const before = editor.serialize(); + const position = tiptap.state.doc.child(0).nodeSize; + expect(tiptap.state.doc.child(1).type.name).toBe(type); + + const slice = NodeSelection.create(tiptap.state.doc, position).content(); + const html = tiptap.view.serializeForClipboard(slice).dom.innerHTML; + tiptap.chain().setNodeSelection(position).deleteSelection().run(); + typeAfter(tiptap, "End.", ""); + tiptap.view.pasteHTML(html); + + expect(tiptap.state.doc.lastChild?.type.name).toBe(type); + const info = opener.replace(/^~+/, ""); + expect(reconcileMarkdown({ original, before, after: editor.serialize() })).toBe( + `Intro.\n\nEnd.\n\n${fencedCode(code, info)}\n`, + ); + }); + + it("reconciles an untouched editor to the original bytes", () => { + for (const [name, original] of supported) { + const { editor } = open(original); + const before = editor.serialize(); + expect(reconcileMarkdown({ original, before, after: editor.serialize() }), name).toBe( + original, + ); + } + }); + + it("changes only the typed paragraph after reconcile", () => { + const original = corpus.fourBacktickFence; + const { editor, tiptap, onUpdate } = open(original); + const before = editor.serialize(); + + typeAfter(tiptap, "The last paragraph.", " again"); + + expect(onUpdate).toHaveBeenCalled(); + expect(reconcileMarkdown({ original, before, after: editor.serialize() })).toBe( + original.replace("The last paragraph.", "The last paragraph. again"), + ); + }); + + it("changes only the typed row of a table after reconcile", () => { + const original = corpus.alignedTable; + const { editor, tiptap } = open(original); + const before = editor.serialize(); + + typeAfter(tiptap, "one", "!"); + + const lines = reconcileMarkdown({ original, before, after: editor.serialize() }).split("\n"); + const originalLines = original.split("\n"); + expect(lines).toHaveLength(originalLines.length); + lines.forEach((line, index) => { + if (index === 2) expect(line).toMatch(/^\|\s*one!\s*\|\s*1\s*\|\s*a\s*\|$/); + else expect(line).toBe(originalLines[index]); + }); + }); + + it("a ticked task checkbox changes only that item's marker, and Undo reverses it", () => { + const original = "- [ ] Keep\n- [ ] Other\n"; + const { editor, element } = open(original); + const before = editor.serialize(); + + const checkbox = element.querySelector("input[type='checkbox']")!; + checkbox.checked = true; + checkbox.dispatchEvent(new Event("change", { bubbles: true })); + + expect(reconcileMarkdown({ original, before, after: editor.serialize() })).toBe( + "- [x] Keep\n- [ ] Other\n", + ); + editor.undo(); + expect(reconcileMarkdown({ original, before, after: editor.serialize() })).toBe(original); + }); + + it("an inserted image links its file even when the name holds #, ?, a space or parentheses", () => { + const names = ["diagram#1.png", "a?b.png", "my image.png", "fig (1).png", "fig).png"]; + for (const name of names) { + const { editor } = open("Text\n"); + editor.insertImage(`img/${name}`); + const images: string[] = []; + const collect = (node: Nodes) => { + if (node.type === "image") images.push(node.url); + if ("children" in node) node.children.forEach(collect); + }; + collect(processor.parse(editor.serialize())); + expect(images, name).toHaveLength(1); + // Resolved the way a browser resolves it: the whole name is the path, with no query or fragment. + const resolved = new URL(images[0]!, "https://files.example.test/docs/"); + expect(decodeURIComponent(resolved.pathname), name).toBe(`/docs/img/${name}`); + expect(`${resolved.search}${resolved.hash}`, name).toBe(""); + } + }); + + it("never draws a file's relative image src against the page, and keeps it in the file", async () => { + const onImageViewsChange = vi.fn<(views: readonly RichMarkdownImageView[]) => void>(); + const body = "See ![A pixel](images/pic.png) here.\n"; + const { editor, element } = open(body, vi.fn(), onImageViewsChange); + await Promise.resolve(); + + // The page would resolve it against its own address (#345); the surface loads it instead. + expect(element.querySelector('img[src="images/pic.png"]')).toBeNull(); + const views = onImageViewsChange.mock.lastCall?.[0] ?? []; + expect(views.map(({ src, alt }) => ({ src, alt }))).toEqual([ + { src: "images/pic.png", alt: "A pixel" }, + ]); + expect(element.contains(views[0]!.element)).toBe(true); + expect(editor.serialize()).toBe("See ![A pixel](images/pic.png) here."); + expect(canReconcile({ original: body, before: editor.serialize() })).toBe(true); + }); + + it("an inserted image gets a view at once and is still selected and deleted whole", async () => { + const onImageViewsChange = vi.fn<(views: readonly RichMarkdownImageView[]) => void>(); + const { editor, tiptap } = open("Text\n", vi.fn(), onImageViewsChange); + editor.insertImage("my image.png"); + await Promise.resolve(); + expect(onImageViewsChange.mock.lastCall?.[0].map((view) => view.src)).toEqual([ + "my%20image.png", + ]); + + let position = -1; + tiptap.state.doc.descendants((node, pos) => { + if (node.type.name === "image") position = pos; + }); + tiptap.chain().setNodeSelection(position).deleteSelection().run(); + await Promise.resolve(); + expect(editor.serialize()).toBe("Text"); + expect(onImageViewsChange.mock.lastCall?.[0]).toEqual([]); + }); + + it("reports and performs undo and redo", () => { + const { editor, tiptap } = open("Hello world\n"); + expect(editor.canUndo).toBe(false); + + typeAfter(tiptap, "Hello world", " again"); + expect(editor.serialize()).toBe("Hello world again"); + expect(editor.canUndo).toBe(true); + + editor.undo(); + expect(editor.serialize()).toBe("Hello world"); + expect(editor.canRedo).toBe(true); + editor.redo(); + expect(editor.serialize()).toBe("Hello world again"); + }); + + it("stops notifying and releases its view when destroyed", () => { + const { editor, element, tiptap } = open("Hello\n"); + const listener = vi.fn(); + editor.subscribe(listener); + typeAfter(tiptap, "Hello", "!"); + expect(listener).toHaveBeenCalled(); + listener.mockClear(); + + editor.destroy(); + + expect(tiptap.isDestroyed).toBe(true); + expect(element.querySelector("[contenteditable='true']")).toBeNull(); + expect(listener).not.toHaveBeenCalled(); + }); +}); + +describe("an edit at any boundary of any level", () => { + const table = "| a | b |\n|:--|--:|\n| one | two |\n| three | four |\n"; + const quote = (text: string) => text.replace(/^(?=.)/gm, "> "); + const cases = [ + ...supported, + ["tableBeforeParagraph", `${table}\nAfter.\n`], + ["crlfTable", `${table}\nAfter.\n`.replaceAll("\n", "\r\n")], + ["quotedTable", `${quote(table)}\nAfter.\n`], + ["tableInDetails", `
\nMore\n\n${table}\n
\n\nAfter.\n`], + ["orderedList", "1) one\n2) two\n3) three\n\nAfter.\n"], + ["nestedList", "- one\n - nested a\n - nested b\n- two\n\nAfter.\n"], + ["quotedOrderedList", `${quote("1. one\n2. two\n")}\nAfter.\n`], + ["quotedTaskList", `${quote("- [ ] open\n- [x] done\n")}\nAfter.\n`], + ] as const; + const containers = new Set([ + "table", + "bulletList", + "orderedList", + "taskList", + "blockquote", + "detailsContent", + ]); + type Doc = Editor["state"]["doc"]; + type Edit = { readonly where: string; readonly apply: (tiptap: Editor) => void }; + + interface Block { + readonly type: string; + readonly key: string; + readonly start: number; + readonly end: number; + } + /** Every block the reconcile copies whole, plus list items, in document order. */ + const blocksOf = (source: string) => { + const blocks: Block[] = []; + const text = (node: Nodes): string => + "value" in node ? node.value : "children" in node ? node.children.map(text).join(" ") : ""; + const lists: List[] = []; + const summary = (html: string) => /([^<]*)<\/summary>/.exec(html)?.[1] ?? ""; + const visit = (node: Nodes, index: number, siblings: readonly Nodes[]) => { + // A details block is one block too, written as one HTML node or as an opener and closer. + if (node.type === "html" && node.value.startsWith(""); + const close = whole + ? index + : siblings.findIndex( + (other, at) => at > index && "value" in other && other.value === "
", + ); + const body = siblings + .slice(index + 1, Math.max(index + 1, close)) + .map(text) + .join(" "); + blocks.push({ + type: "details", + key: `details:${summary(node.value)}:${body.replaceAll("\r\n", "\n")}`, + start: node.position!.start.offset!, + end: siblings[close]!.position!.end.offset!, + }); + if (whole) return; + } + const isBlock = !["root", "list", "blockquote", "table"].includes(node.type); + if (isBlock && node.position) { + // The first item of an ordered list spells the number the list starts at. + const parent = siblings === lists.at(-1)?.children ? lists.at(-1) : undefined; + const start = parent?.ordered && index === 0 ? String(parent.start) : ""; + const checked = node.type === "listItem" ? `${String(node.checked)}${start}` : ""; + blocks.push({ + type: node.type, + key: `${node.type}:${checked}:${text(node).replaceAll("\r\n", "\n")}`, + start: node.position.start.offset!, + end: node.position.end.offset!, + }); + } + if (node.type !== "listItem" && isBlock) return; + if (node.type === "list") lists.push(node); + if ("children" in node) node.children.forEach(visit); + if (node.type === "list") lists.pop(); + }; + visit(processor.parse(source), 0, []); + return blocks; + }; + /** Pairs of indexes matched by the longest common subsequence of the keys. */ + const matches = (left: readonly Block[], right: readonly Block[]) => { + const table = Array.from({ length: left.length + 1 }, () => + Array.from({ length: right.length + 1 }, () => 0), + ); + for (let i = left.length - 1; i >= 0; i -= 1) { + for (let j = right.length - 1; j >= 0; j -= 1) { + table[i]![j] = + left[i]!.key === right[j]!.key + ? table[i + 1]![j + 1]! + 1 + : Math.max(table[i + 1]![j]!, table[i]![j + 1]!); + } + } + const pairs = new Map(); + for (let i = 0, j = 0; i < left.length && j < right.length;) { + if (left[i]!.key === right[j]!.key) pairs.set(i++, j++); + else if (table[i + 1]![j]! >= table[i]![j + 1]!) i += 1; + else j += 1; + } + return pairs; + }; + + /** + * Applies each edit to a fresh editor and reconciles. Every original block must keep its + * bytes, in order, except the edited ones: a block whose content the edit changed or + * removed, and everything inside a list item it changed (the receiving item and the items + * that hold it). + */ + const sweep = (edits: (doc: Doc) => Edit[], inserts: boolean) => { + const failures: string[] = []; + let count = 0; + for (const [name, original] of cases) { + const { tiptap: probe } = open(original); + const before = probe.getMarkdown(); + expect(canReconcile({ original, before }), name).toBe(true); + const originalBlocks = blocksOf(original); + const beforeBlocks = blocksOf(before); + const toBefore = matches(originalBlocks, beforeBlocks); + for (const { where, apply } of edits(probe.state.doc)) { + const { editor, tiptap } = open(original); + apply(tiptap); + const after = editor.serialize(); + const result = reconcileMarkdown({ original, before, after }); + const kept = new Set(matches(beforeBlocks, blocksOf(after)).keys()); + const edited = originalBlocks.filter((_block, index) => { + const beforeIndex = toBefore.get(index); + return beforeIndex !== undefined && !kept.has(beforeIndex); + }); + const inEditedItem = (block: Block) => + edited.some( + (item) => + item.type === "listItem" && item.start <= block.start && block.end <= item.end, + ); + const label = `${name} ${where}`; + if (inserts && !result.includes("Inserted")) failures.push(`${label} lost the edit`); + let cursor = 0; + for (const block of originalBlocks) { + if (edited.includes(block) || inEditedItem(block)) continue; + const text = original.slice(block.start, block.end); + const found = result.indexOf(text, cursor); + if (found < 0) failures.push(`${label} changed ${JSON.stringify(text)}`); + else cursor = found; + } + count += 1; + } + } + return { failures, count }; + }; + + /** The doc and every container the reconcile recurses into, with their positions. */ + const levels = (doc: Doc) => { + const found: { node: Doc; at: number; parent: number }[] = [{ node: doc, at: 0, parent: -1 }]; + doc.descendants((node, pos) => { + // An empty details body is one leaf for the reconcile, not a level it recurses into. + if (containers.has(node.type.name) && node.textContent !== "") { + found.push({ node, at: pos + 1, parent: pos }); + } + }); + return found; + }; + const paragraph = (tiptap: Editor) => + tiptap.schema.nodes.paragraph!.create(null, tiptap.schema.text("Inserted")); + + it("inserting a block keeps every untouched block's bytes", () => { + const { failures, count } = sweep( + (doc) => + levels(doc).flatMap(({ node, at, parent }) => + Array.from({ length: node.childCount + 1 }, (_unused, index): Edit => { + let offset = 0; + for (let child = 0; child < index; child += 1) offset += node.child(child).nodeSize; + return { + where: `inserted at ${node.type.name} ${index}`, + apply: (tiptap) => { + const container = parent < 0 ? tiptap.state.doc : tiptap.state.doc.nodeAt(parent)!; + const neighbour = container.child(Math.max(0, index - 1)); + const inserted = + container.type.name === "table" + ? neighbour.type.create( + null, + neighbour.content.content.map((cell) => + cell.type.create(cell.attrs, paragraph(tiptap)), + ), + ) + : container.type.name.endsWith("List") + ? neighbour.type.create(null, paragraph(tiptap)) + : paragraph(tiptap); + tiptap.view.dispatch(tiptap.state.tr.insert(at + offset, inserted)); + }, + }; + }), + ), + true, + ); + expect(failures).toEqual([]); + expect(count).toBe(130); + }); + + it("inserting a paragraph or a first nested item inside a list item keeps every other block's bytes", () => { + const { failures, count } = sweep((doc) => { + const edits: Edit[] = []; + doc.descendants((node, pos) => { + if (node.type.name !== "listItem" && node.type.name !== "taskItem") return true; + const end = pos + 1 + node.content.size; + const list = doc.resolve(pos).parent; + edits.push({ + where: `paragraph in ${list.type.name} item at ${pos}`, + apply: (tiptap) => tiptap.view.dispatch(tiptap.state.tr.insert(end, paragraph(tiptap))), + }); + let nested = false; + node.forEach((child) => (nested ||= child.type.name.endsWith("List"))); + if (!nested) { + edits.push({ + where: `nested item in ${list.type.name} item at ${pos}`, + apply: (tiptap) => + tiptap.view.dispatch( + tiptap.state.tr.insert( + end, + list.type.create(null, node.type.create(null, paragraph(tiptap))), + ), + ), + }); + } + return true; + }); + return edits; + }, true); + expect(failures).toEqual([]); + expect(count).toBe(43); + }); + + it("deleting a block keeps every untouched block's bytes", () => { + const { failures, count } = sweep( + (doc) => + levels(doc).flatMap(({ node, at }) => { + if (node.childCount < 2) return []; + const edits: Edit[] = []; + node.forEach((child, offset, index) => + edits.push({ + where: `deleted ${node.type.name} ${index}`, + apply: (tiptap) => + tiptap.view.dispatch( + tiptap.state.tr.delete(at + offset, at + offset + child.nodeSize), + ), + }), + ); + return edits; + }), + false, + ); + expect(failures).toEqual([]); + expect(count).toBe(84); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/richMarkdownExtensions.ts b/apps/web/src/components/files/richMarkdown/richMarkdownExtensions.ts new file mode 100644 index 0000000000..6cf6769e4d --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/richMarkdownExtensions.ts @@ -0,0 +1,323 @@ +/* + * The extension set follows Orca's rich Markdown schema (https://github.com/stablyai/orca, + * src/renderer/src/components/editor/rich-markdown/schema/rich-markdown-extensions.ts). + * Copyright (c) 2026 Lovecast Inc. Licensed under the MIT License (full text in + * detailsExtension.ts). + */ +import { Editor, type AnyExtension, type JSONContent } from "@tiptap/core"; +import CodeBlock from "@tiptap/extension-code-block"; +import Image from "@tiptap/extension-image"; +import { BulletList, ListItem, TaskItem, TaskList } from "@tiptap/extension-list"; +import { TableKit } from "@tiptap/extension-table"; +import { Markdown } from "@tiptap/markdown"; +import StarterKit from "@tiptap/starter-kit"; + +import { createDetailsExtensions } from "./detailsExtension"; +import { + createMermaidDiagramExtension, + FENCE_INFO_ATTRIBUTE, + fencedCode, + type MermaidDiagramView, +} from "./mermaidDiagramExtension"; +import { createNodeViewSet } from "./nodeViewSet"; + +export type FormatName = + | "paragraph" + | "heading1" + | "heading2" + | "heading3" + | "heading4" + | "heading5" + | "bold" + | "italic" + | "strike" + | "bulletList" + | "orderedList" + | "taskList" + | "blockquote" + | "link" + | "details"; + +export type FormatCommand = FormatName | "image"; + +/** The rendered-view editor as the rest of Files sees it, without TipTap types. */ +export type RichMarkdownEditor = { + readonly canUndo: boolean; + readonly canRedo: boolean; + undo(): void; + redo(): void; + isActive(name: FormatName): boolean; + run(command: FormatCommand): void; + subscribe(listener: () => void): () => void; + /** The link under the caret or selection, or null. */ + currentLink(): { href: string } | null; + /** Links the selection, or inserts `href` as linked text when nothing is selected. */ + setLink(href: string): void; + /** Removes the link under the caret and keeps its text. */ + removeLink(): void; + /** Inserts an image linking `relativePath` (a plain, unencoded path) at the caret. */ + insertImage(relativePath: string): void; + serialize(): string; + destroy(): void; +}; + +// Two adjacent lists that differ only in marker are two lists; writing both as `-` merges them. +const MarkedBulletList = BulletList.extend({ + addAttributes() { + return { marker: { default: "-", renderHTML: () => ({}) } }; + }, + parseMarkdown: (token, helpers) => { + if (token.type !== "list" || token.ordered) return []; + const marker = /^\s*([-*+])/.exec(token.raw ?? "")?.[1] ?? "-"; + return { + type: "bulletList", + attrs: { marker }, + content: token.items ? helpers.parseChildren(token.items) : [], + }; + }, +}); + +const MarkedListItem = ListItem.extend({ + renderMarkdown: (node: JSONContent, helpers, context) => { + const rendered = ListItem.config.renderMarkdown!.call( + undefined as never, + node, + helpers, + context, + ); + const marker = (context?.meta?.["parentAttrs"] as { marker?: string } | undefined)?.marker; + return context?.parentType === "bulletList" && marker && marker !== "-" + ? rendered.replace(/^- /, `${marker} `) + : rendered; + }, +}); + +// Three backticks would end early at a three-backtick line inside the code. +const FencedCodeBlock = CodeBlock.extend({ + // The whole info string (`js title="x"`) survives a copy; the class holds only `js`. + addAttributes() { + const parent = this.parent?.() ?? {}; + const language = parent["language"] ?? {}; + return { + ...parent, + language: { + ...language, + rendered: true, + parseHTML: (element: HTMLElement) => + element.getAttribute(FENCE_INFO_ATTRIBUTE) ?? language.parseHTML?.(element) ?? null, + renderHTML: (attributes: Record) => + attributes["language"] ? { [FENCE_INFO_ATTRIBUTE]: attributes["language"] } : {}, + }, + }; + }, + renderMarkdown: (node: JSONContent, helpers) => { + const code = node.content ? helpers.renderChildren(node.content) : ""; + return fencedCode(code, (node.attrs?.["language"] as string | null) ?? ""); + }, +}); + +/** An image's place in the editor, where the surface loads it from the workspace. */ +export interface RichMarkdownImageView { + readonly id: number; + readonly element: HTMLElement; + /** The `src` as the file holds it. */ + readonly src: string; + readonly alt: string; +} + +let nextImageViewId = 0; + +/** + * Markdown puts images inside paragraphs. The editor never renders an `` with the file's + * `src`: a relative path would resolve against the page's address, not the Markdown file's + * folder. The surface draws each image into its view; the document keeps the `src` unchanged. + */ +function createImageExtension(onViewsChange?: (views: readonly RichMarkdownImageView[]) => void) { + const views = createNodeViewSet(onViewsChange); + return Image.extend({ + onDestroy() { + views.destroy(); + }, + addNodeView() { + return ({ node }) => { + const element = document.createElement("span"); + element.className = "rich-markdown-image"; + element.setAttribute("contenteditable", "false"); + const view: RichMarkdownImageView = { + id: (nextImageViewId += 1), + element, + src: String(node.attrs["src"] ?? ""), + alt: String(node.attrs["alt"] ?? ""), + }; + views.add(view); + return { + dom: element, + // An image with another source or text gets a new view. + update: (next) => + next.type === node.type && + String(next.attrs["src"] ?? "") === view.src && + String(next.attrs["alt"] ?? "") === view.alt, + // React owns everything inside. + ignoreMutation: () => true, + destroy: () => views.delete(view), + }; + }; + }, + }).configure({ inline: true }); +} + +function extensions( + onDiagramViewsChange?: (views: readonly MermaidDiagramView[]) => void, + onImageViewsChange?: (views: readonly RichMarkdownImageView[]) => void, +): AnyExtension[] { + return [ + StarterKit.configure({ + bulletList: false, + listItem: false, + codeBlock: false, + underline: false, + trailingNode: false, + link: { openOnClick: false, autolink: false }, + }), + MarkedBulletList, + MarkedListItem, + FencedCodeBlock, + createMermaidDiagramExtension(onDiagramViewsChange), + TaskList, + TaskItem.configure({ nested: true }), + TableKit.configure({ table: { resizable: false } }), + createImageExtension(onImageViewsChange), + ...createDetailsExtensions(), + Markdown.configure({ markedOptions: { gfm: true } }), + ]; +} + +const HEADING_LEVELS = { + heading1: 1, + heading2: 2, + heading3: 3, + heading4: 4, + heading5: 5, +} as const; + +function isActive(editor: Editor, name: FormatName): boolean { + if (name in HEADING_LEVELS) { + return editor.isActive("heading", { + level: HEADING_LEVELS[name as keyof typeof HEADING_LEVELS], + }); + } + return editor.isActive(name); +} + +function run(editor: Editor, command: FormatCommand): void { + const chain = editor.chain().focus(); + switch (command) { + case "paragraph": + chain.setParagraph().run(); + return; + case "heading1": + case "heading2": + case "heading3": + case "heading4": + case "heading5": + chain.toggleHeading({ level: HEADING_LEVELS[command] }).run(); + return; + case "bold": + chain.toggleBold().run(); + return; + case "italic": + chain.toggleItalic().run(); + return; + case "strike": + chain.toggleStrike().run(); + return; + case "bulletList": + chain.toggleBulletList().run(); + return; + case "orderedList": + chain.toggleOrderedList().run(); + return; + case "taskList": + chain.toggleTaskList().run(); + return; + case "blockquote": + chain.toggleBlockquote().run(); + return; + case "details": + chain.insertDetails().run(); + return; + // The link dialog and the image picker take input first; the format group opens them. + case "link": + case "image": + return; + } +} + +export function createRichMarkdownEditor(input: { + body: string; + element: HTMLElement | null; + onUpdate(): void; + /** Where each Mermaid diagram block sits, whenever one is added or removed. */ + onDiagramViewsChange?: (views: readonly MermaidDiagramView[]) => void; + /** Where each image sits, whenever one is added, removed or given another source. */ + onImageViewsChange?: (views: readonly RichMarkdownImageView[]) => void; +}): RichMarkdownEditor { + const editor = new Editor({ + element: input.element, + extensions: extensions(input.onDiagramViewsChange, input.onImageViewsChange), + content: input.body, + contentType: "markdown", + onUpdate: () => input.onUpdate(), + }); + const listeners = new Set<() => void>(); + const notify = () => { + for (const listener of listeners) listener(); + }; + editor.on("transaction", notify); + return { + get canUndo() { + return editor.can().undo(); + }, + get canRedo() { + return editor.can().redo(); + }, + undo: () => editor.commands.undo(), + redo: () => editor.commands.redo(), + isActive: (name) => isActive(editor, name), + run: (command) => run(editor, command), + subscribe: (listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + currentLink: () => + editor.isActive("link") ? { href: String(editor.getAttributes("link")["href"] ?? "") } : null, + setLink: (href) => { + const chain = editor.chain().focus(); + if (editor.isActive("link")) chain.extendMarkRange("link").setLink({ href }).run(); + else if (editor.state.selection.empty) { + chain + .insertContent({ type: "text", text: href, marks: [{ type: "link", attrs: { href } }] }) + .run(); + } else chain.setLink({ href }).run(); + }, + removeLink: () => { + editor.chain().focus().extendMarkRange("link").unsetLink().run(); + }, + insertImage: (relativePath) => { + // `#`, `?` and spaces would split the URL, and parentheses can end the Markdown destination. + const src = relativePath + .split("/") + .map((segment) => + encodeURIComponent(segment).replace(/[()]/g, (paren) => (paren === "(" ? "%28" : "%29")), + ) + .join("/"); + editor.chain().focus().setImage({ src, alt: "" }).run(); + }, + serialize: () => editor.getMarkdown(), + destroy: () => { + listeners.clear(); + editor.off("transaction", notify); + editor.destroy(); + }, + }; +} diff --git a/apps/web/src/components/files/richMarkdown/richMarkdownImageSource.test.ts b/apps/web/src/components/files/richMarkdown/richMarkdownImageSource.test.ts new file mode 100644 index 0000000000..eb313e5447 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/richMarkdownImageSource.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveRichMarkdownImageSource } from "./richMarkdownImageSource"; + +const resolve = (src: string, markdownPath = "docs/guide/readme.md") => + resolveRichMarkdownImageSource(markdownPath, src); + +describe("resolveRichMarkdownImageSource", () => { + it("reads a relative path from the Markdown file's folder", () => { + expect(resolve("images/pic.png")).toEqual({ + kind: "workspace", + path: "docs/guide/images/pic.png", + }); + expect(resolve("./pic.png")).toEqual({ kind: "workspace", path: "docs/guide/pic.png" }); + expect(resolve("pic.png", "readme.md")).toEqual({ kind: "workspace", path: "pic.png" }); + }); + + it("decodes each segment, and drops a query or fragment", () => { + expect(resolve("my%20image%231.png")).toEqual({ + kind: "workspace", + path: "docs/guide/my image#1.png", + }); + expect(resolve("fig%20%281%29.png?v=2#top")).toEqual({ + kind: "workspace", + path: "docs/guide/fig (1).png", + }); + }); + + it("allows `..` that stays inside the workspace", () => { + expect(resolve("../shared.png")).toEqual({ kind: "workspace", path: "docs/shared.png" }); + expect(resolve("../../top.png")).toEqual({ kind: "workspace", path: "top.png" }); + }); + + it("refuses a path that leaves the workspace or hides a separator", () => { + for (const src of [ + "../../../outside.png", + "%2e%2e/%2e%2e/%2e%2e/outside.png", + "..%2F..%2F..%2Foutside.png", + "images%5C..%5Cpic.png", + "/etc/pic.png", + "//host.example.test/pic.png", + "file:///pic.png", + // Without `//` the page would read these as paths on its own address. + "http:/pic.png", + "https:pic.png", + "C:/pic.png", + "images\\pic.png", + "bad%E0%A4%A.png", + "../../..", + ]) { + expect(resolve(src), src).toEqual({ kind: "refused" }); + } + }); + + it("keeps a home-looking path inside the workspace", () => { + expect(resolve("~/pic.png", "readme.md")).toEqual({ kind: "workspace", path: "~/pic.png" }); + }); + + it("leaves web, data and blob URLs alone", () => { + for (const src of [ + "https://images.example.test/pic.png", + "http://images.example.test/pic.png", + "data:image/png;base64,AAAA", + "blob:https://app.example.test/1234", + ]) { + expect(resolve(src), src).toEqual({ kind: "url", url: src }); + } + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/richMarkdownImageSource.ts b/apps/web/src/components/files/richMarkdown/richMarkdownImageSource.ts new file mode 100644 index 0000000000..cc750e79b1 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/richMarkdownImageSource.ts @@ -0,0 +1,52 @@ +import { parentRelativePath } from "../FileTreeContextMenu.logic"; + +/** Where an image in a Markdown file comes from. */ +export type RichMarkdownImageSource = + /** A URL the page loads as it is; the content security policy decides whether it may. */ + | { readonly kind: "url"; readonly url: string } + /** A file in the workspace, as a workspace-relative path with `/` separators. */ + | { readonly kind: "workspace"; readonly path: string } + /** Not loaded: another scheme, an absolute path, or a path that leaves the workspace. */ + | { readonly kind: "refused" }; + +// `http:/x.png` without `//` would resolve against the page's own address. +const PASS_THROUGH_SCHEME = /^(?:https?:\/\/|data:|blob:)/i; +const ANY_SCHEME = /^[a-z][a-z0-9+.-]*:/i; + +/** + * Resolves an image's `src` against the folder of the Markdown file that holds it. File content + * is untrusted: only plain relative paths that stay inside the workspace become workspace files. + */ +export function resolveRichMarkdownImageSource( + markdownPath: string, + src: string, +): RichMarkdownImageSource { + if (PASS_THROUGH_SCHEME.test(src)) return { kind: "url", url: src }; + if (ANY_SCHEME.test(src) || src.startsWith("/") || src.includes("\\")) { + return { kind: "refused" }; + } + const pathPart = src.replace(/[?#].*$/s, ""); + const stack = parentRelativePath(markdownPath) + .split("/") + .filter((segment) => segment.length > 0); + for (const raw of pathPart.split("/")) { + let segment: string; + try { + segment = decodeURIComponent(raw); + } catch { + return { kind: "refused" }; + } + // An encoded separator would hide a `..` from the walk below. + if (segment.includes("/") || segment.includes("\\") || segment.includes("\0")) { + return { kind: "refused" }; + } + if (segment === "" || segment === ".") continue; + if (segment === "..") { + if (stack.length === 0) return { kind: "refused" }; + stack.pop(); + continue; + } + stack.push(segment); + } + return stack.length === 0 ? { kind: "refused" } : { kind: "workspace", path: stack.join("/") }; +} diff --git a/apps/web/src/components/files/richMarkdown/useInsertImage.test.tsx b/apps/web/src/components/files/richMarkdown/useInsertImage.test.tsx new file mode 100644 index 0000000000..9bd148bbcf --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/useInsertImage.test.tsx @@ -0,0 +1,173 @@ +// @vitest-environment happy-dom + +import { act, useEffect } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const mocks = vi.hoisted(() => ({ + createUploadUrl: vi.fn(), + toastAdd: vi.fn(), +})); + +vi.mock("~/state/use-atom-command", () => ({ useAtomCommand: () => mocks.createUploadUrl })); +vi.mock("~/state/projects", () => ({ projectEnvironment: { createUploadUrl: {} } })); +vi.mock("~/state/environments", () => ({ + useEnvironmentHttpBaseUrl: () => "http://example-server:3773", +})); +vi.mock("~/components/ui/toast", () => ({ + stackedThreadToast: (options: Record) => options, + toastManager: { add: (toast: unknown) => mocks.toastAdd(toast) }, +})); + +import type { RichMarkdownEditor } from "./richMarkdownExtensions"; +import { useInsertImage } from "./useInsertImage"; + +type UploadResponse = { status: number; body: string }; + +let container: HTMLDivElement; +let root: Root; +let insertImage: ReturnType void>>; +let uploadFile: ReturnType< + typeof vi.fn<(input: { url: string; path: string }) => Promise> +>; + +function fakeEditor(): RichMarkdownEditor { + insertImage = vi.fn(); + return { insertImage } as unknown as RichMarkdownEditor; +} + +const probe = { pick: () => {} }; + +function Probe(props: { markdownPath: string; editor: RichMarkdownEditor | null }) { + const image = useInsertImage({ cwd: "/workspace", environmentId: "environment-1", ...props }); + useEffect(() => { + probe.pick = image.pick; + }); + return ; +} + +async function render(props: { markdownPath: string; editor: RichMarkdownEditor | null }) { + await act(async () => root.render()); +} + +function bridgePicks(path: string) { + (window as unknown as { desktopBridge: unknown }).desktopBridge = { + pickFiles: vi.fn(async () => [path]), + uploadFile, + }; +} + +const created = { status: 201, body: JSON.stringify({ relativePath: "docs/diagram.png" }) }; +const taken = { status: 409, body: "{}" }; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + mocks.toastAdd.mockReset(); + mocks.createUploadUrl + .mockReset() + .mockImplementation(async ({ input }: { input: { fileName: string } }) => ({ + _tag: "Success", + value: { relativeUrl: `/api/transfers/upload.${input.fileName}` }, + })); + uploadFile = vi.fn(); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); + delete (window as unknown as { desktopBridge?: unknown }).desktopBridge; +}); + +describe("useInsertImage", () => { + it("a picked image is copied next to the markdown file and linked", async () => { + uploadFile.mockResolvedValue(created); + bridgePicks("pictures/diagram.png"); + await render({ markdownPath: "docs/guide.md", editor: fakeEditor() }); + + await act(async () => probe.pick()); + + await vi.waitFor(() => expect(insertImage).toHaveBeenCalledOnce()); + expect(mocks.createUploadUrl).toHaveBeenCalledWith( + expect.objectContaining({ + input: { cwd: "/workspace", relativeDirectory: "docs", fileName: "diagram.png" }, + }), + ); + expect(uploadFile).toHaveBeenCalledWith( + expect.objectContaining({ path: "pictures/diagram.png" }), + ); + expect(insertImage).toHaveBeenCalledWith("diagram.png"); + }); + + it("passes the file name unencoded, because the editor encodes the link", async () => { + uploadFile.mockResolvedValue(created); + bridgePicks("pictures/my image#1.png"); + await render({ markdownPath: "docs/guide.md", editor: fakeEditor() }); + + await act(async () => probe.pick()); + + await vi.waitFor(() => expect(insertImage).toHaveBeenCalledWith("my image#1.png")); + }); + + it("a taken name gets a numbered suffix and nothing is overwritten", async () => { + uploadFile.mockResolvedValueOnce(taken).mockResolvedValueOnce(created); + bridgePicks("pictures/diagram.png"); + await render({ markdownPath: "docs/guide.md", editor: fakeEditor() }); + + await act(async () => probe.pick()); + + await vi.waitFor(() => expect(insertImage).toHaveBeenCalledWith("diagram-1.png")); + expect(mocks.createUploadUrl.mock.calls.map(([call]) => call.input.fileName)).toEqual([ + "diagram.png", + "diagram-1.png", + ]); + for (const [request] of uploadFile.mock.calls) { + expect(request.url).not.toContain("overwrite=1"); + } + }); + + it("a failed copy shows an error and inserts nothing", async () => { + uploadFile.mockResolvedValue({ status: 500, body: JSON.stringify({ message: "Disk full." }) }); + bridgePicks("pictures/diagram.png"); + await render({ markdownPath: "docs/guide.md", editor: fakeEditor() }); + + await act(async () => probe.pick()); + + await vi.waitFor(() => expect(mocks.toastAdd).toHaveBeenCalledOnce()); + expect(mocks.toastAdd).toHaveBeenCalledWith( + expect.objectContaining({ type: "error", description: "Disk full." }), + ); + expect(insertImage).not.toHaveBeenCalled(); + }); + + it("a non-image file is refused before any upload", async () => { + bridgePicks("notes/plan.txt"); + await render({ markdownPath: "docs/guide.md", editor: fakeEditor() }); + + await act(async () => probe.pick()); + + await vi.waitFor(() => expect(mocks.toastAdd).toHaveBeenCalledOnce()); + expect(mocks.createUploadUrl).not.toHaveBeenCalled(); + expect(uploadFile).not.toHaveBeenCalled(); + expect(insertImage).not.toHaveBeenCalled(); + }); + + it("an upload that finishes after the file was closed or replaced inserts nothing", async () => { + let finish!: (response: UploadResponse) => void; + uploadFile.mockImplementation(() => new Promise((resolve) => (finish = resolve))); + bridgePicks("pictures/diagram.png"); + const editor = fakeEditor(); + await render({ markdownPath: "docs/guide.md", editor }); + + await act(async () => probe.pick()); + await vi.waitFor(() => expect(uploadFile).toHaveBeenCalled()); + await render({ markdownPath: "docs/guide.md", editor: fakeEditor() }); + const replacement = insertImage; + await act(async () => finish(created)); + + expect(editor.insertImage).not.toHaveBeenCalled(); + expect(replacement).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/components/files/richMarkdown/useInsertImage.ts b/apps/web/src/components/files/richMarkdown/useInsertImage.ts new file mode 100644 index 0000000000..c12872b6d7 --- /dev/null +++ b/apps/web/src/components/files/richMarkdown/useInsertImage.ts @@ -0,0 +1,151 @@ +import type { EnvironmentId } from "@bibcode/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@bibcode/client-runtime/state/runtime"; +import { + type ChangeEvent, + type InputHTMLAttributes, + type RefObject, + useCallback, + useEffect, + useRef, +} from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; +import { useEnvironmentHttpBaseUrl } from "~/state/environments"; +import { projectEnvironment } from "~/state/projects"; +import { useAtomCommand } from "~/state/use-atom-command"; + +import { filePreviewKind } from "../filePreviewMode"; +import { + interpretUploadResponse, + nextFreeFileName, + resolveTransferUrl, + sendBrowserUpload, +} from "../fileTransfers"; +import type { RichMarkdownEditor } from "./richMarkdownExtensions"; + +/** The numbered names tried after the picked one is taken (D8). */ +const MAX_NAME_ATTEMPTS = 99; + +type Send = (url: string) => Promise<{ status: number; body: string }>; + +class InsertImageError extends Error {} + +function folderOf(path: string): string { + const slash = path.lastIndexOf("/"); + return slash < 0 ? "" : path.slice(0, slash); +} + +/** + * Insert image (R28): copies a picked image into the Markdown file's folder under a free name and + * links it at the caret. An existing file is never replaced: a taken name moves to the next + * numbered one. The link is inserted only if the same editor still shows the same file. + */ +export function useInsertImage(input: { + cwd: string; + environmentId: string; + markdownPath: string; + editor: RichMarkdownEditor | null; +}): { + pick(): void; + inputProps: InputHTMLAttributes & { ref: RefObject }; +} { + const { cwd, environmentId, markdownPath, editor } = input; + const httpBaseUrl = useEnvironmentHttpBaseUrl(environmentId as EnvironmentId); + const createUploadUrl = useAtomCommand(projectEnvironment.createUploadUrl, { + reportFailure: false, + }); + const inputRef = useRef(null); + // What the panel shows now, read when an upload finishes. + const live = useRef({ editor, markdownPath }); + useEffect(() => { + live.current = { editor, markdownPath }; + }, [editor, markdownPath]); + + const upload = useCallback( + async (name: string, send: Send) => { + const started = live.current; + const report = (message: string) => + toastManager.add( + stackedThreadToast({ type: "error", title: "Image not inserted", description: message }), + ); + try { + if (!["image", "svg"].includes(filePreviewKind(name))) { + throw new InsertImageError(`${name} is not an image.`); + } + if (!httpBaseUrl) throw new InsertImageError("There is no connection to the server."); + const relativeDirectory = folderOf(started.markdownPath); + for (let attempt = 0; attempt <= MAX_NAME_ATTEMPTS; attempt += 1) { + const fileName = attempt === 0 ? name : nextFreeFileName(name, attempt); + // Each upload URL names one file, so every new name needs its own. + const minted = await createUploadUrl({ + environmentId: environmentId as EnvironmentId, + input: { cwd, relativeDirectory, fileName }, + }); + if (minted._tag === "Failure") { + if (isAtomCommandInterrupted(minted)) return; + const error = squashAtomCommandFailure(minted); + throw new InsertImageError( + error instanceof Error ? error.message : "The upload could not be prepared.", + ); + } + const url = resolveTransferUrl(httpBaseUrl, minted.value.relativeUrl); + if (url === null) + throw new InsertImageError("The server did not return a usable upload URL."); + const response = await send(url); + const step = interpretUploadResponse(response.status, response.body); + if (step._tag === "Exists") continue; + if (step._tag === "TooLarge") + throw new InsertImageError("The image is larger than the server accepts."); + if (step._tag === "Failed") throw new InsertImageError(step.message); + const now = live.current; + // The image went next to the file it was picked for; a moved or closed file gets no link. + if (now.editor === started.editor && now.markdownPath === started.markdownPath) { + started.editor?.insertImage(fileName); + } + return; + } + throw new InsertImageError( + `Every name from ${name} to ${nextFreeFileName(name, MAX_NAME_ATTEMPTS)} is taken.`, + ); + } catch (error) { + report( + error instanceof InsertImageError ? error.message : "The image could not be copied.", + ); + } + }, + [createUploadUrl, cwd, environmentId, httpBaseUrl], + ); + + const pick = useCallback(() => { + const bridge = typeof window === "undefined" ? undefined : window.desktopBridge; + const pickFiles = bridge?.pickFiles; + const uploadFile = bridge?.uploadFile; + if (pickFiles === undefined || uploadFile === undefined) { + inputRef.current?.click(); + return; + } + void (async () => { + const [path] = await pickFiles({ title: "Choose an image" }); + if (path === undefined) return; + await upload(path.split(/[\\/]/).pop() ?? path, (url) => uploadFile({ url, path })); + })(); + }, [upload]); + + const onChange = useCallback( + (event: ChangeEvent) => { + const file = event.currentTarget.files?.[0]; + // Clear the picker so choosing the same image twice in a row fires another change event. + event.currentTarget.value = ""; + if (file) void upload(file.name, (url) => sendBrowserUpload(url, file)); + }, + [upload], + ); + + return { + pick, + inputProps: { ref: inputRef, type: "file", accept: "image/*", hidden: true, onChange }, + }; +} diff --git a/apps/web/src/components/files/unsavedFileEdits.test.ts b/apps/web/src/components/files/unsavedFileEdits.test.ts new file mode 100644 index 0000000000..d2ab28f3a5 --- /dev/null +++ b/apps/web/src/components/files/unsavedFileEdits.test.ts @@ -0,0 +1,224 @@ +// @vitest-environment happy-dom +import type { DesktopBridge, DesktopCloseRequest } from "@bibcode/contracts"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { + askBeforeLeaving, + attachLeaveQuestionHost, + getLeaveQuestion, + registerUnsavedSource, + respondToDesktopCloseRequests, + releaseLeaveHolds, + windowUnsavedFiles, + type UnsavedSource, +} from "./unsavedFileEdits"; + +function fakeSource(paths: string[] = []) { + const release = vi.fn(); + const source = { + paths, + unsavedSessions: () => source.paths.map((path) => ({ path })), + saveAll: vi.fn(async () => { + source.paths = []; + return "saved" as const; + }), + holdSavingForLeave: vi.fn(() => release), + release, + }; + return source satisfies UnsavedSource; +} + +const cleanups: (() => void)[] = []; +function register(source: S): S { + cleanups.push(registerUnsavedSource(source)); + return source; +} + +beforeEach(() => { + cleanups.push(attachLeaveQuestionHost()); +}); +afterEach(() => { + getLeaveQuestion()?.answer("cancel"); + for (const cleanup of cleanups.splice(0).toReversed()) cleanup(); + releaseLeaveHolds(); + delete window.desktopBridge; +}); + +describe("unsaved file edits across the window", () => { + it("lists the unsaved files of every registered registry once", () => { + register(fakeSource(["a.md", "b.md"])); + register(fakeSource(["b.md", "c.md"])); + expect(windowUnsavedFiles()).toEqual([{ path: "a.md" }, { path: "b.md" }, { path: "c.md" }]); + }); + + it("a registry that leaves the set is no longer asked", () => { + const leave = registerUnsavedSource(fakeSource(["a.md"])); + leave(); + expect(windowUnsavedFiles()).toEqual([]); + }); + + it("on a Settings route with no registry the close proceeds at once", async () => { + await expect(askBeforeLeaving("close")).resolves.toBe("proceed"); + expect(getLeaveQuestion()).toBeNull(); + }); + + it("a clean panel registry cannot allow a close while the host registry has an unsaved edit", async () => { + register(fakeSource(["notes.md"])); + register(fakeSource()); + const answer = askBeforeLeaving("close"); + await vi.waitFor(() => expect(getLeaveQuestion()?.files).toEqual(["notes.md"])); + getLeaveQuestion()!.answer("cancel"); + await expect(answer).resolves.toBe("stay"); + }); + + it("a second ask while the question is open gets the same answer", async () => { + register(fakeSource(["notes.md"])); + const first = askBeforeLeaving("close"); + const second = askBeforeLeaving("quit"); + await vi.waitFor(() => expect(getLeaveQuestion()).not.toBeNull()); + getLeaveQuestion()!.answer("discard"); + await expect(first).resolves.toBe("proceed"); + await expect(second).resolves.toBe("proceed"); + }); + + it("the question holds every save, Cancel releases the hold and Discard keeps it until released", async () => { + const source = register(fakeSource(["notes.md"])); + const cancelled = askBeforeLeaving("close"); + await vi.waitFor(() => expect(source.holdSavingForLeave).toHaveBeenCalledOnce()); + getLeaveQuestion()!.answer("cancel"); + await cancelled; + expect(source.release).toHaveBeenCalledOnce(); + + const discarded = askBeforeLeaving("restart"); + await vi.waitFor(() => expect(getLeaveQuestion()).not.toBeNull()); + getLeaveQuestion()!.answer("discard"); + await expect(discarded).resolves.toBe("proceed"); + expect(source.release).toHaveBeenCalledOnce(); + releaseLeaveHolds(); + expect(source.release).toHaveBeenCalledTimes(2); + }); + + it("blurs the focused element before it reads the registries, so a composition is committed", async () => { + const source = register(fakeSource()); + const input = document.createElement("input"); + document.body.append(input); + input.focus(); + input.addEventListener("blur", () => { + source.paths = ["composed.md"]; + }); + const answer = askBeforeLeaving("close"); + await vi.waitFor(() => expect(getLeaveQuestion()?.files).toEqual(["composed.md"])); + getLeaveQuestion()!.answer("cancel"); + await expect(answer).resolves.toBe("stay"); + input.remove(); + }); + + it("a host that unmounts with the question open answers stay", async () => { + register(fakeSource(["notes.md"])); + const detach = cleanups.shift()!; + const answer = askBeforeLeaving("close"); + await vi.waitFor(() => expect(getLeaveQuestion()).not.toBeNull()); + detach(); + await expect(answer).resolves.toBe("stay"); + expect(getLeaveQuestion()).toBeNull(); + }); + + it("browser mode warns before unload only while an edit is unsaved", () => { + const source = register(fakeSource()); + const unload = () => { + const event = new Event("beforeunload", { cancelable: true }); + window.dispatchEvent(event); + return event.defaultPrevented; + }; + expect(unload()).toBe(false); + source.paths = ["notes.md"]; + expect(unload()).toBe(true); + window.desktopBridge = {} as DesktopBridge; + expect(unload()).toBe(false); + }); + + describe("desktop close requests", () => { + function fakeBridge() { + let listener: ((request: DesktopCloseRequest) => void) | null = null; + const replies: [number, string][] = []; + return { + replies, + send: (request: DesktopCloseRequest) => listener?.(request), + onCloseRequest: (next: (request: DesktopCloseRequest) => void) => { + listener = next; + return () => { + listener = null; + }; + }, + replyCloseRequest: vi.fn(async (requestId: number, reply: string) => { + replies.push([requestId, reply]); + }), + }; + } + + it("with nothing unsaved the close is acknowledged and allowed", async () => { + const bridge = fakeBridge(); + cleanups.push(respondToDesktopCloseRequests(bridge)); + bridge.send({ requestId: 7, reason: "close" }); + await vi.waitFor(() => + expect(bridge.replies).toEqual([ + [7, "acknowledge"], + [7, "allow"], + ]), + ); + }); + + it("waits for the person with keepalives and answers stay on Cancel", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + try { + register(fakeSource(["notes.md"])); + const bridge = fakeBridge(); + cleanups.push(respondToDesktopCloseRequests(bridge)); + bridge.send({ requestId: 3, reason: "quit" }); + await vi.waitFor(() => expect(getLeaveQuestion()).not.toBeNull()); + await vi.advanceTimersByTimeAsync(3_000); + expect( + bridge.replies.filter(([, reply]) => reply === "keepalive").length, + ).toBeGreaterThanOrEqual(2); + getLeaveQuestion()!.answer("cancel"); + await vi.waitFor(() => expect(bridge.replies.at(-1)).toEqual([3, "stay"])); + const count = bridge.replies.length; + await vi.advanceTimersByTimeAsync(3_000); + expect(bridge.replies).toHaveLength(count); + } finally { + vi.useRealTimers(); + } + }); + + it("a remote update install is asked about like a close", async () => { + register(fakeSource(["notes.md"])); + const bridge = fakeBridge(); + cleanups.push(respondToDesktopCloseRequests(bridge)); + bridge.send({ requestId: 6, reason: "update" }); + await vi.waitFor(() => expect(getLeaveQuestion()?.files).toEqual(["notes.md"])); + getLeaveQuestion()!.answer("cancel"); + await vi.waitFor(() => expect(bridge.replies.at(-1)).toEqual([6, "stay"])); + }); + + it("Save all answers allow once every file is saved", async () => { + const source = register(fakeSource(["notes.md"])); + const bridge = fakeBridge(); + cleanups.push(respondToDesktopCloseRequests(bridge)); + bridge.send({ requestId: 4, reason: "close" }); + await vi.waitFor(() => expect(getLeaveQuestion()).not.toBeNull()); + getLeaveQuestion()!.answer("save"); + await vi.waitFor(() => expect(bridge.replies.at(-1)).toEqual([4, "allow"])); + expect(source.saveAll).toHaveBeenCalledOnce(); + }); + + it("unsubscribing with a request pending answers stay", async () => { + register(fakeSource(["notes.md"])); + const bridge = fakeBridge(); + const stop = respondToDesktopCloseRequests(bridge); + bridge.send({ requestId: 5, reason: "close" }); + await vi.waitFor(() => expect(getLeaveQuestion()).not.toBeNull()); + stop(); + expect(bridge.replies.at(-1)).toEqual([5, "stay"]); + }); + }); +}); diff --git a/apps/web/src/components/files/unsavedFileEdits.ts b/apps/web/src/components/files/unsavedFileEdits.ts new file mode 100644 index 0000000000..2ebec07bd2 --- /dev/null +++ b/apps/web/src/components/files/unsavedFileEdits.ts @@ -0,0 +1,269 @@ +import type { DesktopBridge, DesktopCloseReply, DesktopCloseRequest } from "@bibcode/contracts"; + +import { toastManager } from "../ui/toast"; + +/** A `FileEditingSessionRegistry` as the window-wide guard sees it. */ +export interface UnsavedSource { + unsavedSessions(): { path: string }[]; + saveAll(): Promise<"saved" | "blocked">; + holdSavingForLeave(): () => void; +} + +export type LeaveReason = "close" | "quit" | "restart" | "update"; +export type LeaveChoice = "save" | "discard" | "cancel"; + +export interface LeaveQuestion { + readonly reason: LeaveReason; + readonly files: readonly string[]; + /** True while Save all runs; the dialog stays open and shows it. */ + readonly saving: boolean; + answer(choice: LeaveChoice): void; +} + +const sources = new Set(); +const questionListeners = new Set<() => void>(); +let question: LeaveQuestion | null = null; +let pendingAsk: Promise<"proceed" | "stay"> | null = null; +let hostCount = 0; +/** Holds kept after Discard, released if the leave then fails. */ +let discardHolds: (() => void)[] = []; +/** + * Set from the moment the window allows a desktop update install until the desktop says it ended + * without restarting. Saving is held and the installing dialog keeps edits out, so no edit made + * during the install is lost when it restarts the app. + */ +let updateInstall: { readonly release: () => void } | null = null; + +export function registerUnsavedSource(source: UnsavedSource): () => void { + sources.add(source); + return () => { + sources.delete(source); + }; +} + +export function windowUnsavedFiles(): { path: string }[] { + const paths = new Set(); + for (const source of sources) { + for (const { path } of source.unsavedSessions()) paths.add(path); + } + return [...paths].map((path) => ({ path })); +} + +/** Saves every registered registry; "blocked" when any file could not be saved. */ +export async function saveAll(): Promise<"saved" | "blocked"> { + const results = await Promise.all([...sources].map((source) => source.saveAll())); + return results.includes("blocked") ? "blocked" : "saved"; +} + +export function getLeaveQuestion(): LeaveQuestion | null { + return question; +} + +export function subscribeLeaveQuestion(listener: () => void): () => void { + questionListeners.add(listener); + return () => { + questionListeners.delete(listener); + }; +} + +function setQuestion(next: LeaveQuestion | null): void { + question = next; + notifyQuestionListeners(); +} + +function notifyQuestionListeners(): void { + for (const listener of questionListeners) listener(); +} + +/** True while an allowed desktop update install runs; `subscribeLeaveQuestion` reports changes. */ +export function isUpdateInstalling(): boolean { + return updateInstall !== null; +} + +function blockForUpdateInstall(): void { + if (updateInstall !== null) return; + const holds = [...sources].map((source) => source.holdSavingForLeave()); + updateInstall = { + release: () => { + for (const release of holds) release(); + }, + }; + notifyQuestionListeners(); +} + +/** The desktop install ended without a restart: saving and editing run again. */ +function endUpdateInstall(): void { + releaseLeaveHolds(); + const install = updateInstall; + if (install === null) return; + updateInstall = null; + install.release(); + notifyQuestionListeners(); + toastManager.add({ + type: "error", + title: "The update did not install", + description: + "BiBCode did not restart. Files can be edited again. If saving fails, open the Update not installed dialog and choose Restart server.", + }); +} + +/** + * The one dialog host of the window announces itself here. A host that unmounts with the + * question open answers Cancel, so nothing waits on a dialog nobody can see. + */ +export function attachLeaveQuestionHost(): () => void { + hostCount += 1; + let attached = true; + return () => { + if (!attached) return; + attached = false; + hostCount -= 1; + if (hostCount === 0) question?.answer("cancel"); + }; +} + +/** Lets saving run again after Discard when the restart or install did not happen. */ +export function releaseLeaveHolds(): void { + const holds = discardHolds; + discardHolds = []; + for (const release of holds) release(); +} + +/** + * Asks before the window closes, the app quits, restarts or installs an update. Resolves + * "proceed" at once when nothing is unsaved, otherwise after Save all succeeded or Discard. + */ +export function askBeforeLeaving(reason: LeaveReason): Promise<"proceed" | "stay"> { + pendingAsk ??= ask(reason).finally(() => { + pendingAsk = null; + }); + return pendingAsk; +} + +async function ask(reason: LeaveReason): Promise<"proceed" | "stay"> { + // Blurring commits an IME composition into the editor before the registries are read. + const focused = document.activeElement instanceof HTMLElement ? document.activeElement : null; + focused?.blur(); + const answer = await askUnfocused(reason); + // Staying puts the person back where they were, after the dialog returned its own focus. + if (answer === "stay" && focused?.isConnected) setTimeout(() => focused.focus(), 0); + return answer; +} + +async function askUnfocused(reason: LeaveReason): Promise<"proceed" | "stay"> { + await new Promise((resolve) => setTimeout(resolve, 0)); + const files = windowUnsavedFiles().map(({ path }) => path); + if (files.length === 0) return "proceed"; + if (hostCount === 0) return "stay"; + + const holds = [...sources].map((source) => source.holdSavingForLeave()); + const releaseHolds = () => { + for (const release of holds) release(); + }; + const choice = await new Promise((resolve) => { + let answered = false; + setQuestion({ + reason, + files, + saving: false, + answer: (next) => { + if (answered) return; + answered = true; + setQuestion(next === "save" ? { reason, files, saving: true, answer: () => {} } : null); + resolve(next); + }, + }); + }); + + if (choice === "cancel") { + releaseHolds(); + return "stay"; + } + if (choice === "discard") { + discardHolds.push(...holds); + return "proceed"; + } + releaseHolds(); + const saved = await saveAll(); + setQuestion(null); + if (saved === "saved") return "proceed"; + const unsaved = windowUnsavedFiles().map(({ path }) => path); + toastManager.add({ + type: "error", + title: unsaved.length === 1 ? "A file was not saved" : `${unsaved.length} files were not saved`, + description: `${unsaved.join(", ")}. Open each file to reload, overwrite or retry the save.`, + }); + return "stay"; +} + +/** The desktop waits 5 seconds for each, so a throttled timer still has five chances. */ +const KEEPALIVE_INTERVAL_MS = 1_000; + +/** + * Answers the desktop's close and quit requests through the leave question. Keepalives run only + * while this subscription holds a request, and stopping it with one pending answers stay. + */ +export function respondToDesktopCloseRequests( + bridge: Pick, +): () => void { + const { onCloseRequest, replyCloseRequest, onUpdateInstallEnded } = bridge; + if (!onCloseRequest || !replyCloseRequest) return () => {}; + let active: { + readonly requestId: number; + readonly reason: DesktopCloseRequest["reason"]; + readonly keepalive: ReturnType; + } | null = null; + const reply = (requestId: number, answer: DesktopCloseReply) => { + replyCloseRequest(requestId, answer).catch((error: unknown) => { + console.warn("[unsaved-file-edits] could not answer the desktop close request", error); + }); + }; + const finish = (answer: "allow" | "stay") => { + if (active === null) return; + clearInterval(active.keepalive); + reply(active.requestId, answer); + active = null; + }; + const unsubscribe = onCloseRequest(({ requestId, reason }) => { + if (active?.requestId === requestId) return; + finish("stay"); + reply(requestId, "acknowledge"); + const current = { + requestId, + reason, + keepalive: setInterval(() => reply(requestId, "keepalive"), KEEPALIVE_INTERVAL_MS), + }; + active = current; + void askBeforeLeaving(reason).then((answer) => { + if (active !== current) return; + // Only a remote install asks with "update"; it keeps running long after this answer. + if (answer === "proceed" && reason === "update") blockForUpdateInstall(); + finish(answer === "proceed" ? "allow" : "stay"); + }); + }); + const stopEnded = onUpdateInstallEnded?.(() => { + // The person forced the install from the native prompt while this question was open; a late + // answer must not start a block that no signal will end. + if (active?.reason === "update") { + clearInterval(active.keepalive); + active = null; + question?.answer("cancel"); + } + endUpdateInstall(); + }); + return () => { + unsubscribe(); + stopEnded?.(); + if (active === null) return; + finish("stay"); + question?.answer("cancel"); + }; +} + +if (typeof window !== "undefined") { + // Browser mode only: the desktop app asks through its close protocol instead. + window.addEventListener("beforeunload", (event) => { + if (window.desktopBridge !== undefined) return; + if (windowUnsavedFiles().length > 0) event.preventDefault(); + }); +} diff --git a/apps/web/src/components/files/useFileSaveShortcut.ts b/apps/web/src/components/files/useFileSaveShortcut.ts new file mode 100644 index 0000000000..b8f8bd8611 --- /dev/null +++ b/apps/web/src/components/files/useFileSaveShortcut.ts @@ -0,0 +1,56 @@ +import { type RefObject, useEffect } from "react"; + +import { stackedThreadToast, toastManager } from "~/components/ui/toast"; + +export interface SavableSession { + flush(): Promise; + getSnapshot(): { readonly editPending: boolean }; +} + +/** + * Explains a Save, Reload or Overwrite that did nothing: a rendered edit still pending after + * the action means its delivery threw, and the action refused to run without it. + */ +export function explainUndeliveredEdit(session: Pick): void { + if (!session.getSnapshot().editPending) return; + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Your last edit could not be applied to the file.", + description: "Switch to source view to keep it.", + }), + ); +} + +/** An explicit save from the toolbar or the shortcut. */ +export function saveFile(session: SavableSession): void { + // `flush` delivers the pending edit before its first await, so the snapshot is current here. + void session.flush(); + explainUndeliveredEdit(session); +} + +/** + * Wire an explicit Ctrl+S / Cmd+S save onto the file-surface container. The chord + * flushes the pending debounced write immediately and always preventDefaults so + * the browser's own save dialog can never open. The listener is scoped to the + * container (capture phase, so it wins before the Pierre editor's own handlers) + * and only fires while focus lives inside the surface. + */ +export function useFileSaveShortcut( + containerRef: RefObject, + session: SavableSession | null, +): void { + useEffect(() => { + const container = containerRef.current; + if (!container || !session) return; + const handleKeyDown = (event: KeyboardEvent) => { + const isSaveChord = + (event.metaKey || event.ctrlKey) && !event.altKey && event.key.toLowerCase() === "s"; + if (!isSaveChord) return; + event.preventDefault(); + saveFile(session); + }; + container.addEventListener("keydown", handleKeyDown, true); + return () => container.removeEventListener("keydown", handleKeyDown, true); + }, [containerRef, session]); +} diff --git a/apps/web/src/components/gitManager/GitManagerPanel.test.tsx b/apps/web/src/components/gitManager/GitManagerPanel.test.tsx index bb77662c89..7db3be8862 100644 --- a/apps/web/src/components/gitManager/GitManagerPanel.test.tsx +++ b/apps/web/src/components/gitManager/GitManagerPanel.test.tsx @@ -369,6 +369,22 @@ beforeEach(() => { useGitManagerStore.setState({ byProjectKey: {} }); }); +const REMOTE_PUSH_GUIDANCE = + "Push ran on the remote server example-server. In a terminal on example-server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode."; +const GIT_USERNAME_LINE = + "fatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled"; +const REMOTE_PUSH_FAILURE = `${REMOTE_PUSH_GUIDANCE}\n\n${GIT_USERNAME_LINE}`; + +/** The element showing the failure keeps both paragraphs on their own lines. */ +function expectMultiLineFailure(root: ParentNode): void { + const shown = [...root.querySelectorAll("p")].find((element) => + element.textContent?.includes(REMOTE_PUSH_GUIDANCE), + ); + expect(shown?.textContent).toContain(REMOTE_PUSH_FAILURE); + expect(shown?.classList).toContain("whitespace-pre-line"); + expect(shown?.classList).toContain("select-text"); +} + describe("GitManagerPanel", () => { it("reports catalog loading only until the first live snapshot", () => { h.catalogPending = true; @@ -914,6 +930,46 @@ describe("GitManagerPanel", () => { } }); + it("a failed push in history shows the multi-line message", async () => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + useGitManagerStore.getState().setActiveTab(projectRef, "history"); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + const failed = { + _tag: "failed", + operation: "push", + code: "authentication", + message: REMOTE_PUSH_FAILURE, + blocked: null, + }; + h.runOperation.mockImplementation((_registry, _target, onEvent) => { + (onEvent as (event: unknown) => void)(failed); + return { + result: Promise.resolve({ _tag: "Success", value: failed, waiting: false }), + cancel: vi.fn(), + }; + }); + try { + await act(async () => root.render()); + const onAction = h.historyProps.at(-1)?.onAction as (action: unknown) => void; + await act(async () => onAction({ _tag: "revert", sha: "d".repeat(40) })); + await act(async () => { + for (const effect of h.effects.splice(0)) effect(); + }); + expectMultiLineFailure(container); + } finally { + h.runOperation.mockReset(); + h.runOperation.mockImplementation(() => ({ + result: new Promise(() => undefined), + cancel: vi.fn(), + })); + await act(async () => root.unmount()); + container.remove(); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; + } + }); + it("asks before a Git Credential Manager sign-in and resumes the named request once", async () => { (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; useGitManagerStore.getState().setActiveTab(projectRef, "history"); diff --git a/apps/web/src/components/gitManager/changes/GitManagerChangeRow.tsx b/apps/web/src/components/gitManager/changes/GitManagerChangeRow.tsx index 9e04b228a8..930fd30e33 100644 --- a/apps/web/src/components/gitManager/changes/GitManagerChangeRow.tsx +++ b/apps/web/src/components/gitManager/changes/GitManagerChangeRow.tsx @@ -111,7 +111,7 @@ export const GitManagerChangeRow = memo(function GitManagerChangeRow({ aria-selected={selected} className={cn( "group flex h-[29px] min-w-0 items-center gap-2 border-b border-border/35 px-2 text-xs outline-none hover:bg-accent/45 focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring", - selected && "bg-accent/65", + selected && "bg-selected text-selected-foreground", )} data-path={row.path} role="option" diff --git a/apps/web/src/components/gitManager/history/GitManagerCommitDetail.tsx b/apps/web/src/components/gitManager/history/GitManagerCommitDetail.tsx index a3ba33b117..6ce2d0e366 100644 --- a/apps/web/src/components/gitManager/history/GitManagerCommitDetail.tsx +++ b/apps/web/src/components/gitManager/history/GitManagerCommitDetail.tsx @@ -122,7 +122,7 @@ const ChangedFileRow = memo(function ChangedFileRow({ aria-selected={selected} className={cn( "flex h-[29px] w-full min-w-0 items-center gap-1.5 px-2 text-left text-xs focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring", - selected ? "bg-accent text-accent-foreground" : "hover:bg-muted/45", + selected ? "bg-selected text-selected-foreground" : "hover:bg-muted/45", )} data-changed-file-path={path} role="option" diff --git a/apps/web/src/components/gitManager/history/GitManagerCommitList.tsx b/apps/web/src/components/gitManager/history/GitManagerCommitList.tsx index 3e5b532b22..176649a1e1 100644 --- a/apps/web/src/components/gitManager/history/GitManagerCommitList.tsx +++ b/apps/web/src/components/gitManager/history/GitManagerCommitList.tsx @@ -116,7 +116,7 @@ const GitManagerCommitRow = memo(function GitManagerCommitRow({ aria-selected={selected} className={cn( "flex h-[50px] w-full min-w-0 touch-manipulation items-center gap-2 border-b border-border/60 px-3 text-left transition-colors focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring", - selected ? "bg-accent text-accent-foreground" : "hover:bg-muted/45", + selected ? "bg-selected text-selected-foreground" : "hover:bg-muted/45", isDragging && "select-none opacity-40", )} data-commit-drag-source={rewriteDisabledReason === null ? commit.sha : undefined} diff --git a/apps/web/src/components/gitManager/stash/GitManagerStashDiff.tsx b/apps/web/src/components/gitManager/stash/GitManagerStashDiff.tsx index 3622a8ffe3..8e6e607cd5 100644 --- a/apps/web/src/components/gitManager/stash/GitManagerStashDiff.tsx +++ b/apps/web/src/components/gitManager/stash/GitManagerStashDiff.tsx @@ -79,7 +79,7 @@ const StashFileRow = memo(function StashFileRow({ aria-selected={selected} className={cn( "flex h-[29px] w-full min-w-0 items-center gap-1.5 px-2 text-left text-xs focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-ring", - selected ? "bg-accent text-accent-foreground" : "hover:bg-muted/45", + selected ? "bg-selected text-selected-foreground" : "hover:bg-muted/45", )} role="option" title={file.path} diff --git a/apps/web/src/components/gitManager/stash/GitManagerStashList.tsx b/apps/web/src/components/gitManager/stash/GitManagerStashList.tsx index 7573b33a16..f66f9bd6a8 100644 --- a/apps/web/src/components/gitManager/stash/GitManagerStashList.tsx +++ b/apps/web/src/components/gitManager/stash/GitManagerStashList.tsx @@ -123,7 +123,7 @@ const GitManagerStashRowView = memo(function GitManagerStashRowView({
{ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = false; }); +const REMOTE_PUSH_GUIDANCE = + "Push ran on the remote server example-server. In a terminal on example-server, open this repository as the user that runs BiBCode and run `git push` to sign in to Git. Then retry the push in BiBCode."; +const GIT_USERNAME_LINE = + "fatal: could not read Username for 'https://gitlab.example.test': terminal prompts disabled"; +const REMOTE_PUSH_FAILURE = `${REMOTE_PUSH_GUIDANCE}\n\n${GIT_USERNAME_LINE}`; + +/** The element showing the failure keeps both paragraphs on their own lines. */ +function expectMultiLineFailure(root: ParentNode): void { + const shown = [...root.querySelectorAll("p")].find((element) => + element.textContent?.includes(REMOTE_PUSH_GUIDANCE), + ); + expect(shown?.textContent).toContain(REMOTE_PUSH_FAILURE); + expect(shown?.classList).toContain("whitespace-pre-line"); + expect(shown?.classList).toContain("select-text"); +} + describe("GitManagerOperationBanner", () => { + it("a failed push shows a multi-line message with its lines kept", async () => { + await emit({ + _tag: "failed", + operation: "push", + code: "authentication", + message: REMOTE_PUSH_FAILURE, + blocked: null, + }); + expectMultiLineFailure(document); + }); + it("tracks started, chunked output, finished and failed events", async () => { await emit({ _tag: "started", operation: "fetch" }); expect(document.querySelector('[role="status"]')?.textContent).toContain("fetch"); diff --git a/apps/web/src/components/gitManager/toolbar/GitManagerOperationBanner.tsx b/apps/web/src/components/gitManager/toolbar/GitManagerOperationBanner.tsx index 5a9de5fbc5..2e73e0ff1c 100644 --- a/apps/web/src/components/gitManager/toolbar/GitManagerOperationBanner.tsx +++ b/apps/web/src/components/gitManager/toolbar/GitManagerOperationBanner.tsx @@ -137,7 +137,7 @@ export const GitManagerOperationBanner = memo(function GitManagerOperationBanner ) : null}
{state.failureMessage === null ? null : ( -

+

{state.failureMessage} ({state.failureCode})

)} diff --git a/apps/web/src/components/hostDashboard/HostDashboardSheet.stream.test.tsx b/apps/web/src/components/hostDashboard/HostDashboardSheet.stream.test.tsx new file mode 100644 index 0000000000..0bc3cda3fd --- /dev/null +++ b/apps/web/src/components/hostDashboard/HostDashboardSheet.stream.test.tsx @@ -0,0 +1,192 @@ +// @vitest-environment happy-dom +// The Host dashboard over the real `serverEnvironment.hostMetrics` atom and the real query hook: +// a fake server stream counts how often it is opened and closed, so a leaked subscription fails. +import { RegistryContext } from "@effect/atom-react"; +import { EnvironmentId, type ServerHostMetricsEvent, WS_METHODS } from "@bibcode/contracts"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { AtomRegistry } from "effect/reactivity"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const h = vi.hoisted(() => ({ + opened: new Map(), + closed: new Map(), + visibility: "visible" as DocumentVisibilityState, +})); + +vi.mock("../ui/sheet", () => { + const Box = ({ children }: { children?: ReactNode }) =>
{children}
; + return { + Sheet: Box, + SheetPopup: ({ children }: { children?: ReactNode }) => ( +
{children}
+ ), + SheetHeader: Box, + SheetTitle: Box, + SheetDescription: Box, + SheetPanel: Box, + }; +}); +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, + TooltipTrigger: ({ render }: { render: ReactNode }) => render, + TooltipPopup: () => null, +})); +vi.mock("~/hooks/useSettings", () => ({ useClientSettings: () => "locale" })); +vi.mock("~/state/environments", () => ({ + useEnvironment: (environmentId: string) => ({ + environmentId, + label: environmentId, + connection: { phase: "connected", error: null, traceId: null }, + serverConfig: { environment: { capabilities: { hostMetrics: true } } }, + }), +})); +vi.mock("~/state/server", async () => { + const Effect = await import("effect/Effect"); + const Layer = await import("effect/Layer"); + const Option = await import("effect/Option"); + const Stream = await import("effect/Stream"); + const SubscriptionRef = await import("effect/SubscriptionRef"); + const { Atom } = await import("effect/reactivity"); + const { createServerEnvironmentAtoms } = await import("@bibcode/client-runtime/state/server"); + const { EnvironmentRegistry, EnvironmentSupervisor } = + await import("@bibcode/client-runtime/connection"); + const bump = (counts: Map, id: string) => + counts.set(id, (counts.get(id) ?? 0) + 1); + const snapshot = (): ServerHostMetricsEvent => ({ + _tag: "Snapshot", + host: { os: "linux", arch: "x86_64", cpuCount: 12, memoryTotalBytes: 1_000 }, + bootId: "boot-1", + startedAt: "2026-11-11T19:00:00.000Z", + statisticsSince: "2026-11-11T19:00:00.000Z", + sample: { + sampledAt: "2026-11-11T19:00:01.000Z", + cpuPercent: 23.5, + memoryTotalBytes: 1_000, + memoryUsedBytes: 250, + memoryAvailableBytes: 750, + volumes: [], + volumesPaused: false, + }, + cpu: null, + memory: null, + recent: [], + uptime: [], + hiddenVolumes: [], + volumeStatistics: [], + }); + const fakeStream = (environmentId: string) => + Stream.unwrap( + Effect.sync(() => { + bump(h.opened, environmentId); + return Stream.make(snapshot()).pipe(Stream.concat(Stream.never)); + }), + ).pipe(Stream.ensuring(Effect.sync(() => bump(h.closed, environmentId)))); + const registry = EnvironmentRegistry.of({ + followStream: (environmentId: string, stream: unknown) => + Stream.unwrap( + Effect.map( + SubscriptionRef.make( + Option.some({ + client: { [WS_METHODS.serverSubscribeHostMetrics]: () => fakeStream(environmentId) }, + }), + ), + (session) => + (stream as Parameters[0]).pipe( + Stream.provideService( + EnvironmentSupervisor, + EnvironmentSupervisor.of({ target: { environmentId }, session } as never), + ), + ), + ), + ), + } as never); + return { + serverEnvironment: createServerEnvironmentAtoms( + Atom.runtime(Layer.succeed(EnvironmentRegistry, registry)), + { initialConfigValueAtom: () => Atom.make(null) }, + ), + }; +}); + +import { hostDashboardRequest } from "~/state/hostDashboard"; +import { HostDashboardCoordinator } from "./HostDashboardSheet"; + +const FIRST = EnvironmentId.make("example-server"); +const SECOND = EnvironmentId.make("example-server-2"); + +let root: Root; +let container: HTMLDivElement; +let registry: AtomRegistry.AtomRegistry; + +beforeEach(async () => { + h.opened.clear(); + h.closed.clear(); + h.visibility = "visible"; + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => h.visibility, + }); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + registry = AtomRegistry.make(); + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + await act(async () => + root.render( + + undefined} /> + , + ), + ); +}); + +afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + registry.dispose(); +}); + +/** Lets the atom registry run its idle disposal and the stream's finalizers. */ +async function settle() { + for (let turn = 0; turn < 5; turn += 1) { + await act(async () => new Promise((resolve) => setTimeout(resolve, 0))); + } +} + +async function open(environmentId: EnvironmentId) { + await act(async () => registry.set(hostDashboardRequest, { environmentId })); + await settle(); +} + +describe("Host dashboard stream lifetime", () => { + it("opens one stream, shows its snapshot, and closes it when the sheet closes", async () => { + await open(FIRST); + expect(h.opened.get(FIRST)).toBe(1); + expect(container.textContent).toContain("23.5%"); + await act(async () => registry.set(hostDashboardRequest, null)); + await settle(); + expect(h.closed.get(FIRST)).toBe(1); + }); + + it("closes the stream while the document is hidden and opens a new one after", async () => { + await open(FIRST); + h.visibility = "hidden"; + await act(async () => document.dispatchEvent(new Event("visibilitychange"))); + await settle(); + expect(h.closed.get(FIRST)).toBe(1); + h.visibility = "visible"; + await act(async () => document.dispatchEvent(new Event("visibilitychange"))); + await settle(); + expect(h.opened.get(FIRST)).toBe(2); + expect(h.closed.get(FIRST)).toBe(1); + }); + + it("closes the first environment's stream when another environment replaces it", async () => { + await open(FIRST); + await open(SECOND); + expect(h.closed.get(FIRST)).toBe(1); + expect(h.opened.get(SECOND)).toBe(1); + expect(h.closed.get(SECOND)).toBeUndefined(); + }); +}); diff --git a/apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx b/apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx new file mode 100644 index 0000000000..1e536706e6 --- /dev/null +++ b/apps/web/src/components/hostDashboard/HostDashboardSheet.test.tsx @@ -0,0 +1,454 @@ +// @vitest-environment happy-dom +import type { HostMetricsView } from "@bibcode/client-runtime/state/server"; +import { EnvironmentId } from "@bibcode/contracts"; +import { act, type ReactElement, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const h = vi.hoisted(() => ({ + data: null as unknown, + error: null as string | null, + /** Every atom the sheet passed to `useEnvironmentQuery`, null when it did not subscribe. */ + queries: [] as unknown[], + refresh: vi.fn(), + navigate: vi.fn(), + visibility: "visible" as DocumentVisibilityState, + environment: null as unknown, +})); + +vi.mock("../ui/sheet", () => { + const Box = ({ children }: { children?: ReactNode }) =>
{children}
; + return { + Sheet: Box, + SheetPopup: ({ children, ...props }: { children?: ReactNode }) => ( +
{children}
+ ), + SheetHeader: Box, + SheetTitle: ({ children }: { children?: ReactNode }) =>

{children}

, + SheetDescription: Box, + SheetPanel: Box, + }; +}); +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => <>{children}, + TooltipTrigger: ({ render }: { render: ReactElement }) => render, + TooltipPopup: () => null, +})); +vi.mock("~/hooks/useSettings", () => ({ useClientSettings: () => "locale" })); +vi.mock("~/state/environments", () => ({ useEnvironment: () => h.environment })); +vi.mock("~/state/server", () => ({ + serverEnvironment: { hostMetrics: (key: unknown) => ({ hostMetrics: key }) }, +})); +vi.mock("~/state/query", () => ({ + useEnvironmentQuery: (atom: unknown) => { + h.queries.push(atom); + return { + data: atom === null ? null : h.data, + error: atom === null ? null : h.error, + isPending: atom !== null && h.data === null && h.error === null, + refresh: h.refresh, + }; + }, +})); + +import { RegistryContext } from "@effect/atom-react"; +import { AtomRegistry } from "effect/reactivity"; + +import { hostDashboardRequest } from "~/state/hostDashboard"; +import { HostDashboardCoordinator, HostDashboardSheet } from "./HostDashboardSheet"; + +const ENVIRONMENT_ID = EnvironmentId.make("example-server"); + +function environment(input: { phase?: string; hostMetrics?: boolean | undefined } = {}) { + const capabilities = input.hostMetrics === undefined ? {} : { hostMetrics: input.hostMetrics }; + return { + environmentId: ENVIRONMENT_ID, + label: "example-server", + connection: { phase: input.phase ?? "connected", error: null, traceId: null }, + serverConfig: { environment: { capabilities } }, + }; +} + +function view(overrides: Partial = {}): HostMetricsView { + const startedAt = new Date(Date.now() - (3 * 60 + 5) * 60_000).toISOString(); + return { + bootId: "boot-1", + startedAt, + statisticsSince: startedAt, + host: { os: "macos", arch: "aarch64", cpuCount: 12, memoryTotalBytes: 34_359_738_368 }, + sample: { + sampledAt: new Date(Date.now() - 1_000).toISOString(), + cpuPercent: 23.5, + memoryTotalBytes: 34_359_738_368, + memoryUsedBytes: 12_025_908_428, + memoryAvailableBytes: 34_359_738_368 - 12_025_908_428, + volumes: [ + { + mountPoint: "/", + name: "System", + fileSystem: "apfs", + totalBytes: 2_147_483_648_000, + freeBytes: 671_088_640_000, + usedBytes: 2_147_483_648_000 - 671_088_640_000, + removable: false, + readOnly: false, + state: "ok", + }, + { + mountPoint: "/mnt/slow", + name: "", + fileSystem: "ext4", + totalBytes: null, + freeBytes: null, + usedBytes: null, + removable: true, + readOnly: true, + state: "notResponding", + }, + ], + volumesPaused: false, + }, + cpu: { current: 23.5, minimum: 2, average: 11.25, p95: 40, maximum: 97.5 }, + memory: { current: 35, minimum: 30, average: 33, p95: 36, maximum: 40 }, + recent: [ + { sampledAt: new Date(Date.now() - 2_000).toISOString(), cpuPercent: 20, memoryPercent: 34 }, + { + sampledAt: new Date(Date.now() - 1_000).toISOString(), + cpuPercent: 23.5, + memoryPercent: 35, + }, + ], + uptime: [ + { + startedAt: new Date(Date.now() - 120_000).toISOString(), + durationMs: 60_000, + cpuAverage: 10, + cpuPeak: 40, + memoryAverage: 33, + memoryPeak: 36, + }, + ], + hiddenVolumes: [], + volumeStatistics: [], + ...overrides, + }; +} + +const roots: ReturnType[] = []; +let root: ReturnType | null = null; +let container: HTMLDivElement | null = null; + +beforeEach(() => { + Object.assign(h, { + data: null, + error: null, + queries: [], + visibility: "visible", + environment: environment({ hostMetrics: true }), + }); + h.refresh.mockReset(); + h.navigate.mockReset(); + Object.defineProperty(document, "visibilityState", { + configurable: true, + get: () => h.visibility, + }); + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(async () => { + for (const mounted of roots.splice(0)) await act(async () => mounted.unmount()); + document.body.replaceChildren(); + vi.useRealTimers(); +}); + +async function render(onClose = vi.fn()) { + if (root === null || container === null || !document.body.contains(container)) { + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); + roots.push(root); + } + const target = root; + await act(async () => + target.render( + , + ), + ); + return { container: container!, onClose }; +} + +function text(): string { + return container?.textContent ?? ""; +} + +function subscribed(): boolean { + return h.queries.at(-1) !== null && h.queries.at(-1) !== undefined; +} + +afterEach(() => { + root = null; + container = null; +}); + +describe("HostDashboardSheet", () => { + it("shows skeletons until the first snapshot", async () => { + await render(); + expect(container?.querySelector('[data-slot="skeleton"]')).not.toBeNull(); + expect(subscribed()).toBe(true); + expect(text()).toContain("example-server"); + }); + + it("shows the populated dashboard", async () => { + h.data = view(); + await render(); + expect(text()).not.toContain("—"); + const cpu = container!.querySelector('[data-testid="host-dashboard-cpu"]'); + expect(cpu?.textContent).toContain("23.5%"); + expect(cpu?.getAttribute("data-sampled-at")).toBe( + (h.data as HostMetricsView).sample?.sampledAt, + ); + expect(text()).toContain("12 cores"); + expect( + container!.querySelector('[data-testid="host-dashboard-memory"]')?.textContent, + ).toContain("11.2 GB of 32.0 GB, 35%"); + const volumes = [...container!.querySelectorAll('[data-testid="host-dashboard-volume"]')]; + expect(volumes).toHaveLength(2); + expect(volumes[0]?.textContent).toContain("/"); + expect(volumes[0]?.textContent).toContain("625.0 GB free"); + expect(volumes[1]?.textContent).toContain("Removable"); + expect(volumes[1]?.textContent).toContain("Read-only"); + expect(volumes[1]?.textContent).toContain("Not responding"); + const since = container!.querySelector('[data-testid="host-dashboard-since"]'); + expect(since?.textContent).toMatch(/Since .+\(up 3h\)/); + for (const label of ["Current", "Minimum", "Average", "p95", "Maximum"]) { + expect(since?.textContent).toContain(label); + } + expect(since?.textContent).toContain("97.5%"); + expect(since?.textContent).toContain("40.0%"); + expect(since?.querySelector('[aria-label="CPU since start"]')?.children.length).toBe(1); + expect(since?.querySelector('[aria-label="Memory since start"]')?.children.length).toBe(1); + }); + + it("lists hidden volumes with their reason under a closed disclosure", async () => { + h.data = view({ + hiddenVolumes: [ + { mountPoint: "/mnt/share", fileSystem: "nfs4", reason: "network" }, + { mountPoint: "/run/overlay", fileSystem: "overlay", reason: "overlay" }, + { mountPoint: "/etc/hostname", fileSystem: "ext4", reason: "fileMount" }, + { mountPoint: "/dev/shm", fileSystem: "tmpfs", reason: "system" }, + { mountPoint: "/mnt/empty", fileSystem: "tmpfs", reason: "empty" }, + { + mountPoint: "/System/Volumes/Data", + fileSystem: "apfs", + reason: "sameStorage", + sameStorageAs: "/", + }, + { mountPoint: "/mnt/future", fileSystem: "ext4", reason: "someNewReason" }, + ], + }); + await render(); + const toggle = [...container!.querySelectorAll("button")].find((button) => + button.textContent?.includes("Hidden volumes (7)"), + ); + expect(toggle).toBeDefined(); + expect(toggle?.getAttribute("aria-expanded")).toBe("false"); + expect(text()).not.toContain("/mnt/share"); + await act(async () => toggle!.click()); + expect(toggle?.getAttribute("aria-expanded")).toBe("true"); + const rows = [...container!.querySelectorAll('[data-testid="host-dashboard-hidden-volume"]')]; + expect(rows.map((row) => row.textContent)).toEqual([ + "/mnt/shareNetwork volume", + "/run/overlayContainer layer", + "/etc/hostnameFile mount", + "/dev/shmSystem mount", + "/mnt/emptyEmpty", + "/System/Volumes/DataSame storage as /", + "/mnt/futureHidden by the volume filter", + ]); + // Hidden volumes are listed, never sized. + expect(rows.some((row) => /free|\bof\b|GB/u.test(row.textContent ?? ""))).toBe(false); + }); + + it("shows each volume's used percent since start in the Since section", async () => { + h.data = view({ + volumeStatistics: [ + { + mountPoint: "/", + statistic: { current: 60, minimum: 40, average: 50, p95: 59, maximum: 60 }, + }, + ], + }); + await render(); + const since = container!.querySelector('[data-testid="host-dashboard-since"]'); + const rows = [...since!.querySelectorAll("tbody tr")].map((row) => row.textContent); + expect(rows).toEqual([ + expect.stringContaining("CPU"), + expect.stringContaining("Memory"), + "/ used60.0%40.0%50.0%59.0%60.0%", + ]); + }); + + it("shows no hidden volumes disclosure when nothing is hidden", async () => { + h.data = view(); + await render(); + expect(text()).not.toContain("Hidden volumes"); + }); + + it("says the volume list is not answering only when the server reports it", async () => { + const withFlag = (flag: boolean | undefined) => { + const base = view() as HostMetricsView; + return { + ...base, + sample: { + ...base.sample!, + ...(flag === undefined ? {} : { volumeListingNotResponding: flag }), + }, + } as HostMetricsView; + }; + const notice = + "The volume list is not answering. Volumes shown are from the last list that did."; + h.data = withFlag(true); + await render(); + expect(text()).toContain(notice); + for (const flag of [false, undefined]) { + h.data = withFlag(flag); + await render(); + expect(text()).not.toContain(notice); + } + }); + + it("dims the values after 3 seconds without an item", async () => { + vi.useFakeTimers({ + now: Date.UTC(2026, 10, 11, 20, 0, 0), + toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"], + }); + h.data = view(); + await render(); + expect(text()).not.toContain("Last updated"); + await act(async () => vi.advanceTimersByTime(3_000)); + expect(text()).toContain("Last updated 3 s ago"); + expect(text()).toContain("23.5%"); + }); + + it("keeps the last values dimmed while disconnected and subscribes again after", async () => { + h.data = view(); + await render(); + h.environment = environment({ phase: "reconnecting", hostMetrics: true }); + await render(); + expect(subscribed()).toBe(false); + expect(text()).toContain("Disconnected"); + expect(text()).toContain("23.5%"); + h.environment = environment({ hostMetrics: true }); + await render(); + expect(subscribed()).toBe(true); + expect(text()).not.toContain("Disconnected"); + }); + + it("says the server restarted when a new snapshot has another boot ID", async () => { + h.data = view(); + await render(); + h.environment = environment({ phase: "reconnecting", hostMetrics: true }); + await render(); + h.environment = environment({ hostMetrics: true }); + h.data = view({ + bootId: "boot-2", + cpu: { current: 5, minimum: 5, average: 5, p95: 5, maximum: 5 }, + }); + await render(); + expect(text()).toContain("Server restarted at"); + expect( + container!.querySelector('[data-testid="host-dashboard-since"]')?.textContent, + ).not.toContain("97.5%"); + }); + + it("shows the failure with Retry, which subscribes again", async () => { + h.error = "The server refused the request."; + await render(); + expect(text()).toContain("The server refused the request."); + expect(container?.querySelector('[data-slot="skeleton"]')).toBeNull(); + const retry = [...container!.querySelectorAll("button")].find( + (button) => button.textContent === "Retry", + ); + expect(retry).toBeDefined(); + await act(async () => retry!.click()); + expect(h.refresh).toHaveBeenCalledTimes(1); + }); + + it("does not flash a stale age for the snapshot after the document was hidden", async () => { + vi.useFakeTimers({ + now: Date.UTC(2026, 10, 11, 20, 0, 0), + toFake: ["Date", "setInterval", "clearInterval", "setTimeout", "clearTimeout"], + }); + h.data = view(); + await render(); + h.visibility = "hidden"; + await act(async () => document.dispatchEvent(new Event("visibilitychange"))); + await act(async () => vi.advanceTimersByTime(60_000)); + h.data = null; + h.visibility = "visible"; + await act(async () => document.dispatchEvent(new Event("visibilitychange"))); + await act(async () => vi.advanceTimersByTime(0)); + h.data = view({ bootId: "boot-1" }); + await render(); + await act(async () => vi.advanceTimersByTime(1_000)); + expect(text()).not.toContain("Last updated"); + }); + + it("never asks an older server and points to Remote Servers", async () => { + h.environment = environment({ hostMetrics: false }); + await render(); + expect(h.queries.length).toBeGreaterThan(0); + expect(h.queries.every((query) => query === null)).toBe(true); + expect(text()).toContain( + "This server's version doesn't report host metrics. Update it to see this dashboard.", + ); + const open = [...container!.querySelectorAll("button")].find( + (button) => button.textContent === "Open Remote Servers", + ); + await act(async () => open!.click()); + expect(h.navigate).toHaveBeenCalledTimes(1); + }); + + it("closes the stream while the document is hidden, not when the window loses focus", async () => { + h.data = view(); + await render(); + expect(subscribed()).toBe(true); + await act(async () => window.dispatchEvent(new Event("blur"))); + expect(subscribed()).toBe(true); + h.visibility = "hidden"; + await act(async () => document.dispatchEvent(new Event("visibilitychange"))); + expect(subscribed()).toBe(false); + h.visibility = "visible"; + await act(async () => document.dispatchEvent(new Event("visibilitychange"))); + expect(subscribed()).toBe(true); + }); + + it("opens from the request atom and unmounts when it is cleared", async () => { + h.data = view(); + const registry = AtomRegistry.make(); + const host = document.createElement("div"); + document.body.append(host); + const mounted = createRoot(host); + roots.push(mounted); + await act(async () => + mounted.render( + + + , + ), + ); + expect(host.textContent).toBe(""); + expect(h.queries).toEqual([]); + await act(async () => registry.set(hostDashboardRequest, { environmentId: ENVIRONMENT_ID })); + expect(host.querySelector('[data-testid="host-dashboard"]')).not.toBeNull(); + expect(subscribed()).toBe(true); + const before = h.queries.length; + await act(async () => registry.set(hostDashboardRequest, null)); + expect(host.querySelector('[data-testid="host-dashboard"]')).toBeNull(); + expect(h.queries.length).toBe(before); + }); +}); diff --git a/apps/web/src/components/hostDashboard/HostDashboardSheet.tsx b/apps/web/src/components/hostDashboard/HostDashboardSheet.tsx new file mode 100644 index 0000000000..084ff1a6a6 --- /dev/null +++ b/apps/web/src/components/hostDashboard/HostDashboardSheet.tsx @@ -0,0 +1,376 @@ +import { RegistryContext, useAtomValue } from "@effect/atom-react"; +import type { HostMetricsView } from "@bibcode/client-runtime/state/server"; +import type { EnvironmentId } from "@bibcode/contracts"; +import type { ClientSettings } from "@bibcode/contracts/settings"; +import { ChevronRightIcon } from "lucide-react"; +import { type JSX, useCallback, useContext, useEffect, useMemo, useState } from "react"; + +import { useClientSettings } from "~/hooks/useSettings"; +import { cn } from "~/lib/utils"; +import { useEnvironment } from "~/state/environments"; +import { hostDashboardRequest } from "~/state/hostDashboard"; +import { useEnvironmentQuery } from "~/state/query"; +import { serverEnvironment } from "~/state/server"; + +import { useDocumentVisible } from "../pullRequests/useDocumentVisible"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; +import { RetryButton } from "../ui/retry-button"; +import { + Sheet, + SheetDescription, + SheetHeader, + SheetPanel, + SheetPopup, + SheetTitle, +} from "../ui/sheet"; +import { Skeleton } from "../ui/skeleton"; +import { + type HostDashboardValues, + hostMetricsRecentBars, + hostMetricsUptimeBars, + presentHostDashboard, + shouldSubscribeToHostMetrics, +} from "./hostDashboard.logic"; +import { HostMetricBars } from "./HostMetricBars"; + +/** Mounted once in `AppRoot`, outside the router, so navigation comes in as a callback. */ +export function HostDashboardCoordinator({ + onOpenRemoteServers, +}: { + readonly onOpenRemoteServers: () => void; +}): JSX.Element | null { + const registry = useContext(RegistryContext); + const request = useAtomValue(hostDashboardRequest); + const close = useCallback(() => registry.set(hostDashboardRequest, null), [registry]); + const openRemoteServers = useCallback(() => { + close(); + onOpenRemoteServers(); + }, [close, onOpenRemoteServers]); + return request === null ? null : ( + + ); +} + +const selectTimestampFormat = (settings: ClientSettings) => settings.timestampFormat; + +/** + * A 1 s clock that stops while the document is hidden and restarts fresh when it shows again, + * so the snapshot after a return is never stamped with a time from before it hid. + */ +function useVisibleSecondTick(visible: boolean): number { + const [nowMs, setNowMs] = useState(() => Date.now()); + useEffect(() => { + if (!visible) return; + const tick = () => setNowMs(Date.now()); + const first = setTimeout(tick, 0); + const id = setInterval(tick, 1_000); + return () => { + clearTimeout(first); + clearInterval(id); + }; + }, [visible]); + return nowMs; +} + +interface ShownViews { + /** The latest view of any subscription. */ + readonly last: HostMetricsView | null; + /** The view shown before the current subscription; a different server identity is a restart. */ + readonly baseline: HostMetricsView | null; + /** When the client received the latest view, on its own clock. */ + readonly receivedAtMs: number | null; +} + +const NO_VIEWS: ShownViews = { last: null, baseline: null, receivedAtMs: null }; + +export function HostDashboardSheet({ + environmentId, + onClose, + onOpenRemoteServers, +}: { + readonly environmentId: EnvironmentId; + readonly onClose: () => void; + readonly onOpenRemoteServers: () => void; +}) { + const environment = useEnvironment(environmentId); + const visible = useDocumentVisible(); + const timestampFormat = useClientSettings(selectTimestampFormat); + const nowMs = useVisibleSecondTick(visible); + const connected = environment?.connection.phase === "connected"; + const capabilities = environment?.serverConfig?.environment.capabilities; + const supported = capabilities === undefined ? null : capabilities.hostMetrics; + const query = useEnvironmentQuery( + shouldSubscribeToHostMetrics({ open: true, visible, connected, supported: supported === true }) + ? serverEnvironment.hostMetrics({ environmentId, input: {} }) + : null, + ); + const view = query.data; + + // Derived from the stream during render (no effect): a disconnect keeps the last values, + // and the view from before a resubscription is the baseline a restart is detected against. + const [shown, setShown] = useState(NO_VIEWS); + // Receipt time on the 1 s tick (render stays pure), so staleness is measured to the second. + if (view !== null && view !== shown.last) { + setShown({ last: view, baseline: shown.baseline, receivedAtMs: nowMs }); + } else if (view === null && shown.baseline !== shown.last) { + setShown({ ...shown, baseline: shown.last }); + } + + const presentation = presentHostDashboard({ + view, + shownView: shown.baseline, + connected, + supported, + failure: query.error, + lastItemAtMs: shown.receivedAtMs, + nowMs, + timestampFormat, + }); + const chartView = view ?? shown.baseline; + + return ( + { + if (!open) onClose(); + }} + > + + + {environment?.label ?? "Host dashboard"} + Host dashboard + + +
+ {presentation.notice === null ? null : ( +
+ + {presentation.notice} + + {presentation.state === "error" ? ( + + ) : null} + {presentation.state === "unsupported" ? ( + + ) : null} +
+ )} + {presentation.values === null ? ( + presentation.state === "loading" ? ( + + ) : null + ) : ( +
+ +
+ )} +
+
+
+
+ ); +} + +function HostDashboardSkeleton() { + return ( +
+ + + + +
+ ); +} + +function SectionHeading({ children, aside }: { children: string; aside?: string }) { + return ( +
+

{children}

+ {aside === undefined ? null : {aside}} +
+ ); +} + +function HostDashboardValuesView({ + values, + view, + timestampFormat, +}: { + readonly values: HostDashboardValues; + readonly view: HostMetricsView | null; + readonly timestampFormat: ClientSettings["timestampFormat"]; +}) { + const recent = view?.recent; + const uptime = view?.uptime; + const charts = useMemo( + () => ({ + cpuRecent: hostMetricsRecentBars(recent ?? [], "cpu", timestampFormat), + memoryRecent: hostMetricsRecentBars(recent ?? [], "memory", timestampFormat), + }), + [recent, timestampFormat], + ); + const uptimeCharts = useMemo( + () => ({ + cpu: hostMetricsUptimeBars(uptime ?? [], "cpu", timestampFormat), + memory: hostMetricsUptimeBars(uptime ?? [], "memory", timestampFormat), + }), + [uptime, timestampFormat], + ); + return ( + <> +
+ CPU +

+ {values.cpu} +

+ +
+
+ Memory +

+ {values.memory} +

+ +
+
+ Volumes + {values.volumesPaused ? ( +

+ Volume sizes are paused: earlier reads have not answered yet. +

+ ) : null} + {values.volumeListingNotResponding ? ( +

+ The volume list is not answering. Volumes shown are from the last list that did. +

+ ) : null} + {values.volumes.length === 0 ? ( +

No volumes reported yet.

+ ) : ( +
    + {values.volumes.map((volume) => ( +
  • +
    + + {volume.mountPoint} + + {volume.name.length > 0 && volume.name !== volume.mountPoint ? ( + {volume.name} + ) : null} + {volume.badges.map((badge) => ( + + {badge} + + ))} +
    +
    +
    +
    +
    + {volume.usage ?? "Size unknown"} + {volume.free === null ? null : {volume.free}} +
    +
  • + ))} +
+ )} + {values.hiddenVolumes.length === 0 ? null : ( + + + + Hidden volumes ({values.hiddenVolumes.length}) + + +
    + {values.hiddenVolumes.map((volume) => ( +
  • + + {volume.mountPoint} + + {volume.reason} +
  • + ))} +
+
+
+ )} +
+
+ {values.since ?? "Since start"} + {values.statistics.length === 0 ? null : ( + + + + + ))} + + + + {values.statistics.map((row) => ( + + + {row.figures.map((figure) => ( + + ))} + + ))} + +
+ {values.statistics[0]!.figures.map((figure) => ( + + {figure.label} +
+ {row.label} + {figure.value}
+ )} +
+ CPU + +
+
+ Memory + +
+

+ Each bar is an average, with its peak drawn lighter above it. +

+
+ + ); +} diff --git a/apps/web/src/components/hostDashboard/HostMetricBars.test.tsx b/apps/web/src/components/hostDashboard/HostMetricBars.test.tsx new file mode 100644 index 0000000000..6655113119 --- /dev/null +++ b/apps/web/src/components/hostDashboard/HostMetricBars.test.tsx @@ -0,0 +1,92 @@ +// @vitest-environment happy-dom +import { act, type ReactElement, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +const h = vi.hoisted(() => ({ tooltipRenders: 0 })); + +vi.mock("../ui/tooltip", () => ({ + Tooltip: ({ children }: { children?: ReactNode }) => { + h.tooltipRenders += 1; + return <>{children}; + }, + TooltipTrigger: ({ render }: { render: ReactElement }) => render, + TooltipPopup: () => null, +})); + +import { HostMetricBars } from "./HostMetricBars"; + +const BARS = [ + { key: "a", average: 10, peak: 20, tooltip: "7:00 PM: average 10.0%, peak 20.0%" }, + { key: "b", average: 30, peak: 40, tooltip: "7:01 PM: average 30.0%, peak 40.0%" }, + { key: "c", average: 50, peak: 60, tooltip: "7:02 PM: average 50.0%, peak 60.0%" }, +]; + +let root: Root | null = null; +let container: HTMLDivElement | null = null; + +beforeEach(() => { + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + h.tooltipRenders = 0; + container = document.createElement("div"); + document.body.append(container); + root = createRoot(container); +}); + +afterEach(async () => { + await act(async () => root?.unmount()); + container?.remove(); +}); + +async function render(bars: typeof BARS) { + await act(async () => root!.render()); + return container!.querySelector('[aria-label="CPU since start"]')!; +} + +async function key(chart: HTMLElement, name: string) { + await act(async () => { + chart.dispatchEvent(new KeyboardEvent("keydown", { key: name, bubbles: true })); + }); +} + +describe("HostMetricBars keyboard", () => { + it("is one tab stop whose arrow keys move through the bars and show the value", async () => { + const chart = await render(BARS); + expect(chart.getAttribute("role")).toBe("listbox"); + expect(chart.tabIndex).toBe(0); + expect(container!.querySelectorAll("[tabindex]")).toHaveLength(1); + const options = [...chart.querySelectorAll('[role="option"]')]; + expect(options.map((option) => option.getAttribute("aria-label"))).toEqual( + BARS.map((bar) => bar.tooltip), + ); + + await act(async () => chart.focus()); + const active = () => chart.getAttribute("aria-activedescendant"); + expect(active()).toBe(options[2]!.id); + expect(container!.textContent).toContain(BARS[2]!.tooltip); + + await key(chart, "ArrowLeft"); + expect(active()).toBe(options[1]!.id); + expect(options[1]!.getAttribute("aria-selected")).toBe("true"); + expect(container!.textContent).toContain(BARS[1]!.tooltip); + await key(chart, "Home"); + expect(active()).toBe(options[0]!.id); + await key(chart, "ArrowLeft"); + expect(active()).toBe(options[0]!.id); + await key(chart, "End"); + expect(active()).toBe(options[2]!.id); + + await act(async () => chart.blur()); + expect(container!.textContent).not.toContain(BARS[2]!.tooltip); + }); + + it("re-renders only the bar whose value changed", async () => { + await render(BARS); + h.tooltipRenders = 0; + await render([ + ...BARS.slice(0, 2).map((bar) => ({ ...bar })), + { ...BARS[2]!, average: 55, tooltip: "7:02 PM: average 55.0%, peak 60.0%" }, + ]); + expect(h.tooltipRenders).toBe(1); + }); +}); diff --git a/apps/web/src/components/hostDashboard/HostMetricBars.tsx b/apps/web/src/components/hostDashboard/HostMetricBars.tsx new file mode 100644 index 0000000000..7ad26f4a97 --- /dev/null +++ b/apps/web/src/components/hostDashboard/HostMetricBars.tsx @@ -0,0 +1,139 @@ +import { type KeyboardEvent, memo, useId, useState } from "react"; + +import { cn } from "~/lib/utils"; + +import { clampPercent } from "../status-bar/statusBarFormat"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; + +interface Bar { + readonly key: string; + readonly average: number; + readonly peak: number; + readonly tooltip: string; +} + +/** Where a key moves the active bar in a horizontal chart, or null to leave the key alone. */ +function nextBarIndex(key: string, current: number, count: number): number | null { + const last = count - 1; + switch (key) { + case "ArrowLeft": + return Math.max(0, current - 1); + case "ArrowRight": + return Math.min(last, current + 1); + case "Home": + return 0; + case "End": + return last; + default: + return null; + } +} + +/** + * Percent bars, each an average with its peak drawn lighter above it, in the look of the + * Resource Manager history chart. The chart is one tab stop, a horizontal listbox like the diff + * file list: arrow keys, Home and End move the active bar (`aria-activedescendant`), and its value + * shows under the chart. Bars are memoized on their values, so a new reading re-renders only the + * bars it changed. + */ +export const HostMetricBars = memo(function HostMetricBars({ + bars, + label, +}: { + readonly bars: ReadonlyArray; + readonly label: string; +}) { + const chartId = useId(); + const [activeKey, setActiveKey] = useState(null); + const activeIndex = activeKey === null ? -1 : bars.findIndex((bar) => bar.key === activeKey); + const active = activeIndex < 0 ? null : bars[activeIndex]!; + const barId = (key: string) => `${chartId}-${key}`; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.altKey || event.ctrlKey || event.metaKey || bars.length === 0) return; + const next = nextBarIndex( + event.key, + activeIndex < 0 ? bars.length - 1 : activeIndex, + bars.length, + ); + if (next === null) return; + event.preventDefault(); + setActiveKey(bars[next]!.key); + }; + + return ( +
+
setActiveKey(bars.at(-1)?.key ?? null)} + onBlur={() => setActiveKey(null)} + onKeyDown={onKeyDown} + className="flex h-16 items-end gap-px overflow-hidden rounded-sm bg-muted/40 p-1 outline-none focus-visible:ring-2 focus-visible:ring-ring" + > + {bars.map((bar) => ( + + ))} +
+

+ {active?.tooltip ?? ""} +

+
+ ); +}); + +const ChartBar = memo(function ChartBar({ + id, + average, + peak, + tooltip, + active, +}: { + readonly id: string; + readonly average: number; + readonly peak: number; + readonly tooltip: string; + readonly active: boolean; +}) { + return ( + + +
+
+
+ } + /> + + {tooltip} + + + ); +}); diff --git a/apps/web/src/components/hostDashboard/hostDashboard.logic.test.ts b/apps/web/src/components/hostDashboard/hostDashboard.logic.test.ts new file mode 100644 index 0000000000..9e8defdd56 --- /dev/null +++ b/apps/web/src/components/hostDashboard/hostDashboard.logic.test.ts @@ -0,0 +1,279 @@ +import type { HostMetricsView } from "@bibcode/client-runtime/state/server"; +import { describe, expect, it } from "vite-plus/test"; + +import { + HOST_METRICS_UNSUPPORTED_MESSAGE, + hostMetricsRecentBars, + hostMetricsUptimeBars, + isServerRestart, + presentHostDashboard, + shouldSubscribeToHostMetrics, +} from "./hostDashboard.logic"; + +const NOW_MS = Date.UTC(2026, 10, 11, 20, 0, 0); +const STARTED_AT = new Date(NOW_MS - (3 * 60 + 5) * 60_000).toISOString(); + +function view(overrides: Partial = {}): HostMetricsView { + return { + bootId: "boot-1", + startedAt: STARTED_AT, + statisticsSince: STARTED_AT, + host: { os: "macos", arch: "aarch64", cpuCount: 12, memoryTotalBytes: 34_359_738_368 }, + sample: { + sampledAt: new Date(NOW_MS - 1_000).toISOString(), + cpuPercent: 23.5, + memoryTotalBytes: 34_359_738_368, + memoryUsedBytes: 12_025_908_428, + memoryAvailableBytes: 34_359_738_368 - 12_025_908_428, + volumes: [ + { + mountPoint: "/", + name: "System", + fileSystem: "apfs", + totalBytes: 2_147_483_648_000, + freeBytes: 671_088_640_000, + usedBytes: 2_147_483_648_000 - 671_088_640_000, + removable: false, + readOnly: false, + state: "ok", + }, + { + mountPoint: "/mnt/slow", + name: "slow", + fileSystem: "ext4", + totalBytes: null, + freeBytes: null, + usedBytes: null, + removable: true, + readOnly: true, + state: "notResponding", + }, + ], + volumesPaused: false, + }, + cpu: { current: 23.5, minimum: 2, average: 11.25, p95: 40, maximum: 97.5 }, + memory: { current: 35, minimum: 30, average: 33, p95: 36, maximum: 40 }, + recent: [], + uptime: [], + hiddenVolumes: [], + volumeStatistics: [], + ...overrides, + }; +} + +const BASE = { + view: null, + shownView: null, + connected: true, + supported: true, + failure: null, + lastItemAtMs: null, + nowMs: NOW_MS, +} as const; + +function allText(value: unknown): string { + return JSON.stringify(value); +} + +describe("shouldSubscribeToHostMetrics", () => { + it("subscribes only while open, visible, connected and supported", () => { + const all = { open: true, visible: true, connected: true, supported: true }; + expect(shouldSubscribeToHostMetrics(all)).toBe(true); + expect(shouldSubscribeToHostMetrics({ ...all, open: false })).toBe(false); + expect(shouldSubscribeToHostMetrics({ ...all, visible: false })).toBe(false); + expect(shouldSubscribeToHostMetrics({ ...all, connected: false })).toBe(false); + expect(shouldSubscribeToHostMetrics({ ...all, supported: false })).toBe(false); + }); +}); + +describe("isServerRestart", () => { + it("compares boot IDs, and start times only when a boot ID is missing", () => { + const later = new Date(NOW_MS).toISOString(); + expect(isServerRestart(view(), view({ bootId: "boot-2" }))).toBe(true); + expect(isServerRestart(view(), view({ startedAt: later }))).toBe(false); + expect(isServerRestart(view({ bootId: null }), view({ bootId: null, startedAt: later }))).toBe( + true, + ); + expect(isServerRestart(view({ bootId: null }), view({ bootId: null }))).toBe(false); + }); +}); + +describe("presentHostDashboard", () => { + it("is loading with skeletons before the first snapshot", () => { + const presentation = presentHostDashboard(BASE); + expect(presentation.state).toBe("loading"); + expect(presentation.values).toBeNull(); + }); + + it("is live with values when an item arrived a second ago", () => { + const presentation = presentHostDashboard({ + ...BASE, + view: view(), + lastItemAtMs: NOW_MS - 1_000, + }); + expect(presentation.state).toBe("live"); + expect(presentation.dimmed).toBe(false); + expect(presentation.notice).toBeNull(); + }); + + it("is stale with the age after 3 seconds without an item", () => { + const presentation = presentHostDashboard({ + ...BASE, + view: view(), + lastItemAtMs: NOW_MS - 3_000, + }); + expect(presentation.state).toBe("stale"); + expect(presentation.dimmed).toBe(true); + expect(presentation.notice).toBe("Last updated 3 s ago"); + }); + + it("dims a snapshot whose sample the server says is old, even when it just arrived", () => { + const old = view(); + const aged = view({ sample: { ...old.sample!, ageMs: 120_000 } as typeof old.sample }); + const presentation = presentHostDashboard({ ...BASE, view: aged, lastItemAtMs: NOW_MS }); + expect(presentation.state).toBe("stale"); + expect(presentation.notice).toBe("Last updated 120 s ago"); + const later = presentHostDashboard({ ...BASE, view: aged, lastItemAtMs: NOW_MS - 2_000 }); + expect(later.notice).toBe("Last updated 122 s ago"); + const fresh = view({ sample: { ...old.sample!, ageMs: 500 } as typeof old.sample }); + expect(presentHostDashboard({ ...BASE, view: fresh, lastItemAtMs: NOW_MS }).state).toBe("live"); + }); + + it("keeps the last values dimmed while disconnected, even after a dropped request", () => { + const presentation = presentHostDashboard({ + ...BASE, + connected: false, + supported: false, + shownView: view(), + failure: "The connection dropped before the result arrived.", + }); + expect(presentation.state).toBe("disconnected"); + expect(presentation.dimmed).toBe(true); + expect(presentation.notice).toBe("Disconnected"); + expect(presentation.values?.cpu).toBe("23.5%"); + }); + + it("says when the server restarted", () => { + const restartedAt = new Date(NOW_MS - 10_000).toISOString(); + const presentation = presentHostDashboard({ + ...BASE, + shownView: view(), + view: view({ bootId: "boot-2", startedAt: restartedAt, statisticsSince: restartedAt }), + lastItemAtMs: NOW_MS, + }); + expect(presentation.state).toBe("restarted"); + expect(presentation.notice).toMatch(/^Server restarted at \S/); + }); + + it("explains an older server", () => { + const presentation = presentHostDashboard({ ...BASE, supported: false }); + expect(presentation.state).toBe("unsupported"); + expect(presentation.notice).toBe( + "This server's version doesn't report host metrics. Update it to see this dashboard.", + ); + expect(HOST_METRICS_UNSUPPORTED_MESSAGE).toBe(presentation.notice); + }); + + it("is loading while the server's capabilities are still unknown", () => { + expect(presentHostDashboard({ ...BASE, supported: null }).state).toBe("loading"); + }); + + it("shows the failure message", () => { + const presentation = presentHostDashboard({ + ...BASE, + shownView: view(), + failure: "The server refused the request.", + }); + expect(presentation.state).toBe("error"); + expect(presentation.notice).toBe("The server refused the request."); + }); + + it("formats the populated dashboard without em dashes", () => { + const presentation = presentHostDashboard({ + ...BASE, + view: view(), + lastItemAtMs: NOW_MS, + }); + const values = presentation.values!; + expect(values.cpu).toBe("23.5%"); + expect(values.cores).toBe("12 cores"); + expect(values.memory).toBe("11.2 GB of 32.0 GB, 35%"); + expect(values.volumes[0]?.free).toBe("625.0 GB free"); + expect(values.volumes[0]?.badges).toEqual([]); + expect(values.volumes[1]?.badges).toEqual(["Removable", "Read-only", "Not responding"]); + expect(values.volumes[1]?.usage).toBeNull(); + expect(values.since).toMatch(/^Since .+ \(up 3h\)$/); + expect(values.statistics.map((row) => row.label)).toEqual(["CPU", "Memory"]); + expect(values.statistics[0]?.figures.map((figure) => figure.label)).toEqual([ + "Current", + "Minimum", + "Average", + "p95", + "Maximum", + ]); + expect(values.statistics[0]?.figures[4]?.value).toBe("97.5%"); + for (const presented of [ + presentation, + presentHostDashboard({ ...BASE, supported: false }), + presentHostDashboard({ ...BASE, connected: false, shownView: view() }), + presentHostDashboard({ ...BASE, view: view(), lastItemAtMs: NOW_MS - 5_000 }), + ]) { + expect(allText(presented)).not.toContain("—"); + } + }); +}); + +describe("host metric bars", () => { + it("groups 900 recent readings into 60 slot bars of average and peak", () => { + const recent = Array.from({ length: 900 }, (_, index) => ({ + sampledAt: new Date(NOW_MS - (900 - index) * 1_000).toISOString(), + cpuPercent: index % 15 === 0 ? 90 : 10, + memoryPercent: 40, + })); + const bars = hostMetricsRecentBars(recent, "cpu", "locale"); + expect(bars).toHaveLength(60); + expect(bars[0]?.peak).toBe(90); + expect(bars[0]?.average).toBeCloseTo((90 + 14 * 10) / 15); + expect(bars[0]?.tooltip).toMatch(/: average 15\.3%, peak 90\.0%$/); + expect(hostMetricsRecentBars(recent.slice(0, 3), "memory", "locale")).toHaveLength(1); + // A new reading changes only the newest bar; older bars keep their keys. + const next = [ + ...recent.slice(1), + { sampledAt: new Date(NOW_MS).toISOString(), cpuPercent: 10, memoryPercent: 40 }, + ]; + const after = hostMetricsRecentBars(next, "cpu", "locale"); + expect(after.slice(0, -1).map((bar) => bar.key)).toEqual(bars.map((bar) => bar.key)); + }); + + it("keeps bar keys unique when the server's clock moved backwards", () => { + const point = (ms: number) => ({ + sampledAt: new Date(ms).toISOString(), + cpuPercent: 10, + memoryPercent: 40, + }); + const bars = hostMetricsRecentBars( + [point(NOW_MS), point(NOW_MS + 20_000), point(NOW_MS + 1_000)], + "cpu", + "locale", + ); + expect(new Set(bars.map((bar) => bar.key)).size).toBe(bars.length); + }); + + it("draws one uptime bar per bucket", () => { + const bars = hostMetricsUptimeBars( + [ + { + startedAt: STARTED_AT, + durationMs: 60_000, + cpuAverage: 10, + cpuPeak: 40, + memoryAverage: 30, + memoryPeak: 35, + }, + ], + "memory", + "locale", + ); + expect(bars).toEqual([expect.objectContaining({ key: STARTED_AT, average: 30, peak: 35 })]); + }); +}); diff --git a/apps/web/src/components/hostDashboard/hostDashboard.logic.ts b/apps/web/src/components/hostDashboard/hostDashboard.logic.ts new file mode 100644 index 0000000000..620fdf5409 --- /dev/null +++ b/apps/web/src/components/hostDashboard/hostDashboard.logic.ts @@ -0,0 +1,335 @@ +import type { + HostMetricsSampleExtension, + HostMetricsView, +} from "@bibcode/client-runtime/state/server"; +import type { + ServerHostMetricsChartBucket, + ServerHostMetricsChartPoint, + ServerHostMetricsHiddenVolume, + ServerHostMetricsStatistic, + ServerHostMetricsVolume, +} from "@bibcode/contracts"; +import type { TimestampFormat } from "@bibcode/contracts/settings"; + +import { + formatElapsedDurationLabel, + formatChatTimestampTooltip, + formatShortTimestamp, + formatTimestamp, +} from "../../timestampFormat"; +import { formatCpuPercent, formatMemoryBytes } from "../status-bar/statusBarFormat"; + +export type HostDashboardState = + | "loading" + | "live" + | "stale" + | "disconnected" + | "restarted" + | "unsupported" + | "error"; + +/** R73's text for a server whose descriptor has no `hostMetrics`. */ +export const HOST_METRICS_UNSUPPORTED_MESSAGE = + "This server's version doesn't report host metrics. Update it to see this dashboard."; + +/** No item for this long while subscribed dims the values (R77). */ +export const HOST_DASHBOARD_STALE_AFTER_MS = 3_000; + +export interface HostDashboardVolumeRow { + readonly key: string; + readonly mountPoint: string; + readonly name: string; + /** "1.4 GB of 2.0 GB", or null before the volume first answered. */ + readonly usage: string | null; + readonly free: string | null; + readonly usedPercent: number | null; + readonly badges: ReadonlyArray<"Removable" | "Read-only" | "Not responding">; +} + +export interface HostDashboardHiddenVolumeRow { + readonly key: string; + readonly mountPoint: string; + readonly reason: string; +} + +export interface HostDashboardStatisticRow { + /** "CPU", "Memory", or " used" for a volume (R83). */ + readonly label: string; + readonly figures: ReadonlyArray<{ + readonly label: "Current" | "Minimum" | "Average" | "p95" | "Maximum"; + readonly value: string; + }>; +} + +export interface HostDashboardValues { + readonly sampledAt: string | null; + readonly cpu: string; + readonly cores: string; + readonly memory: string; + readonly volumes: ReadonlyArray; + readonly volumesPaused: boolean; + readonly volumeListingNotResponding: boolean; + /** Volumes the filter hid, with the reason (R81); never sized. */ + readonly hiddenVolumes: ReadonlyArray; + /** "Since
+ } + /> + + + + + +
+ } + > +
+ {actionError ? ( +

+ {actionError} +

+ ) : null} + {creating ? ( + setCreating(false)} + onCreated={(id) => { + setCreating(false); + setEditingId(id); + }} + /> + ) : null} + {/* Arrow keys move between rows; Enter and Space click the focused row's button. */} +
+ {GROUPS.map(({ base, label }) => ( +
+
+

{label}

+ {resolvedTheme === base ? ( + + Showing now + + ) : null} +
+
    + {themes + .filter((theme) => theme.base === base) + .map((theme) => { + const custom = !isBuiltinThemeId(theme.id); + const inUse = slots[base] === theme.id; + return ( +
  • + + {/* Reserves the Button's 44 px coarse-pointer target so it never covers the row. */} + + + + } + > + + + {/* After Delete, focus goes to the confirmation's Cancel. */} + + listRef.current?.querySelector( + `[data-theme-row="${CSS.escape(theme.id)}"] [data-delete-cancel]`, + ) ?? true + } + > + {custom ? ( + setEditingId(theme.id)} + > + Edit + + ) : null} + duplicate(theme)} + > + Duplicate + + {custom ? ( + <> + void downloadTheme(theme)} + > + Export + + setConfirmingDelete(theme.id)} + > + Delete + + + ) : null} + + + + {confirmingDelete === theme.id ? ( +
    + {`Delete ${theme.name}?`} + + +
    + ) : null} +
  • + ); + })} +
+ {/* After the group, so a row click never moves the rows above the pointer. The + region stays in the tree when empty (out of the grid, not display:none) so it is announced. */} +
+ {undoOffer?.base === base ? ( +

+ {`Switched to ${undoOffer.base} mode to show ${undoOffer.themeName}.`} + +

+ ) : null} +
+
+ ))} +
+

+ Running terminals keep the colors they started with. New terminals use the theme. +

+
+ + + + ); +} diff --git a/apps/web/src/components/settings/KeybindingsSettings.keys.test.tsx b/apps/web/src/components/settings/KeybindingsSettings.keys.test.tsx new file mode 100644 index 0000000000..e6da420057 --- /dev/null +++ b/apps/web/src/components/settings/KeybindingsSettings.keys.test.tsx @@ -0,0 +1,52 @@ +// @vitest-environment happy-dom +import { DEFAULT_RESOLVED_KEYBINDINGS } from "@bibcode/shared/keybindings"; +import { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, expect, it, vi } from "vite-plus/test"; + +vi.mock("@effect/atom-react", () => ({ + useAtomValue: (atom: { value: unknown }) => atom.value, +})); +vi.mock("../../state/server", () => ({ + primaryServerKeybindingsAtom: { value: DEFAULT_RESOLVED_KEYBINDINGS }, + primaryServerKeybindingsConfigPathAtom: { value: null }, + primaryServerAvailableEditorsAtom: { value: [] }, + serverEnvironment: {}, +})); +vi.mock("../../state/environments", () => ({ usePrimaryEnvironment: () => null })); +vi.mock("../../state/use-atom-command", () => ({ useAtomCommand: () => vi.fn() })); +vi.mock("../../editorPreferences", () => ({ useOpenInPreferredEditor: () => vi.fn() })); + +import { KeybindingsSettingsPanel } from "./KeybindingsSettings"; + +describe("KeybindingsSettingsPanel with the shipped defaults", () => { + it("renders every row without a duplicate React key", async () => { + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render()); + const keycaps = [...container.querySelectorAll("kbd")].map((element) => element.textContent); + await act(async () => root.unmount()); + const keyWarnings = error.mock.calls.filter((call) => String(call[0]).includes("same key")); + error.mockRestore(); + expect(keyWarnings).toEqual([]); + // The zoom-in rule is bound to the "+" key and shows it as one keycap, not two blanks. + expect(keycaps).toContain("+"); + expect(keycaps).not.toContain(""); + }); + + it("lays the table out in four columns only from the sm breakpoint, so a 320 px viewport stacks it", async () => { + const container = document.createElement("div"); + document.body.append(container); + const root = createRoot(container); + await act(async () => root.render()); + const classes = [...container.querySelectorAll("[class]")].flatMap((element) => + element.className.split(/\s+/), + ); + await act(async () => root.unmount()); + const wide = classes.filter((name) => name.includes("680px") || name.includes("190px")); + expect(wide.length).toBeGreaterThan(0); + expect(wide.filter((name) => !name.startsWith("sm:"))).toEqual([]); + }); +}); diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts index 6667876fdf..b5fd545f8c 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.test.ts @@ -147,6 +147,11 @@ describe("KeybindingsSettings.logic", () => { expect(commandLabel("chat.find")).toBe("Chat: Find"); }); + it("offers sourceControl.review, whose key is built in, as a bindable command", () => { + expect(buildKeybindingCommandOptions([])).toContain("sourceControl.review"); + expect(commandLabel("sourceControl.review")).toBe("Source Control: Review"); + }); + it("builds command options from defaults and resolved project bindings", () => { const options = buildKeybindingCommandOptions([ { diff --git a/apps/web/src/components/settings/KeybindingsSettings.logic.ts b/apps/web/src/components/settings/KeybindingsSettings.logic.ts index c229536a2c..30e9e7ea92 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.logic.ts +++ b/apps/web/src/components/settings/KeybindingsSettings.logic.ts @@ -43,6 +43,7 @@ const BINDABLE_COMMANDS_WITHOUT_DEFAULTS: ReadonlyArray = [ "chat.find", "diff.nextChange", "diff.previousChange", + "sourceControl.review", ]; const DEFAULT_WHEN_VARIABLES = new Set(CORE_WHEN_VARIABLES); diff --git a/apps/web/src/components/settings/KeybindingsSettings.tsx b/apps/web/src/components/settings/KeybindingsSettings.tsx index abd3b52e8a..89bccdb30b 100644 --- a/apps/web/src/components/settings/KeybindingsSettings.tsx +++ b/apps/web/src/components/settings/KeybindingsSettings.tsx @@ -74,7 +74,8 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { useAtomCommand } from "../../state/use-atom-command"; function KeybindingPill({ value }: { value: string }) { - const parts = value.split("+"); + // Split only on a "+" that follows a key, so the "+" key itself ("mod++") stays one part. + const parts = value.split(/(?<=[^+])\+/); return ( {parts.map((part) => ( @@ -813,8 +814,8 @@ function KeybindingTableRow({ }; return ( -
-
+
+
-
+
{showPill ? (
-
+
-
+
{hasRowActions ? ( @@ -984,8 +985,8 @@ function NewKeybindingTableRow({ }; return ( -
-
+
+
-
+
-
+
-
+
-
+
Command
Keybinding
When
Status
-
+
{isAddingBinding ? ( { sections: [] as Array>, theme: "system" as string, setTheme: vi.fn(), + slots: { light: "light", dark: "dark" }, + resetSlots: vi.fn(), settings: null as unknown, updateSettings: vi.fn(), observability: null as unknown, @@ -118,6 +121,11 @@ vi.mock("../../hooks/useTheme", () => ({ useTheme: () => ({ theme: h.theme, setTheme: h.setTheme, resolvedTheme: "light" }), })); +vi.mock("../../themes/customThemes", () => ({ + useThemeLibrary: () => ({ themes: [], slots: h.slots }), + resetSlots: h.resetSlots, +})); + vi.mock("../../hooks/useSettings", () => ({ usePrimarySettings: () => h.settings, useUpdatePrimarySettings: () => h.updateSettings, @@ -426,6 +434,17 @@ vi.mock("../ui/tooltip", () => ({ TooltipPopup: (props: AnyProps) =>
{props.children as ReactNode}
, })); +vi.mock("../ui/textarea", () => ({ + Textarea: (props: AnyProps) => { + h.controls.push({ + kind: "textarea", + label: (props["aria-label"] as string | undefined) ?? "", + props, + }); + return