diff --git a/.agents/agent-console.md b/.agents/agent-console.md
index 94c13e03..68bce008 100644
--- a/.agents/agent-console.md
+++ b/.agents/agent-console.md
@@ -1,28 +1,36 @@
-# Agent console presentation
+# Agent cockpit presentation
-The Dev Agents surface combines provider JSONL control records with a Markdown
-conversation. Keep those two layers visually and semantically separate.
+The Dev Agents surface is a worktree-first cockpit: project/worktree
+navigation, named concurrent sessions, a compact tab strip, and one ordered
+conversation/execution timeline.
-- Claude `type="system"` is not synonymous with Ready. Only
- `subtype="init"` becomes `AgentStreamEvent::Ready`; hook and status records
- must not flood the visible trace.
-- Consecutive identical activity labels collapse. Keep technical activity out
- of the transcript flow: a round header button opens a bounded popover with
- the twelve newest labels, so progress never pushes the conversation away.
-- The transcript is chat prose, not a document. Render it with
- `Markdown::compact()` to retain the shared 8 px conversation rhythm used by
- Support, Monique, and the assistant.
-- Bound the Markdown renderer to the transcript column with a zero minimum
- width and clipped overflow. Its horizontal-rule child is absolutely sized
- and otherwise escapes the 860 px conversation measure on wide windows.
-- Keep the transcript/activity column as the single scroll owner and the
- composer as its non-shrinking sibling. Do not nest another transcript scroll
- or place the composer inside it.
-- Keep the prompt on the shared `Composer`; its frame inherits the same theme
- radius, border, shadow, hover and focus tokens as ShellDeck `Input`. Do not
- add Agent-only card chrome around it.
-- Treat provider, target, permissions, and workdir as one execution context.
- The first three use adabraka `Select::context_label` inside one divided
- frame; workdir is the editable final row. The model override belongs in the
- Composer footer because it applies to the next message, and must remain a
- free-form CLI value rather than a hard-coded shortlist.
+- Use `shelldeck_core::agent_session::AgentSessionCollection` as the canonical
+ session, status, attention, message, and trace model. Keep only GPUI draft
+ state and run-to-session routing in the view.
+- Route provider events by run UUID. The compatibility singleton event method
+ is insufficient for parallel agents and background-session attention.
+- Render every structured `AgentTraceKind` inline with conversation messages:
+ commands, file reads, diffs, tests, tools, and fallback activity. Do not hide
+ technical work in a popover.
+- Render agent prose with `Markdown::compact()` and technical details with the
+ shared monospace style. Bound both through the core session/runtime model.
+- Keep provider, status, unread attention, and identity visible in navigator
+ rows and tabs. Selecting an actually visible session marks it read and
+ restores its context and composer draft; hidden selection is navigation
+ identity only.
+- Treat provider, target, permissions, workdir, and model as one execution
+ context, collapsed into the compact session header by default.
+- Change execution context through the core context setter so an opaque resume
+ token never crosses target, permission, workdir, model, or provider changes.
+- Lock execution-context controls once Workspace binds a session to a checkout;
+ worktree navigation creates or selects a separately bound session instead of
+ mutating the context underneath an existing workspace tab.
+- A catalog-bound local run enters its retained Workspace cockpit. Keep its
+ agent tabs in one pane, create the authorized checkout file pane alongside
+ it, and let the typed split controls expose the retained terminal without
+ duplicating one AgentConsole host for different session bindings.
+- Mutating access always requires confirmation. Running sessions cannot be
+ removed, and concurrency limits remain enforced by the core model/runtime.
+- Keep the timeline as the single scroll owner and the composer as its
+ non-shrinking sibling. Do not nest a transcript scroll or put the composer
+ inside it.
diff --git a/.cargo/audit.toml b/.cargo/audit.toml
index a8391082..415541b0 100644
--- a/.cargo/audit.toml
+++ b/.cargo/audit.toml
@@ -5,21 +5,11 @@
ignore = [
# GPUI / Linux platform stack. Track removal in
# docs/security-advisory-baseline.md instead of one bot issue per crate.
- "RUSTSEC-2024-0370", # proc-macro-error through GTK3 macros
- "RUSTSEC-2024-0384", # instant through GPUI's futures-lite 1 stack
- "RUSTSEC-2024-0412", # GTK3 bindings required by the Linux tray
- "RUSTSEC-2024-0413", # GTK3 bindings required by the Linux tray
- "RUSTSEC-2024-0415", # GTK3 bindings required by the Linux tray
- "RUSTSEC-2024-0416", # GTK3 bindings required by the Linux tray
- "RUSTSEC-2024-0418", # GTK3 bindings required by the Linux tray
- "RUSTSEC-2024-0419", # GTK3 bindings required by the Linux tray
- "RUSTSEC-2024-0420", # GTK3 bindings required by the Linux tray
- "RUSTSEC-2024-0429", # glib VariantStrIter path is unused by ShellDeck
- "RUSTSEC-2024-0436", # paste through image codecs
- "RUSTSEC-2025-0052", # async-std through GPUI's HTTP/tar stack
+ "RUSTSEC-2024-0384", # instant through futures-lite 1 in adabraka_util
+ "RUSTSEC-2024-0436", # paste through metal, pulp/exr and rav1e/ravif
+ "RUSTSEC-2025-0052", # async-std through async-tar 0.5 in adabraka_http_client
"RUSTSEC-2025-0134", # rustls-pemfile through zed-reqwest
- "RUSTSEC-2026-0173", # proc-macro-error2 through GPUI's stacksafe
- "RUSTSEC-2026-0192", # ttf-parser through cosmic-text/fontdb and lopdf
+ "RUSTSEC-2026-0192", # ttf-parser through cosmic-text/fontdb; pop-os/cosmic-text#526
# Existing protocol/backend constraints.
"RUSTSEC-2023-0071", # rsa: no patched release; transitive through russh
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5941ce19..ad241bc3 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -37,9 +37,7 @@ jobs:
libxcb1-dev \
libxkbcommon-x11-dev \
libdbus-1-dev \
- libgtk-3-dev \
- libxdo-dev \
- libayatana-appindicator3-dev
+ libxdo-dev
- name: Install Rust toolchain
# Pinned to match rust-toolchain.toml — a floating nightly drops the
@@ -71,7 +69,7 @@ jobs:
- name: Test portable-pty Unix cwd authority regression
shell: bash
run: |
- crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.8.1' | head -1)"
+ crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.9.0' | head -1)"
test -n "$crate_dir"
grep -F -q '// SDTEST-1745' "$crate_dir/src/cmdbuilder.rs"
cargo update --manifest-path "$crate_dir/Cargo.toml"
@@ -130,6 +128,12 @@ jobs:
shell: bash
run: ./scripts/apply-crate-patches.sh
+ # The changed AppKit/GPUI tray path lives outside shelldeck-core. Keep a
+ # native macOS compile gate for the actual application binary.
+ - name: Check native macOS tray integration
+ if: runner.os == 'macOS'
+ run: cargo check -p shelldeck --bin shelldeck
+
# ShellDeck's own PTY boundary must reject a vanished authorized cwd on
# every supported OS before any native PTY or process side effect.
- name: Test terminal cwd authority boundary
@@ -139,7 +143,7 @@ jobs:
if: runner.os != 'Windows'
shell: bash
run: |
- crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.8.1' | head -1)"
+ crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.9.0' | head -1)"
test -n "$crate_dir"
grep -F -q '// SDTEST-1745' "$crate_dir/src/cmdbuilder.rs"
cargo update --manifest-path "$crate_dir/Cargo.toml"
@@ -155,12 +159,35 @@ jobs:
if: runner.os == 'Windows'
shell: bash
run: |
- crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.8.1' | head -1)"
+ crate_dir="$(find "${CARGO_HOME:-$HOME/.cargo}/registry/src" -maxdepth 2 -type d -name 'portable-pty-0.9.0' | head -1)"
test -n "$crate_dir"
grep -F -q '// SDTEST-1744' "$crate_dir/src/cmdbuilder.rs"
cargo update --manifest-path "$crate_dir/Cargo.toml"
cargo test --manifest-path "$crate_dir/Cargo.toml" explicit_missing_cwd_is_forwarded_instead_of_falling_back_home
+ # SDPATCH-120 owns the ICO selector, CreateIconFromResourceEx boundary,
+ # and fail-closed Explorer-restart custody. Run its vendored unit tests
+ # explicitly because dependency tests are not part of ShellDeck's suite.
+ - name: Test native Windows tray integration
+ if: runner.os == 'Windows'
+ shell: bash
+ run: |
+ # Path dependencies are intentionally outside the workspace, so copy
+ # the vendored crate into the disposable runner temp tree to execute
+ # its own unit tests without broadening `cargo test --workspace`.
+ gpui_test_root="${RUNNER_TEMP}/shelldeck-gpui-test"
+ mkdir -p "$gpui_test_root/patches" "$gpui_test_root/packaging/icons"
+ cp -R patches/adabraka-gpui "$gpui_test_root/patches/"
+ cp packaging/icons/shelldeck.ico "$gpui_test_root/packaging/icons/"
+ cp Cargo.lock "$gpui_test_root/patches/adabraka-gpui/Cargo.lock"
+ native_tray_tests="$(CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/target" cargo test --manifest-path "$gpui_test_root/patches/adabraka-gpui/Cargo.toml" --lib -- --list | tr -d '\r')"
+ grep -Fqx 'platform::windows::platform::tests::sdtest_1813_taskbar_created_message_registration_is_live: test' <<<"$native_tray_tests"
+ grep -Fqx 'platform::windows::tray::tests::sdtest_1813_explorer_restart_readd_is_authoritative_and_retains_icon: test' <<<"$native_tray_tests"
+ grep -Fqx 'platform::windows::tray::tests::sdtest_1814_ico_resource_selects_closest_then_highest_depth: test' <<<"$native_tray_tests"
+ grep -Fqx 'platform::windows::tray::tests::sdtest_1815_shell_deck_ico_creates_a_native_hicon: test' <<<"$native_tray_tests"
+ CARGO_TARGET_DIR="${GITHUB_WORKSPACE}/target" \
+ cargo test --manifest-path "$gpui_test_root/patches/adabraka-gpui/Cargo.toml" --lib sdtest_181
+
- name: Test shelldeck-core
run: cargo test -p shelldeck-core
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index fc7f4d0f..17395982 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -108,9 +108,7 @@ jobs:
libxcb1-dev \
libxkbcommon-x11-dev \
libdbus-1-dev \
- libgtk-3-dev \
libxdo-dev \
- libayatana-appindicator3-dev \
libfuse2
# macOS dependencies
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
index acb436ce..429edef8 100644
--- a/.github/workflows/security.yml
+++ b/.github/workflows/security.yml
@@ -44,7 +44,7 @@ jobs:
token: ${{ secrets.GITHUB_TOKEN }}
# Keep this in sync with .cargo/audit.toml. audit-check otherwise opens
# one scheduled issue per package occurrence, including duplicates.
- ignore: "RUSTSEC-2023-0071,RUSTSEC-2024-0370,RUSTSEC-2024-0384,RUSTSEC-2024-0412,RUSTSEC-2024-0413,RUSTSEC-2024-0415,RUSTSEC-2024-0416,RUSTSEC-2024-0418,RUSTSEC-2024-0419,RUSTSEC-2024-0420,RUSTSEC-2024-0429,RUSTSEC-2024-0436,RUSTSEC-2025-0052,RUSTSEC-2025-0134,RUSTSEC-2026-0173,RUSTSEC-2026-0192"
+ ignore: "RUSTSEC-2023-0071,RUSTSEC-2024-0384,RUSTSEC-2024-0436,RUSTSEC-2025-0052,RUSTSEC-2025-0134,RUSTSEC-2026-0192"
dependency-policy:
name: Licenses, bans and sources
diff --git a/Cargo.lock b/Cargo.lock
index 8e6a3950..8d80dae5 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -228,7 +228,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ffbfa3de35b2b2d300986e728d6f3d3e436d166453ebd6b8fe721712e563d7b0"
dependencies = [
"adabraka_collections",
- "adabraka_util_macros",
"anyhow",
"async-fs",
"async_zip",
@@ -237,7 +236,6 @@ dependencies = [
"dunce",
"futures",
"futures-lite 1.13.0",
- "git2",
"globset",
"itertools 0.14.0",
"libc",
@@ -553,7 +551,7 @@ version = "0.38.0+1.3.281"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f"
dependencies = [
- "libloading 0.8.9",
+ "libloading",
]
[[package]]
@@ -851,29 +849,6 @@ dependencies = [
"thiserror 1.0.69",
]
-[[package]]
-name = "atk"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
-dependencies = [
- "atk-sys",
- "glib",
- "libc",
-]
-
-[[package]]
-name = "atk-sys"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
-dependencies = [
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps",
-]
-
[[package]]
name = "atomic"
version = "0.5.3"
@@ -906,7 +881,7 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "automonique-platform-client"
version = "0.1.0"
-source = "git+https://github.com/bext-stack/automonique?rev=1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7#1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7"
+source = "git+https://github.com/bext-stack/automonique?rev=524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7#524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7"
dependencies = [
"automonique-protocol",
"base64 0.22.1",
@@ -917,7 +892,7 @@ dependencies = [
[[package]]
name = "automonique-protocol"
version = "0.1.0"
-source = "git+https://github.com/bext-stack/automonique?rev=1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7#1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7"
+source = "git+https://github.com/bext-stack/automonique?rev=524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7#524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7"
[[package]]
name = "av-scenechange"
@@ -1094,9 +1069,6 @@ name = "bitflags"
version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
-dependencies = [
- "serde_core",
-]
[[package]]
name = "bitstream-io"
@@ -1124,7 +1096,7 @@ dependencies = [
"hidden-trait",
"js-sys",
"khronos-egl",
- "libloading 0.8.9",
+ "libloading",
"log",
"mint",
"naga",
@@ -1317,31 +1289,6 @@ version = "1.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
-[[package]]
-name = "cairo-rs"
-version = "0.18.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
-dependencies = [
- "bitflags 2.13.0",
- "cairo-sys-rs",
- "glib",
- "libc",
- "once_cell",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "cairo-sys-rs"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
-dependencies = [
- "glib-sys",
- "libc",
- "system-deps",
-]
-
[[package]]
name = "calloop"
version = "0.13.0"
@@ -1440,22 +1387,18 @@ dependencies = [
"nom 7.1.3",
]
-[[package]]
-name = "cfg-expr"
-version = "0.15.8"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
-dependencies = [
- "smallvec",
- "target-lexicon",
-]
-
[[package]]
name = "cfg-if"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
+[[package]]
+name = "cfg_aliases"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e"
+
[[package]]
name = "cfg_aliases"
version = "0.2.1"
@@ -1564,7 +1507,7 @@ checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4"
dependencies = [
"glob",
"libc",
- "libloading 0.8.9",
+ "libloading",
]
[[package]]
@@ -2007,15 +1950,6 @@ dependencies = [
"itertools 0.10.5",
]
-[[package]]
-name = "crossbeam-channel"
-version = "0.5.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e"
-dependencies = [
- "crossbeam-utils",
-]
-
[[package]]
name = "crossbeam-deque"
version = "0.8.7"
@@ -2556,7 +2490,7 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a"
dependencies = [
- "libloading 0.8.9",
+ "libloading",
]
[[package]]
@@ -2565,12 +2499,6 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
-[[package]]
-name = "dpi"
-version = "0.1.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
-
[[package]]
name = "dtor"
version = "0.0.6"
@@ -2926,16 +2854,6 @@ version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
-[[package]]
-name = "field-offset"
-version = "0.3.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f"
-dependencies = [
- "memoffset 0.9.1",
- "rustc_version",
-]
-
[[package]]
name = "filedescriptor"
version = "0.8.3"
@@ -3286,64 +3204,6 @@ dependencies = [
"slab",
]
-[[package]]
-name = "gdk"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691"
-dependencies = [
- "cairo-rs",
- "gdk-pixbuf",
- "gdk-sys",
- "gio",
- "glib",
- "libc",
- "pango",
-]
-
-[[package]]
-name = "gdk-pixbuf"
-version = "0.18.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
-dependencies = [
- "gdk-pixbuf-sys",
- "gio",
- "glib",
- "libc",
- "once_cell",
-]
-
-[[package]]
-name = "gdk-pixbuf-sys"
-version = "0.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
-dependencies = [
- "gio-sys",
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps",
-]
-
-[[package]]
-name = "gdk-sys"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
-dependencies = [
- "cairo-sys-rs",
- "gdk-pixbuf-sys",
- "gio-sys",
- "glib-sys",
- "gobject-sys",
- "libc",
- "pango-sys",
- "pkg-config",
- "system-deps",
-]
-
[[package]]
name = "generic-array"
version = "0.14.7"
@@ -3449,98 +3309,6 @@ version = "0.32.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7"
-[[package]]
-name = "gio"
-version = "0.18.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73"
-dependencies = [
- "futures-channel",
- "futures-core",
- "futures-io",
- "futures-util",
- "gio-sys",
- "glib",
- "libc",
- "once_cell",
- "pin-project-lite",
- "smallvec",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "gio-sys"
-version = "0.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
-dependencies = [
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps",
- "winapi",
-]
-
-[[package]]
-name = "git2"
-version = "0.20.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b88256088d75a56f8ecfa070513a775dd9107f6530ef14919dac831af9cfe2b"
-dependencies = [
- "bitflags 2.13.0",
- "libc",
- "libgit2-sys",
- "log",
- "url",
-]
-
-[[package]]
-name = "glib"
-version = "0.18.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5"
-dependencies = [
- "bitflags 2.13.0",
- "futures-channel",
- "futures-core",
- "futures-executor",
- "futures-task",
- "futures-util",
- "gio-sys",
- "glib-macros",
- "glib-sys",
- "gobject-sys",
- "libc",
- "memchr",
- "once_cell",
- "smallvec",
- "thiserror 1.0.69",
-]
-
-[[package]]
-name = "glib-macros"
-version = "0.18.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc"
-dependencies = [
- "heck 0.4.1",
- "proc-macro-crate 2.0.0",
- "proc-macro-error",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
-[[package]]
-name = "glib-sys"
-version = "0.18.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
-dependencies = [
- "libc",
- "system-deps",
-]
-
[[package]]
name = "glob"
version = "0.3.3"
@@ -3595,17 +3363,6 @@ dependencies = [
"web-sys",
]
-[[package]]
-name = "gobject-sys"
-version = "0.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
-dependencies = [
- "glib-sys",
- "libc",
- "system-deps",
-]
-
[[package]]
name = "gpu-alloc"
version = "0.6.2"
@@ -3642,58 +3399,6 @@ version = "0.18.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12101ecc8225ea6d675bc70263074eab6169079621c2186fe0c66590b2df9681"
-[[package]]
-name = "gtk"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a"
-dependencies = [
- "atk",
- "cairo-rs",
- "field-offset",
- "futures-channel",
- "gdk",
- "gdk-pixbuf",
- "gio",
- "glib",
- "gtk-sys",
- "gtk3-macros",
- "libc",
- "pango",
- "pkg-config",
-]
-
-[[package]]
-name = "gtk-sys"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414"
-dependencies = [
- "atk-sys",
- "cairo-sys-rs",
- "gdk-pixbuf-sys",
- "gdk-sys",
- "gio-sys",
- "glib-sys",
- "gobject-sys",
- "libc",
- "pango-sys",
- "system-deps",
-]
-
-[[package]]
-name = "gtk3-macros"
-version = "0.18.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d"
-dependencies = [
- "proc-macro-crate 1.3.1",
- "proc-macro-error",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
[[package]]
name = "h2"
version = "0.4.16"
@@ -4155,7 +3860,7 @@ dependencies = [
"image-webp",
"moxcms",
"num-traits",
- "png 0.18.1",
+ "png",
"qoi",
"ravif",
"rayon",
@@ -4212,11 +3917,11 @@ dependencies = [
[[package]]
name = "inotify"
-version = "0.10.2"
+version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fdd168d97690d0b8c412d6b6c10360277f4d7ee495c5d0d5d5fe0854923255cc"
+checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592"
dependencies = [
- "bitflags 1.3.2",
+ "bitflags 2.13.0",
"inotify-sys",
"libc",
]
@@ -4332,15 +4037,6 @@ dependencies = [
"leaky-cow",
]
-[[package]]
-name = "ioctl-rs"
-version = "0.1.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "ipnet"
version = "2.12.0"
@@ -4539,17 +4235,6 @@ dependencies = [
"rand_core 0.10.1",
]
-[[package]]
-name = "keyboard-types"
-version = "0.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a"
-dependencies = [
- "bitflags 2.13.0",
- "serde",
- "unicode-segmentation",
-]
-
[[package]]
name = "keyring"
version = "3.6.3"
@@ -4573,7 +4258,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
dependencies = [
"libc",
- "libloading 0.8.9",
+ "libloading",
]
[[package]]
@@ -4665,30 +4350,6 @@ version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
-[[package]]
-name = "libappindicator"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
-dependencies = [
- "glib",
- "gtk",
- "gtk-sys",
- "libappindicator-sys",
- "log",
-]
-
-[[package]]
-name = "libappindicator-sys"
-version = "0.9.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
-dependencies = [
- "gtk-sys",
- "libloading 0.7.4",
- "once_cell",
-]
-
[[package]]
name = "libc"
version = "0.2.186"
@@ -4714,28 +4375,6 @@ dependencies = [
"cc",
]
-[[package]]
-name = "libgit2-sys"
-version = "0.18.8+1.9.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f7c568b25d7489bc3fb2988ed69ab111d2944d2f5fec3d5c987fe545ea97b50"
-dependencies = [
- "cc",
- "libc",
- "libz-sys",
- "pkg-config",
-]
-
-[[package]]
-name = "libloading"
-version = "0.7.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f"
-dependencies = [
- "cfg-if",
- "winapi",
-]
-
[[package]]
name = "libloading"
version = "0.8.9"
@@ -4771,25 +4410,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "libxdo"
-version = "0.6.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "00333b8756a3d28e78def82067a377de7fa61b24909000aeaa2b446a948d14db"
-dependencies = [
- "libxdo-sys",
-]
-
-[[package]]
-name = "libxdo-sys"
-version = "0.11.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "db23b9e7e2b7831bbd8aac0bbeeeb7b68cbebc162b227e7052e8e55829a09212"
-dependencies = [
- "libc",
- "x11",
-]
-
[[package]]
name = "libz-sys"
version = "1.1.29"
@@ -5031,15 +4651,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "memoffset"
-version = "0.6.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
-dependencies = [
- "autocfg",
-]
-
[[package]]
name = "memoffset"
version = "0.9.1"
@@ -5148,27 +4759,6 @@ dependencies = [
"pxfm",
]
-[[package]]
-name = "muda"
-version = "0.17.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177"
-dependencies = [
- "crossbeam-channel",
- "dpi",
- "gtk",
- "keyboard-types",
- "libxdo",
- "objc2",
- "objc2-app-kit",
- "objc2-core-foundation",
- "objc2-foundation",
- "once_cell",
- "png 0.17.16",
- "thiserror 2.0.18",
- "windows-sys 0.60.2",
-]
-
[[package]]
name = "naga"
version = "25.0.1"
@@ -5178,7 +4768,7 @@ dependencies = [
"arrayvec",
"bit-set",
"bitflags 2.13.0",
- "cfg_aliases",
+ "cfg_aliases 0.2.1",
"codespan-reporting",
"half",
"hashbrown 0.15.5",
@@ -5211,16 +4801,14 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
-version = "0.25.1"
+version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f346ff70e7dbfd675fe90590b92d59ef2de15a8779ae305ebcbfd3f0caf59be4"
+checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [
- "autocfg",
- "bitflags 1.3.2",
+ "bitflags 2.13.0",
"cfg-if",
+ "cfg_aliases 0.1.1",
"libc",
- "memoffset 0.6.5",
- "pin-utils",
]
[[package]]
@@ -5231,9 +4819,9 @@ checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
- "cfg_aliases",
+ "cfg_aliases 0.2.1",
"libc",
- "memoffset 0.9.1",
+ "memoffset",
]
[[package]]
@@ -5244,7 +4832,7 @@ checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.0",
"cfg-if",
- "cfg_aliases",
+ "cfg_aliases 0.2.1",
"libc",
]
@@ -5293,12 +4881,11 @@ dependencies = [
[[package]]
name = "notify"
-version = "7.0.0"
+version = "8.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c533b4c39709f9ba5005d8002048266593c1cfaf3c5f0739d5b8ab0c6c504009"
+checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
dependencies = [
"bitflags 2.13.0",
- "filetime",
"fsevent-sys",
"inotify",
"kqueue",
@@ -5307,7 +4894,7 @@ dependencies = [
"mio",
"notify-types",
"walkdir",
- "windows-sys 0.52.0",
+ "windows-sys 0.60.2",
]
[[package]]
@@ -5326,11 +4913,11 @@ dependencies = [
[[package]]
name = "notify-types"
-version = "1.0.1"
+version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "585d3cb5e12e01aed9e8a1f70d5c6b5e86fe2a6e48fc8cd0b3e0b8df6f6eb174"
+checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [
- "instant",
+ "bitflags 2.13.0",
]
[[package]]
@@ -5523,16 +5110,6 @@ dependencies = [
"objc2",
]
-[[package]]
-name = "objc2-core-graphics"
-version = "0.3.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
-dependencies = [
- "bitflags 2.13.0",
- "objc2-core-foundation",
-]
-
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -5781,31 +5358,6 @@ dependencies = [
"windows-strings 0.5.1",
]
-[[package]]
-name = "pango"
-version = "0.18.3"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
-dependencies = [
- "gio",
- "glib",
- "libc",
- "once_cell",
- "pango-sys",
-]
-
-[[package]]
-name = "pango-sys"
-version = "0.18.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
-dependencies = [
- "glib-sys",
- "gobject-sys",
- "libc",
- "system-deps",
-]
-
[[package]]
name = "parking"
version = "2.2.1"
@@ -6059,19 +5611,6 @@ dependencies = [
"plotters-backend",
]
-[[package]]
-name = "png"
-version = "0.17.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
-dependencies = [
- "bitflags 1.3.2",
- "crc32fast",
- "fdeflate",
- "flate2",
- "miniz_oxide",
-]
-
[[package]]
name = "png"
version = "0.18.1"
@@ -6165,9 +5704,9 @@ dependencies = [
[[package]]
name = "portable-pty"
-version = "0.8.1"
+version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "806ee80c2a03dbe1a9fb9534f8d19e4c0546b790cde8fd1fea9d6390644cb0be"
+checksum = "b4a596a2b3d2752d94f51fac2d4a96737b8705dddd311a32b9af47211f08671e"
dependencies = [
"anyhow",
"bitflags 1.3.2",
@@ -6176,8 +5715,8 @@ dependencies = [
"lazy_static",
"libc",
"log",
- "nix 0.25.1",
- "serial",
+ "nix 0.28.0",
+ "serial2",
"shared_library",
"shell-words",
"winapi",
@@ -6258,25 +5797,6 @@ dependencies = [
"elliptic-curve",
]
-[[package]]
-name = "proc-macro-crate"
-version = "1.3.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919"
-dependencies = [
- "once_cell",
- "toml_edit 0.19.15",
-]
-
-[[package]]
-name = "proc-macro-crate"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e8366a6159044a37876a2b9817124296703c586a5c92e2c53751fa06d8d43e8"
-dependencies = [
- "toml_edit 0.20.7",
-]
-
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -6286,52 +5806,6 @@ dependencies = [
"toml_edit 0.25.12+spec-1.1.0",
]
-[[package]]
-name = "proc-macro-error"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c"
-dependencies = [
- "proc-macro-error-attr",
- "proc-macro2",
- "quote",
- "syn 1.0.109",
- "version_check",
-]
-
-[[package]]
-name = "proc-macro-error-attr"
-version = "1.0.4"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869"
-dependencies = [
- "proc-macro2",
- "quote",
- "version_check",
-]
-
-[[package]]
-name = "proc-macro-error-attr2"
-version = "2.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
-dependencies = [
- "proc-macro2",
- "quote",
-]
-
-[[package]]
-name = "proc-macro-error2"
-version = "2.0.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
-dependencies = [
- "proc-macro-error-attr2",
- "proc-macro2",
- "quote",
- "syn 2.0.118",
-]
-
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -6450,7 +5924,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8"
dependencies = [
"bytes",
- "cfg_aliases",
+ "cfg_aliases 0.2.1",
"pin-project-lite",
"quinn-proto",
"quinn-udp",
@@ -6491,7 +5965,7 @@ version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694"
dependencies = [
- "cfg_aliases",
+ "cfg_aliases 0.2.1",
"libc",
"once_cell",
"socket2",
@@ -7677,45 +7151,14 @@ dependencies = [
]
[[package]]
-name = "serial"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86"
-dependencies = [
- "serial-core",
- "serial-unix",
- "serial-windows",
-]
-
-[[package]]
-name = "serial-core"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581"
-dependencies = [
- "libc",
-]
-
-[[package]]
-name = "serial-unix"
-version = "0.4.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7"
-dependencies = [
- "ioctl-rs",
- "libc",
- "serial-core",
- "termios",
-]
-
-[[package]]
-name = "serial-windows"
-version = "0.4.0"
+name = "serial2"
+version = "0.2.38"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162"
+checksum = "b16809bc35793b19ce4e0c53924bc0dce3937f15487997cfdaed936004180730"
dependencies = [
+ "cfg-if",
"libc",
- "serial-core",
+ "windows-sys 0.61.2",
]
[[package]]
@@ -7805,13 +7248,12 @@ checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]]
name = "shelldeck"
-version = "0.9.4"
+version = "0.9.6"
dependencies = [
"adabraka-gpui",
"adabraka-ui",
"anyhow",
"directories",
- "gtk",
"image",
"notify-rust",
"parking_lot",
@@ -7822,14 +7264,13 @@ dependencies = [
"tokio",
"tracing",
"tracing-subscriber",
- "tray-icon",
"uuid",
"winresource",
]
[[package]]
name = "shelldeck-core"
-version = "0.9.4"
+version = "0.9.6"
dependencies = [
"agent-client-protocol",
"anyhow",
@@ -7859,7 +7300,7 @@ dependencies = [
[[package]]
name = "shelldeck-ssh"
-version = "0.9.4"
+version = "0.9.6"
dependencies = [
"anyhow",
"async-trait",
@@ -7879,7 +7320,7 @@ dependencies = [
[[package]]
name = "shelldeck-terminal"
-version = "0.9.4"
+version = "0.9.6"
dependencies = [
"anyhow",
"chrono",
@@ -7900,13 +7341,14 @@ dependencies = [
[[package]]
name = "shelldeck-ui"
-version = "0.9.4"
+version = "0.9.6"
dependencies = [
"adabraka-gpui",
"adabraka-ui",
"anyhow",
"async-io",
"async-process",
+ "automonique-protocol",
"chrono",
"futures-lite 2.6.1",
"image",
@@ -7952,7 +7394,7 @@ dependencies = [
[[package]]
name = "shelldeck-update"
-version = "0.9.4"
+version = "0.9.6"
dependencies = [
"adabraka-gpui",
"anyhow",
@@ -8237,9 +7679,9 @@ dependencies = [
[[package]]
name = "stacksafe"
-version = "0.1.4"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d9c1172965d317e87ddb6d364a040d958b40a1db82b6ef97da26253a8b3d090"
+checksum = "95f9c34983ac74195c710c473db6fdf1085f64a47dbaa0090d1bea03be70da66"
dependencies = [
"stacker",
"stacksafe-macro",
@@ -8247,13 +7689,13 @@ dependencies = [
[[package]]
name = "stacksafe-macro"
-version = "0.1.4"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69"
+checksum = "6feeae42a2d6b0dcb8aeb2f08d9e48cdac600239cf8a20fc59f9e252e86bdfe1"
dependencies = [
- "proc-macro-error2",
+ "proc-macro2",
"quote",
- "syn 2.0.118",
+ "syn 3.0.4",
]
[[package]]
@@ -8572,19 +8014,6 @@ dependencies = [
"libc",
]
-[[package]]
-name = "system-deps"
-version = "6.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
-dependencies = [
- "cfg-expr",
- "heck 0.5.0",
- "pkg-config",
- "toml 0.8.23",
- "version-compare",
-]
-
[[package]]
name = "taffy"
version = "0.9.0"
@@ -8615,12 +8044,6 @@ dependencies = [
"objc",
]
-[[package]]
-name = "target-lexicon"
-version = "0.12.16"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
-
[[package]]
name = "task-local"
version = "0.1.1"
@@ -8648,7 +8071,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand 2.4.1",
- "getrandom 0.4.3",
+ "getrandom 0.3.4",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.61.2",
@@ -8674,15 +8097,6 @@ dependencies = [
"winapi-util",
]
-[[package]]
-name = "termios"
-version = "0.2.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a"
-dependencies = [
- "libc",
-]
-
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -8796,7 +8210,7 @@ dependencies = [
"bytemuck",
"cfg-if",
"log",
- "png 0.18.1",
+ "png",
"tiny-skia-path",
]
@@ -8954,28 +8368,6 @@ dependencies = [
"serde_core",
]
-[[package]]
-name = "toml_edit"
-version = "0.19.15"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
-dependencies = [
- "indexmap 2.14.0",
- "toml_datetime 0.6.11",
- "winnow 0.5.40",
-]
-
-[[package]]
-name = "toml_edit"
-version = "0.20.7"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
-dependencies = [
- "indexmap 2.14.0",
- "toml_datetime 0.6.11",
- "winnow 0.5.40",
-]
-
[[package]]
name = "toml_edit"
version = "0.22.27"
@@ -9140,27 +8532,6 @@ dependencies = [
"tracing-log",
]
-[[package]]
-name = "tray-icon"
-version = "0.22.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "76b42a907631429634f9f57ef72f9e7aec3e12e74050636138acb3752ecb8df3"
-dependencies = [
- "crossbeam-channel",
- "dirs 6.0.0",
- "libappindicator",
- "muda",
- "objc2",
- "objc2-app-kit",
- "objc2-core-foundation",
- "objc2-core-graphics",
- "objc2-foundation",
- "once_cell",
- "png 0.18.1",
- "thiserror 2.0.18",
- "windows-sys 0.61.2",
-]
-
[[package]]
name = "tree-sitter"
version = "0.25.10"
@@ -9337,7 +8708,7 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
- "memoffset 0.9.1",
+ "memoffset",
"tempfile",
"windows-sys 0.61.2",
]
@@ -9613,12 +8984,6 @@ version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
-[[package]]
-name = "version-compare"
-version = "0.2.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
-
[[package]]
name = "version_check"
version = "0.9.5"
@@ -10497,15 +9862,6 @@ version = "0.53.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-[[package]]
-name = "winnow"
-version = "0.5.40"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876"
-dependencies = [
- "memchr",
-]
-
[[package]]
name = "winnow"
version = "0.7.15"
@@ -10818,7 +10174,7 @@ version = "4.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "267db9407081e90bbfa46d841d3cbc60f59c0351838c4bc65199ecd79ab1983e"
dependencies = [
- "proc-macro-crate 3.5.0",
+ "proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -10831,7 +10187,7 @@ version = "5.17.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e05ad887425eecf5e8384dc2406a4a9313eb73468712fc1cdea362eb4fe0469"
dependencies = [
- "proc-macro-crate 3.5.0",
+ "proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -11136,7 +10492,7 @@ version = "4.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73e2ba546bda683a90652bac4a279bc146adad1386f25379cf73200d2002c449"
dependencies = [
- "proc-macro-crate 3.5.0",
+ "proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -11149,7 +10505,7 @@ version = "5.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8118ca6bda77bfc0ab51d660db0c955f2505eef854c9a449435bccb616933b31"
dependencies = [
- "proc-macro-crate 3.5.0",
+ "proc-macro-crate",
"proc-macro2",
"quote",
"syn 2.0.118",
diff --git a/Cargo.toml b/Cargo.toml
index ffb7433c..607c640d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -10,7 +10,7 @@ members = [
]
[workspace.package]
-version = "0.9.4"
+version = "0.9.6"
edition = "2021"
license = "MIT"
authors = ["ShellDeck Contributors"]
@@ -28,7 +28,7 @@ russh = { version = "0.60.3" }
ssh2-config = "0.2"
# Terminal
-portable-pty = "0.8"
+portable-pty = "0.9"
vte = "0.13"
# Async runtime
@@ -49,14 +49,14 @@ serde_json = "1"
toml = "0.8"
# Utilities
-uuid = { version = "1", features = ["v4", "serde"] }
+uuid = { version = "1", features = ["v4", "v5", "serde"] }
chrono = { version = "0.4", features = ["serde"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
anyhow = "1"
thiserror = "2"
directories = "6"
-notify = "7"
+notify = "8.2"
# Platform credential stores are all behind feature flags in keyring 3: with
# none enabled, `keyring::default` resolves to the in-process **mock** store on
# every OS, so nothing is ever persisted. The deps are target-gated upstream,
@@ -69,14 +69,8 @@ keyring = { version = "3", features = [
"crypto-rust",
] }
auto-launch = "0.5"
-tray-icon = "0.22"
notify-rust = "4"
image = { version = "0.25", default-features = false, features = ["png", "ico"] }
-# GTK — Linux only; the tray-icon crate depends on libappindicator which
-# itself sits on GTK. adabraka-gpui uses Wayland/X11 natively without
-# initialising GTK, so we init + run gtk::main() on a dedicated thread
-# (see crates/shelldeck/src/tray/mod.rs).
-gtk = "0.18"
parking_lot = "0.12"
fs2 = "0.4"
smallvec = "1.13"
@@ -90,8 +84,8 @@ sha2 = "0.10"
base64 = "0.22"
ed25519-dalek = "2"
criterion = "0.5"
-automonique-platform-client = { git = "https://github.com/bext-stack/automonique", rev = "1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7" }
-automonique-protocol = { git = "https://github.com/bext-stack/automonique", rev = "1dd5c13f7a53aac9e28d3cd004bf73789ca66bb7" }
+automonique-platform-client = { git = "https://github.com/bext-stack/automonique", rev = "524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7" }
+automonique-protocol = { git = "https://github.com/bext-stack/automonique", rev = "524073c3a55ec73d57e4b05abf5ba3eb0abd4cb7" }
windows-sys = { version = "0.60", features = [
"Win32_Foundation",
"Win32_Security",
diff --git a/README.md b/README.md
index 127e87f9..b003f144 100644
--- a/README.md
+++ b/README.md
@@ -86,8 +86,7 @@ permissions described in
sudo apt install \
libssl-dev pkg-config libxkbcommon-dev libwayland-dev libvulkan-dev \
libfontconfig1-dev libxcb-shape0-dev libxcb-xfixes0-dev libxcb1-dev \
- libxkbcommon-x11-dev libdbus-1-dev libgtk-3-dev libxdo-dev \
- libayatana-appindicator3-dev
+ libxkbcommon-x11-dev libdbus-1-dev libxdo-dev
```
## Build & Run
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop-poster.webp b/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop-poster.webp
deleted file mode 100644
index 3b4fa050..00000000
Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop-poster.webp and /dev/null differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.mp4 b/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.mp4
deleted file mode 100644
index de07ad89..00000000
Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.mp4 and /dev/null differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.webm b/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.webm
deleted file mode 100644
index 32de000c..00000000
Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-desktop.webm and /dev/null differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile-poster.webp b/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile-poster.webp
deleted file mode 100644
index e7bf4dd1..00000000
Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile-poster.webp and /dev/null differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.mp4 b/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.mp4
deleted file mode 100644
index 9c80f7ff..00000000
Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.mp4 and /dev/null differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.webm b/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.webm
deleted file mode 100644
index d87520d2..00000000
Binary files a/cloudflare/update-worker/public/campaign/roles-v1/hero-mobile.webm and /dev/null differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop-poster.webp b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop-poster.webp
new file mode 100644
index 00000000..5d4077c8
Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop-poster.webp differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.mp4 b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.mp4
new file mode 100644
index 00000000..d020554f
Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.mp4 differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.webm b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.webm
new file mode 100644
index 00000000..add4fc80
Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-desktop.webm differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile-poster.webp b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile-poster.webp
new file mode 100644
index 00000000..a64010f2
Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile-poster.webp differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.mp4 b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.mp4
new file mode 100644
index 00000000..b819a7a6
Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.mp4 differ
diff --git a/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.webm b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.webm
new file mode 100644
index 00000000..473b9534
Binary files /dev/null and b/cloudflare/update-worker/public/campaign/roles-v1/user-support-promo-mobile.webm differ
diff --git a/cloudflare/update-worker/src/landing.ts b/cloudflare/update-worker/src/landing.ts
index 18ae0ecb..04292a5f 100644
--- a/cloudflare/update-worker/src/landing.ts
+++ b/cloudflare/update-worker/src/landing.ts
@@ -34,7 +34,7 @@ export function renderMarketingLandingPage(dl: LandingDownloadInfo): Response {
-
+
@@ -216,8 +216,8 @@ export function renderMarketingLandingPage(dl: LandingDownloadInfo): Response {
Version ${v} · Gratuit et open source · Linux, macOS et Windows
-
-
+
+
@@ -267,7 +267,7 @@ export function renderMarketingLandingPage(dl: LandingDownloadInfo): Response {
Relié à votre environnement
Le même fil, jusqu’à la résolution. Sites, demandes et accès restent reliés à Inklura Manage. Automonique apporte l’exécution souveraine, Monique réalise le travail et ShellDeck garde la supervision au même endroit.
-
Utilisateur Crée et suit sa demande → Support Qualifie, répond et coordonne → Dev Diagnostique et intervient ✓
+
Utilisateur Crée et suit sa demande → Support Qualifie, répond et coordonne → Dev Diagnostique et intervient ✓
@@ -288,7 +288,7 @@ export function renderMarketingLandingPage(dl: LandingDownloadInfo): Response {
if(!os&&/Android/.test(ua))os='linux';if(!os&&/iPhone|iPad/.test(ua))os='macos';
var card=os&&document.querySelector('[data-platform="'+os+'"]');if(card)card.classList.add('detected');
var reduced=window.matchMedia('(prefers-reduced-motion: reduce)');var compact=window.matchMedia('(max-width: 900px)');var heroVideo=document.querySelector('.hero-motion');
- function syncMotion(){if(!heroVideo)return;if(reduced.matches){heroVideo.pause();heroVideo.removeAttribute('src');while(heroVideo.firstChild)heroVideo.removeChild(heroVideo.firstChild);heroVideo.load();return}var variant=compact.matches?'mobile':'desktop';if(heroVideo.dataset.variant===variant){heroVideo.play().catch(function(){});return}heroVideo.dataset.variant=variant;heroVideo.poster='/campaign/roles-v1/hero-'+variant+'-poster.webp';while(heroVideo.firstChild)heroVideo.removeChild(heroVideo.firstChild);[['Webm','video/webm'],['Mp4','video/mp4']].forEach(function(item){var source=document.createElement('source');source.src=heroVideo.dataset[variant+item[0]];source.type=item[1];heroVideo.appendChild(source)});heroVideo.load();heroVideo.play().catch(function(){})}
+ function syncMotion(){if(!heroVideo)return;if(reduced.matches){heroVideo.pause();heroVideo.removeAttribute('src');while(heroVideo.firstChild)heroVideo.removeChild(heroVideo.firstChild);heroVideo.load();return}var variant=compact.matches?'mobile':'desktop';if(heroVideo.dataset.variant===variant){heroVideo.play().catch(function(){});return}heroVideo.dataset.variant=variant;heroVideo.poster='/campaign/roles-v1/user-support-promo-'+variant+'-poster.webp';while(heroVideo.firstChild)heroVideo.removeChild(heroVideo.firstChild);[['Webm','video/webm'],['Mp4','video/mp4']].forEach(function(item){var source=document.createElement('source');source.src=heroVideo.dataset[variant+item[0]];source.type=item[1];heroVideo.appendChild(source)});heroVideo.load();heroVideo.play().catch(function(){})}
if(reduced.addEventListener)reduced.addEventListener('change',syncMotion);if(compact.addEventListener)compact.addEventListener('change',syncMotion);syncMotion();
var header=document.querySelector('.site-header');function syncHeader(){if(header)header.classList.toggle('scrolled',window.scrollY>16)}window.addEventListener('scroll',syncHeader,{passive:true});syncHeader();
if('IntersectionObserver' in window&&!reduced.matches){
diff --git a/crates/shelldeck-core/locales/en.toml b/crates/shelldeck-core/locales/en.toml
index 2fdf4fc4..f6d9964f 100644
--- a/crates/shelldeck-core/locales/en.toml
+++ b/crates/shelldeck-core/locales/en.toml
@@ -238,6 +238,23 @@ notification.fleet.failed = "AI Operations job failed"
notification.ai.summary = "ShellDeck — AI Assistant"
notification.ai.success = "An AI task completed"
notification.ai.failed = "An AI task failed"
+notification.attention.summary = "ShellDeck — Attention"
+notification.attention.open = "Open"
+notification.attention.body = "%{reason} · workspace %{workspace}"
+platform_attention.title = "Attention"
+platform_attention.activity_title = "Chronological activity"
+platform_attention.activity_workspace = "Workspace %{workspace}"
+platform_attention.activity_more = "%{count} more in the chronology"
+platform_attention.reason.review_requested = "Review requested"
+platform_attention.reason.comment_reply = "Comment reply"
+platform_attention.reason.approval_required = "Approval required"
+platform_attention.reason.agent_working = "Agent working"
+platform_attention.reason.check_running = "Check running"
+platform_attention.reason.delivery_pending = "Delivery pending"
+platform_attention.reason.complete = "Work completed"
+platform_attention.reason.conflict = "Conflict needs attention"
+platform_attention.reason.check_failed = "Check failed"
+platform_attention.reason.external_blocker = "External blocker"
settings.cloud_sync.section = "CLOUD SYNC"
settings.cloud_sync.account.label = "Account"
@@ -403,6 +420,20 @@ agents.status.preparing = "The agent is preparing its response"
agents.status.exit = "Process exited with code %{code}"
agents.status.stopped = "Run stopped"
agents.activity.title = "Run activity"
+agents.attention.acknowledge = "Acknowledge attention"
+agents.inspector.files = "Files"
+agents.inspector.changes = "Changes"
+agents.inspector.local = "LOCAL"
+agents.inspector.remote = "REMOTE"
+agents.inspector.observed = "Activity"
+agents.inspector.empty_files = "No files are visible in this working directory."
+agents.inspector.empty_changes = "Changed files and diff previews will appear here as the agent works."
+agents.inspector.focus_hint = "Choose a change to highlight its event in the agent timeline."
+agents.inspector.remote_empty = "Browse the selected SSH target without pretending these are local files."
+agents.inspector.remote_load = "Browse remote files"
+agents.inspector.remote_parent = "Parent directory"
+agents.inspector.remote_loading = "Loading the remote directory…"
+agents.inspector.retry = "Retry"
agents.error.target_missing = "The selected SSH connection no longer exists."
agents.error.prompt_empty = "Describe the task before running it."
agents.error.prompt_too_long = "The instruction is too long for safe local or SSH execution."
@@ -412,8 +443,13 @@ agents.error.workdir_missing = "The “%{workdir}” directory does not exist or
agents.error.model_too_long = "The model name is too long."
agents.error.binary_missing = "The “%{binary}” command was not found on this machine. Install it and sign in to the provider before retrying."
agents.error.remote_binary_missing = "The “%{binary}” command was not found on the SSH server. Install it and sign in to the provider on that target."
+agents.error.automonique_ssh_boundary = "Automonique ACP runs only on this machine. To manage Automonique-owned remote work, map the workspace in AI Operations and open its Platform session; ShellDeck will not silently run it on the wrong host."
+agents.error.automonique_model_override = "Automonique ACP chooses its model from the authenticated Automonique account. Clear the CLI model override for this session."
agents.error.already_running = "An agent is already running."
+agents.error.parallel_limit = "The %{count}-agent concurrency limit has been reached. Stop an agent before starting another."
agents.error.runtime_lost = "The agent process ended without a result."
+agents.error.session_missing = "The selected agent session no longer exists."
+agents.error.session_checkout_changed = "This agent session is already bound to another worktree. Open a new session for this worktree."
agents.toast.completed = "The agent completed its work."
agents.toast.stopped = "The agent was stopped."
agents.toast.exit = "The agent exited with code %{code}."
@@ -1193,6 +1229,19 @@ fleet.review.resolution_keep_current = "keep current"
fleet.review.resolution_keep_incoming = "keep incoming"
fleet.review.files_title = "Files, hunks, and safe previews"
fleet.review.comments_title = "Attributed comments"
+fleet.review.notes_title = "Anchored notes"
+fleet.review.note = "%{file} %{side}:%{line} · snapshot %{revision} · %{body}"
+fleet.review.note_prepare = "Prepare the comment"
+fleet.review.note_delete = "Delete"
+fleet.review.note_stale = "Superseded snapshot"
+fleet.review.note_failed = "The anchored note could not be stored."
+fleet.review.save_note = "Save the note"
+fleet.review.notes_selected = "%{count} note(s) selected and retained. A note is a local draft: prepare it as a comment before it can be delivered to the authorized session."
+fleet.review.prepare_batch_send = "Send %{count} comment(s) to the session"
+fleet.review.delivery_withheld_title = "Batch delivery unavailable"
+fleet.review.delivery_withheld_no_server_capability = "No capability response is attributed to this project, workspace and snapshot revision: nothing was advertised, so no send control is offered."
+fleet.review.delivery_withheld_no_deliverable_comment = "The server advertises no deliverable comment for this snapshot: no authorized session is bound to it, or no comment is in a sendable state. The advertisement is re-read on every refresh, including after a send."
+fleet.review.delivery_withheld_no_custody_lane = "Durable custody is unavailable in this process: no batch send control is offered."
fleet.review.attention_title = "Attention chronology"
fleet.review.confirm_comment = "In project %{project}, workspace %{workspace}: add “%{body}” on %{file} · %{hunk} · %{side}:%{line}, from exact snapshot %{revision}."
fleet.review.confirm_approval = "In project %{project}, workspace %{workspace}: approve review rev %{review_revision}, from exact snapshot %{revision}."
@@ -1206,6 +1255,40 @@ fleet.review.state_ambiguous = "ambiguous"
fleet.review.state_refused = "refused"
fleet.review.pending_detail = "Action dispatched once; later attempts only look up its receipt."
fleet.review.invalid_preview = "The action no longer matches the exact active review."
+fleet.review.never_dispatched = "The preview prepared before restart was never sent."
+fleet.review.capability_stale = "The rerun capability changed; refresh and confirm a new preview."
+fleet.review.delivery_capability_stale = "The delivery advertisement changed; reload, then prepare a new selection."
+fleet.review.custody_failed = "Durable custody refused dispatch; no provider call started."
+fleet.review.receipt_actor = "%{detail} · actor %{actor}"
+fleet.review.receipt_actor_short = "actor %{actor}"
+fleet.review.lane_conflicted = "Unresolved conflicts"
+fleet.review.lane_staged = "Staged"
+fleet.review.lane_unstaged = "Unstaged"
+fleet.review.lane_untracked = "Untracked"
+fleet.review.lane_partial = "partially staged"
+fleet.review.staging_proposed = "%{action} · proposal %{id}"
+fleet.review.staging_blocked = "%{action} · blocked · proposal %{id}"
+fleet.review.staging_withheld_title = "Staging is unavailable"
+fleet.review.staging_withheld_no_server_capability = "No capability response is attributed to this project, workspace and snapshot revision: nothing has been advertised, so no staging control is offered."
+fleet.review.staging_withheld_no_stageable_proposal = "The server advertises no staging and no conflict resolution for this snapshot: the registry binding does not grant it, the repository could not be read, or the worktree is in a state no write can fence exactly once. The advertisement is re-read on every refresh, including after a write."
+fleet.review.staging_withheld_no_custody_lane = "Durable custody is unavailable in this process: no staging control is offered."
+fleet.review.staging_observation = "Server fence, HEAD %{head} · index %{index}"
+fleet.review.prepare_stage = "Stage"
+fleet.review.prepare_unstage = "Unstage"
+fleet.review.prepare_commit = "Commit"
+fleet.review.resolve_keep_current = "Keep current"
+fleet.review.resolve_keep_incoming = "Keep incoming"
+fleet.review.preview_text = "Sanitized text preview"
+fleet.review.preview_image = "Image %{media} · %{width}×%{height} px · %{bytes} bytes"
+fleet.review.preview_image_box = "Bounded preview, never decoded"
+fleet.review.preview_html = "Sanitized HTML %{media} · %{bytes} bytes · described only, never interpreted nor shown as source"
+fleet.review.preview_truncated = "Preview truncated"
+fleet.review.preview_withheld_no_content = "No preview declared"
+fleet.review.preview_withheld_binary = "Opaque binary: no preview"
+fleet.review.preview_withheld_unsanitized = "Unsanitized content: preview withheld"
+fleet.review.preview_withheld_oversized = "Declared size over budget: preview withheld"
+fleet.review.preview_withheld_oversized_raster = "Declared dimensions over budget: preview withheld"
+fleet.review.preview_withheld_incoherent = "Declared kind and content disagree: preview withheld"
fleet.sessions.search = "Search sessions"
fleet.session.unread = "%{count} unread"
fleet.session.control_lost = "control lost"
@@ -2085,6 +2168,17 @@ workspaces.agent.running = "Agent running"
workspaces.agent.waiting = "Agent waiting"
workspaces.agent.failed = "Agent failed"
workspaces.agent.completed = "Agent completed"
+workspaces.attention.title = "Attention"
+workspaces.attention.needs_you = "Needs you"
+workspaces.attention.working = "Working"
+workspaces.attention.blocked = "Blocked"
+workspaces.attention.done = "Done"
+workspaces.attention.idle = "Idle"
+workspaces.attention.unread = "Unread"
+workspaces.attention.read = "Read"
+workspaces.attention.agent_root = "Root agent"
+workspaces.attention.agent_path = "Agent · %{path}"
+workspaces.attention.open_refused = "This attention item no longer matches an exact retained workspace surface."
workspaces.freshness.fresh = "Fresh"
workspaces.freshness.aging = "Aging"
workspaces.freshness.stale = "Stale"
diff --git a/crates/shelldeck-core/locales/fr.toml b/crates/shelldeck-core/locales/fr.toml
index 7e76231a..686b7e86 100644
--- a/crates/shelldeck-core/locales/fr.toml
+++ b/crates/shelldeck-core/locales/fr.toml
@@ -238,6 +238,23 @@ notification.fleet.failed = "Job AI Operations échoué"
notification.ai.summary = "ShellDeck – Assistant IA"
notification.ai.success = "Une tâche IA est terminée"
notification.ai.failed = "Une tâche IA a échoué"
+notification.attention.summary = "ShellDeck – Attention"
+notification.attention.open = "Ouvrir"
+notification.attention.body = "%{reason} · espace %{workspace}"
+platform_attention.title = "Attention"
+platform_attention.activity_title = "Activité chronologique"
+platform_attention.activity_workspace = "Espace %{workspace}"
+platform_attention.activity_more = "%{count} de plus dans la chronologie"
+platform_attention.reason.review_requested = "Revue demandée"
+platform_attention.reason.comment_reply = "Réponse à un commentaire"
+platform_attention.reason.approval_required = "Validation requise"
+platform_attention.reason.agent_working = "Agent en cours"
+platform_attention.reason.check_running = "Vérification en cours"
+platform_attention.reason.delivery_pending = "Livraison en attente"
+platform_attention.reason.complete = "Travail terminé"
+platform_attention.reason.conflict = "Conflit à traiter"
+platform_attention.reason.check_failed = "Vérification échouée"
+platform_attention.reason.external_blocker = "Blocage externe"
settings.cloud_sync.section = "SYNCHRONISATION CLOUD"
settings.cloud_sync.account.label = "Compte"
@@ -403,6 +420,20 @@ agents.status.preparing = "L’agent prépare sa réponse"
agents.status.exit = "Processus terminé avec le code %{code}"
agents.status.stopped = "Exécution arrêtée"
agents.activity.title = "Journal d’exécution"
+agents.attention.acknowledge = "Marquer l’alerte comme traitée"
+agents.inspector.files = "Fichiers"
+agents.inspector.changes = "Modifications"
+agents.inspector.local = "LOCAL"
+agents.inspector.remote = "DISTANT"
+agents.inspector.observed = "Activité"
+agents.inspector.empty_files = "Aucun fichier visible dans ce répertoire de travail."
+agents.inspector.empty_changes = "Les fichiers modifiés et leurs aperçus de diff apparaîtront ici pendant le travail de l’agent."
+agents.inspector.focus_hint = "Choisissez une modification pour surligner son événement dans la chronologie de l’agent."
+agents.inspector.remote_empty = "Parcourez la cible SSH sélectionnée sans présenter ces fichiers comme locaux."
+agents.inspector.remote_load = "Parcourir les fichiers distants"
+agents.inspector.remote_parent = "Répertoire parent"
+agents.inspector.remote_loading = "Chargement du répertoire distant…"
+agents.inspector.retry = "Réessayer"
agents.error.target_missing = "La connexion SSH sélectionnée n’existe plus."
agents.error.prompt_empty = "Décris d’abord la tâche à exécuter."
agents.error.prompt_too_long = "La consigne est trop longue pour une exécution locale ou SSH sûre."
@@ -412,8 +443,13 @@ agents.error.workdir_missing = "Le répertoire « %{workdir} » n’existe pas o
agents.error.model_too_long = "Le nom du modèle est trop long."
agents.error.binary_missing = "La commande « %{binary} » est introuvable sur cette machine. Installe-la et connecte le fournisseur avant de relancer."
agents.error.remote_binary_missing = "La commande « %{binary} » est introuvable sur le serveur SSH. Installe-la et connecte le fournisseur sur cette cible."
+agents.error.automonique_ssh_boundary = "Automonique ACP s’exécute uniquement sur cette machine. Pour gérer un travail distant détenu par Automonique, associez l’espace dans AI Operations et ouvrez sa session Plateforme ; ShellDeck ne l’exécutera jamais silencieusement sur le mauvais hôte."
+agents.error.automonique_model_override = "Automonique ACP choisit son modèle depuis le compte Automonique authentifié. Effacez la surcharge de modèle CLI pour cette session."
agents.error.already_running = "Un agent est déjà en cours d’exécution."
+agents.error.parallel_limit = "La limite de %{count} agents simultanés est atteinte. Arrête un agent avant d’en lancer un autre."
agents.error.runtime_lost = "Le processus d’agent s’est interrompu sans résultat."
+agents.error.session_missing = "La session d’agent sélectionnée n’existe plus."
+agents.error.session_checkout_changed = "Cette session d’agent est déjà liée à un autre worktree. Ouvre une nouvelle session pour ce worktree."
agents.toast.completed = "L’agent a terminé son travail."
agents.toast.stopped = "L’agent a été arrêté."
agents.toast.exit = "L’agent s’est terminé avec le code %{code}."
@@ -1193,6 +1229,19 @@ fleet.review.resolution_keep_current = "conserver la version actuelle"
fleet.review.resolution_keep_incoming = "conserver la version entrante"
fleet.review.files_title = "Fichiers, sections et aperçus sûrs"
fleet.review.comments_title = "Commentaires attribués"
+fleet.review.notes_title = "Notes ancrées"
+fleet.review.note = "%{file} %{side}:%{line} · instantané %{revision} · %{body}"
+fleet.review.note_prepare = "Préparer le commentaire"
+fleet.review.note_delete = "Supprimer"
+fleet.review.note_stale = "Instantané dépassé"
+fleet.review.note_failed = "La note ancrée n’a pas pu être enregistrée."
+fleet.review.save_note = "Enregistrer la note"
+fleet.review.notes_selected = "%{count} note(s) sélectionnée(s) et conservées. Une note est un brouillon local : préparez-la en commentaire pour qu’elle devienne livrable à la session autorisée."
+fleet.review.prepare_batch_send = "Envoyer %{count} commentaire(s) à la session"
+fleet.review.delivery_withheld_title = "Envoi groupé indisponible"
+fleet.review.delivery_withheld_no_server_capability = "Aucune réponse de capacité n’est attribuée à ce projet, cet espace et cette révision d’instantané : rien n’a été annoncé, donc aucune commande d’envoi n’est proposée."
+fleet.review.delivery_withheld_no_deliverable_comment = "Le serveur n’annonce aucun commentaire livrable pour cet instantané : aucune session autorisée n’y est reliée, ou aucun commentaire n’est dans un état envoyable. L’annonce est relue à chaque rafraîchissement, y compris après un envoi."
+fleet.review.delivery_withheld_no_custody_lane = "La garde durable est indisponible dans ce processus : aucune commande d’envoi groupé n’est proposée."
fleet.review.attention_title = "Chronologie d’attention"
fleet.review.confirm_comment = "Dans le projet %{project}, espace %{workspace} : ajouter « %{body} » sur %{file} · %{hunk} · %{side}:%{line}, depuis l’instantané exact %{revision}."
fleet.review.confirm_approval = "Dans le projet %{project}, espace %{workspace} : approuver la rév. de revue %{review_revision}, depuis l’instantané exact %{revision}."
@@ -1206,6 +1255,40 @@ fleet.review.state_ambiguous = "ambigu"
fleet.review.state_refused = "refusé"
fleet.review.pending_detail = "Action envoyée une seule fois ; les tentatives suivantes consultent uniquement le reçu."
fleet.review.invalid_preview = "L’action ne correspond plus exactement à la revue active."
+fleet.review.never_dispatched = "L’aperçu préparé avant le redémarrage n’a jamais été envoyé."
+fleet.review.capability_stale = "La capacité de relance a changé ; rechargez puis confirmez un nouvel aperçu."
+fleet.review.delivery_capability_stale = "L’annonce de livraison a changé ; rechargez puis préparez une nouvelle sélection."
+fleet.review.custody_failed = "La garde durable a refusé l’envoi ; aucun appel fournisseur n’a commencé."
+fleet.review.receipt_actor = "%{detail} · acteur %{actor}"
+fleet.review.receipt_actor_short = "acteur %{actor}"
+fleet.review.lane_conflicted = "Conflits non résolus"
+fleet.review.lane_staged = "Indexé"
+fleet.review.lane_unstaged = "Non indexé"
+fleet.review.lane_untracked = "Non suivi"
+fleet.review.lane_partial = "partiellement indexé"
+fleet.review.staging_proposed = "%{action} · proposition %{id}"
+fleet.review.staging_blocked = "%{action} · bloqué · proposition %{id}"
+fleet.review.staging_withheld_title = "Indexation indisponible"
+fleet.review.staging_withheld_no_server_capability = "Aucune réponse de capacité n’est attribuée à ce projet, cet espace et cette révision d’instantané : rien n’a été annoncé, donc aucune commande d’indexation n’est proposée."
+fleet.review.staging_withheld_no_stageable_proposal = "Le serveur n’annonce aucune indexation ni résolution de conflit pour cet instantané : la liaison du registre ne l’accorde pas, le dépôt n’a pas pu être lu, ou l’arbre de travail est dans un état qu’aucune écriture ne peut clôturer exactement une fois. L’annonce est relue à chaque rafraîchissement, y compris après une écriture."
+fleet.review.staging_withheld_no_custody_lane = "La garde durable est indisponible dans ce processus : aucune commande d’indexation n’est proposée."
+fleet.review.staging_observation = "Barrière serveur, HEAD %{head} · index %{index}"
+fleet.review.prepare_stage = "Indexer"
+fleet.review.prepare_unstage = "Désindexer"
+fleet.review.prepare_commit = "Valider"
+fleet.review.resolve_keep_current = "Garder la version actuelle"
+fleet.review.resolve_keep_incoming = "Garder la version entrante"
+fleet.review.preview_text = "Aperçu texte assaini"
+fleet.review.preview_image = "Image %{media} · %{width}×%{height} px · %{bytes} octets"
+fleet.review.preview_image_box = "Aperçu borné, jamais décodé"
+fleet.review.preview_html = "HTML assaini %{media} · %{bytes} octets · décrit uniquement, jamais interprété ni affiché en source"
+fleet.review.preview_truncated = "Aperçu tronqué"
+fleet.review.preview_withheld_no_content = "Aucun aperçu déclaré"
+fleet.review.preview_withheld_binary = "Binaire opaque : aucun aperçu"
+fleet.review.preview_withheld_unsanitized = "Contenu non assaini : aperçu retenu"
+fleet.review.preview_withheld_oversized = "Taille déclarée hors budget : aperçu retenu"
+fleet.review.preview_withheld_oversized_raster = "Dimensions déclarées hors budget : aperçu retenu"
+fleet.review.preview_withheld_incoherent = "Type et contenu déclarés incohérents : aperçu retenu"
fleet.sessions.search = "Rechercher des sessions"
fleet.session.unread = "%{count} non lus"
fleet.session.control_lost = "contrôle perdu"
@@ -2085,6 +2168,17 @@ workspaces.agent.running = "Agent actif"
workspaces.agent.waiting = "Agent en attente"
workspaces.agent.failed = "Agent en échec"
workspaces.agent.completed = "Agent terminé"
+workspaces.attention.title = "Attention"
+workspaces.attention.needs_you = "Besoin de vous"
+workspaces.attention.working = "En cours"
+workspaces.attention.blocked = "Bloqué"
+workspaces.attention.done = "Terminé"
+workspaces.attention.idle = "Inactif"
+workspaces.attention.unread = "Non lu"
+workspaces.attention.read = "Lu"
+workspaces.attention.agent_root = "Agent racine"
+workspaces.attention.agent_path = "Agent · %{path}"
+workspaces.attention.open_refused = "Cette attention ne correspond plus exactement à une surface de workspace retenue."
workspaces.freshness.fresh = "À jour"
workspaces.freshness.aging = "Vieillit"
workspaces.freshness.stale = "Périmé"
diff --git a/crates/shelldeck-core/src/acp.rs b/crates/shelldeck-core/src/acp.rs
index 63291a5b..ae96271f 100644
--- a/crates/shelldeck-core/src/acp.rs
+++ b/crates/shelldeck-core/src/acp.rs
@@ -12,21 +12,29 @@ use std::path::PathBuf;
use std::sync::Arc;
use agent_client_protocol::schema::v1::{
- AgentCapabilities, AuthMethodId, AuthenticateRequest, ClientCapabilities, ContentBlock,
- Implementation, InitializeRequest, LoadSessionRequest, McpServer, NewSessionRequest,
- PermissionOptionId, PromptRequest, PromptResponse, RequestPermissionOutcome,
+ AgentCapabilities, AuthMethodId, AuthenticateRequest, CancelNotification, ClientCapabilities,
+ ContentBlock, Implementation, InitializeRequest, LoadSessionRequest, McpServer,
+ NewSessionRequest, PermissionOptionId, PromptRequest, PromptResponse, RequestPermissionOutcome,
RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome, SessionId,
SessionNotification,
};
use agent_client_protocol::schema::ProtocolVersion;
use agent_client_protocol::{AcpAgent, AcpAgentConfig, Agent, ConnectionTo};
use parking_lot::Mutex;
+use std::sync::atomic::{AtomicBool, Ordering};
use crate::error::{Result, ShellDeckError};
/// Stable SDK release used by both ShellDeck and Automonique.
pub const ACP_SDK_VERSION: &str = "2.0.0";
+pub type AcpCancelSender = futures::channel::oneshot::Sender<()>;
+pub type AcpCancelReceiver = futures::channel::oneshot::Receiver<()>;
+
+pub fn cancel_channel() -> (AcpCancelSender, AcpCancelReceiver) {
+ futures::channel::oneshot::channel()
+}
+
/// Explicit child-process launch configuration. No shell is involved.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct AcpLaunch {
@@ -76,6 +84,16 @@ impl AcpTurnRequest {
}
}
+ #[must_use]
+ pub fn text(cwd: impl Into, prompt: impl Into) -> Self {
+ Self::new(
+ cwd,
+ vec![ContentBlock::Text(
+ agent_client_protocol::schema::v1::TextContent::new(prompt),
+ )],
+ )
+ }
+
#[must_use]
pub fn resume(mut self, session_id: impl Into) -> Self {
self.session_id = Some(session_id.into());
@@ -119,6 +137,7 @@ impl AcpPermissionBroker for CancelPermissions {
pub struct AcpClient {
launch: AcpLaunch,
permissions: Arc,
+ updates: Arc,
}
impl AcpClient {
@@ -127,6 +146,7 @@ impl AcpClient {
Self {
launch,
permissions: Arc::new(CancelPermissions),
+ updates: Arc::new(|_| {}),
}
}
@@ -136,16 +156,68 @@ impl AcpClient {
self
}
+ /// Observe each ordered ACP update as it arrives. The callback must remain
+ /// lightweight; provider I/O continues on the ACP connection task.
+ #[must_use]
+ pub fn with_updates(
+ mut self,
+ updates: Arc,
+ ) -> Self {
+ self.updates = updates;
+ self
+ }
+
+ /// Route ACP updates directly into the provider-neutral coding-agent
+ /// timeline while preserving protocol order.
+ #[must_use]
+ pub fn with_agent_events(
+ self,
+ events: std::sync::mpsc::Sender,
+ ) -> Self {
+ let ready = Arc::new(AtomicBool::new(false));
+ self.with_updates(Arc::new(move |notification| {
+ if !ready.swap(true, Ordering::AcqRel) {
+ let _ = events.send(crate::agent_runtime::AgentStreamEvent::Ready);
+ }
+ for event in crate::agent_runtime::acp_notification_events(notification) {
+ let _ = events.send(event);
+ }
+ }))
+ }
+
/// Run one complete turn. The process group is torn down by the SDK when
/// the connection ends, including wrapper subprocesses on Unix.
pub fn prompt(&self, request: AcpTurnRequest) -> Result {
+ self.prompt_inner(request, None)?
+ .ok_or_else(|| ShellDeckError::Connection("ACP turn was unexpectedly cancelled".into()))
+ }
+
+ /// Run one turn with protocol-level cancellation. Dropping the connection
+ /// after `session/cancel` also activates the SDK's child process-group
+ /// guard, so Stop cannot leave an Automonique wrapper or tool orphaned.
+ pub fn prompt_cancellable(
+ &self,
+ request: AcpTurnRequest,
+ cancel: AcpCancelReceiver,
+ ) -> Result> {
+ self.prompt_inner(request, Some(cancel))
+ }
+
+ fn prompt_inner(
+ &self,
+ request: AcpTurnRequest,
+ mut cancel: Option,
+ ) -> Result> {
validate_request(&request)?;
let updates = Arc::new(Mutex::new(Vec::new()));
let result = Arc::new(Mutex::new(None));
let updates_handler = Arc::clone(&updates);
+ let live_updates = Arc::clone(&self.updates);
let result_handler = Arc::clone(&result);
let permissions = Arc::clone(&self.permissions);
+ let cancelled = Arc::new(AtomicBool::new(false));
+ let cancelled_handler = Arc::clone(&cancelled);
let agent = AcpAgent::new(
AcpAgentConfig::new(&self.launch.program).args(self.launch.arguments.clone()),
);
@@ -156,6 +228,7 @@ impl AcpClient {
.name("shelldeck")
.on_receive_notification(
async move |notification: SessionNotification, _connection| {
+ live_updates(¬ification);
updates_handler.lock().push(notification);
Ok(())
},
@@ -233,28 +306,48 @@ impl AcpClient {
.await?
.session_id
};
- let response = connection
+ let prompt = connection
.send_request(PromptRequest::new(session_id.clone(), request.prompt))
- .block_task()
- .await?;
+ .block_task();
+ let response = if let Some(cancel) = cancel.as_mut() {
+ use futures::future::{select, Either};
+ use futures::FutureExt as _;
+
+ let prompt = prompt.fuse();
+ let stop = cancel.fuse();
+ futures::pin_mut!(prompt, stop);
+ match select(prompt, stop).await {
+ Either::Left((response, _)) => response?,
+ Either::Right((_, _)) => {
+ cancelled_handler.store(true, Ordering::Release);
+ connection
+ .send_notification(CancelNotification::new(session_id))?;
+ return Ok(());
+ }
+ }
+ } else {
+ prompt.await?
+ };
*result_handler.lock() = Some((initialized, session_id, response));
Ok(())
}),
)
.map_err(acp_error)?;
- let (initialized, session_id, response) = result
- .lock()
- .take()
+ let completed = result.lock().take();
+ if completed.is_none() && cancelled.load(Ordering::Acquire) {
+ return Ok(None);
+ }
+ let (initialized, session_id, response) = completed
.ok_or_else(|| ShellDeckError::Connection("ACP turn ended without a result".into()))?;
let updates = std::mem::take(&mut *updates.lock());
- Ok(AcpTurnResult {
+ Ok(Some(AcpTurnResult {
agent_info: initialized.agent_info,
agent_capabilities: initialized.agent_capabilities,
session_id,
updates,
response,
- })
+ }))
}
}
diff --git a/crates/shelldeck-core/src/agent_runtime.rs b/crates/shelldeck-core/src/agent_runtime.rs
index 53d4997d..c647bc3b 100644
--- a/crates/shelldeck-core/src/agent_runtime.rs
+++ b/crates/shelldeck-core/src/agent_runtime.rs
@@ -11,18 +11,119 @@ use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus};
use uuid::Uuid;
+use agent_client_protocol::schema::v1::{
+ ContentBlock, PermissionOptionId, PermissionOptionKind, RequestPermissionRequest,
+ SessionNotification, SessionUpdate, ToolCallStatus,
+};
+
+use crate::agent_session::{AgentTraceKind, AgentTraceStatus, AgentTraceUpdate};
use crate::{Result, ShellDeckError};
pub const MAX_AGENT_PROMPT_BYTES: usize = 48 * 1024;
pub const MAX_AGENT_MODEL_BYTES: usize = 256;
pub const MAX_AGENT_WORKDIR_BYTES: usize = 4096;
pub const MAX_AGENT_SESSION_BYTES: usize = 256;
+pub const MAX_AGENT_STREAM_LINE_BYTES: usize = 256 * 1024;
+pub const MAX_AGENT_STREAM_TEXT_BYTES: usize = 128 * 1024;
+pub const MAX_AGENT_TRACE_FIELD_BYTES: usize = 8 * 1024;
+pub const MAX_AGENT_ACTIVITY_BYTES: usize = 512;
+pub const AGENT_STREAM_OVERSIZED_LABEL: &str = "Provider event omitted (too large)";
+/// Canonical workdir used when an SSH agent should start in the remote
+/// account's home directory. It is expanded by [`AgentCommandSpec`] on the
+/// remote shell; it is never passed through as a literal filesystem path.
+pub const AGENT_REMOTE_HOME_WORKDIR: &str = "~";
+
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AgentStreamFrame {
+ Line(String),
+ Oversized,
+}
+
+/// Incrementally frames an arbitrary provider byte stream without ever
+/// retaining more than one admitted line. Once a line exceeds the parser's
+/// byte bound, its remainder is discarded through the next newline and one
+/// omission frame is emitted.
+#[derive(Debug, Default)]
+pub struct AgentStreamFramer {
+ pending: Vec,
+ discarding_oversized: bool,
+}
+
+impl AgentStreamFramer {
+ pub fn push(&mut self, mut bytes: &[u8]) -> Vec {
+ let mut frames = Vec::new();
+ while !bytes.is_empty() {
+ if self.discarding_oversized {
+ let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') else {
+ break;
+ };
+ self.discarding_oversized = false;
+ bytes = &bytes[newline + 1..];
+ continue;
+ }
+
+ if let Some(newline) = bytes.iter().position(|byte| *byte == b'\n') {
+ let segment = &bytes[..newline];
+ if self.pending.len().saturating_add(segment.len()) > MAX_AGENT_STREAM_LINE_BYTES {
+ self.pending.clear();
+ frames.push(AgentStreamFrame::Oversized);
+ } else {
+ self.pending.extend_from_slice(segment);
+ if self.pending.last() == Some(&b'\r') {
+ self.pending.pop();
+ }
+ frames.push(AgentStreamFrame::Line(
+ String::from_utf8_lossy(&self.pending).into_owned(),
+ ));
+ self.pending.clear();
+ }
+ bytes = &bytes[newline + 1..];
+ continue;
+ }
+
+ let remaining = MAX_AGENT_STREAM_LINE_BYTES.saturating_sub(self.pending.len());
+ if bytes.len() > remaining {
+ self.pending.clear();
+ self.discarding_oversized = true;
+ frames.push(AgentStreamFrame::Oversized);
+ } else {
+ self.pending.extend_from_slice(bytes);
+ }
+ break;
+ }
+ frames
+ }
+
+ pub fn finish(&mut self) -> Vec {
+ if self.discarding_oversized {
+ self.discarding_oversized = false;
+ self.pending.clear();
+ return Vec::new();
+ }
+ if self.pending.is_empty() {
+ return Vec::new();
+ }
+ let mut pending = std::mem::take(&mut self.pending);
+ if pending.last() == Some(&b'\r') {
+ pending.pop();
+ }
+ vec![AgentStreamFrame::Line(
+ String::from_utf8_lossy(&pending).into_owned(),
+ )]
+ }
+
+ pub fn buffered_bytes(&self) -> usize {
+ self.pending.len()
+ }
+}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AgentProvider {
Claude,
Codex,
+ /// Automonique's stable ACP v1 agent surface.
+ Automonique,
/// Jcode with the provider selected by its configuration on the target.
Jcode,
/// DeepSeek models are launched through the managed Jcode runner. Jcode
@@ -35,6 +136,7 @@ impl AgentProvider {
match self {
Self::Claude => "Claude Code",
Self::Codex => "Codex",
+ Self::Automonique => "Automonique (ACP)",
Self::Jcode => "Jcode (auto)",
Self::DeepSeek => "DeepSeek (Jcode)",
}
@@ -44,6 +146,7 @@ impl AgentProvider {
match self {
Self::Claude => "claude",
Self::Codex => "codex",
+ Self::Automonique => "automonique",
Self::Jcode | Self::DeepSeek => "jcode",
}
}
@@ -137,7 +240,9 @@ impl AgentRunRequest {
let absolute = match self.target {
AgentTarget::Local => Path::new(workdir).is_absolute(),
// Remote agent transport is POSIX shell based on every host OS.
- AgentTarget::Ssh { .. } => workdir.starts_with('/'),
+ AgentTarget::Ssh { .. } => {
+ workdir == AGENT_REMOTE_HOME_WORKDIR || workdir.starts_with('/')
+ }
};
if workdir.is_empty() || !absolute {
return Err(ShellDeckError::Config(
@@ -191,6 +296,11 @@ impl AgentCommandSpec {
.filter(|value| !value.is_empty());
let (args, stdin, remove_env) = match request.provider {
+ AgentProvider::Automonique => {
+ return Err(ShellDeckError::Config(
+ "Automonique uses the ACP transport, not a command stream".to_string(),
+ ));
+ }
AgentProvider::Claude => {
let permission_mode = match request.access {
AgentAccessMode::ReadOnly => "plan",
@@ -251,6 +361,16 @@ impl AgentCommandSpec {
(args, Some(request.prompt.as_bytes().to_vec()), Vec::new())
}
AgentProvider::Jcode | AgentProvider::DeepSeek => {
+ // The SSH home sentinel is expanded by the outer `cd`. Keep
+ // Jcode rooted in that resulting directory rather than
+ // passing a quoted literal `~`, which no shell would expand.
+ let provider_workdir = if matches!(request.target, AgentTarget::Ssh { .. })
+ && request.workdir.trim() == AGENT_REMOTE_HOME_WORKDIR
+ {
+ "."
+ } else {
+ request.workdir.trim()
+ };
let mut args = vec![
"run".to_string(),
"--ndjson".to_string(),
@@ -258,7 +378,7 @@ impl AgentCommandSpec {
"--no-update".to_string(),
"--no-selfdev".to_string(),
"-C".to_string(),
- request.workdir.trim().to_string(),
+ provider_workdir.to_string(),
];
if request.provider == AgentProvider::DeepSeek {
args.push("--provider".to_string());
@@ -330,9 +450,16 @@ impl AgentCommandSpec {
}
None => command,
};
+ let workdir = self.cwd.to_string_lossy();
+ let workdir = if workdir == AGENT_REMOTE_HOME_WORKDIR {
+ // This exact constant is the only unquoted workdir expression.
+ // Every user-controlled absolute path still crosses shell_quote.
+ "\"$HOME\"".to_string()
+ } else {
+ shell_quote(&workdir)
+ };
format!(
- "export PATH=\"$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; cd -- {} && {command}",
- shell_quote(&self.cwd.to_string_lossy())
+ "export PATH=\"$HOME/.local/bin:$HOME/bin:/usr/local/bin:/opt/homebrew/bin:$PATH\"; cd -- {workdir} && {command}"
)
}
}
@@ -584,6 +711,16 @@ pub enum AgentStreamEvent {
/// Provider initialization completed. The UI localizes this state rather
/// than baking a language into the provider-neutral parser.
Ready,
+ /// Structured technical activity rendered independently from conversation
+ /// prose. All provider-derived strings are bounded and redacted.
+ Trace(AgentTraceUpdate),
+ /// Completion/status for a previously correlated technical activity.
+ TraceStatus {
+ correlation_id: String,
+ status: AgentTraceStatus,
+ summary: Option,
+ },
+ /// Safe fallback for provider activity not recognized structurally.
Activity(String),
Error(String),
}
@@ -596,16 +733,130 @@ pub fn parse_stream_line(provider: AgentProvider, line: &str) -> Vec MAX_AGENT_STREAM_LINE_BYTES {
+ return vec![AgentStreamEvent::Activity(
+ AGENT_STREAM_OVERSIZED_LABEL.to_string(),
+ )];
+ }
let Ok(value) = serde_json::from_str::(trimmed) else {
- return vec![AgentStreamEvent::Activity(trimmed.to_string())];
+ let label = if trimmed.starts_with(['{', '[']) {
+ "Malformed provider event".to_string()
+ } else {
+ safe_provider_text(trimmed, MAX_AGENT_ACTIVITY_BYTES)
+ };
+ return vec![AgentStreamEvent::Activity(label)];
};
match provider {
+ AgentProvider::Automonique => vec![AgentStreamEvent::Activity(
+ "Unexpected raw Automonique ACP record".to_string(),
+ )],
AgentProvider::Claude => parse_claude_event(&value),
AgentProvider::Codex => parse_codex_event(&value),
AgentProvider::Jcode | AgentProvider::DeepSeek => parse_jcode_event(&value),
}
}
+/// Map one typed ACP update into the same bounded timeline vocabulary used by
+/// CLI providers. Raw ACP metadata, tool input and tool output never cross
+/// into the transcript.
+pub fn acp_notification_events(notification: &SessionNotification) -> Vec {
+ let mut events = vec![AgentStreamEvent::Session(
+ notification.session_id.0.to_string(),
+ )];
+ match ¬ification.update {
+ SessionUpdate::AgentMessageChunk(chunk) => {
+ if let ContentBlock::Text(text) = &chunk.content {
+ events.push(AgentStreamEvent::TextDelta(safe_visible_text(&text.text)));
+ }
+ }
+ SessionUpdate::AgentThoughtChunk(chunk) => {
+ if let ContentBlock::Text(text) = &chunk.content {
+ events.push(AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: None,
+ detail: AgentTraceKind::Activity {
+ label: safe_provider_text(&text.text, MAX_AGENT_ACTIVITY_BYTES),
+ },
+ }));
+ }
+ }
+ SessionUpdate::ToolCall(call) => {
+ events.push(AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: Some(call.tool_call_id.0.to_string()),
+ detail: AgentTraceKind::Tool {
+ name: safe_provider_text(&call.title, MAX_AGENT_TRACE_FIELD_BYTES),
+ status: acp_trace_status(call.status),
+ summary: None,
+ },
+ }));
+ }
+ SessionUpdate::ToolCallUpdate(update) => {
+ if let Some(status) = update.fields.status {
+ events.push(AgentStreamEvent::TraceStatus {
+ correlation_id: update.tool_call_id.0.to_string(),
+ status: acp_trace_status(status),
+ summary: update
+ .fields
+ .title
+ .as_deref()
+ .map(|title| safe_provider_text(title, MAX_AGENT_TRACE_FIELD_BYTES)),
+ });
+ }
+ }
+ SessionUpdate::Plan(plan) => {
+ events.push(AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: None,
+ detail: AgentTraceKind::Activity {
+ label: format!("Plan updated ({} steps)", plan.entries.len()),
+ },
+ }));
+ }
+ SessionUpdate::AvailableCommandsUpdate(_)
+ | SessionUpdate::CurrentModeUpdate(_)
+ | SessionUpdate::ConfigOptionUpdate(_)
+ | SessionUpdate::SessionInfoUpdate(_)
+ | SessionUpdate::UsageUpdate(_) => {}
+ SessionUpdate::UserMessageChunk(_) => {}
+ _ => {}
+ }
+ events
+}
+
+fn acp_trace_status(status: ToolCallStatus) -> AgentTraceStatus {
+ match status {
+ ToolCallStatus::Pending | ToolCallStatus::InProgress => AgentTraceStatus::Running,
+ ToolCallStatus::Completed => AgentTraceStatus::Succeeded,
+ ToolCallStatus::Failed => AgentTraceStatus::Failed,
+ _ => AgentTraceStatus::Running,
+ }
+}
+
+/// ACP permission policy derived from the run-level access confirmation.
+/// Read-only never grants a tool request; mutating modes grant only the exact
+/// one-shot option offered by Automonique and never persist an allow decision.
+pub struct AcpAgentPermissionBroker {
+ access: AgentAccessMode,
+}
+
+impl AcpAgentPermissionBroker {
+ #[must_use]
+ pub const fn new(access: AgentAccessMode) -> Self {
+ Self { access }
+ }
+}
+
+impl crate::acp::AcpPermissionBroker for AcpAgentPermissionBroker {
+ fn select(&self, request: &RequestPermissionRequest) -> Option {
+ if self.access == AgentAccessMode::ReadOnly {
+ return None;
+ }
+ request
+ .options
+ .iter()
+ .find(|option| option.kind == PermissionOptionKind::AllowOnce)
+ .map(|option| option.option_id.clone())
+ }
+}
+
fn parse_claude_event(value: &Value) -> Vec {
let mut events = session_events(value);
match value.get("type").and_then(Value::as_str) {
@@ -616,21 +867,25 @@ fn parse_claude_event(value: &Value) -> Vec {
value
.pointer("/message/content")
.and_then(text_from_value)
- .map(AgentStreamEvent::Error),
+ .map(|text| AgentStreamEvent::Error(safe_visible_text(&text))),
)
}
- Some("assistant") => events.extend(
- value
- .pointer("/message/content")
- .and_then(text_from_value)
- .map(AgentStreamEvent::Text),
- ),
+ Some("assistant") => {
+ events.extend(
+ value
+ .pointer("/message/content")
+ .and_then(text_from_value)
+ .map(|text| AgentStreamEvent::Text(safe_visible_text(&text))),
+ );
+ events.extend(claude_trace_events(value));
+ }
+ Some("user") => events.extend(claude_tool_result_events(value)),
Some("result") if value.get("is_error").and_then(Value::as_bool) == Some(true) => events
.extend(
value
.get("result")
.and_then(text_from_value)
- .map(AgentStreamEvent::Error),
+ .map(|text| AgentStreamEvent::Error(safe_visible_text(&text))),
),
Some("system") if value.get("subtype").and_then(Value::as_str) == Some("init") => {
events.push(AgentStreamEvent::Ready)
@@ -649,19 +904,24 @@ fn parse_codex_event(value: &Value) -> Vec {
Some("agent_message") => events.extend(
item.get("text")
.and_then(text_from_value)
- .map(AgentStreamEvent::Text),
+ .map(|text| AgentStreamEvent::Text(safe_visible_text(&text))),
),
- Some(kind) => events.push(AgentStreamEvent::Activity(humanize_kind(kind))),
+ Some(_) => events.extend(codex_trace_events(item, AgentTraceStatus::Succeeded)),
None => {}
}
}
}
+ Some("item.started") => {
+ if let Some(item) = value.get("item") {
+ events.extend(codex_trace_events(item, AgentTraceStatus::Running));
+ }
+ }
Some("turn.failed") | Some("error") => events.extend(
value
.get("error")
.or_else(|| value.get("message"))
.and_then(text_from_value)
- .map(AgentStreamEvent::Error),
+ .map(|text| AgentStreamEvent::Error(safe_visible_text(&text))),
),
Some("thread.started") => events.push(AgentStreamEvent::Ready),
_ => {}
@@ -676,13 +936,13 @@ fn parse_jcode_event(value: &Value) -> Vec {
.and_then(Value::as_str)
.unwrap_or_default();
if let Some(delta) = value.get("delta").and_then(text_from_value) {
- events.push(AgentStreamEvent::TextDelta(delta));
+ events.push(AgentStreamEvent::TextDelta(safe_visible_text(&delta)));
return events;
}
if matches!(kind, "result" | "assistant" | "message") {
for key in ["result", "text", "content", "message", "response"] {
if let Some(text) = value.get(key).and_then(text_from_value) {
- events.push(AgentStreamEvent::Text(text));
+ events.push(AgentStreamEvent::Text(safe_visible_text(&text)));
return events;
}
}
@@ -693,16 +953,343 @@ fn parse_jcode_event(value: &Value) -> Vec {
.get("error")
.or_else(|| value.get("message"))
.and_then(text_from_value)
- .map(AgentStreamEvent::Error),
+ .map(|text| AgentStreamEvent::Error(safe_visible_text(&text))),
);
return events;
}
if !kind.is_empty() {
- events.push(AgentStreamEvent::Activity(humanize_kind(kind)));
+ events.push(AgentStreamEvent::Activity(safe_provider_text(
+ &humanize_kind(kind),
+ MAX_AGENT_ACTIVITY_BYTES,
+ )));
}
events
}
+fn claude_trace_events(value: &Value) -> Vec {
+ value
+ .pointer("/message/content")
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_use"))
+ .map(|block| {
+ let name = block.get("name").and_then(Value::as_str).unwrap_or("tool");
+ let input = block.get("input").unwrap_or(&Value::Null);
+ trace_update(
+ trace_for_tool(name, input, AgentTraceStatus::Running),
+ block.get("id").and_then(Value::as_str),
+ )
+ })
+ .collect()
+}
+
+fn claude_tool_result_events(value: &Value) -> Vec {
+ value
+ .pointer("/message/content")
+ .and_then(Value::as_array)
+ .into_iter()
+ .flatten()
+ .filter(|block| block.get("type").and_then(Value::as_str) == Some("tool_result"))
+ .filter_map(|block| {
+ let correlation_id = block.get("tool_use_id").and_then(Value::as_str)?;
+ let status = if block.get("is_error").and_then(Value::as_bool) == Some(true) {
+ AgentTraceStatus::Failed
+ } else {
+ AgentTraceStatus::Succeeded
+ };
+ let summary = block
+ .get("content")
+ .and_then(text_from_value)
+ .map(|text| safe_provider_text(&text, MAX_AGENT_TRACE_FIELD_BYTES));
+ Some(AgentStreamEvent::TraceStatus {
+ correlation_id: safe_provider_text(correlation_id, MAX_AGENT_SESSION_BYTES),
+ status,
+ summary,
+ })
+ })
+ .collect()
+}
+
+fn codex_trace_events(item: &Value, default_status: AgentTraceStatus) -> Vec {
+ let kind = item
+ .get("type")
+ .and_then(Value::as_str)
+ .unwrap_or("activity");
+ let status = trace_status(item).unwrap_or(default_status);
+ let correlation_id = item.get("id").and_then(Value::as_str);
+ match kind {
+ "command_execution" | "command" => {
+ let command = item
+ .get("command")
+ .or_else(|| item.get("cmd"))
+ .and_then(text_from_value)
+ .unwrap_or_else(|| "command".to_string());
+ let summary = item
+ .get("aggregated_output")
+ .or_else(|| item.get("output"))
+ .and_then(text_from_value)
+ .map(|text| safe_provider_text(&text, MAX_AGENT_TRACE_FIELD_BYTES));
+ let exit_code = item
+ .get("exit_code")
+ .or_else(|| item.get("exitCode"))
+ .and_then(Value::as_i64)
+ .and_then(|code| i32::try_from(code).ok());
+ let command = safe_provider_text(&command, MAX_AGENT_TRACE_FIELD_BYTES);
+ if looks_like_test(&command) {
+ vec![trace_update(
+ AgentTraceKind::Test {
+ name: command,
+ status,
+ summary,
+ },
+ correlation_id,
+ )]
+ } else {
+ vec![trace_update(
+ AgentTraceKind::Command {
+ command,
+ status,
+ exit_code,
+ summary,
+ },
+ correlation_id,
+ )]
+ }
+ }
+ "file_change" | "file_changes" => {
+ let changes = item
+ .get("changes")
+ .and_then(Value::as_array)
+ .cloned()
+ .unwrap_or_default();
+ if changes.is_empty() {
+ return vec![trace_update(
+ AgentTraceKind::Tool {
+ name: humanize_kind(kind),
+ status,
+ summary: None,
+ },
+ correlation_id,
+ )];
+ }
+ changes
+ .iter()
+ .enumerate()
+ .map(|(index, change)| {
+ let path = value_string(change, &["path", "file_path", "filePath"])
+ .unwrap_or_else(|| "file".to_string());
+ let correlation = correlation_id.map(|id| format!("{id}:{index}"));
+ trace_update(
+ AgentTraceKind::Diff {
+ path: safe_provider_text(&path, MAX_AGENT_TRACE_FIELD_BYTES),
+ status,
+ additions: value_u32(change, &["additions", "added"]),
+ deletions: value_u32(change, &["deletions", "removed"]),
+ preview: value_string(change, &["diff", "patch"])
+ .map(|text| safe_provider_text(&text, MAX_AGENT_TRACE_FIELD_BYTES)),
+ },
+ correlation.as_deref(),
+ )
+ })
+ .collect()
+ }
+ "file_read" => {
+ let path = value_string(item, &["path", "file_path", "filePath"])
+ .unwrap_or_else(|| "file".to_string());
+ vec![trace_update(
+ AgentTraceKind::FileRead {
+ path: safe_provider_text(&path, MAX_AGENT_TRACE_FIELD_BYTES),
+ status,
+ line_start: value_optional_u32(item, &["line_start", "lineStart"]),
+ line_end: value_optional_u32(item, &["line_end", "lineEnd"]),
+ },
+ correlation_id,
+ )]
+ }
+ _ => vec![trace_update(
+ AgentTraceKind::Tool {
+ name: safe_provider_text(&humanize_kind(kind), MAX_AGENT_ACTIVITY_BYTES),
+ status,
+ summary: None,
+ },
+ correlation_id,
+ )],
+ }
+}
+
+fn trace_for_tool(name: &str, input: &Value, status: AgentTraceStatus) -> AgentTraceKind {
+ match name.to_ascii_lowercase().as_str() {
+ "read" | "read_file" => {
+ let path =
+ value_string(input, &["file_path", "path"]).unwrap_or_else(|| "file".to_string());
+ let start = value_optional_u32(input, &["offset", "line_start"]);
+ let count = value_optional_u32(input, &["limit"]);
+ AgentTraceKind::FileRead {
+ path: safe_provider_text(&path, MAX_AGENT_TRACE_FIELD_BYTES),
+ status,
+ line_start: start,
+ line_end: start
+ .zip(count)
+ .map(|(start, count)| start.saturating_add(count)),
+ }
+ }
+ "bash" | "shell" | "exec_command" => {
+ let command =
+ value_string(input, &["command", "cmd"]).unwrap_or_else(|| "command".to_string());
+ let command = safe_provider_text(&command, MAX_AGENT_TRACE_FIELD_BYTES);
+ if looks_like_test(&command) {
+ AgentTraceKind::Test {
+ name: command,
+ status,
+ summary: None,
+ }
+ } else {
+ AgentTraceKind::Command {
+ command,
+ status,
+ exit_code: None,
+ summary: None,
+ }
+ }
+ }
+ "edit" | "write" | "apply_patch" => {
+ let path =
+ value_string(input, &["file_path", "path"]).unwrap_or_else(|| "file".to_string());
+ AgentTraceKind::Diff {
+ path: safe_provider_text(&path, MAX_AGENT_TRACE_FIELD_BYTES),
+ status,
+ additions: 0,
+ deletions: 0,
+ preview: value_string(input, &["patch", "diff"])
+ .map(|text| safe_provider_text(&text, MAX_AGENT_TRACE_FIELD_BYTES)),
+ }
+ }
+ _ => AgentTraceKind::Tool {
+ name: safe_provider_text(name, MAX_AGENT_ACTIVITY_BYTES),
+ status,
+ summary: None,
+ },
+ }
+}
+
+fn trace_update(detail: AgentTraceKind, correlation_id: Option<&str>) -> AgentStreamEvent {
+ AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: correlation_id.map(|id| safe_provider_text(id, MAX_AGENT_SESSION_BYTES)),
+ detail,
+ })
+}
+
+fn trace_status(value: &Value) -> Option {
+ match value.get("status").and_then(Value::as_str)? {
+ "pending" => Some(AgentTraceStatus::Pending),
+ "in_progress" | "running" | "started" => Some(AgentTraceStatus::Running),
+ "completed" | "success" | "succeeded" => Some(AgentTraceStatus::Succeeded),
+ "failed" | "error" => Some(AgentTraceStatus::Failed),
+ "cancelled" | "canceled" => Some(AgentTraceStatus::Cancelled),
+ _ => Some(AgentTraceStatus::Unknown),
+ }
+}
+
+fn value_string(value: &Value, keys: &[&str]) -> Option {
+ keys.iter()
+ .find_map(|key| value.get(*key).and_then(text_from_value))
+}
+
+fn value_optional_u32(value: &Value, keys: &[&str]) -> Option {
+ keys.iter().find_map(|key| {
+ value
+ .get(*key)
+ .and_then(Value::as_u64)
+ .and_then(|number| u32::try_from(number).ok())
+ })
+}
+
+fn value_u32(value: &Value, keys: &[&str]) -> u32 {
+ value_optional_u32(value, keys).unwrap_or(0)
+}
+
+fn looks_like_test(command: &str) -> bool {
+ let command = command.trim_start();
+ [
+ "cargo test",
+ "cargo nextest",
+ "npm test",
+ "npm run test",
+ "pnpm test",
+ "yarn test",
+ "pytest",
+ "go test",
+ ]
+ .iter()
+ .any(|prefix| command.starts_with(prefix))
+}
+
+fn safe_visible_text(text: &str) -> String {
+ safe_provider_text(text, MAX_AGENT_STREAM_TEXT_BYTES)
+}
+
+fn safe_provider_text(text: &str, max_bytes: usize) -> String {
+ let redacted = redact_credentials(text);
+ truncate_utf8(&redacted, max_bytes)
+}
+
+fn redact_credentials(text: &str) -> String {
+ let mut redact_next = false;
+ text.split_inclusive(char::is_whitespace)
+ .map(|part| {
+ let trimmed = part.trim_end_matches(char::is_whitespace);
+ let suffix = &part[trimmed.len()..];
+ if redact_next && !trimmed.is_empty() {
+ redact_next = false;
+ return format!("[redacted]{suffix}");
+ }
+ if trimmed.eq_ignore_ascii_case("bearer") {
+ redact_next = true;
+ return part.to_string();
+ }
+ if ["sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-"]
+ .iter()
+ .any(|prefix| trimmed.starts_with(prefix) && trimmed.len() > prefix.len() + 4)
+ {
+ return format!("[redacted]{suffix}");
+ }
+ if let Some((key, _)) = trimmed.split_once('=') {
+ let key_upper = key.to_ascii_uppercase();
+ if [
+ "TOKEN",
+ "SECRET",
+ "PASSWORD",
+ "PASSWD",
+ "API_KEY",
+ "PRIVATE_KEY",
+ ]
+ .iter()
+ .any(|sensitive| key_upper.contains(sensitive))
+ {
+ return format!("{key}=[redacted]{suffix}");
+ }
+ }
+ part.to_string()
+ })
+ .collect()
+}
+
+fn truncate_utf8(text: &str, max_bytes: usize) -> String {
+ if text.len() <= max_bytes {
+ return text.to_string();
+ }
+ let suffix = "…";
+ if max_bytes < suffix.len() {
+ return String::new();
+ }
+ let mut end = max_bytes.saturating_sub(suffix.len()).min(text.len());
+ while !text.is_char_boundary(end) {
+ end = end.saturating_sub(1);
+ }
+ format!("{}{suffix}", &text[..end])
+}
+
fn session_events(value: &Value) -> Vec {
["session_id", "sessionId", "thread_id", "threadId"]
.into_iter()
@@ -746,6 +1333,83 @@ fn humanize_kind(kind: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
+ use crate::acp::AcpPermissionBroker as _;
+ use agent_client_protocol::schema::v1::{
+ ContentBlock, ContentChunk, PermissionOption, PermissionOptionKind, SessionNotification,
+ SessionUpdate, TextContent, ToolCall, ToolCallStatus,
+ };
+
+ // SDTEST-1906 — SDUC-475, SDUC-480
+ #[test]
+ fn sdtest_1906_automonique_acp_updates_and_permissions_keep_typed_boundaries() {
+ let message = SessionNotification::new(
+ "acp-session-1",
+ SessionUpdate::AgentMessageChunk(ContentChunk::new(ContentBlock::Text(
+ TextContent::new("working"),
+ ))),
+ );
+ assert_eq!(
+ acp_notification_events(&message),
+ vec![
+ AgentStreamEvent::Session("acp-session-1".to_string()),
+ AgentStreamEvent::TextDelta("working".to_string()),
+ ]
+ );
+
+ let tool = SessionNotification::new(
+ "acp-session-1",
+ SessionUpdate::ToolCall(
+ ToolCall::new("tool-1", "Read workspace")
+ .status(ToolCallStatus::InProgress)
+ .raw_input(serde_json::json!({"token":"must-not-render"})),
+ ),
+ );
+ let events = acp_notification_events(&tool);
+ assert!(matches!(
+ &events[1],
+ AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: Some(id),
+ detail: AgentTraceKind::Tool { name, status: AgentTraceStatus::Running, .. },
+ }) if id == "tool-1" && name == "Read workspace"
+ ));
+ assert!(!format!("{events:?}").contains("must-not-render"));
+
+ let permission = RequestPermissionRequest::new(
+ "acp-session-1",
+ agent_client_protocol::schema::v1::ToolCallUpdate::new(
+ "tool-1",
+ agent_client_protocol::schema::v1::ToolCallUpdateFields::new(),
+ ),
+ vec![
+ PermissionOption::new("reject", "Reject", PermissionOptionKind::RejectOnce),
+ PermissionOption::new("allow-once", "Allow once", PermissionOptionKind::AllowOnce),
+ PermissionOption::new("allow-always", "Always", PermissionOptionKind::AllowAlways),
+ ],
+ );
+ assert!(AcpAgentPermissionBroker::new(AgentAccessMode::ReadOnly)
+ .select(&permission)
+ .is_none());
+ assert_eq!(
+ AcpAgentPermissionBroker::new(AgentAccessMode::WorkspaceWrite)
+ .select(&permission)
+ .map(|id| id.0.to_string()),
+ Some("allow-once".to_string())
+ );
+
+ let serialized = serde_json::to_string(&AgentProvider::Automonique).unwrap();
+ assert_eq!(serialized, "\"automonique\"");
+ assert!(AgentCommandSpec::for_request(&AgentRunRequest::new(
+ AgentProvider::Automonique,
+ AgentTarget::Local,
+ AgentAccessMode::ReadOnly,
+ std::env::temp_dir().display().to_string(),
+ None,
+ "hello",
+ ))
+ .unwrap_err()
+ .to_string()
+ .contains("ACP transport"));
+ }
#[cfg(not(windows))]
const LOCAL_TEST_WORKDIR: &str = "/srv/project";
@@ -941,6 +1605,99 @@ mod tests {
);
}
+ // SDTEST-1880 — SDUC-499
+ #[test]
+ fn sdtest_1880_provider_tools_become_bounded_redacted_structured_trace() {
+ assert_eq!(
+ parse_stream_line(
+ AgentProvider::Claude,
+ r#"{"type":"assistant","message":{"content":[{"type":"tool_use","name":"Read","input":{"file_path":"src/lib.rs","offset":10,"limit":20}},{"type":"tool_use","name":"Bash","input":{"command":"cargo test API_TOKEN=secret"}}]}}"#,
+ ),
+ vec![
+ trace_update(
+ AgentTraceKind::FileRead {
+ path: "src/lib.rs".to_string(),
+ status: AgentTraceStatus::Running,
+ line_start: Some(10),
+ line_end: Some(30),
+ },
+ None,
+ ),
+ trace_update(
+ AgentTraceKind::Test {
+ name: "cargo test API_TOKEN=[redacted]".to_string(),
+ status: AgentTraceStatus::Running,
+ summary: None,
+ },
+ None,
+ ),
+ ]
+ );
+ assert_eq!(
+ parse_stream_line(
+ AgentProvider::Codex,
+ r#"{"type":"item.completed","item":{"type":"command_execution","command":"cargo test","status":"failed","exit_code":101,"aggregated_output":"sk-secret-token"}}"#,
+ ),
+ vec![trace_update(
+ AgentTraceKind::Test {
+ name: "cargo test".to_string(),
+ status: AgentTraceStatus::Failed,
+ summary: Some("[redacted]".to_string()),
+ },
+ None,
+ )]
+ );
+ assert_eq!(
+ parse_stream_line(AgentProvider::Codex, r#"{"broken": "TOKEN=secret""#),
+ vec![AgentStreamEvent::Activity(
+ "Malformed provider event".to_string()
+ )]
+ );
+ let oversized = "x".repeat(MAX_AGENT_STREAM_LINE_BYTES + 1);
+ assert_eq!(
+ parse_stream_line(AgentProvider::Jcode, &oversized),
+ vec![AgentStreamEvent::Activity(
+ "Provider event omitted (too large)".to_string()
+ )]
+ );
+ }
+
+ // SDTEST-1896 — SDUC-499
+ #[test]
+ fn sdtest_1896_stream_framer_discards_one_oversized_line_and_recovers() {
+ let mut framer = AgentStreamFramer::default();
+ let chunk = vec![b'x'; 64 * 1024];
+ let mut frames = Vec::new();
+ for _ in 0..6 {
+ frames.extend(framer.push(&chunk));
+ assert!(framer.buffered_bytes() <= MAX_AGENT_STREAM_LINE_BYTES);
+ }
+ assert_eq!(
+ frames
+ .iter()
+ .filter(|frame| **frame == AgentStreamFrame::Oversized)
+ .count(),
+ 1
+ );
+ assert_eq!(framer.buffered_bytes(), 0);
+
+ assert_eq!(
+ framer.push(b"discarded remainder\n{\"type\":\"content.delta\",\"delta\":\"ok\"}\n"),
+ vec![AgentStreamFrame::Line(
+ "{\"type\":\"content.delta\",\"delta\":\"ok\"}".to_string()
+ )]
+ );
+ assert!(framer.finish().is_empty());
+
+ let exact = vec![b'y'; MAX_AGENT_STREAM_LINE_BYTES];
+ assert!(framer.push(&exact).is_empty());
+ assert_eq!(framer.buffered_bytes(), MAX_AGENT_STREAM_LINE_BYTES);
+ let admitted = framer.push(b"\n");
+ assert!(
+ matches!(admitted.as_slice(), [AgentStreamFrame::Line(line)] if line.len() == MAX_AGENT_STREAM_LINE_BYTES)
+ );
+ }
+
// SDTEST-1680 — SDUC-475
#[cfg(unix)]
#[test]
@@ -985,6 +1742,80 @@ mod tests {
fs::remove_dir_all(&root).unwrap();
}
+ // SDTEST-1904 — SDUC-475
+ #[cfg(unix)]
+ #[test]
+ fn sdtest_1904_ssh_home_workdir_expands_once_and_never_becomes_literal_tilde() {
+ use std::fs;
+ use std::os::unix::fs::PermissionsExt as _;
+
+ let root = std::env::temp_dir().join(format!("shelldeck-agent-home-{}", Uuid::new_v4()));
+ let home = root.join("remote home");
+ fs::create_dir_all(&home).unwrap();
+ let capture = root.join("cwd.txt");
+ let fake = root.join("fake-agent");
+ fs::write(
+ &fake,
+ format!(
+ "#!/bin/sh\npwd > {}\n",
+ shell_quote(&capture.to_string_lossy())
+ ),
+ )
+ .unwrap();
+ let mut permissions = fs::metadata(&fake).unwrap().permissions();
+ permissions.set_mode(0o700);
+ fs::set_permissions(&fake, permissions).unwrap();
+
+ let target = AgentTarget::Ssh {
+ connection_id: Uuid::nil(),
+ connection_label: "remote".to_string(),
+ };
+ let request = AgentRunRequest::new(
+ AgentProvider::Jcode,
+ target.clone(),
+ AgentAccessMode::ReadOnly,
+ AGENT_REMOTE_HOME_WORKDIR,
+ None,
+ "inspect",
+ );
+ request.validate().unwrap();
+ let jcode = AgentCommandSpec::for_request(&request).unwrap();
+ assert!(jcode.args.windows(2).any(|pair| pair == ["-C", "."]));
+
+ let invalid = AgentRunRequest::new(
+ AgentProvider::Claude,
+ target,
+ AgentAccessMode::ReadOnly,
+ "~/project",
+ None,
+ "inspect",
+ );
+ assert!(invalid.validate().is_err());
+
+ let spec = AgentCommandSpec {
+ program: fake.display().to_string(),
+ args: Vec::new(),
+ cwd: PathBuf::from(AGENT_REMOTE_HOME_WORKDIR),
+ stdin: None,
+ remove_env: Vec::new(),
+ };
+ let command = spec.remote_shell_command();
+ assert!(command.contains("cd -- \"$HOME\""));
+ assert!(!command.contains("cd -- '~'"));
+ let status = Command::new("sh")
+ .arg("-c")
+ .arg(command)
+ .env("HOME", &home)
+ .status()
+ .unwrap();
+ assert!(status.success());
+ assert_eq!(
+ fs::canonicalize(fs::read_to_string(&capture).unwrap().trim()).unwrap(),
+ fs::canonicalize(&home).unwrap()
+ );
+ fs::remove_dir_all(&root).unwrap();
+ }
+
// SDTEST-1771 — SDUC-491
#[cfg(windows)]
#[test]
diff --git a/crates/shelldeck-core/src/agent_session.rs b/crates/shelldeck-core/src/agent_session.rs
new file mode 100644
index 00000000..71c4c520
--- /dev/null
+++ b/crates/shelldeck-core/src/agent_session.rs
@@ -0,0 +1,1161 @@
+//! Durable, provider-neutral state for the Dev Agents cockpit.
+//!
+//! Process handles remain in the UI/runtime layer. This module owns the
+//! bounded state that may be rendered, retained while panes switch, and
+//! serialized without persisting provider resume identifiers.
+
+use serde::{Deserialize, Serialize};
+use uuid::Uuid;
+
+use crate::agent_runtime::{
+ AgentAccessMode, AgentProvider, AgentRunRequest, AgentStreamEvent, AgentTarget,
+};
+use crate::{Result, ShellDeckError};
+
+pub const DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS: usize = 4;
+pub const MAX_CONCURRENT_AGENT_SESSIONS: usize = 8;
+pub const MAX_AGENT_SESSIONS: usize = 64;
+pub const MAX_AGENT_SESSION_NAME_BYTES: usize = 160;
+pub const MAX_AGENT_MESSAGES_PER_SESSION: usize = 256;
+pub const MAX_AGENT_TRACE_EVENTS_PER_SESSION: usize = 512;
+pub const MAX_AGENT_MESSAGE_BYTES: usize = 128 * 1024;
+pub const MAX_AGENT_TRACE_STRING_BYTES: usize = 8 * 1024;
+pub const MAX_AGENT_TRACE_CORRELATION_BYTES: usize = 256;
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentExecutionContext {
+ pub provider: AgentProvider,
+ pub target: AgentTarget,
+ pub access: AgentAccessMode,
+ pub workdir: String,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub model: Option,
+}
+
+impl AgentExecutionContext {
+ pub fn from_request(request: &AgentRunRequest) -> Self {
+ Self {
+ provider: request.provider,
+ target: request.target.clone(),
+ access: request.access,
+ workdir: request.workdir.trim().to_string(),
+ model: request
+ .model
+ .as_deref()
+ .map(str::trim)
+ .filter(|model| !model.is_empty())
+ .map(str::to_string),
+ }
+ }
+
+ pub fn run_request(&self, prompt: impl Into) -> AgentRunRequest {
+ AgentRunRequest::new(
+ self.provider,
+ self.target.clone(),
+ self.access,
+ self.workdir.clone(),
+ self.model.clone(),
+ prompt,
+ )
+ }
+
+ fn normalized(mut self) -> Result {
+ self.workdir = self.workdir.trim().to_string();
+ self.model = self
+ .model
+ .take()
+ .map(|model| model.trim().to_string())
+ .filter(|model| !model.is_empty());
+ self.run_request("validate execution context").validate()?;
+ Ok(self)
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentSessionStatus {
+ #[default]
+ Idle,
+ Starting,
+ Running,
+ Stopping,
+ Completed,
+ Failed,
+ Cancelled,
+}
+
+impl AgentSessionStatus {
+ pub fn is_active(self) -> bool {
+ matches!(self, Self::Starting | Self::Running | Self::Stopping)
+ }
+
+ pub fn is_terminal(self) -> bool {
+ matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
+ }
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentSessionAttention {
+ #[default]
+ None,
+ Unread,
+ NeedsAttention,
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentMessageRole {
+ User,
+ Agent,
+ Error,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentMessage {
+ pub id: Uuid,
+ #[serde(default)]
+ pub sequence: u64,
+ pub role: AgentMessageRole,
+ pub text: String,
+ pub at_ms: i64,
+}
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum AgentTraceStatus {
+ Pending,
+ Running,
+ Succeeded,
+ Failed,
+ Cancelled,
+ #[default]
+ Unknown,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case")]
+pub enum AgentTraceKind {
+ Command {
+ command: String,
+ status: AgentTraceStatus,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ exit_code: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ summary: Option,
+ },
+ FileRead {
+ path: String,
+ #[serde(default)]
+ status: AgentTraceStatus,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ line_start: Option,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ line_end: Option,
+ },
+ Diff {
+ path: String,
+ #[serde(default)]
+ status: AgentTraceStatus,
+ additions: u32,
+ deletions: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ preview: Option,
+ },
+ Test {
+ name: String,
+ status: AgentTraceStatus,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ summary: Option,
+ },
+ Tool {
+ name: String,
+ status: AgentTraceStatus,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ summary: Option,
+ },
+ Activity {
+ label: String,
+ },
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentTraceUpdate {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub correlation_id: Option,
+ pub detail: AgentTraceKind,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentTraceEvent {
+ pub id: Uuid,
+ #[serde(default)]
+ pub sequence: u64,
+ pub at_ms: i64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub correlation_id: Option,
+ pub detail: AgentTraceKind,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentSession {
+ pub id: Uuid,
+ pub name: String,
+ pub context: AgentExecutionContext,
+ pub status: AgentSessionStatus,
+ pub created_at_ms: i64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub started_at_ms: Option,
+ pub updated_at_ms: i64,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub finished_at_ms: Option,
+ #[serde(default)]
+ pub unread_count: u32,
+ #[serde(default)]
+ pub attention: AgentSessionAttention,
+ #[serde(default)]
+ pub messages: Vec,
+ #[serde(default)]
+ pub trace: Vec,
+ #[serde(default = "initial_sequence")]
+ next_sequence: u64,
+ /// A provider conversation id is scoped to this live application process.
+ /// It is deliberately excluded from durable session state.
+ #[serde(skip)]
+ provider_session_id: Option,
+ #[serde(skip)]
+ provider_session_context: Option,
+}
+
+impl AgentSession {
+ pub fn new(
+ name: impl Into,
+ context: AgentExecutionContext,
+ now_ms: i64,
+ ) -> Result {
+ let name = normalized_name(name.into())?;
+ let context = context.normalized()?;
+ Ok(Self {
+ id: Uuid::new_v4(),
+ name,
+ context,
+ status: AgentSessionStatus::Idle,
+ created_at_ms: now_ms,
+ started_at_ms: None,
+ updated_at_ms: now_ms,
+ finished_at_ms: None,
+ unread_count: 0,
+ attention: AgentSessionAttention::None,
+ messages: Vec::new(),
+ trace: Vec::new(),
+ next_sequence: initial_sequence(),
+ provider_session_id: None,
+ provider_session_context: None,
+ })
+ }
+
+ pub fn provider_session_id(&self) -> Option<&str> {
+ self.provider_session_id.as_deref()
+ }
+
+ pub fn rename(&mut self, name: impl Into, now_ms: i64) -> Result<()> {
+ self.name = normalized_name(name.into())?;
+ self.updated_at_ms = now_ms;
+ Ok(())
+ }
+
+ /// Replace the execution context between runs. Resume authority is cleared
+ /// whenever any provider, target, access, workdir, or model field changes.
+ pub fn set_context(&mut self, context: AgentExecutionContext, now_ms: i64) -> Result {
+ if self.status.is_active() {
+ return Err(ShellDeckError::Config(
+ "stop the agent session before changing its execution context".to_string(),
+ ));
+ }
+ let context = context.normalized()?;
+ if context == self.context {
+ return Ok(false);
+ }
+ self.context = context;
+ self.provider_session_id = None;
+ self.provider_session_context = None;
+ self.updated_at_ms = now_ms;
+ Ok(true)
+ }
+
+ pub fn run_request(&self, prompt: impl Into) -> AgentRunRequest {
+ let resume_session = self
+ .provider_session_context
+ .as_ref()
+ .filter(|context| *context == &self.context)
+ .and(self.provider_session_id.clone());
+ self.context
+ .run_request(prompt)
+ .with_resume_session(resume_session)
+ }
+
+ pub fn begin_run(&mut self, prompt: impl Into, now_ms: i64) -> Result {
+ if self.status.is_active() {
+ return Err(ShellDeckError::Config(
+ "agent session is already running".to_string(),
+ ));
+ }
+ let prompt = prompt.into();
+ let request = self.run_request(prompt.clone());
+ request.validate()?;
+ self.status = AgentSessionStatus::Starting;
+ self.started_at_ms = Some(now_ms);
+ self.updated_at_ms = now_ms;
+ self.finished_at_ms = None;
+ self.push_message(AgentMessageRole::User, prompt, now_ms);
+ Ok(request)
+ }
+
+ pub fn request_stop(&mut self, now_ms: i64) -> bool {
+ if matches!(
+ self.status,
+ AgentSessionStatus::Starting | AgentSessionStatus::Running
+ ) {
+ self.status = AgentSessionStatus::Stopping;
+ self.updated_at_ms = now_ms;
+ true
+ } else {
+ false
+ }
+ }
+
+ pub fn apply_stream_event(&mut self, event: AgentStreamEvent, now_ms: i64, visible: bool) {
+ let mut user_visible = true;
+ match event {
+ AgentStreamEvent::Text(text) => self.push_message(
+ AgentMessageRole::Agent,
+ sanitize_retained_output(&text, MAX_AGENT_MESSAGE_BYTES),
+ now_ms,
+ ),
+ AgentStreamEvent::TextDelta(delta) => self.push_text_delta(
+ sanitize_retained_output(&delta, MAX_AGENT_MESSAGE_BYTES),
+ now_ms,
+ ),
+ AgentStreamEvent::Session(session_id) => {
+ self.provider_session_id = Some(session_id);
+ self.provider_session_context = Some(self.context.clone());
+ user_visible = false;
+ }
+ AgentStreamEvent::Ready => {
+ self.status = AgentSessionStatus::Running;
+ user_visible = false;
+ }
+ AgentStreamEvent::Trace(update) => {
+ user_visible = self.upsert_trace(update, now_ms);
+ }
+ AgentStreamEvent::TraceStatus {
+ correlation_id,
+ status,
+ summary,
+ } => {
+ user_visible = false;
+ self.update_trace_status(&correlation_id, status, summary, now_ms);
+ }
+ AgentStreamEvent::Activity(label) => {
+ self.upsert_trace(
+ AgentTraceUpdate {
+ correlation_id: None,
+ detail: AgentTraceKind::Activity { label },
+ },
+ now_ms,
+ );
+ }
+ AgentStreamEvent::Error(error) => {
+ self.push_message(
+ AgentMessageRole::Error,
+ sanitize_retained_output(&error, MAX_AGENT_MESSAGE_BYTES),
+ now_ms,
+ );
+ self.attention = AgentSessionAttention::NeedsAttention;
+ }
+ }
+ self.updated_at_ms = now_ms;
+ if user_visible && !visible {
+ self.unread_count = self.unread_count.saturating_add(1);
+ if self.attention == AgentSessionAttention::None {
+ self.attention = AgentSessionAttention::Unread;
+ }
+ }
+ }
+
+ pub fn finish(&mut self, status: AgentSessionStatus, now_ms: i64, visible: bool) -> Result<()> {
+ if !matches!(
+ status,
+ AgentSessionStatus::Completed
+ | AgentSessionStatus::Failed
+ | AgentSessionStatus::Cancelled
+ ) {
+ return Err(ShellDeckError::Config(
+ "agent run must finish with a terminal status".to_string(),
+ ));
+ }
+ self.status = status;
+ self.updated_at_ms = now_ms;
+ self.finished_at_ms = Some(now_ms);
+ if !visible {
+ self.unread_count = self.unread_count.saturating_add(1);
+ if status == AgentSessionStatus::Failed {
+ self.attention = AgentSessionAttention::NeedsAttention;
+ } else if self.attention == AgentSessionAttention::None {
+ self.attention = AgentSessionAttention::Unread;
+ }
+ } else if status == AgentSessionStatus::Failed {
+ self.attention = AgentSessionAttention::NeedsAttention;
+ }
+ Ok(())
+ }
+
+ pub fn mark_read(&mut self) {
+ self.unread_count = 0;
+ if self.attention == AgentSessionAttention::Unread {
+ self.attention = AgentSessionAttention::None;
+ }
+ }
+
+ pub fn acknowledge_attention(&mut self) {
+ self.unread_count = 0;
+ self.attention = AgentSessionAttention::None;
+ }
+
+ fn push_message(&mut self, role: AgentMessageRole, text: String, now_ms: i64) {
+ let sequence = self.allocate_sequence();
+ push_bounded(
+ &mut self.messages,
+ AgentMessage {
+ id: Uuid::new_v4(),
+ sequence,
+ role,
+ text: truncate_utf8(&text, MAX_AGENT_MESSAGE_BYTES),
+ at_ms: now_ms,
+ },
+ MAX_AGENT_MESSAGES_PER_SESSION,
+ );
+ }
+
+ fn push_text_delta(&mut self, delta: String, now_ms: i64) {
+ if let Some(message) = self
+ .messages
+ .last_mut()
+ .filter(|message| message.role == AgentMessageRole::Agent)
+ {
+ let remaining = MAX_AGENT_MESSAGE_BYTES.saturating_sub(message.text.len());
+ if remaining > 0 {
+ message.text.push_str(&truncate_utf8(&delta, remaining));
+ }
+ } else {
+ self.push_message(AgentMessageRole::Agent, delta, now_ms);
+ }
+ }
+
+ fn upsert_trace(&mut self, update: AgentTraceUpdate, now_ms: i64) -> bool {
+ let detail = bounded_trace(update.detail);
+ let correlation_id = update
+ .correlation_id
+ .map(|id| truncate_utf8(&id, MAX_AGENT_TRACE_CORRELATION_BYTES));
+ if let Some(existing) = correlation_id.as_deref().and_then(|id| {
+ self.trace
+ .iter_mut()
+ .rev()
+ .find(|event| event.correlation_id.as_deref() == Some(id))
+ }) {
+ existing.detail = detail;
+ return false;
+ }
+ if matches!(&detail, AgentTraceKind::Activity { label } if self.trace.last().is_some_and(|last| matches!(&last.detail, AgentTraceKind::Activity { label: previous } if previous == label)))
+ {
+ return false;
+ }
+ let sequence = self.allocate_sequence();
+ push_bounded(
+ &mut self.trace,
+ AgentTraceEvent {
+ id: Uuid::new_v4(),
+ sequence,
+ at_ms: now_ms,
+ correlation_id,
+ detail,
+ },
+ MAX_AGENT_TRACE_EVENTS_PER_SESSION,
+ );
+ true
+ }
+
+ fn update_trace_status(
+ &mut self,
+ correlation_id: &str,
+ status: AgentTraceStatus,
+ summary: Option,
+ _now_ms: i64,
+ ) {
+ let correlation_id = truncate_utf8(correlation_id, MAX_AGENT_TRACE_CORRELATION_BYTES);
+ let Some(event) = self
+ .trace
+ .iter_mut()
+ .rev()
+ .find(|event| event.correlation_id.as_deref() == Some(correlation_id.as_str()))
+ else {
+ return;
+ };
+ set_trace_status(&mut event.detail, status);
+ if let Some(summary) = summary {
+ set_trace_summary(
+ &mut event.detail,
+ sanitize_retained_output(&summary, MAX_AGENT_TRACE_STRING_BYTES),
+ );
+ }
+ }
+
+ fn allocate_sequence(&mut self) -> u64 {
+ let retained_max = self
+ .messages
+ .last()
+ .map(|message| message.sequence)
+ .into_iter()
+ .chain(self.trace.last().map(|trace| trace.sequence))
+ .max()
+ .unwrap_or(0);
+ let sequence = self.next_sequence.max(retained_max.saturating_add(1));
+ self.next_sequence = sequence.saturating_add(1);
+ sequence
+ }
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
+pub struct AgentSessionCollection {
+ #[serde(default)]
+ sessions: Vec,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ selected_id: Option,
+ max_concurrent: usize,
+ #[serde(skip)]
+ surface_visible: bool,
+}
+
+impl Default for AgentSessionCollection {
+ fn default() -> Self {
+ Self::new(DEFAULT_MAX_CONCURRENT_AGENT_SESSIONS)
+ .expect("the default agent concurrency limit is valid")
+ }
+}
+
+impl AgentSessionCollection {
+ pub fn new(max_concurrent: usize) -> Result {
+ if !(1..=MAX_CONCURRENT_AGENT_SESSIONS).contains(&max_concurrent) {
+ return Err(ShellDeckError::Config(format!(
+ "agent concurrency limit must be between 1 and {MAX_CONCURRENT_AGENT_SESSIONS}"
+ )));
+ }
+ Ok(Self {
+ sessions: Vec::new(),
+ selected_id: None,
+ max_concurrent,
+ surface_visible: false,
+ })
+ }
+
+ pub fn sessions(&self) -> &[AgentSession] {
+ &self.sessions
+ }
+
+ pub fn selected_id(&self) -> Option {
+ self.selected_id
+ }
+
+ pub fn selected(&self) -> Option<&AgentSession> {
+ let id = self.selected_id?;
+ self.get(id)
+ }
+
+ pub fn get(&self, id: Uuid) -> Option<&AgentSession> {
+ self.sessions.iter().find(|session| session.id == id)
+ }
+
+ pub fn get_mut(&mut self, id: Uuid) -> Option<&mut AgentSession> {
+ self.sessions.iter_mut().find(|session| session.id == id)
+ }
+
+ pub fn active_count(&self) -> usize {
+ self.sessions
+ .iter()
+ .filter(|session| session.status.is_active())
+ .count()
+ }
+
+ pub fn has_capacity(&self) -> bool {
+ self.active_count() < self.max_concurrent
+ }
+
+ pub fn create(
+ &mut self,
+ name: impl Into,
+ context: AgentExecutionContext,
+ now_ms: i64,
+ ) -> Result {
+ if self.sessions.len() >= MAX_AGENT_SESSIONS {
+ return Err(ShellDeckError::Config(format!(
+ "at most {MAX_AGENT_SESSIONS} agent sessions may be retained"
+ )));
+ }
+ let session = AgentSession::new(name, context, now_ms)?;
+ let id = session.id;
+ self.sessions.push(session);
+ self.select(id)?;
+ Ok(id)
+ }
+
+ pub fn select(&mut self, id: Uuid) -> Result<()> {
+ if self.get(id).is_none() {
+ return Err(ShellDeckError::Config(
+ "agent session no longer exists".to_string(),
+ ));
+ }
+ self.selected_id = Some(id);
+ if self.surface_visible {
+ self.get_mut(id)
+ .expect("session existence was checked")
+ .mark_read();
+ }
+ Ok(())
+ }
+
+ /// Set whether the selected session is actually presented on screen.
+ /// Selection is navigation identity; only visibility consumes unread state.
+ pub fn set_surface_visible(&mut self, visible: bool) -> bool {
+ if self.surface_visible == visible {
+ return false;
+ }
+ self.surface_visible = visible;
+ if visible {
+ if let Some(id) = self.selected_id {
+ if let Some(session) = self.get_mut(id) {
+ session.mark_read();
+ }
+ }
+ }
+ true
+ }
+
+ pub fn surface_visible(&self) -> bool {
+ self.surface_visible
+ }
+
+ pub fn begin_run(
+ &mut self,
+ id: Uuid,
+ prompt: impl Into,
+ now_ms: i64,
+ ) -> Result {
+ let already_active = self
+ .get(id)
+ .ok_or_else(|| ShellDeckError::Config("agent session no longer exists".to_string()))?
+ .status
+ .is_active();
+ if !already_active && !self.has_capacity() {
+ return Err(ShellDeckError::Config(format!(
+ "at most {} agent sessions may run concurrently",
+ self.max_concurrent
+ )));
+ }
+ self.get_mut(id)
+ .expect("session existence was checked")
+ .begin_run(prompt, now_ms)
+ }
+
+ pub fn apply_stream_event(
+ &mut self,
+ id: Uuid,
+ event: AgentStreamEvent,
+ now_ms: i64,
+ ) -> Result<()> {
+ let visible = self.surface_visible && self.selected_id == Some(id);
+ self.get_mut(id)
+ .ok_or_else(|| ShellDeckError::Config("agent session no longer exists".to_string()))?
+ .apply_stream_event(event, now_ms, visible);
+ Ok(())
+ }
+
+ pub fn finish(&mut self, id: Uuid, status: AgentSessionStatus, now_ms: i64) -> Result<()> {
+ let visible = self.surface_visible && self.selected_id == Some(id);
+ self.get_mut(id)
+ .ok_or_else(|| ShellDeckError::Config("agent session no longer exists".to_string()))?
+ .finish(status, now_ms, visible)
+ }
+
+ pub fn remove(&mut self, id: Uuid) -> Result {
+ let index = self
+ .sessions
+ .iter()
+ .position(|session| session.id == id)
+ .ok_or_else(|| ShellDeckError::Config("agent session no longer exists".to_string()))?;
+ if self.sessions[index].status.is_active() {
+ return Err(ShellDeckError::Config(
+ "stop the agent session before removing it".to_string(),
+ ));
+ }
+ let removed = self.sessions.remove(index);
+ if self.selected_id == Some(id) {
+ self.selected_id = self
+ .sessions
+ .get(index)
+ .or_else(|| self.sessions.last())
+ .map(|s| s.id);
+ if self.surface_visible {
+ if let Some(selected_id) = self.selected_id {
+ if let Some(session) = self.get_mut(selected_id) {
+ session.mark_read();
+ }
+ }
+ }
+ }
+ Ok(removed)
+ }
+
+ /// An active process cannot survive application restart. This explicit
+ /// recovery step makes stale durable state presentable but never claims
+ /// that work is still running.
+ pub fn recover_interrupted(&mut self, now_ms: i64) {
+ for session in &mut self.sessions {
+ if session.status.is_active() {
+ session.status = AgentSessionStatus::Failed;
+ session.updated_at_ms = now_ms;
+ session.finished_at_ms = Some(now_ms);
+ session.attention = AgentSessionAttention::NeedsAttention;
+ session.provider_session_id = None;
+ session.provider_session_context = None;
+ }
+ }
+ }
+}
+
+fn normalized_name(name: String) -> Result {
+ let name = name.trim();
+ if name.is_empty() {
+ return Err(ShellDeckError::Config(
+ "agent session name must not be empty".to_string(),
+ ));
+ }
+ if name.len() > MAX_AGENT_SESSION_NAME_BYTES {
+ return Err(ShellDeckError::Config(
+ "agent session name is too long".to_string(),
+ ));
+ }
+ Ok(name.to_string())
+}
+
+const fn initial_sequence() -> u64 {
+ 1
+}
+
+fn push_bounded(items: &mut Vec, item: T, limit: usize) {
+ if items.len() == limit {
+ items.remove(0);
+ }
+ items.push(item);
+}
+
+fn bounded_trace(detail: AgentTraceKind) -> AgentTraceKind {
+ match detail {
+ AgentTraceKind::Command {
+ command,
+ status,
+ exit_code,
+ summary,
+ } => AgentTraceKind::Command {
+ command: sanitize_retained_output(&command, MAX_AGENT_TRACE_STRING_BYTES),
+ status,
+ exit_code,
+ summary: summary
+ .map(|text| sanitize_retained_output(&text, MAX_AGENT_TRACE_STRING_BYTES)),
+ },
+ AgentTraceKind::FileRead {
+ path,
+ status,
+ line_start,
+ line_end,
+ } => AgentTraceKind::FileRead {
+ path: sanitize_retained_output(&path, MAX_AGENT_TRACE_STRING_BYTES),
+ status,
+ line_start,
+ line_end,
+ },
+ AgentTraceKind::Diff {
+ path,
+ status,
+ additions,
+ deletions,
+ preview,
+ } => AgentTraceKind::Diff {
+ path: sanitize_retained_output(&path, MAX_AGENT_TRACE_STRING_BYTES),
+ status,
+ additions,
+ deletions,
+ preview: preview
+ .map(|text| sanitize_retained_output(&text, MAX_AGENT_TRACE_STRING_BYTES)),
+ },
+ AgentTraceKind::Test {
+ name,
+ status,
+ summary,
+ } => AgentTraceKind::Test {
+ name: sanitize_retained_output(&name, MAX_AGENT_TRACE_STRING_BYTES),
+ status,
+ summary: summary
+ .map(|text| sanitize_retained_output(&text, MAX_AGENT_TRACE_STRING_BYTES)),
+ },
+ AgentTraceKind::Tool {
+ name,
+ status,
+ summary,
+ } => AgentTraceKind::Tool {
+ name: sanitize_retained_output(&name, MAX_AGENT_TRACE_STRING_BYTES),
+ status,
+ summary: summary
+ .map(|text| sanitize_retained_output(&text, MAX_AGENT_TRACE_STRING_BYTES)),
+ },
+ AgentTraceKind::Activity { label } => AgentTraceKind::Activity {
+ label: sanitize_retained_output(&label, MAX_AGENT_TRACE_STRING_BYTES),
+ },
+ }
+}
+
+fn set_trace_status(detail: &mut AgentTraceKind, new_status: AgentTraceStatus) {
+ match detail {
+ AgentTraceKind::Command { status, .. }
+ | AgentTraceKind::FileRead { status, .. }
+ | AgentTraceKind::Diff { status, .. }
+ | AgentTraceKind::Test { status, .. }
+ | AgentTraceKind::Tool { status, .. } => *status = new_status,
+ AgentTraceKind::Activity { .. } => {}
+ }
+}
+
+fn set_trace_summary(detail: &mut AgentTraceKind, new_summary: String) {
+ match detail {
+ AgentTraceKind::Command { summary, .. }
+ | AgentTraceKind::Test { summary, .. }
+ | AgentTraceKind::Tool { summary, .. } => *summary = Some(new_summary),
+ AgentTraceKind::FileRead { .. }
+ | AgentTraceKind::Diff { .. }
+ | AgentTraceKind::Activity { .. } => {}
+ }
+}
+
+fn truncate_utf8(text: &str, max_bytes: usize) -> String {
+ if text.len() <= max_bytes {
+ return text.to_string();
+ }
+ let suffix = "…";
+ if max_bytes < suffix.len() {
+ return String::new();
+ }
+ let mut end = max_bytes.saturating_sub(suffix.len()).min(text.len());
+ while !text.is_char_boundary(end) {
+ end = end.saturating_sub(1);
+ }
+ format!("{}{suffix}", &text[..end])
+}
+
+fn sanitize_retained_output(text: &str, max_bytes: usize) -> String {
+ truncate_utf8(&redact_credentials(text), max_bytes)
+}
+
+fn redact_credentials(text: &str) -> String {
+ let mut redact_next = false;
+ text.split_inclusive(char::is_whitespace)
+ .map(|part| {
+ let trimmed = part.trim_end_matches(char::is_whitespace);
+ let suffix = &part[trimmed.len()..];
+ if redact_next && !trimmed.is_empty() {
+ redact_next = false;
+ return format!("[redacted]{suffix}");
+ }
+ if trimmed.eq_ignore_ascii_case("bearer") {
+ redact_next = true;
+ return part.to_string();
+ }
+ if ["sk-", "ghp_", "github_pat_", "xoxb-", "xoxp-"]
+ .iter()
+ .any(|prefix| trimmed.starts_with(prefix) && trimmed.len() > prefix.len() + 4)
+ {
+ return format!("[redacted]{suffix}");
+ }
+ if let Some((key, _)) = trimmed.split_once('=') {
+ let key_upper = key.to_ascii_uppercase();
+ if [
+ "TOKEN",
+ "SECRET",
+ "PASSWORD",
+ "PASSWD",
+ "API_KEY",
+ "PRIVATE_KEY",
+ ]
+ .iter()
+ .any(|sensitive| key_upper.contains(sensitive))
+ {
+ return format!("{key}=[redacted]{suffix}");
+ }
+ }
+ part.to_string()
+ })
+ .collect()
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[cfg(not(windows))]
+ const TEST_WORKDIR: &str = "/srv/project";
+ #[cfg(windows)]
+ const TEST_WORKDIR: &str = r"C:\srv\project";
+
+ fn context(provider: AgentProvider) -> AgentExecutionContext {
+ AgentExecutionContext {
+ provider,
+ target: AgentTarget::Local,
+ access: AgentAccessMode::WorkspaceWrite,
+ workdir: TEST_WORKDIR.to_string(),
+ model: None,
+ }
+ }
+
+ // SDTEST-1879 — SDUC-499
+ #[test]
+ fn sdtest_1879_collection_runs_bounded_sessions_concurrently_and_tracks_attention() {
+ let mut sessions = AgentSessionCollection::new(2).unwrap();
+ let first = sessions
+ .create("Fix parser", context(AgentProvider::Codex), 10)
+ .unwrap();
+ let second = sessions
+ .create("Add tests", context(AgentProvider::Claude), 11)
+ .unwrap();
+ let waiting = sessions
+ .create("Review", context(AgentProvider::Jcode), 12)
+ .unwrap();
+
+ sessions.begin_run(first, "fix it", 20).unwrap();
+ sessions.begin_run(second, "test it", 21).unwrap();
+ assert_eq!(sessions.active_count(), 2);
+ assert!(sessions.begin_run(waiting, "review it", 22).is_err());
+
+ sessions
+ .apply_stream_event(first, AgentStreamEvent::Ready, 23)
+ .unwrap();
+ sessions
+ .apply_stream_event(
+ first,
+ AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: Some("read-1".to_string()),
+ detail: AgentTraceKind::FileRead {
+ path: "src/lib.rs".to_string(),
+ status: AgentTraceStatus::Running,
+ line_start: Some(1),
+ line_end: Some(20),
+ },
+ }),
+ 24,
+ )
+ .unwrap();
+ sessions
+ .apply_stream_event(
+ first,
+ AgentStreamEvent::TraceStatus {
+ correlation_id: "read-1".to_string(),
+ status: AgentTraceStatus::Succeeded,
+ summary: None,
+ },
+ 25,
+ )
+ .unwrap();
+ sessions
+ .apply_stream_event(first, AgentStreamEvent::Text("Implemented".to_string()), 26)
+ .unwrap();
+ let first_session = sessions.get(first).unwrap();
+ assert_eq!(first_session.status, AgentSessionStatus::Running);
+ assert_eq!(first_session.unread_count, 2);
+ assert_eq!(first_session.attention, AgentSessionAttention::Unread);
+ assert_eq!(first_session.trace.len(), 1);
+ assert!(matches!(
+ first_session.trace[0].detail,
+ AgentTraceKind::FileRead {
+ status: AgentTraceStatus::Succeeded,
+ ..
+ }
+ ));
+
+ sessions.set_surface_visible(true);
+ sessions.select(first).unwrap();
+ assert_eq!(sessions.get(first).unwrap().unread_count, 0);
+ sessions
+ .finish(second, AgentSessionStatus::Failed, 30)
+ .unwrap();
+ assert_eq!(
+ sessions.get(second).unwrap().attention,
+ AgentSessionAttention::NeedsAttention
+ );
+ assert!(sessions.has_capacity());
+ }
+
+ // SDTEST-1881 — SDUC-499
+ #[test]
+ fn sdtest_1881_durable_state_excludes_resume_ids_recovers_runs_and_bounds_streams() {
+ let mut sessions = AgentSessionCollection::default();
+ let id = sessions
+ .create("Bound output", context(AgentProvider::Claude), 10)
+ .unwrap();
+ sessions.begin_run(id, "inspect", 11).unwrap();
+ sessions
+ .apply_stream_event(
+ id,
+ AgentStreamEvent::Session("provider-secret-resume-id".to_string()),
+ 12,
+ )
+ .unwrap();
+ {
+ let session = sessions.get_mut(id).unwrap();
+ session.context.model = Some("changed-model".to_string());
+ assert_eq!(session.run_request("continue").resume_session, None);
+ session.context.model = None;
+ assert_eq!(
+ session.run_request("continue").resume_session.as_deref(),
+ Some("provider-secret-resume-id")
+ );
+ }
+ for _ in 0..3 {
+ sessions
+ .apply_stream_event(
+ id,
+ AgentStreamEvent::TextDelta("é".repeat(MAX_AGENT_MESSAGE_BYTES)),
+ 13,
+ )
+ .unwrap();
+ }
+ sessions
+ .apply_stream_event(
+ id,
+ AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: Some("command-1".to_string()),
+ detail: AgentTraceKind::Command {
+ command: "é".repeat(MAX_AGENT_TRACE_STRING_BYTES),
+ status: AgentTraceStatus::Running,
+ exit_code: None,
+ summary: None,
+ },
+ }),
+ 14,
+ )
+ .unwrap();
+ let session = sessions.get(id).unwrap();
+ assert!(session.messages.last().unwrap().text.len() <= MAX_AGENT_MESSAGE_BYTES);
+ let AgentTraceKind::Command { command, .. } = &session.trace.last().unwrap().detail else {
+ panic!("expected command trace");
+ };
+ assert!(command.len() <= MAX_AGENT_TRACE_STRING_BYTES);
+
+ let json = serde_json::to_string(&sessions).unwrap();
+ assert!(!json.contains("provider-secret-resume-id"));
+ let mut restored: AgentSessionCollection = serde_json::from_str(&json).unwrap();
+ assert_eq!(restored.get(id).unwrap().provider_session_id(), None);
+ restored.recover_interrupted(20);
+ assert_eq!(restored.get(id).unwrap().status, AgentSessionStatus::Failed);
+ assert_eq!(
+ restored.get(id).unwrap().attention,
+ AgentSessionAttention::NeedsAttention
+ );
+ }
+
+ // SDTEST-1895 — SDUC-499
+ #[test]
+ fn sdtest_1895_equal_timestamps_and_incremental_updates_keep_one_monotonic_timeline() {
+ let mut session = AgentSession::new("Sequence", context(AgentProvider::Codex), 10).unwrap();
+ session.begin_run("first", 20).unwrap();
+ session.apply_stream_event(
+ AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: Some("command-1".to_string()),
+ detail: AgentTraceKind::Command {
+ command: "cargo check".to_string(),
+ status: AgentTraceStatus::Running,
+ exit_code: None,
+ summary: None,
+ },
+ }),
+ 20,
+ true,
+ );
+ session.apply_stream_event(AgentStreamEvent::Text("working".to_string()), 20, true);
+ session.apply_stream_event(AgentStreamEvent::TextDelta("…done".to_string()), 30, true);
+ session.apply_stream_event(
+ AgentStreamEvent::Trace(AgentTraceUpdate {
+ correlation_id: Some("command-1".to_string()),
+ detail: AgentTraceKind::Command {
+ command: "cargo check".to_string(),
+ status: AgentTraceStatus::Succeeded,
+ exit_code: Some(0),
+ summary: None,
+ },
+ }),
+ 40,
+ true,
+ );
+
+ let mut timeline = session
+ .messages
+ .iter()
+ .map(|message| (message.sequence, "message", message.at_ms))
+ .chain(
+ session
+ .trace
+ .iter()
+ .map(|trace| (trace.sequence, "trace", trace.at_ms)),
+ )
+ .collect::>();
+ timeline.sort_by_key(|item| item.0);
+ assert_eq!(
+ timeline,
+ vec![(1, "message", 20), (2, "trace", 20), (3, "message", 20)]
+ );
+ assert_eq!(session.messages[1].text, "working…done");
+ assert!(matches!(
+ session.trace[0].detail,
+ AgentTraceKind::Command {
+ status: AgentTraceStatus::Succeeded,
+ exit_code: Some(0),
+ ..
+ }
+ ));
+ }
+
+ // SDTEST-1898 — SDUC-499
+ #[test]
+ fn sdtest_1898_selected_but_hidden_session_accumulates_unread_until_visible() {
+ let mut sessions = AgentSessionCollection::default();
+ let id = sessions
+ .create("Visibility", context(AgentProvider::Claude), 10)
+ .unwrap();
+ assert_eq!(sessions.selected_id(), Some(id));
+ assert!(!sessions.surface_visible());
+
+ sessions
+ .apply_stream_event(id, AgentStreamEvent::Text("hidden output".to_string()), 20)
+ .unwrap();
+ assert_eq!(sessions.get(id).unwrap().unread_count, 1);
+
+ assert!(sessions.set_surface_visible(true));
+ assert_eq!(sessions.get(id).unwrap().unread_count, 0);
+ sessions
+ .apply_stream_event(id, AgentStreamEvent::Text("visible output".to_string()), 30)
+ .unwrap();
+ assert_eq!(sessions.get(id).unwrap().unread_count, 0);
+
+ assert!(sessions.set_surface_visible(false));
+ sessions
+ .apply_stream_event(id, AgentStreamEvent::Text("hidden again".to_string()), 40)
+ .unwrap();
+ assert_eq!(sessions.get(id).unwrap().unread_count, 1);
+ }
+}
diff --git a/crates/shelldeck-core/src/config/mod.rs b/crates/shelldeck-core/src/config/mod.rs
index 0a48ed51..61fa0fd4 100644
--- a/crates/shelldeck-core/src/config/mod.rs
+++ b/crates/shelldeck-core/src/config/mod.rs
@@ -13,6 +13,7 @@ pub mod manage_sites;
pub mod manage_support;
pub mod monique;
pub mod platform;
+pub mod platform_attention;
pub mod platform_review;
pub mod single_instance;
pub mod ssh_config;
diff --git a/crates/shelldeck-core/src/config/platform.rs b/crates/shelldeck-core/src/config/platform.rs
index 1e5b9ea0..7ee09118 100644
--- a/crates/shelldeck-core/src/config/platform.rs
+++ b/crates/shelldeck-core/src/config/platform.rs
@@ -8,31 +8,41 @@ use std::collections::BTreeMap;
use std::fmt;
use std::time::{SystemTime, UNIX_EPOCH};
+pub use automonique_platform_client::platform_v2_client::AttentionReadResult;
use automonique_platform_client::platform_v2_client::{
- NegotiationResult, PlatformV2Client, PlatformV2ClientError, ReviewReadResult,
- ReviewReceiptResult,
+ NegotiationResult, PlatformV2Client, PlatformV2ClientError, ReviewCapabilitiesResult,
+ ReviewReadResult, ReviewReceiptResult, WorkContextQueryResult,
};
-pub use automonique_platform_client::{ActionResult, ControlClaimResult};
+pub use automonique_platform_client::{ActionResult, ControlClaimResult, PlatformView};
use automonique_platform_client::{
- BearerToken, HttpsTransport, PlatformClient, PlatformView, SessionCommandStateResult,
- SessionHistoryResult, SessionListResult, SubscriptionApply, SubscriptionResult,
+ BearerToken, HttpsTransport, PlatformClient, SessionCommandStateResult, SessionHistoryResult,
+ SessionListResult, SubscriptionApply, SubscriptionResult,
};
pub use automonique_protocol::platform::{
ActionReceipt, Attachment, Capabilities, ClientId, ControlLease, ControlLeaseId,
ExecuteRequest, FreshnessState, GetReceiptRequest, IdempotencyKey, PlatformAction,
PlatformCursor, PlatformMethod, PlatformParameter, PlatformText, PlatformTransport, ReceiptId,
- ReceiptOutcome, ReleaseControlRequest, ResourceAuthority, ResourceCoordinate, ResourceKind,
- ResourceRecord, SessionCommandState, SessionFollowUpRequest, SessionHistoryEvent,
+ ReceiptOutcome, ReleaseControlRequest, ResourceAuthority, ResourceCoordinate, ResourceId,
+ ResourceKind, ResourceRecord, SessionCommandState, SessionFollowUpRequest, SessionHistoryEvent,
SessionHistoryEvidence, SessionHistoryPage, SessionHistoryRole, SessionHistoryRunState,
SessionHistoryToolState, SessionHistoryUnknownSource, SessionRecord,
};
-use automonique_protocol::platform_v2::PlatformVersionOffer;
+use automonique_protocol::platform_v2::{
+ PlatformVersionOffer, WorkContextKind, WorkContextQuery, WorkContextRecord,
+ MAX_WORK_CONTEXT_PAGE_ITEMS,
+};
+use automonique_protocol::platform_v2_attention::AttentionSource;
pub use automonique_protocol::primitives::Revision;
use url::Url;
+use super::platform_attention::{
+ AttentionSourceInventory, PlatformAttentionTarget, ReviewAttentionPresence,
+ MAX_ATTENTION_INVENTORY_RECORDS,
+};
use super::platform_review::{
- PlatformReviewActionPreview, PlatformReviewLoad, PlatformReviewSemantic, PlatformReviewTarget,
- PlatformReviewUnavailable, ReviewActionReceipt, ReviewReceiptOutcome, ReviewReconciliation,
+ PlatformReviewActionPreview, PlatformReviewCapabilitiesLoad, PlatformReviewLoad,
+ PlatformReviewSemantic, PlatformReviewTarget, PlatformReviewUnavailable, ReviewActionReceipt,
+ ReviewReceiptOutcome, ReviewReconciliation,
};
use crate::error::{Result, ShellDeckError};
@@ -54,6 +64,12 @@ pub struct PlatformRefresh {
pub resynchronized: bool,
}
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PlatformAttentionLoad {
+ pub inventory: AttentionSourceInventory,
+ pub reads: Vec<(AttentionSource, AttentionReadResult)>,
+}
+
const RETAINED_HISTORY_PAGE_LIMIT: u16 = 128;
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -630,6 +646,43 @@ impl PlatformConnection {
load_review(&mut client, target)
}
+ /// Load the server's current, separately authorized review capabilities.
+ /// A review snapshot or provider session never substitutes for this lane.
+ pub fn review_capabilities(
+ &self,
+ target: &PlatformReviewTarget,
+ ) -> Result {
+ let mut client = self.review_client()?;
+ load_review_capabilities(&mut client, target)
+ }
+
+ /// Load one complete, bounded attention inventory and every source in it
+ /// through the negotiated read-only v2 lane.
+ pub fn attention(
+ &self,
+ target: PlatformAttentionTarget,
+ review: ReviewAttentionPresence,
+ ) -> Result {
+ let mut client = self.review_client()?;
+ negotiate_review(&mut client)?;
+ let records = load_attention_inventory(&mut client, &target)?;
+ let inventory =
+ AttentionSourceInventory::from_authoritative_records(target.clone(), &records, review)
+ .map_err(|error| ShellDeckError::Connection(error.to_string()))?;
+ let mut reads = Vec::with_capacity(inventory.sources().len());
+ for source in inventory.sources() {
+ let read = client
+ .get_attention_source_snapshot(
+ source.clone(),
+ target.project.clone(),
+ target.user_workspace.clone(),
+ )
+ .map_err(platform_v2_error)?;
+ reads.push((source.clone(), read));
+ }
+ Ok(PlatformAttentionLoad { inventory, reads })
+ }
+
/// Dispatch one already-confirmed review action exactly once.
///
/// Any uncertain transport outcome immediately switches to lookup by the
@@ -1335,6 +1388,27 @@ fn load_review(
}
}
+fn load_review_capabilities(
+ client: &mut PlatformV2Client,
+ target: &PlatformReviewTarget,
+) -> Result {
+ negotiate_review(client)?;
+ match client
+ .get_review_capabilities(target.project.clone(), target.workspace.clone())
+ .map_err(platform_v2_error)?
+ {
+ ReviewCapabilitiesResult::Capabilities(capabilities) => {
+ Ok(PlatformReviewCapabilitiesLoad::Available(capabilities))
+ }
+ ReviewCapabilitiesResult::Refused(refusal) => Ok(
+ PlatformReviewCapabilitiesLoad::Unavailable(PlatformReviewUnavailable {
+ category: refusal.category().as_str().to_owned(),
+ explanation: refusal.explanation().as_str().to_owned(),
+ }),
+ ),
+ }
+}
+
fn negotiate_review(client: &mut PlatformV2Client) -> Result<()> {
let offer = PlatformVersionOffer::new(vec![2]).map_err(|_| {
ShellDeckError::Connection("platform v2 negotiation offer is invalid".to_owned())
@@ -1351,19 +1425,84 @@ fn negotiate_review(client: &mut PlatformV2Client) -> Result<()> {
}
}
+fn load_attention_inventory(
+ client: &mut PlatformV2Client,
+ target: &PlatformAttentionTarget,
+) -> Result> {
+ let kinds = vec![
+ WorkContextKind::Project,
+ WorkContextKind::UserWorkspace,
+ WorkContextKind::AttemptWorkspace,
+ WorkContextKind::Session,
+ ];
+ let mut records = Vec::new();
+ let mut after = None;
+ loop {
+ let query = WorkContextQuery::new(
+ kinds.clone(),
+ Vec::new(),
+ Some(target.project.clone()),
+ None,
+ after.clone(),
+ MAX_WORK_CONTEXT_PAGE_ITEMS as u16,
+ )
+ .map_err(|_| ShellDeckError::Connection("attention inventory query is invalid".into()))?;
+ let page = match client
+ .query_work_contexts(query)
+ .map_err(platform_v2_error)?
+ {
+ WorkContextQueryResult::Page(page) => page,
+ WorkContextQueryResult::Resync(_) => {
+ return Err(ShellDeckError::Connection(
+ "attention inventory cursor expired".into(),
+ ));
+ }
+ WorkContextQueryResult::Refused(refusal) => {
+ return Err(ShellDeckError::Connection(format!(
+ "attention inventory refused: {}",
+ refusal.category().as_str()
+ )));
+ }
+ };
+ if records.len().saturating_add(page.items().len()) > MAX_ATTENTION_INVENTORY_RECORDS {
+ return Err(ShellDeckError::Connection(
+ "attention inventory exceeds its client bound".into(),
+ ));
+ }
+ let has_more = page.has_more();
+ after = page.next_cursor().cloned();
+ records.extend(page.into_items());
+ if !has_more {
+ return Ok(records);
+ }
+ }
+}
+
fn dispatch_review_action(
client: &mut PlatformV2Client,
preview: &PlatformReviewActionPreview,
) -> Result {
negotiate_review(client)?;
- client
- .execute_review_action(
- preview.target().workspace.clone(),
- preview.expected_revision(),
- preview.action().clone(),
- preview.idempotency_key().clone(),
- )
- .map_err(platform_v2_error)
+ if let Some(confirmation) = preview.confirmation() {
+ client
+ .execute_confirmed_review_action(
+ preview.target().workspace.clone(),
+ preview.expected_revision(),
+ preview.action().clone(),
+ preview.idempotency_key().clone(),
+ confirmation.clone(),
+ )
+ .map_err(platform_v2_error)
+ } else {
+ client
+ .execute_review_action(
+ preview.target().workspace.clone(),
+ preview.expected_revision(),
+ preview.action().clone(),
+ preview.idempotency_key().clone(),
+ )
+ .map_err(platform_v2_error)
+ }
}
fn lookup_review_action(
@@ -1371,13 +1510,24 @@ fn lookup_review_action(
preview: &PlatformReviewActionPreview,
) -> Result {
negotiate_review(client)?;
- client
- .get_review_receipt(
- preview.target().project.clone(),
- preview.target().workspace.clone(),
- preview.idempotency_key().clone(),
- )
- .map_err(platform_v2_error)
+ if let Some(confirmation) = preview.confirmation() {
+ client
+ .get_correlated_review_receipt(
+ preview.target().project.clone(),
+ preview.target().workspace.clone(),
+ preview.idempotency_key().clone(),
+ confirmation.receipt_correlation_digest().clone(),
+ )
+ .map_err(platform_v2_error)
+ } else {
+ client
+ .get_review_receipt(
+ preview.target().project.clone(),
+ preview.target().workspace.clone(),
+ preview.idempotency_key().clone(),
+ )
+ .map_err(platform_v2_error)
+ }
}
#[cfg(test)]
@@ -1518,6 +1668,134 @@ mod tests {
));
}
+ // SDTEST-1827 — capability loading is its own authenticated request, and
+ // a confirmed rerun carries every exact fence on dispatch and uses only
+ // the correlated receipt lookup during recovery.
+ #[test]
+ fn confirmed_rerun_uses_authoritative_capability_and_correlated_lookup_only() {
+ use automonique_platform_client::platform_v2_client::testing::{
+ DeterministicPlatformV2Step, DeterministicPlatformV2Transport,
+ };
+ use automonique_protocol::platform_v2::{
+ NegotiatedPlatform, PlatformVersion, WorkContextAvailability,
+ };
+ use automonique_protocol::platform_v2_review::{ReviewAuthority, ReviewAuthorityId};
+ use automonique_protocol::platform_v2_review_api::decode_review_snapshot;
+ use automonique_protocol::platform_v2_transport::{
+ PlatformNegotiationResponse, PlatformV2Refusal, PlatformV2Request, PlatformV2Response,
+ ReviewCapabilities, ReviewCheckRerunCapability, ReviewConfirmationDigest,
+ ReviewGitStagingCapabilities, ReviewPullRequestCapabilities,
+ ReviewReceiptCorrelationDigest,
+ };
+
+ let snapshot = decode_review_snapshot(include_bytes!(
+ "../../tests/fixtures/platform-v2-review-v2.json"
+ ))
+ .unwrap();
+ let semantic = PlatformReviewSemantic::from(&snapshot);
+ let target = PlatformReviewTarget {
+ project: automonique_protocol::platform_v2::ProjectId::new("project-1").unwrap(),
+ workspace: snapshot.workspace().clone(),
+ };
+ let check = &semantic.checks[0];
+ let confirmation = ReviewConfirmationDigest::new("a".repeat(64)).unwrap();
+ let correlation = ReviewReceiptCorrelationDigest::new("b".repeat(64)).unwrap();
+ let workspace_revision = Revision::new(92).unwrap();
+ let capabilities = ReviewCapabilities::new(
+ target.project.clone(),
+ target.workspace.clone(),
+ semantic.revision,
+ workspace_revision,
+ vec![ReviewCheckRerunCapability::new(
+ crate::config::platform_review::ReviewCheckId::new(check.id.clone()).unwrap(),
+ check.freshness.observed_revision,
+ ReviewAuthority::new(
+ check.authority.kind,
+ ReviewAuthorityId::new(check.authority.id.clone()).unwrap(),
+ ),
+ confirmation.clone(),
+ correlation.clone(),
+ )
+ .unwrap()],
+ Vec::new(),
+ ReviewPullRequestCapabilities::default(),
+ ReviewGitStagingCapabilities::default(),
+ )
+ .unwrap();
+ let negotiated = || {
+ NegotiatedPlatform::new(
+ PlatformVersion::V2,
+ PlatformVersion::V2.schema(),
+ WorkContextAvailability::V2Structured,
+ )
+ .unwrap()
+ };
+
+ let capability_transport = DeterministicPlatformV2Transport::new([
+ DeterministicPlatformV2Step::Negotiation(PlatformNegotiationResponse::Negotiated(
+ negotiated(),
+ )),
+ DeterministicPlatformV2Step::V2(Box::new(PlatformV2Response::ReviewCapabilities(
+ capabilities.clone(),
+ ))),
+ ]);
+ let mut capability_client = PlatformV2Client::new_testing(capability_transport);
+ assert_eq!(
+ load_review_capabilities(&mut capability_client, &target).unwrap(),
+ PlatformReviewCapabilitiesLoad::Available(Box::new(capabilities.clone()))
+ );
+ assert!(matches!(
+ capability_client.transport().requests()[0].request(),
+ PlatformV2Request::GetReviewCapabilities(request)
+ if request.project() == &target.project && request.workspace() == &target.workspace
+ ));
+
+ let preview = PlatformReviewActionPreview::rerun_check(
+ target.clone(),
+ &semantic,
+ &capabilities,
+ &check.id,
+ )
+ .unwrap();
+ let refusal = || PlatformV2Refusal::new("fixture_refusal", "fixture refusal").unwrap();
+ let dispatch_transport = DeterministicPlatformV2Transport::new([
+ DeterministicPlatformV2Step::Negotiation(PlatformNegotiationResponse::Negotiated(
+ negotiated(),
+ )),
+ DeterministicPlatformV2Step::V2(Box::new(PlatformV2Response::Refused(refusal()))),
+ ]);
+ let mut dispatch_client = PlatformV2Client::new_testing(dispatch_transport);
+ assert!(matches!(
+ dispatch_review_action(&mut dispatch_client, &preview).unwrap(),
+ ReviewReceiptResult::Refused(_)
+ ));
+ assert!(matches!(
+ dispatch_client.transport().requests()[0].request(),
+ PlatformV2Request::ExecuteReviewAction(request)
+ if request.confirmation_digest() == Some(&confirmation)
+ && request.expected_workspace_revision() == Some(workspace_revision)
+ && request.receipt_correlation_digest() == Some(&correlation)
+ ));
+
+ let lookup_transport = DeterministicPlatformV2Transport::new([
+ DeterministicPlatformV2Step::Negotiation(PlatformNegotiationResponse::Negotiated(
+ negotiated(),
+ )),
+ DeterministicPlatformV2Step::V2(Box::new(PlatformV2Response::Refused(refusal()))),
+ ]);
+ let mut lookup_client = PlatformV2Client::new_testing(lookup_transport);
+ assert!(matches!(
+ lookup_review_action(&mut lookup_client, &preview).unwrap(),
+ ReviewReceiptResult::Refused(_)
+ ));
+ assert!(matches!(
+ lookup_client.transport().requests()[0].request(),
+ PlatformV2Request::GetReviewReceipt(lookup)
+ if lookup.receipt_correlation_digest() == Some(&correlation)
+ && lookup.idempotency_key() == preview.idempotency_key()
+ ));
+ }
+
#[test]
fn endpoint_is_canonical_and_https_only() {
assert_eq!(
diff --git a/crates/shelldeck-core/src/config/platform_attention.rs b/crates/shelldeck-core/src/config/platform_attention.rs
new file mode 100644
index 00000000..fb7a3f5d
--- /dev/null
+++ b/crates/shelldeck-core/src/config/platform_attention.rs
@@ -0,0 +1,3183 @@
+//! Source-atomic native consumption of Platform v2 attention.
+//!
+//! It retains the complete authoritative source, project, user-workspace, item,
+//! revision, and authority-qualified Platform session coordinates. Native
+//! activation may resolve those coordinates through current client-local
+//! catalogues, but never manufactures a pane, tab, terminal, or path.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::path::{Path, PathBuf};
+
+use automonique_platform_client::platform_v2_client::AttentionReadResult;
+use automonique_protocol::platform_v2::{
+ ProjectId, UserWorkspaceId, WorkContextIdentity, WorkContextKind, WorkContextRecord,
+ WorkContextRelationKind,
+};
+pub use automonique_protocol::platform_v2_attention::{
+ AttentionItem, AttentionItemId, AttentionItemReason, AttentionItemState, AttentionSource,
+ AttentionSourceId, AttentionSourceKind, AttentionSourceSnapshot, MAX_ATTENTION_ITEMS,
+};
+use automonique_protocol::platform_v2_transport::PlatformV2Refusal;
+use automonique_protocol::primitives::Revision;
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+use uuid::Uuid;
+
+use super::app_config::AppConfig;
+use super::platform::{
+ FreshnessState, ResourceAuthority, ResourceCoordinate, ResourceKind, SessionRecord,
+};
+use super::workspace_catalog::PlatformV2Mapping;
+use super::workspace_catalog::{CatalogWorkspaceId, ProjectCatalog};
+use crate::workspace_navigation::{
+ PaneNode, WorkspaceFocus, WorkspaceNavigationState, WorkspaceTabContent,
+};
+use crate::workspace_review::storage::{
+ bounded_descriptor_read, ensure_private_directory_io, lock_path, open_lock_file,
+ secure_atomic_write,
+};
+
+/// Same per-workspace ceiling as the authoritative hosted cockpit.
+pub const MAX_ATTENTION_SOURCES_PER_WORKSPACE: usize = 64;
+/// A client inventory is accepted only as one bounded, duplicate-free value.
+pub const MAX_ATTENTION_INVENTORY_RECORDS: usize = 512;
+/// Maximum locally retained read/notification tuples.
+pub const MAX_ATTENTION_LOCAL_ENTRIES: usize = 4_096;
+const MAX_ATTENTION_LOCAL_FILE_BYTES: u64 = 1024 * 1024;
+const ATTENTION_LOCAL_SCHEMA: u16 = 2;
+static ATTENTION_LOCAL_STORE_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
+
+/// Namespace reserved for ShellDeck's presentation-only attention UUIDs.
+///
+/// The UUID is never sent to Automonique and is never an authority coordinate.
+/// Its name bytes are the length-delimited raw `(source kind, source id, item
+/// id)` tuple, so delimiter-bearing opaque identifiers cannot alias each other.
+const ATTENTION_UI_NAMESPACE: Uuid = Uuid::from_bytes([
+ 0xa6, 0x61, 0x14, 0x3f, 0x23, 0x88, 0x5a, 0x25, 0xa4, 0xca, 0x53, 0x42, 0x6e, 0x92, 0x7d, 0x31,
+]);
+
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct PlatformAttentionTarget {
+ pub project: ProjectId,
+ pub user_workspace: UserWorkspaceId,
+}
+
+impl PlatformAttentionTarget {
+ /// Admit attention only from a completely reconciled catalog mapping.
+ pub fn from_exact_mapping(mapping: &PlatformV2Mapping) -> Result {
+ if !mapping.is_exact() {
+ return Err(AttentionError::MappingNotExact);
+ }
+ Ok(Self {
+ project: ProjectId::new(mapping.project.id.clone())
+ .map_err(|_| AttentionError::MappingInvalid)?,
+ user_workspace: UserWorkspaceId::new(mapping.user_workspace.id.clone())
+ .map_err(|_| AttentionError::MappingInvalid)?,
+ })
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReviewAttentionPresence {
+ /// A typed `GetReview` returned the exact requested review.
+ Present,
+ /// A typed `GetReview` returned the exact not-found refusal.
+ Absent,
+}
+
+/// Complete bounded source inventory for one exact project/user-workspace.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AttentionSourceInventory {
+ target: PlatformAttentionTarget,
+ sources: Vec,
+ /// Exact Platform session coordinate authoritatively related to each
+ /// provider WorkContext source. This binding must survive inventory
+ /// projection so an item cannot redirect its source to another otherwise
+ /// valid current session.
+ provider_sessions: BTreeMap,
+}
+
+impl AttentionSourceInventory {
+ pub fn from_authoritative_records(
+ target: PlatformAttentionTarget,
+ records: &[WorkContextRecord],
+ review: ReviewAttentionPresence,
+ ) -> Result {
+ if records.len() > MAX_ATTENTION_INVENTORY_RECORDS {
+ return Err(AttentionError::InventoryTooLarge);
+ }
+ let mut identities = BTreeSet::new();
+ if records
+ .iter()
+ .any(|record| !identities.insert(record.identity().clone()))
+ {
+ return Err(AttentionError::InventoryDuplicate);
+ }
+
+ let workspace_identity = WorkContextIdentity::UserWorkspace(target.user_workspace.clone());
+ let workspace = records
+ .iter()
+ .filter(|record| record.identity() == &workspace_identity)
+ .collect::>();
+ if workspace.len() != 1 {
+ return Err(AttentionError::WorkspaceMissingOrAmbiguous);
+ }
+ let project_identity = WorkContextIdentity::Project(target.project.clone());
+ if relation(workspace[0], WorkContextRelationKind::UserWorkspaceProject)
+ != Some(&project_identity)
+ {
+ return Err(AttentionError::WorkspaceProjectMismatch);
+ }
+
+ let workspace_source = AttentionSourceId::new(target.user_workspace.as_str().to_owned())
+ .map_err(|_| AttentionError::SourceInvalid)?;
+ let mut sources = BTreeSet::new();
+ let mut provider_sessions = BTreeMap::new();
+ if review == ReviewAttentionPresence::Present {
+ sources.insert(AttentionSource::new(
+ AttentionSourceKind::Review,
+ workspace_source.clone(),
+ ));
+ }
+ sources.insert(AttentionSource::new(
+ AttentionSourceKind::Orchestration,
+ workspace_source,
+ ));
+
+ let attempts = records
+ .iter()
+ .filter(|record| record.kind() == WorkContextKind::AttemptWorkspace)
+ .filter(|record| {
+ relation(record, WorkContextRelationKind::AttemptUserWorkspace)
+ == Some(&workspace_identity)
+ })
+ .map(|record| record.identity().clone())
+ .collect::>();
+ for session in records
+ .iter()
+ .filter(|record| record.kind() == WorkContextKind::Session)
+ {
+ if relation(session, WorkContextRelationKind::SessionAttemptWorkspace)
+ .is_some_and(|attempt| attempts.contains(attempt))
+ {
+ let id = AttentionSourceId::new(session.identity().id().to_owned())
+ .map_err(|_| AttentionError::SourceInvalid)?;
+ let source = AttentionSource::new(AttentionSourceKind::ProviderSession, id);
+ let coordinate =
+ match relation(session, WorkContextRelationKind::SessionPlatformSession) {
+ Some(WorkContextIdentity::PlatformSession(session))
+ if session.coordinate().authority == ResourceAuthority::Automonique
+ && session.coordinate().kind == ResourceKind::Session =>
+ {
+ session.coordinate().clone()
+ }
+ _ => return Err(AttentionError::ProviderSessionRelationInvalid),
+ };
+ if !sources.insert(source.clone())
+ || provider_sessions.insert(source, coordinate).is_some()
+ {
+ return Err(AttentionError::SourceDuplicate);
+ }
+ }
+ }
+ if sources.len() > MAX_ATTENTION_SOURCES_PER_WORKSPACE {
+ return Err(AttentionError::SourceInventoryTooLarge);
+ }
+ Ok(Self {
+ target,
+ sources: sources.into_iter().collect(),
+ provider_sessions,
+ })
+ }
+
+ #[must_use]
+ pub const fn target(&self) -> &PlatformAttentionTarget {
+ &self.target
+ }
+
+ #[must_use]
+ pub fn sources(&self) -> &[AttentionSource] {
+ &self.sources
+ }
+
+ #[must_use]
+ pub fn contains(&self, source: &AttentionSource) -> bool {
+ self.sources.binary_search(source).is_ok()
+ }
+
+ #[must_use]
+ pub fn provider_session(&self, source: &AttentionSource) -> Option<&ResourceCoordinate> {
+ self.provider_sessions.get(source)
+ }
+}
+
+fn relation(
+ record: &WorkContextRecord,
+ kind: WorkContextRelationKind,
+) -> Option<&WorkContextIdentity> {
+ record
+ .relations()
+ .iter()
+ .find(|relation| relation.kind() == kind)
+ .map(|relation| relation.target())
+}
+
+/// Full raw authority key. The item id has no meaning without its source.
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct AttentionItemKey {
+ source: AttentionSource,
+ item: AttentionItemId,
+}
+
+impl AttentionItemKey {
+ #[must_use]
+ pub const fn new(source: AttentionSource, item: AttentionItemId) -> Self {
+ Self { source, item }
+ }
+
+ #[must_use]
+ pub const fn source(&self) -> &AttentionSource {
+ &self.source
+ }
+
+ #[must_use]
+ pub const fn item(&self) -> &AttentionItemId {
+ &self.item
+ }
+}
+
+/// Presentation-only stable identifier derived from [`AttentionItemKey`].
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct AttentionUiItemId(Uuid);
+
+impl AttentionUiItemId {
+ #[must_use]
+ pub fn from_authoritative_key(key: &AttentionItemKey) -> Self {
+ let mut name = Vec::with_capacity(
+ key.source.kind().as_str().len()
+ + key.source.id().as_str().len()
+ + key.item.as_str().len()
+ + 12,
+ );
+ append_component(&mut name, key.source.kind().as_str());
+ append_component(&mut name, key.source.id().as_str());
+ append_component(&mut name, key.item.as_str());
+ Self(Uuid::new_v5(&ATTENTION_UI_NAMESPACE, &name))
+ }
+
+ #[must_use]
+ pub const fn uuid(self) -> Uuid {
+ self.0
+ }
+}
+
+fn append_component(output: &mut Vec, value: &str) {
+ let length = u32::try_from(value.len()).expect("protocol field bound fits u32");
+ output.extend_from_slice(&length.to_be_bytes());
+ output.extend_from_slice(value.as_bytes());
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AuthoritativeAttentionItem {
+ key: AttentionItemKey,
+ ui_id: AttentionUiItemId,
+ value: AttentionItem,
+}
+
+impl AuthoritativeAttentionItem {
+ #[must_use]
+ pub const fn key(&self) -> &AttentionItemKey {
+ &self.key
+ }
+
+ #[must_use]
+ pub const fn ui_id(&self) -> AttentionUiItemId {
+ self.ui_id
+ }
+
+ #[must_use]
+ pub const fn value(&self) -> &AttentionItem {
+ &self.value
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AttentionUnavailableReason {
+ NotObserved,
+ Transport,
+ Protocol,
+ InventoryIncomplete,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum AttentionSourceStatus {
+ Available,
+ Refused { category: String },
+ Unavailable(AttentionUnavailableReason),
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct AttentionSourceProjection {
+ snapshot: AttentionSourceSnapshot,
+ items: BTreeMap,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+struct AttentionSourceSlot {
+ status: AttentionSourceStatus,
+ /// Retained while unavailable/refused so a later read must still be the
+ /// exact successor rather than silently resetting the revision chain.
+ projection: Option,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum AttentionApplyOutcome {
+ Inserted,
+ Replaced,
+ ExactReplay,
+ AvailabilityRestored,
+ Refused,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+enum AttentionReplacementMode {
+ Continuous,
+ AuthenticatedCompleteBaseline,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PlatformAttentionBoard {
+ inventory: AttentionSourceInventory,
+ slots: BTreeMap,
+ ui_index: BTreeMap,
+}
+
+/// Stable same-process activation request. It deliberately carries the
+/// authoritative presentation identity and revision, not a previously
+/// resolved pane/session. Every click is resolved again against the current
+/// authorized catalogues.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct PlatformAttentionActivation {
+ pub workspace: CatalogWorkspaceId,
+ pub item: AttentionUiItemId,
+ pub item_revision: Revision,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum PlatformAttentionDestination {
+ WorkspaceAttention {
+ workspace: CatalogWorkspaceId,
+ },
+ FleetSession {
+ workspace: CatalogWorkspaceId,
+ session: ResourceCoordinate,
+ },
+ RetainedProviderPane {
+ workspace: CatalogWorkspaceId,
+ session: ResourceCoordinate,
+ focus: WorkspaceFocus,
+ },
+}
+
+/// Resolve an activation through the complete current catalogue.
+///
+/// Provider coordinates must be exact authorized Automonique sessions. A
+/// retained provider pane, when present, must also be unique. Review and
+/// orchestration items intentionally resolve only to the mapped workspace's
+/// attention surface and cannot acquire provider authority.
+pub fn resolve_platform_attention_activation(
+ activation: PlatformAttentionActivation,
+ board: &PlatformAttentionBoard,
+ catalog: &ProjectCatalog,
+ navigation: &WorkspaceNavigationState,
+ sessions: &[SessionRecord],
+) -> Result {
+ let item = board
+ .item_by_ui_id(activation.item)
+ .ok_or(AttentionActivationError::ItemMissing)?;
+ if item.value().revision() != activation.item_revision {
+ return Err(AttentionActivationError::ItemStale);
+ }
+
+ let matches = catalog
+ .workspaces()
+ .filter(|workspace| {
+ workspace.platform_mapping().is_some_and(|mapping| {
+ PlatformAttentionTarget::from_exact_mapping(mapping)
+ .is_ok_and(|target| &target == board.inventory().target())
+ })
+ })
+ .map(|workspace| workspace.id())
+ .collect::>();
+ if matches.as_slice() != [activation.workspace] {
+ return Err(AttentionActivationError::WorkspaceMissingOrAmbiguous);
+ }
+ let retained = navigation
+ .workspace(activation.workspace)
+ .ok_or(AttentionActivationError::WorkspaceMissingOrAmbiguous)?;
+
+ let source_kind = item.key().source().kind();
+ if source_kind != AttentionSourceKind::ProviderSession {
+ if item.value().platform_session().is_some() {
+ return Err(AttentionActivationError::CoordinateInvalid);
+ }
+ return Ok(PlatformAttentionDestination::WorkspaceAttention {
+ workspace: activation.workspace,
+ });
+ }
+
+ let coordinate = item
+ .value()
+ .platform_session()
+ .map(|session| session.coordinate())
+ .ok_or(AttentionActivationError::CoordinateInvalid)?;
+ if coordinate.authority != ResourceAuthority::Automonique
+ || coordinate.kind != ResourceKind::Session
+ || board.inventory().provider_session(item.key().source()) != Some(coordinate)
+ {
+ return Err(AttentionActivationError::CoordinateInvalid);
+ }
+ let matching_sessions = sessions
+ .iter()
+ .filter(|record| &record.session.resource == coordinate)
+ .collect::>();
+ let [session] = matching_sessions.as_slice() else {
+ return Err(AttentionActivationError::SessionMissingOrAmbiguous);
+ };
+ if session.session.freshness.state != FreshnessState::Fresh {
+ return Err(AttentionActivationError::SessionNotFresh);
+ }
+
+ let mapping = catalog
+ .workspace(activation.workspace)
+ .ok()
+ .and_then(|workspace| workspace.platform_mapping())
+ .filter(|mapping| mapping.is_exact())
+ .ok_or(AttentionActivationError::WorkspaceMissingOrAmbiguous)?;
+ let mut pane_matches = Vec::new();
+ collect_provider_panes(
+ retained.surface.root.as_ref(),
+ &mapping.user_workspace.id,
+ coordinate,
+ &mut pane_matches,
+ );
+ match pane_matches.as_slice() {
+ [] => Ok(PlatformAttentionDestination::FleetSession {
+ workspace: activation.workspace,
+ session: coordinate.clone(),
+ }),
+ [focus] => Ok(PlatformAttentionDestination::RetainedProviderPane {
+ workspace: activation.workspace,
+ session: coordinate.clone(),
+ focus: *focus,
+ }),
+ _ => Err(AttentionActivationError::PaneAmbiguous),
+ }
+}
+
+fn collect_provider_panes(
+ node: Option<&PaneNode>,
+ user_workspace: &str,
+ coordinate: &ResourceCoordinate,
+ output: &mut Vec,
+) {
+ let Some(node) = node else { return };
+ match node {
+ PaneNode::Leaf(leaf) => {
+ for tab in &leaf.tabs {
+ if matches!(
+ &tab.content,
+ WorkspaceTabContent::ProviderSession(binding)
+ if binding.platform_user_workspace_id == user_workspace
+ && binding.session_id == coordinate.id.as_str()
+ ) {
+ output.push(WorkspaceFocus {
+ pane_id: leaf.id,
+ tab_id: tab.id,
+ });
+ }
+ }
+ }
+ PaneNode::Split { first, second, .. } => {
+ collect_provider_panes(Some(first), user_workspace, coordinate, output);
+ collect_provider_panes(Some(second), user_workspace, coordinate, output);
+ }
+ }
+}
+
+impl PlatformAttentionBoard {
+ #[must_use]
+ pub fn new(inventory: AttentionSourceInventory) -> Self {
+ let slots = inventory
+ .sources()
+ .iter()
+ .cloned()
+ .map(|source| {
+ (
+ source,
+ AttentionSourceSlot {
+ status: AttentionSourceStatus::Unavailable(
+ AttentionUnavailableReason::NotObserved,
+ ),
+ projection: None,
+ },
+ )
+ })
+ .collect();
+ Self {
+ inventory,
+ slots,
+ ui_index: BTreeMap::new(),
+ }
+ }
+
+ #[must_use]
+ pub const fn inventory(&self) -> &AttentionSourceInventory {
+ &self.inventory
+ }
+
+ /// Replace the complete source inventory. Removed sources and all their UI
+ /// projections disappear together; retained sources keep revision custody.
+ pub fn replace_inventory(
+ &mut self,
+ inventory: AttentionSourceInventory,
+ ) -> Result<(), AttentionError> {
+ if inventory.target() != self.inventory.target() {
+ return Err(AttentionError::TargetMismatch);
+ }
+ let mut slots = BTreeMap::new();
+ for source in inventory.sources() {
+ slots.insert(
+ source.clone(),
+ self.slots
+ .get(source)
+ .cloned()
+ .unwrap_or(AttentionSourceSlot {
+ status: AttentionSourceStatus::Unavailable(
+ AttentionUnavailableReason::NotObserved,
+ ),
+ projection: None,
+ }),
+ );
+ }
+ let ui_index = build_ui_index(&slots)?;
+ self.inventory = inventory;
+ self.slots = slots;
+ self.ui_index = ui_index;
+ Ok(())
+ }
+
+ pub fn apply_read(
+ &mut self,
+ requested_source: &AttentionSource,
+ result: AttentionReadResult,
+ ) -> Result {
+ match result {
+ AttentionReadResult::Snapshot(snapshot) => {
+ if snapshot.source() != requested_source {
+ return Err(AttentionError::SourceMismatch);
+ }
+ self.replace_source(*snapshot)
+ }
+ AttentionReadResult::Refused(refusal) => {
+ self.mark_refused(requested_source, &refusal)?;
+ Ok(AttentionApplyOutcome::Refused)
+ }
+ }
+ }
+
+ /// Install a complete snapshot returned directly by an authenticated
+ /// Platform attention read as a fresh baseline or an explicit gap resync.
+ ///
+ /// Unlike [`Self::apply_read`], this operation may begin above revision one
+ /// and may bridge a missed predecessor. It still rejects rollback,
+ /// conflicting replay, regressing observation/item revisions, wrong
+ /// source/target, and presentation identity collisions. Callers must not
+ /// pass cached, synthesized, or locally assembled snapshots here.
+ pub fn apply_authenticated_baseline_read(
+ &mut self,
+ requested_source: &AttentionSource,
+ result: AttentionReadResult,
+ ) -> Result {
+ match result {
+ AttentionReadResult::Snapshot(snapshot) => {
+ if snapshot.source() != requested_source {
+ return Err(AttentionError::SourceMismatch);
+ }
+ self.replace_source_with_mode(
+ *snapshot,
+ AttentionUiItemId::from_authoritative_key,
+ AttentionReplacementMode::AuthenticatedCompleteBaseline,
+ )
+ }
+ AttentionReadResult::Refused(refusal) => {
+ self.mark_refused(requested_source, &refusal)?;
+ Ok(AttentionApplyOutcome::Refused)
+ }
+ }
+ }
+
+ pub fn replace_source(
+ &mut self,
+ snapshot: AttentionSourceSnapshot,
+ ) -> Result {
+ self.replace_source_with_mode(
+ snapshot,
+ AttentionUiItemId::from_authoritative_key,
+ AttentionReplacementMode::Continuous,
+ )
+ }
+
+ #[cfg(test)]
+ fn replace_source_with(
+ &mut self,
+ snapshot: AttentionSourceSnapshot,
+ projector: impl Fn(&AttentionItemKey) -> AttentionUiItemId,
+ ) -> Result {
+ self.replace_source_with_mode(snapshot, projector, AttentionReplacementMode::Continuous)
+ }
+
+ fn replace_source_with_mode(
+ &mut self,
+ snapshot: AttentionSourceSnapshot,
+ projector: impl Fn(&AttentionItemKey) -> AttentionUiItemId,
+ mode: AttentionReplacementMode,
+ ) -> Result {
+ let source = snapshot.source().clone();
+ let current_slot = self
+ .slots
+ .get(&source)
+ .ok_or(AttentionError::SourceNotInventoried)?;
+ if snapshot.project() != &self.inventory.target.project
+ || snapshot.user_workspace() != &self.inventory.target.user_workspace
+ {
+ return Err(AttentionError::TargetMismatch);
+ }
+
+ let previous_status = current_slot.status.clone();
+ if let Some(current) = ¤t_slot.projection {
+ if snapshot.revision() == current.snapshot.revision() {
+ if snapshot != current.snapshot {
+ return Err(AttentionError::ConflictingReplay);
+ }
+ if previous_status == AttentionSourceStatus::Available {
+ return Ok(AttentionApplyOutcome::ExactReplay);
+ }
+ let mut slots = self.slots.clone();
+ slots
+ .get_mut(&source)
+ .expect("source admission checked above")
+ .status = AttentionSourceStatus::Available;
+ let ui_index = build_ui_index(&slots)?;
+ self.slots = slots;
+ self.ui_index = ui_index;
+ return Ok(AttentionApplyOutcome::AvailabilityRestored);
+ }
+ match mode {
+ AttentionReplacementMode::Continuous => current
+ .snapshot
+ .validate_successor(&snapshot)
+ .map_err(|_| AttentionError::InvalidSuccessor)?,
+ AttentionReplacementMode::AuthenticatedCompleteBaseline => {
+ validate_baseline_advance(¤t.snapshot, &snapshot)?;
+ }
+ }
+ } else if mode == AttentionReplacementMode::Continuous
+ && snapshot.revision() != Revision::FIRST
+ {
+ return Err(AttentionError::InitialRevisionRequired);
+ }
+
+ let mut projected = BTreeMap::new();
+ for item in snapshot.items() {
+ let key = AttentionItemKey::new(source.clone(), item.id().clone());
+ let ui_id = projector(&key);
+ projected.insert(
+ key.clone(),
+ AuthoritativeAttentionItem {
+ key,
+ ui_id,
+ value: item.clone(),
+ },
+ );
+ }
+
+ // Build the entire candidate board and collision index before making
+ // any visible mutation. One malformed/colliding item changes nothing.
+ let mut slots = self.slots.clone();
+ let was_present = current_slot.projection.is_some();
+ slots.insert(
+ source,
+ AttentionSourceSlot {
+ status: AttentionSourceStatus::Available,
+ projection: Some(AttentionSourceProjection {
+ snapshot,
+ items: projected,
+ }),
+ },
+ );
+ let ui_index = build_ui_index(&slots)?;
+ self.slots = slots;
+ self.ui_index = ui_index;
+ Ok(if was_present {
+ AttentionApplyOutcome::Replaced
+ } else {
+ AttentionApplyOutcome::Inserted
+ })
+ }
+
+ /// A refusal hides the old projection but retains its revision chain.
+ pub fn mark_refused(
+ &mut self,
+ source: &AttentionSource,
+ refusal: &PlatformV2Refusal,
+ ) -> Result<(), AttentionError> {
+ let slot = self
+ .slots
+ .get_mut(source)
+ .ok_or(AttentionError::SourceNotInventoried)?;
+ slot.status = AttentionSourceStatus::Refused {
+ category: refusal.category().as_str().to_owned(),
+ };
+ self.rebuild_visible_index()?;
+ Ok(())
+ }
+
+ /// A local transport/protocol failure is not an authoritative empty source.
+ pub fn mark_unavailable(
+ &mut self,
+ source: &AttentionSource,
+ reason: AttentionUnavailableReason,
+ ) -> Result<(), AttentionError> {
+ let slot = self
+ .slots
+ .get_mut(source)
+ .ok_or(AttentionError::SourceNotInventoried)?;
+ slot.status = AttentionSourceStatus::Unavailable(reason);
+ self.rebuild_visible_index()?;
+ Ok(())
+ }
+
+ fn rebuild_visible_index(&mut self) -> Result<(), AttentionError> {
+ let index = build_ui_index(&self.slots)?;
+ self.ui_index = index;
+ Ok(())
+ }
+
+ #[must_use]
+ pub fn status(&self, source: &AttentionSource) -> Option<&AttentionSourceStatus> {
+ self.slots.get(source).map(|slot| &slot.status)
+ }
+
+ #[must_use]
+ pub fn retained_snapshot(&self, source: &AttentionSource) -> Option<&AttentionSourceSnapshot> {
+ self.slots
+ .get(source)
+ .and_then(|slot| slot.projection.as_ref())
+ .map(|projection| &projection.snapshot)
+ }
+
+ pub fn visible_items(&self) -> impl Iterator- {
+ self.slots.values().flat_map(|slot| {
+ (slot.status == AttentionSourceStatus::Available)
+ .then_some(slot.projection.as_ref())
+ .flatten()
+ .into_iter()
+ .flat_map(|projection| projection.items.values())
+ })
+ }
+
+ /// Every visible item, newest observation first.
+ ///
+ /// The authoritative `observed_at_ms` is the only chronology ShellDeck
+ /// has: order is never inferred from poll arrival, source order, or when
+ /// a row reached this process. Equal observations fall back to the item
+ /// revision and then to the authoritative `(source, item)` key, so the
+ /// order is total, deterministic, and identical in every process.
+ #[must_use]
+ pub fn chronology(&self) -> Vec<&AuthoritativeAttentionItem> {
+ let mut items = self.visible_items().collect::
>();
+ items.sort_by(|left, right| {
+ right
+ .value()
+ .observed_at_ms()
+ .cmp(&left.value().observed_at_ms())
+ .then_with(|| right.value().revision().cmp(&left.value().revision()))
+ .then_with(|| left.key().cmp(right.key()))
+ });
+ items
+ }
+
+ #[must_use]
+ pub fn item_by_ui_id(&self, id: AttentionUiItemId) -> Option<&AuthoritativeAttentionItem> {
+ let key = self.ui_index.get(&id)?;
+ self.slots
+ .get(key.source())?
+ .projection
+ .as_ref()?
+ .items
+ .get(key)
+ }
+}
+
+fn validate_baseline_advance(
+ current: &AttentionSourceSnapshot,
+ next: &AttentionSourceSnapshot,
+) -> Result<(), AttentionError> {
+ if next.source() != current.source()
+ || next.project() != current.project()
+ || next.user_workspace() != current.user_workspace()
+ || next.revision() <= current.revision()
+ || next
+ .previous_revision()
+ .is_none_or(|previous| previous < current.revision())
+ || next.observed_at_ms() < current.observed_at_ms()
+ {
+ return Err(AttentionError::InvalidBaseline);
+ }
+ for next_item in next.items() {
+ let current_item = current
+ .items()
+ .iter()
+ .find(|item| item.id() == next_item.id());
+ if current_item.is_some_and(|current_item| {
+ next_item.revision() < current_item.revision()
+ || next_item.observed_at_ms() < current_item.observed_at_ms()
+ || (next_item.revision() == current_item.revision() && next_item != current_item)
+ }) {
+ return Err(AttentionError::InvalidBaseline);
+ }
+ }
+ Ok(())
+}
+
+fn build_ui_index(
+ slots: &BTreeMap,
+) -> Result, AttentionError> {
+ let mut index = BTreeMap::new();
+ for slot in slots.values() {
+ if slot.status != AttentionSourceStatus::Available {
+ continue;
+ }
+ if let Some(projection) = &slot.projection {
+ for item in projection.items.values() {
+ if index
+ .insert(item.ui_id(), item.key().clone())
+ .is_some_and(|existing| existing != *item.key())
+ {
+ return Err(AttentionError::UiIdentityCollision);
+ }
+ }
+ }
+ }
+ Ok(index)
+}
+
+/// Durable local acknowledgement key. Server `unread` remains untouched.
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct AttentionLocalKey {
+ target: PlatformAttentionTarget,
+ item: AttentionItemKey,
+ item_revision: Revision,
+}
+
+/// Durable fence for local overlay custody belonging to a retired Platform
+/// source incarnation. The target is part of the key so a remap can never
+/// make an old target's cleanup look like custody for the replacement.
+#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct AttentionRetirement {
+ target: PlatformAttentionTarget,
+ source: AttentionSource,
+}
+
+impl AttentionRetirement {
+ #[must_use]
+ pub const fn new(target: PlatformAttentionTarget, source: AttentionSource) -> Self {
+ Self { target, source }
+ }
+
+ #[must_use]
+ pub const fn target(&self) -> &PlatformAttentionTarget {
+ &self.target
+ }
+
+ #[must_use]
+ pub const fn source(&self) -> &AttentionSource {
+ &self.source
+ }
+}
+
+impl AttentionLocalKey {
+ #[must_use]
+ pub const fn new(
+ target: PlatformAttentionTarget,
+ item: AttentionItemKey,
+ item_revision: Revision,
+ ) -> Self {
+ Self {
+ target,
+ item,
+ item_revision,
+ }
+ }
+
+ #[must_use]
+ pub const fn target(&self) -> &PlatformAttentionTarget {
+ &self.target
+ }
+
+ #[must_use]
+ pub const fn item(&self) -> &AttentionItemKey {
+ &self.item
+ }
+
+ #[must_use]
+ pub const fn item_revision(&self) -> Revision {
+ self.item_revision
+ }
+}
+
+#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
+struct AttentionLocalFlags {
+ read: bool,
+ notified: bool,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AttentionLocalState {
+ capacity: usize,
+ entries: BTreeMap,
+ retirements: BTreeSet,
+}
+
+impl AttentionLocalState {
+ pub fn with_capacity(capacity: usize) -> Result {
+ if capacity == 0 || capacity > MAX_ATTENTION_LOCAL_ENTRIES {
+ return Err(AttentionLocalStateError::CapacityInvalid);
+ }
+ Ok(Self {
+ capacity,
+ entries: BTreeMap::new(),
+ retirements: BTreeSet::new(),
+ })
+ }
+
+ #[must_use]
+ pub fn is_read(&self, key: &AttentionLocalKey) -> bool {
+ self.entries.get(key).is_some_and(|flags| flags.read)
+ }
+
+ #[must_use]
+ pub fn is_notified(&self, key: &AttentionLocalKey) -> bool {
+ self.entries.get(key).is_some_and(|flags| flags.notified)
+ }
+
+ fn mark_read(&mut self, key: AttentionLocalKey) -> Result {
+ if self.retirements.iter().any(|retirement| {
+ retirement.target() == key.target() && retirement.source() == key.item().source()
+ }) {
+ return Err(AttentionLocalStateError::RetirementPending);
+ }
+ self.update(key, |flags| &mut flags.read)
+ }
+
+ fn mark_notified(&mut self, key: AttentionLocalKey) -> Result {
+ if self.retirements.iter().any(|retirement| {
+ retirement.target() == key.target() && retirement.source() == key.item().source()
+ }) {
+ return Err(AttentionLocalStateError::RetirementPending);
+ }
+ self.update(key, |flags| &mut flags.notified)
+ }
+
+ fn update(
+ &mut self,
+ key: AttentionLocalKey,
+ select: impl Fn(&mut AttentionLocalFlags) -> &mut bool,
+ ) -> Result {
+ if !self.entries.contains_key(&key) && self.entries.len() == self.capacity {
+ return Err(AttentionLocalStateError::CapacityExceeded);
+ }
+ let value = select(self.entries.entry(key).or_default());
+ if *value {
+ return Ok(false);
+ }
+ *value = true;
+ Ok(true)
+ }
+
+ fn retain_source_keys(
+ &mut self,
+ target: &PlatformAttentionTarget,
+ source: &AttentionSource,
+ current: &BTreeSet,
+ ) -> bool {
+ let before = self.entries.len();
+ self.entries.retain(|key, _| {
+ key.target() != target || key.item().source() != source || current.contains(key)
+ });
+ before != self.entries.len()
+ }
+
+ fn begin_retirement(
+ &mut self,
+ retirement: AttentionRetirement,
+ ) -> Result {
+ if !self.retirements.contains(&retirement) && self.retirements.len() == self.capacity {
+ return Err(AttentionLocalStateError::CapacityExceeded);
+ }
+ Ok(self.retirements.insert(retirement))
+ }
+
+ fn finish_retirement(&mut self, retirement: &AttentionRetirement) -> bool {
+ let removed_entries =
+ self.retain_source_keys(retirement.target(), retirement.source(), &BTreeSet::new());
+ self.retirements.remove(retirement) || removed_entries
+ }
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum NotificationReservation {
+ Ineligible,
+ AlreadyReserved,
+ Reserved,
+}
+
+/// Atomic store for local read and at-most-once notification custody.
+///
+/// Every mutation first writes a complete candidate document and changes the
+/// in-memory value only after atomic replacement succeeds. Load, persistence,
+/// and capacity errors are returned to the caller; callers must suppress a
+/// notification on error instead of evicting an older tuple and risking spam.
+#[derive(Clone, Debug)]
+pub struct AttentionLocalStateStore {
+ path: PathBuf,
+ state: AttentionLocalState,
+}
+
+impl AttentionLocalStateStore {
+ pub fn open_default() -> Result {
+ Self::open(
+ AppConfig::config_dir()
+ .join("platform-attention")
+ .join("state")
+ .join("local-v1.json"),
+ MAX_ATTENTION_LOCAL_ENTRIES,
+ )
+ }
+
+ pub fn open(path: PathBuf, capacity: usize) -> Result {
+ AttentionLocalState::with_capacity(capacity)?;
+ let _process_guard = ATTENTION_LOCAL_STORE_LOCK.lock();
+ prepare_local_storage(&path)?;
+ let lock = open_lock_file(&lock_path(&path))?;
+ fs2::FileExt::lock_exclusive(&lock)?;
+ let state = load_local_state(&path, capacity)?;
+ Ok(Self { path, state })
+ }
+
+ #[must_use]
+ pub const fn state(&self) -> &AttentionLocalState {
+ &self.state
+ }
+
+ pub fn record_read(
+ &mut self,
+ key: AttentionLocalKey,
+ ) -> Result {
+ self.transact(|state| state.mark_read(key))
+ }
+
+ pub fn reserve_notification(
+ &mut self,
+ key: AttentionLocalKey,
+ authoritative_unread: bool,
+ ) -> Result {
+ if !authoritative_unread {
+ return Ok(NotificationReservation::Ineligible);
+ }
+ self.transact(|state| {
+ if state.retirements.iter().any(|retirement| {
+ retirement.target() == key.target() && retirement.source() == key.item().source()
+ }) {
+ return Err(AttentionLocalStateError::RetirementPending);
+ }
+ if state.is_notified(&key) {
+ return Ok(NotificationReservation::AlreadyReserved);
+ }
+ state.mark_notified(key)?;
+ Ok(NotificationReservation::Reserved)
+ })
+ }
+
+ /// Prune superseded/removed revisions only after accepting a complete
+ /// authoritative source replacement. Refusal/unavailability must not call
+ /// this method because neither means an empty source.
+ pub fn reconcile_source(
+ &mut self,
+ target: &PlatformAttentionTarget,
+ source: &AttentionSource,
+ current: BTreeSet,
+ ) -> Result {
+ if current.len() > MAX_ATTENTION_ITEMS
+ || current
+ .iter()
+ .any(|key| key.target() != target || key.item().source() != source)
+ {
+ return Err(AttentionLocalStateError::ReconciliationInvalid);
+ }
+ self.transact(|state| {
+ if state
+ .retirements
+ .iter()
+ .any(|retirement| retirement.target() == target && retirement.source() == source)
+ {
+ return Err(AttentionLocalStateError::RetirementPending);
+ }
+ Ok(state.retain_source_keys(target, source, ¤t))
+ })
+ }
+
+ pub fn remove_source(
+ &mut self,
+ target: &PlatformAttentionTarget,
+ source: &AttentionSource,
+ ) -> Result {
+ self.reconcile_source(target, source, BTreeSet::new())
+ }
+
+ /// Persist the retirement fence before the corresponding in-memory board
+ /// is removed. A crash after this succeeds leaves enough custody for the
+ /// next process to finish cleanup before admitting a replacement.
+ pub fn begin_retirement(
+ &mut self,
+ retirement: AttentionRetirement,
+ ) -> Result {
+ self.transact(|state| state.begin_retirement(retirement))
+ }
+
+ /// Atomically remove every overlay tuple for the retired source and its
+ /// durable fence. Failure leaves both intact and therefore fail-closed.
+ pub fn finish_retirement(
+ &mut self,
+ retirement: &AttentionRetirement,
+ ) -> Result {
+ self.transact(|state| Ok(state.finish_retirement(retirement)))
+ }
+
+ #[must_use]
+ pub fn pending_retirements(&self) -> &BTreeSet {
+ &self.state.retirements
+ }
+
+ fn transact(
+ &mut self,
+ update: impl FnOnce(&mut AttentionLocalState) -> Result,
+ ) -> Result {
+ let _process_guard = ATTENTION_LOCAL_STORE_LOCK.lock();
+ prepare_local_storage(&self.path)?;
+ let lock = open_lock_file(&lock_path(&self.path))?;
+ fs2::FileExt::lock_exclusive(&lock)?;
+ let disk = load_local_state(&self.path, self.state.capacity)?;
+ let mut candidate = disk.clone();
+ let outcome = update(&mut candidate)?;
+ if candidate != disk {
+ persist_local_state(&self.path, &candidate)?;
+ }
+ self.state = candidate;
+ Ok(outcome)
+ }
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct AttentionLocalDocument {
+ schema: u16,
+ entries: Vec,
+ retirements: Vec,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct AttentionLocalEntry {
+ project_id: String,
+ workspace_id: String,
+ source_kind: String,
+ source_id: String,
+ item_id: String,
+ item_revision: u64,
+ read: bool,
+ notified: bool,
+}
+
+#[derive(Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct AttentionLocalRetirement {
+ project_id: String,
+ workspace_id: String,
+ source_kind: String,
+ source_id: String,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+struct AttentionLocalDocumentV1 {
+ schema: u16,
+ entries: Vec,
+}
+
+#[derive(Deserialize)]
+#[serde(deny_unknown_fields)]
+#[allow(dead_code)] // Parsed only to validate and boundedly discard unscoped v1 custody.
+struct AttentionLocalEntryV1 {
+ source_kind: String,
+ source_id: String,
+ item_id: String,
+ item_revision: u64,
+ read: bool,
+ notified: bool,
+}
+
+fn load_local_state(
+ path: &Path,
+ capacity: usize,
+) -> Result {
+ let mut state = AttentionLocalState::with_capacity(capacity)?;
+ let Some(bytes) = bounded_descriptor_read(path, MAX_ATTENTION_LOCAL_FILE_BYTES)? else {
+ return Ok(state);
+ };
+ if bytes.len() as u64 > MAX_ATTENTION_LOCAL_FILE_BYTES {
+ return Err(AttentionLocalStateError::DocumentInvalid);
+ }
+ let schema = serde_json::from_slice::(&bytes)?
+ .get("schema")
+ .and_then(serde_json::Value::as_u64)
+ .ok_or(AttentionLocalStateError::DocumentInvalid)?;
+ if schema == 1 {
+ let document: AttentionLocalDocumentV1 = serde_json::from_slice(&bytes)?;
+ if document.schema != 1 || document.entries.len() > capacity {
+ return Err(AttentionLocalStateError::DocumentInvalid);
+ }
+ // V1 tuples were not target-qualified. They cannot be safely
+ // attributed after a remap, so migration deliberately discards them
+ // rather than inheriting read/notification custody.
+ return Ok(state);
+ }
+ if schema != u64::from(ATTENTION_LOCAL_SCHEMA) {
+ return Err(AttentionLocalStateError::DocumentInvalid);
+ }
+ let document: AttentionLocalDocument = serde_json::from_slice(&bytes)?;
+ let (entries, retirements) = (document.entries, document.retirements);
+ if entries.len() > capacity || retirements.len() > capacity {
+ return Err(AttentionLocalStateError::DocumentInvalid);
+ }
+ for entry in entries {
+ if !entry.read && !entry.notified {
+ return Err(AttentionLocalStateError::DocumentInvalid);
+ }
+ let source = AttentionSource::new(
+ AttentionSourceKind::parse(&entry.source_kind)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ AttentionSourceId::new(entry.source_id)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ );
+ let key = AttentionLocalKey::new(
+ PlatformAttentionTarget {
+ project: ProjectId::new(entry.project_id)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ user_workspace: UserWorkspaceId::new(entry.workspace_id)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ },
+ AttentionItemKey::new(
+ source,
+ AttentionItemId::new(entry.item_id)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ ),
+ Revision::new(entry.item_revision)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ );
+ if state
+ .entries
+ .insert(
+ key,
+ AttentionLocalFlags {
+ read: entry.read,
+ notified: entry.notified,
+ },
+ )
+ .is_some()
+ {
+ return Err(AttentionLocalStateError::DocumentInvalid);
+ }
+ }
+ for retirement in retirements {
+ let retirement = AttentionRetirement::new(
+ PlatformAttentionTarget {
+ project: ProjectId::new(retirement.project_id)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ user_workspace: UserWorkspaceId::new(retirement.workspace_id)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ },
+ AttentionSource::new(
+ AttentionSourceKind::parse(&retirement.source_kind)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ AttentionSourceId::new(retirement.source_id)
+ .map_err(|_| AttentionLocalStateError::DocumentInvalid)?,
+ ),
+ );
+ if !state.retirements.insert(retirement) {
+ return Err(AttentionLocalStateError::DocumentInvalid);
+ }
+ }
+ Ok(state)
+}
+
+fn persist_local_state(
+ path: &Path,
+ state: &AttentionLocalState,
+) -> Result<(), AttentionLocalStateError> {
+ let bytes = encode_local_state(state)?;
+ secure_atomic_write(path, &bytes)?;
+ Ok(())
+}
+
+fn encode_local_state(state: &AttentionLocalState) -> Result, AttentionLocalStateError> {
+ let entries = state
+ .entries
+ .iter()
+ .map(|(key, flags)| AttentionLocalEntry {
+ project_id: key.target().project.as_str().to_owned(),
+ workspace_id: key.target().user_workspace.as_str().to_owned(),
+ source_kind: key.item().source().kind().as_str().to_owned(),
+ source_id: key.item().source().id().as_str().to_owned(),
+ item_id: key.item().item().as_str().to_owned(),
+ item_revision: key.item_revision().get(),
+ read: flags.read,
+ notified: flags.notified,
+ })
+ .collect();
+ let document = AttentionLocalDocument {
+ schema: ATTENTION_LOCAL_SCHEMA,
+ entries,
+ retirements: state
+ .retirements
+ .iter()
+ .map(|retirement| AttentionLocalRetirement {
+ project_id: retirement.target().project.as_str().to_owned(),
+ workspace_id: retirement.target().user_workspace.as_str().to_owned(),
+ source_kind: retirement.source().kind().as_str().to_owned(),
+ source_id: retirement.source().id().as_str().to_owned(),
+ })
+ .collect(),
+ };
+ let bytes = serde_json::to_vec_pretty(&document)?;
+ if bytes.len() as u64 > MAX_ATTENTION_LOCAL_FILE_BYTES {
+ return Err(AttentionLocalStateError::DocumentInvalid);
+ }
+ Ok(bytes)
+}
+
+fn prepare_local_storage(path: &Path) -> Result<(), AttentionLocalStateError> {
+ let parent = path.parent().ok_or_else(|| {
+ AttentionLocalStateError::Io(std::io::Error::new(
+ std::io::ErrorKind::InvalidInput,
+ "attention local-state path has no parent",
+ ))
+ })?;
+ ensure_private_directory_io(parent)?;
+ Ok(())
+}
+
+#[derive(Debug, Error)]
+pub enum AttentionError {
+ #[error("platform mapping is not exact")]
+ MappingNotExact,
+ #[error("platform mapping contains an invalid protocol identity")]
+ MappingInvalid,
+ #[error("attention inventory exceeds its client bound")]
+ InventoryTooLarge,
+ #[error("attention inventory repeats a work-context identity")]
+ InventoryDuplicate,
+ #[error("attention workspace is missing or ambiguous")]
+ WorkspaceMissingOrAmbiguous,
+ #[error("attention workspace does not belong to the exact project")]
+ WorkspaceProjectMismatch,
+ #[error("attention source identity is invalid")]
+ SourceInvalid,
+ #[error("attention provider source lacks its exact Platform session relation")]
+ ProviderSessionRelationInvalid,
+ #[error("attention source is duplicated")]
+ SourceDuplicate,
+ #[error("attention source inventory exceeds its per-workspace bound")]
+ SourceInventoryTooLarge,
+ #[error("attention source is not in the authoritative inventory")]
+ SourceNotInventoried,
+ #[error("attention source does not match the requested source")]
+ SourceMismatch,
+ #[error("attention snapshot target does not match the board")]
+ TargetMismatch,
+ #[error("attention source must begin at revision one")]
+ InitialRevisionRequired,
+ #[error("attention source successor is stale or discontinuous")]
+ InvalidSuccessor,
+ #[error("attention complete baseline rolls back retained source or item custody")]
+ InvalidBaseline,
+ #[error("same attention revision has different content")]
+ ConflictingReplay,
+ #[error("attention UI identity collides with another raw source/item tuple")]
+ UiIdentityCollision,
+}
+
+#[derive(Debug, Error, Eq, PartialEq)]
+pub enum AttentionActivationError {
+ #[error("attention item is no longer present")]
+ ItemMissing,
+ #[error("attention item revision is stale")]
+ ItemStale,
+ #[error("attention workspace is missing or ambiguous in the current catalog")]
+ WorkspaceMissingOrAmbiguous,
+ #[error("attention provider coordinate is invalid")]
+ CoordinateInvalid,
+ #[error("attention provider session is missing or ambiguous")]
+ SessionMissingOrAmbiguous,
+ #[error("attention provider session is not fresh")]
+ SessionNotFresh,
+ #[error("attention provider session is retained by multiple panes")]
+ PaneAmbiguous,
+}
+
+#[derive(Debug, Error)]
+pub enum AttentionLocalStateError {
+ #[error("attention local-state capacity is invalid")]
+ CapacityInvalid,
+ #[error("attention local-state capacity is exhausted")]
+ CapacityExceeded,
+ #[error("attention local-state document is invalid")]
+ DocumentInvalid,
+ #[error("attention local-state reconciliation is not source-exact")]
+ ReconciliationInvalid,
+ #[error("attention local-state source retirement is still pending")]
+ RetirementPending,
+ #[error("attention local-state I/O failed: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("attention local-state serialization failed: {0}")]
+ Serialization(#[from] serde_json::Error),
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::workspace_catalog::{
+ CatalogCheckoutId, CatalogProjectId, CheckoutHost, PlatformContextRef,
+ PlatformMappingReconciliation, ProjectCheckout, ProjectRecord, RepositoryIdentity,
+ WorkspaceLaunchIntake, WorkspaceLaunchRequest,
+ };
+ use crate::workspace_navigation::{
+ PaneId, PaneLeaf, ProviderSessionBinding, WorkspaceCardState, WorkspaceNavigationAction,
+ WorkspaceSurfaceState, WorkspaceTab, WorkspaceTabContent, WorkspaceTabId,
+ };
+ use automonique_protocol::platform::{
+ Freshness, FreshnessState, ResourceAuthority, ResourceCoordinate, ResourceId, ResourceKind,
+ ResourceRecord,
+ };
+ use automonique_protocol::platform_v2::{
+ AttemptWorkspaceId, CheckoutId, V1SessionRef, WorkContextAttributes, WorkContextLabel,
+ WorkContextLifecycle, WorkContextRelation, WorkSessionId,
+ };
+ use automonique_protocol::primitives::EpochMillis;
+ use std::sync::atomic::{AtomicU64, Ordering};
+
+ static TEMP_NONCE: AtomicU64 = AtomicU64::new(0);
+
+ fn target() -> PlatformAttentionTarget {
+ PlatformAttentionTarget {
+ project: ProjectId::new("project-1").unwrap(),
+ user_workspace: UserWorkspaceId::new("workspace-1").unwrap(),
+ }
+ }
+
+ fn source(kind: AttentionSourceKind, id: &str) -> AttentionSource {
+ AttentionSource::new(kind, AttentionSourceId::new(id.to_owned()).unwrap())
+ }
+
+ fn item(id: &str, revision: u64, unread: bool) -> AttentionItem {
+ AttentionItem::new(
+ AttentionItemId::new(id.to_owned()).unwrap(),
+ Revision::new(revision).unwrap(),
+ revision * 100,
+ AttentionItemState::Blocked,
+ AttentionItemReason::ExternalBlocker,
+ unread,
+ Vec::new(),
+ None,
+ )
+ .unwrap()
+ }
+
+ fn provider_item_for(id: &str, revision: u64, session_id: &str) -> AttentionItem {
+ AttentionItem::new(
+ AttentionItemId::new(id).unwrap(),
+ Revision::new(revision).unwrap(),
+ revision * 100,
+ AttentionItemState::Done,
+ AttentionItemReason::Complete,
+ true,
+ Vec::new(),
+ Some(
+ V1SessionRef::new(ResourceCoordinate::new(
+ ResourceAuthority::Automonique,
+ ResourceKind::Session,
+ ResourceId::new(session_id).unwrap(),
+ ))
+ .unwrap(),
+ ),
+ )
+ .unwrap()
+ }
+
+ fn provider_item(id: &str, revision: u64) -> AttentionItem {
+ provider_item_for(id, revision, "platform-session-1")
+ }
+
+ fn snapshot(
+ source: AttentionSource,
+ revision: u64,
+ previous: Option,
+ items: Vec,
+ ) -> AttentionSourceSnapshot {
+ AttentionSourceSnapshot::new(
+ source,
+ target().project,
+ target().user_workspace,
+ Revision::new(revision).unwrap(),
+ previous.map(|value| Revision::new(value).unwrap()),
+ revision * 100,
+ items,
+ )
+ .unwrap()
+ }
+
+ fn relation(kind: WorkContextRelationKind, target: WorkContextIdentity) -> WorkContextRelation {
+ WorkContextRelation::new(kind, target).unwrap()
+ }
+
+ fn record(
+ identity: WorkContextIdentity,
+ lifecycle: WorkContextLifecycle,
+ relations: Vec,
+ ) -> WorkContextRecord {
+ WorkContextRecord::new(
+ identity,
+ Revision::FIRST,
+ lifecycle,
+ WorkContextLabel::new("record").unwrap(),
+ WorkContextAttributes::EMPTY,
+ relations,
+ )
+ .unwrap()
+ }
+
+ fn workspace_record(project: &str, workspace: &str) -> WorkContextRecord {
+ record(
+ WorkContextIdentity::UserWorkspace(UserWorkspaceId::new(workspace).unwrap()),
+ WorkContextLifecycle::Active,
+ vec![
+ relation(
+ WorkContextRelationKind::UserWorkspaceProject,
+ WorkContextIdentity::Project(ProjectId::new(project).unwrap()),
+ ),
+ relation(
+ WorkContextRelationKind::UserWorkspaceCheckout,
+ WorkContextIdentity::Checkout(CheckoutId::new("checkout-1").unwrap()),
+ ),
+ ],
+ )
+ }
+
+ fn attempt_record(id: &str) -> WorkContextRecord {
+ record(
+ WorkContextIdentity::AttemptWorkspace(AttemptWorkspaceId::new(id).unwrap()),
+ WorkContextLifecycle::Running,
+ vec![relation(
+ WorkContextRelationKind::AttemptUserWorkspace,
+ WorkContextIdentity::UserWorkspace(UserWorkspaceId::new("workspace-1").unwrap()),
+ )],
+ )
+ }
+
+ fn session_record(id: &str, attempt: &str) -> WorkContextRecord {
+ let platform_session = V1SessionRef::new(ResourceCoordinate::new(
+ ResourceAuthority::Automonique,
+ ResourceKind::Session,
+ ResourceId::new(format!("platform-{id}")).unwrap(),
+ ))
+ .unwrap();
+ record(
+ WorkContextIdentity::Session(WorkSessionId::new(id).unwrap()),
+ WorkContextLifecycle::Active,
+ vec![
+ relation(
+ WorkContextRelationKind::SessionAttemptWorkspace,
+ WorkContextIdentity::AttemptWorkspace(
+ AttemptWorkspaceId::new(attempt).unwrap(),
+ ),
+ ),
+ relation(
+ WorkContextRelationKind::SessionPlatformSession,
+ WorkContextIdentity::PlatformSession(platform_session),
+ ),
+ ],
+ )
+ }
+
+ fn inventory(review: ReviewAttentionPresence) -> AttentionSourceInventory {
+ AttentionSourceInventory::from_authoritative_records(
+ target(),
+ &[
+ workspace_record("project-1", "workspace-1"),
+ attempt_record("attempt-1"),
+ session_record("session-1", "attempt-1"),
+ ],
+ review,
+ )
+ .unwrap()
+ }
+
+ fn local_key(source_id: &str, item_id: &str, revision: u64) -> AttentionLocalKey {
+ AttentionLocalKey::new(
+ target(),
+ AttentionItemKey::new(
+ source(AttentionSourceKind::Orchestration, source_id),
+ AttentionItemId::new(item_id.to_owned()).unwrap(),
+ ),
+ Revision::new(revision).unwrap(),
+ )
+ }
+
+ fn temp_path(name: &str) -> PathBuf {
+ let base = std::env::temp_dir().join(format!(
+ "shelldeck-platform-attention-{}-{}",
+ std::process::id(),
+ TEMP_NONCE.fetch_add(1, Ordering::Relaxed)
+ ));
+ base.join("private").join("state").join(name)
+ }
+
+ // SDTEST-1816 — SDUC-493
+ #[test]
+ fn sdtest_1816_inventory_is_exact_bounded_and_authority_derived() {
+ let source_inventory = inventory(ReviewAttentionPresence::Present);
+ assert_eq!(source_inventory.sources().len(), 3);
+ assert!(source_inventory.contains(&source(AttentionSourceKind::Review, "workspace-1")));
+ assert!(
+ source_inventory.contains(&source(AttentionSourceKind::Orchestration, "workspace-1"))
+ );
+ assert!(
+ source_inventory.contains(&source(AttentionSourceKind::ProviderSession, "session-1"))
+ );
+
+ let without_review = inventory(ReviewAttentionPresence::Absent);
+ assert_eq!(without_review.sources().len(), 2);
+ assert!(!without_review.contains(&source(AttentionSourceKind::Review, "workspace-1")));
+
+ let wrong_project = AttentionSourceInventory::from_authoritative_records(
+ target(),
+ &[workspace_record("project-elsewhere", "workspace-1")],
+ ReviewAttentionPresence::Absent,
+ );
+ assert!(matches!(
+ wrong_project,
+ Err(AttentionError::WorkspaceProjectMismatch)
+ ));
+
+ let duplicate = workspace_record("project-1", "workspace-1");
+ let ambiguous = AttentionSourceInventory::from_authoritative_records(
+ target(),
+ &[duplicate.clone(), duplicate],
+ ReviewAttentionPresence::Absent,
+ );
+ assert!(matches!(ambiguous, Err(AttentionError::InventoryDuplicate)));
+
+ let mut records = vec![workspace_record("project-1", "workspace-1")];
+ for index in 0..63 {
+ let attempt = format!("attempt-{index}");
+ records.push(attempt_record(&attempt));
+ records.push(session_record(&format!("session-{index}"), &attempt));
+ }
+ let overflow = AttentionSourceInventory::from_authoritative_records(
+ target(),
+ &records,
+ ReviewAttentionPresence::Present,
+ );
+ assert!(matches!(
+ overflow,
+ Err(AttentionError::SourceInventoryTooLarge)
+ ));
+ }
+
+ // SDTEST-1817 — SDUC-493
+ #[test]
+ fn sdtest_1817_snapshot_replacement_is_atomic_and_retains_revision_custody() {
+ let orchestration = source(AttentionSourceKind::Orchestration, "workspace-1");
+ let current_after_restart = snapshot(
+ orchestration.clone(),
+ 7,
+ Some(6),
+ vec![item("restart-baseline", 7, true)],
+ );
+ let mut fresh = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present));
+ assert!(matches!(
+ fresh.apply_read(
+ &orchestration,
+ AttentionReadResult::Snapshot(Box::new(current_after_restart.clone())),
+ ),
+ Err(AttentionError::InitialRevisionRequired)
+ ));
+ assert_eq!(
+ fresh
+ .apply_authenticated_baseline_read(
+ &orchestration,
+ AttentionReadResult::Snapshot(Box::new(current_after_restart.clone())),
+ )
+ .unwrap(),
+ AttentionApplyOutcome::Inserted
+ );
+ let mut restarted =
+ PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present));
+ restarted
+ .apply_authenticated_baseline_read(
+ &orchestration,
+ AttentionReadResult::Snapshot(Box::new(current_after_restart)),
+ )
+ .unwrap();
+ assert_eq!(
+ restarted
+ .retained_snapshot(&orchestration)
+ .unwrap()
+ .revision(),
+ Revision::new(7).unwrap()
+ );
+
+ let mut board = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present));
+ assert_eq!(
+ board
+ .replace_source(snapshot(
+ orchestration.clone(),
+ 1,
+ None,
+ vec![item("old", 1, true)],
+ ))
+ .unwrap(),
+ AttentionApplyOutcome::Inserted
+ );
+ let before = board.clone();
+ let discontinuous = snapshot(
+ orchestration.clone(),
+ 3,
+ Some(2),
+ vec![item("old", 2, true)],
+ );
+ assert!(matches!(
+ board.replace_source(discontinuous.clone()),
+ Err(AttentionError::InvalidSuccessor)
+ ));
+ assert_eq!(board, before, "rejected replacement changes nothing");
+ assert_eq!(
+ board
+ .apply_authenticated_baseline_read(
+ &orchestration,
+ AttentionReadResult::Snapshot(Box::new(discontinuous)),
+ )
+ .unwrap(),
+ AttentionApplyOutcome::Replaced
+ );
+ assert_eq!(
+ board.retained_snapshot(&orchestration).unwrap().revision(),
+ Revision::new(3).unwrap()
+ );
+ let before_regression = board.clone();
+ let regressing_baseline = snapshot(
+ orchestration.clone(),
+ 4,
+ Some(3),
+ vec![item("old", 1, true)],
+ );
+ assert!(matches!(
+ board.apply_authenticated_baseline_read(
+ &orchestration,
+ AttentionReadResult::Snapshot(Box::new(regressing_baseline)),
+ ),
+ Err(AttentionError::InvalidBaseline)
+ ));
+ assert_eq!(board, before_regression);
+ let forked_baseline = snapshot(
+ orchestration.clone(),
+ 6,
+ Some(1),
+ vec![item("forked", 6, true)],
+ );
+ assert!(matches!(
+ board.apply_authenticated_baseline_read(
+ &orchestration,
+ AttentionReadResult::Snapshot(Box::new(forked_baseline)),
+ ),
+ Err(AttentionError::InvalidBaseline)
+ ));
+ assert_eq!(board, before_regression);
+
+ let first = board.retained_snapshot(&orchestration).unwrap().clone();
+ assert_eq!(
+ board.replace_source(first).unwrap(),
+ AttentionApplyOutcome::ExactReplay
+ );
+ board
+ .mark_unavailable(&orchestration, AttentionUnavailableReason::Transport)
+ .unwrap();
+ assert_eq!(board.visible_items().count(), 0);
+ assert_eq!(
+ board.retained_snapshot(&orchestration).unwrap().revision(),
+ Revision::new(3).unwrap()
+ );
+
+ let empty = snapshot(orchestration.clone(), 4, Some(3), Vec::new());
+ assert_eq!(
+ board.replace_source(empty).unwrap(),
+ AttentionApplyOutcome::Replaced
+ );
+ assert_eq!(board.visible_items().count(), 0);
+
+ let new_incarnation = snapshot(
+ orchestration.clone(),
+ 5,
+ Some(4),
+ vec![item("new-incarnation", 5, true)],
+ );
+ board.replace_source(new_incarnation).unwrap();
+ let visible = board.visible_items().next().unwrap();
+ assert_eq!(visible.key().item().as_str(), "new-incarnation");
+
+ let review = source(AttentionSourceKind::Review, "workspace-1");
+ let before_mismatched_read = board.clone();
+ assert!(matches!(
+ board.apply_read(
+ &review,
+ AttentionReadResult::Snapshot(Box::new(snapshot(
+ orchestration.clone(),
+ 6,
+ Some(5),
+ Vec::new(),
+ ))),
+ ),
+ Err(AttentionError::SourceMismatch)
+ ));
+ assert_eq!(board, before_mismatched_read);
+
+ let refusal = PlatformV2Refusal::new("not_authorized", "refused").unwrap();
+ assert_eq!(
+ board
+ .apply_read(&orchestration, AttentionReadResult::Refused(refusal))
+ .unwrap(),
+ AttentionApplyOutcome::Refused
+ );
+ assert_eq!(board.visible_items().count(), 0);
+ assert_eq!(
+ board.retained_snapshot(&orchestration).unwrap().revision(),
+ Revision::new(5).unwrap()
+ );
+ let replay = board.retained_snapshot(&orchestration).unwrap().clone();
+ assert_eq!(
+ board.replace_source(replay).unwrap(),
+ AttentionApplyOutcome::AvailabilityRestored
+ );
+
+ let provider = source(AttentionSourceKind::ProviderSession, "session-1");
+ board
+ .replace_source(snapshot(
+ provider.clone(),
+ 1,
+ None,
+ vec![provider_item("provider-item", 1)],
+ ))
+ .unwrap();
+ let provider_ui_id = board
+ .visible_items()
+ .find(|item| item.key().source() == &provider)
+ .unwrap()
+ .ui_id();
+ let new_inventory = AttentionSourceInventory::from_authoritative_records(
+ target(),
+ &[workspace_record("project-1", "workspace-1")],
+ ReviewAttentionPresence::Absent,
+ )
+ .unwrap();
+ board.replace_inventory(new_inventory).unwrap();
+ assert!(board.retained_snapshot(&provider).is_none());
+ assert!(board.item_by_ui_id(provider_ui_id).is_none());
+
+ board
+ .replace_inventory(inventory(ReviewAttentionPresence::Present))
+ .unwrap();
+ assert!(matches!(
+ board.replace_source(snapshot(
+ provider.clone(),
+ 5,
+ Some(4),
+ vec![provider_item("provider-current", 5)],
+ )),
+ Err(AttentionError::InitialRevisionRequired)
+ ));
+ board
+ .apply_authenticated_baseline_read(
+ &provider,
+ AttentionReadResult::Snapshot(Box::new(snapshot(
+ provider.clone(),
+ 5,
+ Some(4),
+ vec![provider_item("provider-current", 5)],
+ ))),
+ )
+ .unwrap();
+ assert_eq!(
+ board.retained_snapshot(&provider).unwrap().revision(),
+ Revision::new(5).unwrap()
+ );
+ }
+
+ // SDTEST-1818 — SDUC-493
+ #[test]
+ fn sdtest_1818_ui_identity_is_source_scoped_and_collisions_fail_atomically() {
+ let left = AttentionItemKey::new(
+ source(AttentionSourceKind::Orchestration, "a:b"),
+ AttentionItemId::new("c").unwrap(),
+ );
+ let right = AttentionItemKey::new(
+ source(AttentionSourceKind::Orchestration, "a"),
+ AttentionItemId::new("b:c").unwrap(),
+ );
+ assert_ne!(
+ AttentionUiItemId::from_authoritative_key(&left),
+ AttentionUiItemId::from_authoritative_key(&right),
+ "length prefixes prevent delimiter aliasing"
+ );
+ assert_eq!(
+ AttentionUiItemId::from_authoritative_key(&left),
+ AttentionUiItemId::from_authoritative_key(&left),
+ "the presentation UUID is deterministic"
+ );
+ assert_eq!(
+ AttentionUiItemId::from_authoritative_key(&left).uuid(),
+ Uuid::parse_str("4b8762bc-3aa9-5973-815c-d0e44ba41aa9").unwrap(),
+ "the documented v1 namespace and tuple encoding are release-stable"
+ );
+
+ let review = source(AttentionSourceKind::Review, "workspace-1");
+ let orchestration = source(AttentionSourceKind::Orchestration, "workspace-1");
+ let mut board = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present));
+ let forced = AttentionUiItemId(Uuid::nil());
+ board
+ .replace_source_with(
+ snapshot(review.clone(), 1, None, vec![item("review-item", 1, true)]),
+ |_| forced,
+ )
+ .unwrap();
+ let before = board.clone();
+ assert!(matches!(
+ board.replace_source_with(
+ snapshot(
+ orchestration.clone(),
+ 1,
+ None,
+ vec![item("orchestration-item", 1, true)],
+ ),
+ |_| forced,
+ ),
+ Err(AttentionError::UiIdentityCollision)
+ ));
+ assert_eq!(board, before);
+ assert!(board.retained_snapshot(&orchestration).is_none());
+ assert_eq!(board.item_by_ui_id(forced).unwrap().key().source(), &review);
+ }
+
+ // SDTEST-1819 — SDUC-493
+ #[test]
+ fn sdtest_1819_local_overlay_is_revision_bound_durable_and_fail_closed() {
+ let path = temp_path("attention.json");
+ let first = local_key("workspace-1", "item-1", 1);
+ let next_revision = local_key("workspace-1", "item-1", 2);
+ let second = local_key("workspace-1", "item-2", 1);
+ let mut store = AttentionLocalStateStore::open(path.clone(), 2).unwrap();
+ assert_eq!(
+ store.reserve_notification(first.clone(), false).unwrap(),
+ NotificationReservation::Ineligible
+ );
+ assert!(!path.exists(), "ineligible state does not create custody");
+ assert!(store.record_read(first.clone()).unwrap());
+ assert_eq!(
+ store.reserve_notification(first.clone(), true).unwrap(),
+ NotificationReservation::Reserved
+ );
+ assert_eq!(
+ store.reserve_notification(first.clone(), true).unwrap(),
+ NotificationReservation::AlreadyReserved
+ );
+
+ let mut restarted = AttentionLocalStateStore::open(path.clone(), 2).unwrap();
+ assert!(restarted.state().is_read(&first));
+ assert!(restarted.state().is_notified(&first));
+ assert!(!restarted.state().is_read(&next_revision));
+ assert!(!restarted.state().is_notified(&next_revision));
+ restarted
+ .reserve_notification(next_revision.clone(), true)
+ .unwrap();
+ let durable_before = std::fs::read(&path).unwrap();
+ assert!(matches!(
+ restarted.record_read(second),
+ Err(AttentionLocalStateError::CapacityExceeded)
+ ));
+ assert_eq!(std::fs::read(&path).unwrap(), durable_before);
+
+ let current = BTreeSet::from([next_revision.clone()]);
+ restarted
+ .reconcile_source(
+ next_revision.target(),
+ next_revision.item().source(),
+ current,
+ )
+ .unwrap();
+ assert!(!restarted.state().is_read(&first));
+ assert!(restarted.state().is_notified(&next_revision));
+ let before_wrong_source = std::fs::read(&path).unwrap();
+ assert!(matches!(
+ restarted.reconcile_source(
+ next_revision.target(),
+ &source(AttentionSourceKind::Review, "workspace-1"),
+ BTreeSet::from([next_revision.clone()]),
+ ),
+ Err(AttentionLocalStateError::ReconciliationInvalid)
+ ));
+ assert_eq!(std::fs::read(&path).unwrap(), before_wrong_source);
+
+ let failing_path = temp_path("will-be-directory");
+ let mut failing = AttentionLocalStateStore::open(failing_path.clone(), 2).unwrap();
+ std::fs::create_dir(&failing_path).unwrap();
+ assert!(matches!(
+ failing.record_read(first.clone()),
+ Err(AttentionLocalStateError::Io(_))
+ ));
+ assert!(!failing.state().is_read(&first));
+
+ let invalid_path = temp_path("invalid.json");
+ prepare_local_storage(&invalid_path).unwrap();
+ std::fs::write(
+ &invalid_path,
+ br#"{"schema":1,"entries":[],"invented":true}"#,
+ )
+ .unwrap();
+ assert!(matches!(
+ AttentionLocalStateStore::open(invalid_path, 2),
+ Err(AttentionLocalStateError::Serialization(_))
+ ));
+
+ let stale_handle_path = temp_path("stale-handles.json");
+ let stale_key = local_key("workspace-1", "stale-handle-item", 1);
+ let mut first_handle =
+ AttentionLocalStateStore::open(stale_handle_path.clone(), 2).unwrap();
+ let mut second_handle =
+ AttentionLocalStateStore::open(stale_handle_path.clone(), 2).unwrap();
+ assert_eq!(
+ first_handle
+ .reserve_notification(stale_key.clone(), true)
+ .unwrap(),
+ NotificationReservation::Reserved
+ );
+ assert_eq!(
+ second_handle
+ .reserve_notification(stale_key.clone(), true)
+ .unwrap(),
+ NotificationReservation::AlreadyReserved
+ );
+ assert!(second_handle.state().is_notified(&stale_key));
+
+ let oversized_path = temp_path("oversized.json");
+ prepare_local_storage(&oversized_path).unwrap();
+ let oversized_source = source(
+ AttentionSourceKind::Orchestration,
+ &"s".repeat(automonique_protocol::platform_v2_attention::MAX_ATTENTION_FIELD_BYTES),
+ );
+ let large_state = |count: usize| {
+ let mut state =
+ AttentionLocalState::with_capacity(MAX_ATTENTION_LOCAL_ENTRIES).unwrap();
+ for index in 0..count {
+ let item_id = format!("{index:04}{}", "i".repeat(252));
+ state.entries.insert(
+ AttentionLocalKey::new(
+ target(),
+ AttentionItemKey::new(
+ oversized_source.clone(),
+ AttentionItemId::new(item_id).unwrap(),
+ ),
+ Revision::FIRST,
+ ),
+ AttentionLocalFlags {
+ read: true,
+ notified: false,
+ },
+ );
+ }
+ state
+ };
+ let mut fits = 0;
+ let mut exceeds = MAX_ATTENTION_LOCAL_ENTRIES;
+ assert!(encode_local_state(&large_state(exceeds)).is_err());
+ while fits + 1 < exceeds {
+ let candidate = fits + (exceeds - fits) / 2;
+ if encode_local_state(&large_state(candidate)).is_ok() {
+ fits = candidate;
+ } else {
+ exceeds = candidate;
+ }
+ }
+ let accepted_large = large_state(fits);
+ persist_local_state(&oversized_path, &accepted_large).unwrap();
+ let durable_large = std::fs::read(&oversized_path).unwrap();
+ let mut size_fenced =
+ AttentionLocalStateStore::open(oversized_path.clone(), MAX_ATTENTION_LOCAL_ENTRIES)
+ .unwrap();
+ let overflow_key = AttentionLocalKey::new(
+ target(),
+ AttentionItemKey::new(
+ oversized_source,
+ AttentionItemId::new(format!("{fits:04}{}", "i".repeat(252))).unwrap(),
+ ),
+ Revision::FIRST,
+ );
+ assert!(matches!(
+ size_fenced.record_read(overflow_key.clone()),
+ Err(AttentionLocalStateError::DocumentInvalid)
+ ));
+ assert!(!size_fenced.state().is_read(&overflow_key));
+ assert_eq!(std::fs::read(&oversized_path).unwrap(), durable_large);
+
+ std::fs::remove_dir_all(path.parent().unwrap()).ok();
+ std::fs::remove_dir_all(failing_path.parent().unwrap()).ok();
+ }
+
+ // SDTEST-1837 — SDUC-493
+ #[test]
+ fn sdtest_1837_retirement_fence_survives_failed_cleanup_and_restart() {
+ let path = temp_path("retirement-restart.json");
+ let key = local_key("workspace-1", "retired-item", 1);
+ let retirement = AttentionRetirement::new(
+ target(),
+ source(AttentionSourceKind::Orchestration, "workspace-1"),
+ );
+ let mut store = AttentionLocalStateStore::open(path.clone(), 4).unwrap();
+ assert!(store.record_read(key.clone()).unwrap());
+ assert!(store.begin_retirement(retirement.clone()).unwrap());
+ assert!(store.pending_retirements().contains(&retirement));
+ assert!(matches!(
+ store.record_read(key.clone()),
+ Err(AttentionLocalStateError::RetirementPending)
+ ));
+ assert!(matches!(
+ store.reserve_notification(key.clone(), true),
+ Err(AttentionLocalStateError::RetirementPending)
+ ));
+
+ let durable_fenced = std::fs::read(&path).unwrap();
+ let displaced = path.with_extension("fenced-backup");
+ std::fs::rename(&path, &displaced).unwrap();
+ std::fs::create_dir(&path).unwrap();
+ assert!(matches!(
+ store.finish_retirement(&retirement),
+ Err(AttentionLocalStateError::Io(_))
+ ));
+ assert!(store.state().is_read(&key));
+ assert!(store.pending_retirements().contains(&retirement));
+
+ std::fs::remove_dir(&path).unwrap();
+ std::fs::rename(&displaced, &path).unwrap();
+ assert_eq!(std::fs::read(&path).unwrap(), durable_fenced);
+ let mut restarted = AttentionLocalStateStore::open(path.clone(), 4).unwrap();
+ assert!(restarted.state().is_read(&key));
+ assert!(restarted.pending_retirements().contains(&retirement));
+ assert!(restarted.finish_retirement(&retirement).unwrap());
+ assert!(!restarted.state().is_read(&key));
+ assert!(restarted.pending_retirements().is_empty());
+
+ let final_restart = AttentionLocalStateStore::open(path.clone(), 4).unwrap();
+ assert!(!final_restart.state().is_read(&key));
+ assert!(final_restart.pending_retirements().is_empty());
+
+ let legacy_path = temp_path("unscoped-v1.json");
+ prepare_local_storage(&legacy_path).unwrap();
+ std::fs::write(
+ &legacy_path,
+ br#"{
+ "schema": 1,
+ "entries": [{
+ "source_kind": "orchestration",
+ "source_id": "workspace-1",
+ "item_id": "retired-item",
+ "item_revision": 1,
+ "read": true,
+ "notified": true
+ }]
+ }"#,
+ )
+ .unwrap();
+ let mut migrated = AttentionLocalStateStore::open(legacy_path.clone(), 4).unwrap();
+ assert!(
+ !migrated.state().is_read(&key),
+ "unscoped v1 custody is discarded"
+ );
+ migrated.record_read(key.clone()).unwrap();
+ let migrated_document = std::fs::read_to_string(&legacy_path).unwrap();
+ assert!(migrated_document.contains("\"schema\": 2"));
+ assert!(migrated_document.contains("\"project_id\": \"project-1\""));
+ assert!(migrated_document.contains("\"workspace_id\": \"workspace-1\""));
+ std::fs::remove_dir_all(path.parent().unwrap()).ok();
+ std::fs::remove_dir_all(legacy_path.parent().unwrap()).ok();
+ }
+
+ // SDTEST-1820 — SDUC-493
+ #[test]
+ fn sdtest_1820_notification_reservation_is_locked_across_processes() {
+ const CHILD_PATH: &str = "SHELLDECK_ATTENTION_CHILD_PATH";
+ const CHILD_READY: &str = "SHELLDECK_ATTENTION_CHILD_READY";
+ const CHILD_START: &str = "SHELLDECK_ATTENTION_CHILD_START";
+ const CHILD_RESULT: &str = "SHELLDECK_ATTENTION_CHILD_RESULT";
+ const TEST_NAME: &str = "config::platform_attention::tests::sdtest_1820_notification_reservation_is_locked_across_processes";
+
+ fn wait_for(path: &Path) {
+ let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
+ while !path.exists() {
+ assert!(
+ std::time::Instant::now() < deadline,
+ "timed out waiting for {}",
+ path.display()
+ );
+ std::thread::sleep(std::time::Duration::from_millis(2));
+ }
+ }
+
+ if let Some(path) = std::env::var_os(CHILD_PATH) {
+ let path = PathBuf::from(path);
+ let ready = PathBuf::from(std::env::var_os(CHILD_READY).unwrap());
+ let start = PathBuf::from(std::env::var_os(CHILD_START).unwrap());
+ let result = PathBuf::from(std::env::var_os(CHILD_RESULT).unwrap());
+ let mut store = AttentionLocalStateStore::open(path, 2).unwrap();
+ std::fs::write(&ready, b"ready").unwrap();
+ wait_for(&start);
+ let outcome = store
+ .reserve_notification(local_key("workspace-1", "process-race", 1), true)
+ .unwrap();
+ let value = match outcome {
+ NotificationReservation::Reserved => "reserved",
+ NotificationReservation::AlreadyReserved => "already",
+ NotificationReservation::Ineligible => panic!("unread item became ineligible"),
+ };
+ std::fs::write(result, value).unwrap();
+ return;
+ }
+
+ let path = temp_path("process-race.json");
+ let base = path
+ .parent()
+ .and_then(Path::parent)
+ .and_then(Path::parent)
+ .unwrap()
+ .to_path_buf();
+ std::fs::create_dir_all(&base).unwrap();
+ let start = base.join("start");
+ let executable = std::env::current_exe().unwrap();
+ let mut children = Vec::new();
+ for index in 0..2 {
+ let ready = base.join(format!("ready-{index}"));
+ let result = base.join(format!("result-{index}"));
+ let child = std::process::Command::new(&executable)
+ .arg("--exact")
+ .arg(TEST_NAME)
+ .arg("--nocapture")
+ .env(CHILD_PATH, &path)
+ .env(CHILD_READY, &ready)
+ .env(CHILD_START, &start)
+ .env(CHILD_RESULT, &result)
+ .spawn()
+ .unwrap();
+ children.push((child, ready, result));
+ }
+ for (_, ready, _) in &children {
+ wait_for(ready);
+ }
+ std::fs::write(&start, b"start").unwrap();
+
+ let mut outcomes = Vec::new();
+ for (mut child, _, result) in children {
+ assert!(child.wait().unwrap().success());
+ outcomes.push(std::fs::read_to_string(result).unwrap());
+ }
+ outcomes.sort();
+ assert_eq!(outcomes, vec!["already".to_owned(), "reserved".to_owned()]);
+
+ let restarted = AttentionLocalStateStore::open(path, 2).unwrap();
+ assert!(restarted
+ .state()
+ .is_notified(&local_key("workspace-1", "process-race", 1)));
+ std::fs::remove_dir_all(base).ok();
+ }
+
+ // SDTEST-1821 — SDUC-493
+ #[cfg(unix)]
+ #[test]
+ fn sdtest_1821_attention_storage_refuses_links_and_reads_one_descriptor() {
+ use std::os::unix::fs::symlink;
+
+ let linked_path = temp_path("linked.json");
+ prepare_local_storage(&linked_path).unwrap();
+ let base = linked_path
+ .parent()
+ .and_then(Path::parent)
+ .and_then(Path::parent)
+ .unwrap()
+ .to_path_buf();
+ let outside = base.join("outside.json");
+ std::fs::write(&outside, br#"{"not":"attention custody"}"#).unwrap();
+
+ symlink(&outside, &linked_path).unwrap();
+ assert!(AttentionLocalStateStore::open(linked_path.clone(), 2).is_err());
+ std::fs::remove_file(&linked_path).unwrap();
+ symlink(base.join("missing.json"), &linked_path).unwrap();
+ assert!(AttentionLocalStateStore::open(linked_path.clone(), 2).is_err());
+ std::fs::remove_file(&linked_path).unwrap();
+
+ let lock_attack_path = temp_path("linked-lock.json");
+ let lock_attack_base = lock_attack_path
+ .parent()
+ .and_then(Path::parent)
+ .and_then(Path::parent)
+ .unwrap()
+ .to_path_buf();
+ prepare_local_storage(&lock_attack_path).unwrap();
+ symlink(&outside, lock_path(&lock_attack_path)).unwrap();
+ assert!(AttentionLocalStateStore::open(lock_attack_path.clone(), 2).is_err());
+ std::fs::remove_file(lock_path(&lock_attack_path)).unwrap();
+
+ let key = local_key("workspace-1", "descriptor", 1);
+ let mut store = AttentionLocalStateStore::open(linked_path.clone(), 2).unwrap();
+ store.record_read(key).unwrap();
+ let accepted = std::fs::read(&linked_path).unwrap();
+ let moved = base.join("accepted.json");
+ let bytes = crate::workspace_review::storage::bounded_descriptor_read_after_open(
+ &linked_path,
+ MAX_ATTENTION_LOCAL_FILE_BYTES,
+ || {
+ std::fs::rename(&linked_path, &moved).unwrap();
+ symlink(&outside, &linked_path).unwrap();
+ },
+ )
+ .unwrap()
+ .unwrap();
+ assert_eq!(bytes, accepted);
+ std::fs::remove_file(&linked_path).unwrap();
+ std::fs::rename(&moved, &linked_path).unwrap();
+
+ let linked_parent_path = temp_path("parent-link.json");
+ let private_root = linked_parent_path
+ .parent()
+ .unwrap()
+ .parent()
+ .unwrap()
+ .to_path_buf();
+ let parent_base = private_root.parent().unwrap().to_path_buf();
+ std::fs::create_dir_all(&parent_base).unwrap();
+ let outside_root = parent_base.join("outside-root");
+ std::fs::create_dir_all(outside_root.join("state")).unwrap();
+ symlink(&outside_root, &private_root).unwrap();
+ assert!(AttentionLocalStateStore::open(linked_parent_path, 2).is_err());
+
+ std::fs::remove_dir_all(base).ok();
+ std::fs::remove_dir_all(lock_attack_base).ok();
+ std::fs::remove_file(&private_root).ok();
+ std::fs::remove_dir_all(&parent_base).ok();
+ }
+
+ // Windows release runners exercise the same no-follow path using native
+ // reparse-point metadata and FILE_FLAG_OPEN_REPARSE_POINT.
+ #[cfg(windows)]
+ #[test]
+ fn sdtest_1821_attention_storage_refuses_windows_reparse_points() {
+ let linked_path = temp_path("parent-reparse.json");
+ let private_root = linked_path
+ .parent()
+ .and_then(Path::parent)
+ .unwrap()
+ .to_path_buf();
+ let base = private_root.parent().unwrap().to_path_buf();
+ let outside = base.join("outside-root");
+ std::fs::create_dir_all(outside.join("state")).unwrap();
+ let status = std::process::Command::new("cmd")
+ .arg("/C")
+ .arg("mklink")
+ .arg("/J")
+ .arg(&private_root)
+ .arg(&outside)
+ .status()
+ .unwrap();
+ assert!(status.success(), "failed to create test junction");
+ assert!(AttentionLocalStateStore::open(linked_path, 2).is_err());
+ std::fs::remove_dir(private_root).ok();
+ std::fs::remove_dir_all(base).ok();
+ }
+
+ fn activation_catalog() -> (ProjectCatalog, CatalogWorkspaceId, CatalogCheckoutId) {
+ let project = CatalogProjectId::from_uuid(Uuid::from_u128(900));
+ let checkout = CatalogCheckoutId::from_uuid(Uuid::from_u128(901));
+ let workspace = CatalogWorkspaceId::from_uuid(Uuid::from_u128(902));
+ let mut project_record = ProjectRecord::new(project, "Attention project");
+ project_record.add_checkout(ProjectCheckout::new(
+ checkout,
+ "main",
+ CheckoutHost::Local {
+ device_label: "local".into(),
+ root: std::env::temp_dir().join("shelldeck-attention-activation"),
+ },
+ RepositoryIdentity {
+ slug: "bext/shelldeck".into(),
+ canonical_url: None,
+ },
+ ));
+ let mut catalog = ProjectCatalog::default();
+ catalog.insert_project(project_record).unwrap();
+ catalog
+ .create_workspace(WorkspaceLaunchRequest {
+ id: workspace,
+ project_id: project,
+ checkout_id: checkout,
+ name: "Attention".into(),
+ intake: WorkspaceLaunchIntake::Manual,
+ })
+ .unwrap();
+ catalog
+ .set_platform_mapping(
+ workspace,
+ None,
+ PlatformV2Mapping {
+ reconciliation_revision: 1,
+ project: PlatformContextRef {
+ id: "project-1".into(),
+ revision: 1,
+ },
+ checkout: PlatformContextRef {
+ id: "checkout-1".into(),
+ revision: 1,
+ },
+ user_workspace: PlatformContextRef {
+ id: "workspace-1".into(),
+ revision: 1,
+ },
+ reconciliation: PlatformMappingReconciliation::Exact {
+ reconciled_at_millis: 1,
+ },
+ },
+ )
+ .unwrap();
+ (catalog, workspace, checkout)
+ }
+
+ fn provider_surface(count: usize) -> WorkspaceSurfaceState {
+ let tabs = (0..count)
+ .map(|index| WorkspaceTab {
+ id: WorkspaceTabId::from_uuid(Uuid::from_u128(920 + index as u128)),
+ title: "Provider".into(),
+ content: WorkspaceTabContent::ProviderSession(ProviderSessionBinding {
+ platform_user_workspace_id: "workspace-1".into(),
+ session_id: "platform-session-1".into(),
+ run_id: None,
+ }),
+ })
+ .collect::>();
+ let active_tab = tabs.first().map(|tab| tab.id);
+ WorkspaceSurfaceState {
+ root: Some(PaneNode::Leaf(PaneLeaf {
+ id: PaneId::from_uuid(Uuid::from_u128(919)),
+ tabs,
+ active_tab,
+ })),
+ focus: active_tab.map(|tab_id| WorkspaceFocus {
+ pane_id: PaneId::from_uuid(Uuid::from_u128(919)),
+ tab_id,
+ }),
+ }
+ }
+
+ fn authorized_session_with_freshness(state: FreshnessState) -> SessionRecord {
+ SessionRecord {
+ session: ResourceRecord {
+ resource: ResourceCoordinate::new(
+ ResourceAuthority::Automonique,
+ ResourceKind::Session,
+ ResourceId::new("platform-session-1").unwrap(),
+ ),
+ freshness: Freshness {
+ state,
+ observed_at: EpochMillis::from_millis(1),
+ revision: Revision::FIRST,
+ },
+ summary: automonique_protocol::platform::PlatformText::new("session").unwrap(),
+ },
+ run: None,
+ attachable: true,
+ controllable: false,
+ }
+ }
+
+ fn authorized_session() -> SessionRecord {
+ authorized_session_with_freshness(FreshnessState::Fresh)
+ }
+
+ // SDTEST-1829
+ #[test]
+ fn sdtest_1829_activation_re_resolves_exact_current_catalogues_and_refuses_ambiguity() {
+ let (catalog, workspace, _) = activation_catalog();
+ let mut navigation = WorkspaceNavigationState::default();
+ navigation
+ .reduce(
+ &catalog,
+ WorkspaceNavigationAction::Retain {
+ id: workspace,
+ surface: provider_surface(0),
+ card: WorkspaceCardState::default(),
+ },
+ )
+ .unwrap();
+ let provider = source(AttentionSourceKind::ProviderSession, "session-1");
+ let mut board = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present));
+ board
+ .apply_authenticated_baseline_read(
+ &provider,
+ AttentionReadResult::Snapshot(Box::new(snapshot(
+ provider.clone(),
+ 1,
+ None,
+ vec![provider_item("provider", 1)],
+ ))),
+ )
+ .unwrap();
+ let visible = board.visible_items().next().unwrap();
+ let activation = PlatformAttentionActivation {
+ workspace,
+ item: visible.ui_id(),
+ item_revision: visible.value().revision(),
+ };
+ let sessions = vec![authorized_session()];
+ assert!(matches!(
+ resolve_platform_attention_activation(
+ activation,
+ &board,
+ &catalog,
+ &navigation,
+ &sessions,
+ ),
+ Ok(PlatformAttentionDestination::FleetSession { .. })
+ ));
+ assert_eq!(
+ resolve_platform_attention_activation(activation, &board, &catalog, &navigation, &[],),
+ Err(AttentionActivationError::SessionMissingOrAmbiguous)
+ );
+ let duplicate = vec![authorized_session(), authorized_session()];
+ assert_eq!(
+ resolve_platform_attention_activation(
+ activation,
+ &board,
+ &catalog,
+ &navigation,
+ &duplicate,
+ ),
+ Err(AttentionActivationError::SessionMissingOrAmbiguous)
+ );
+ for state in [FreshnessState::Stale, FreshnessState::Unknown] {
+ assert_eq!(
+ resolve_platform_attention_activation(
+ activation,
+ &board,
+ &catalog,
+ &navigation,
+ &[authorized_session_with_freshness(state)],
+ ),
+ Err(AttentionActivationError::SessionNotFresh)
+ );
+ }
+
+ let mut redirected =
+ PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present));
+ redirected
+ .apply_authenticated_baseline_read(
+ &provider,
+ AttentionReadResult::Snapshot(Box::new(snapshot(
+ provider.clone(),
+ 1,
+ None,
+ vec![provider_item_for("provider", 1, "platform-session-foreign")],
+ ))),
+ )
+ .unwrap();
+ let redirected_item = redirected.visible_items().next().unwrap();
+ let redirected_activation = PlatformAttentionActivation {
+ workspace,
+ item: redirected_item.ui_id(),
+ item_revision: redirected_item.value().revision(),
+ };
+ let mut foreign = authorized_session();
+ foreign.session.resource.id = ResourceId::new("platform-session-foreign").unwrap();
+ assert_eq!(
+ resolve_platform_attention_activation(
+ redirected_activation,
+ &redirected,
+ &catalog,
+ &navigation,
+ &[foreign],
+ ),
+ Err(AttentionActivationError::CoordinateInvalid),
+ "an item cannot redirect its provider source to another current session"
+ );
+
+ navigation
+ .reduce(
+ &catalog,
+ WorkspaceNavigationAction::UpdateSurface {
+ id: workspace,
+ surface: provider_surface(1),
+ },
+ )
+ .unwrap();
+ assert!(matches!(
+ resolve_platform_attention_activation(
+ activation,
+ &board,
+ &catalog,
+ &navigation,
+ &sessions,
+ ),
+ Ok(PlatformAttentionDestination::RetainedProviderPane { .. })
+ ));
+ navigation
+ .reduce(
+ &catalog,
+ WorkspaceNavigationAction::UpdateSurface {
+ id: workspace,
+ surface: provider_surface(2),
+ },
+ )
+ .unwrap();
+ assert_eq!(
+ resolve_platform_attention_activation(
+ activation,
+ &board,
+ &catalog,
+ &navigation,
+ &sessions,
+ ),
+ Err(AttentionActivationError::PaneAmbiguous)
+ );
+
+ board
+ .apply_read(
+ &provider,
+ AttentionReadResult::Snapshot(Box::new(snapshot(
+ provider.clone(),
+ 2,
+ Some(1),
+ vec![provider_item("provider", 2)],
+ ))),
+ )
+ .unwrap();
+ assert_eq!(
+ resolve_platform_attention_activation(
+ activation,
+ &board,
+ &catalog,
+ &navigation,
+ &sessions,
+ ),
+ Err(AttentionActivationError::ItemStale)
+ );
+ }
+
+ // --- Shared cross-client attention succession corpus -------------------
+
+ const ATTENTION_CORPUS: &[u8] =
+ include_bytes!("../../tests/fixtures/platform-v2-attention-conformance-v1.json");
+
+ #[derive(serde::Deserialize)]
+ struct CorpusFile {
+ schema: String,
+ version: String,
+ target: CorpusTarget,
+ cases: Vec,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct CorpusTarget {
+ project: String,
+ user_workspace: String,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct CorpusCase {
+ id: String,
+ source: CorpusSource,
+ reads: Vec,
+ expected: CorpusExpected,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct CorpusSource {
+ kind: String,
+ id: String,
+ }
+
+ #[derive(serde::Deserialize)]
+ #[serde(tag = "kind", rename_all = "snake_case")]
+ enum CorpusRead {
+ Snapshot {
+ mode: String,
+ outcome: String,
+ snapshot: CorpusSnapshot,
+ },
+ Refusal {
+ category: String,
+ },
+ Unavailable {
+ reason: String,
+ },
+ }
+
+ #[derive(serde::Deserialize)]
+ struct CorpusSnapshot {
+ source: CorpusSource,
+ project: String,
+ user_workspace: String,
+ revision: String,
+ previous_revision: Option,
+ observed_at_ms: String,
+ items: Vec,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct CorpusItem {
+ id: String,
+ revision: String,
+ observed_at_ms: String,
+ state: String,
+ reason: String,
+ unread: bool,
+ nested_agent_path: Vec,
+ platform_session: Option,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct CorpusSession {
+ authority: String,
+ kind: String,
+ id: String,
+ }
+
+ #[derive(serde::Deserialize)]
+ struct CorpusExpected {
+ available: bool,
+ visible_items: Vec,
+ }
+
+ fn corpus_decimal(value: &str) -> u64 {
+ assert!(
+ !value.is_empty()
+ && (value == "0"
+ || (!value.starts_with('0') && value.bytes().all(|b| b.is_ascii_digit()))),
+ "corpus revision is not a canonical decimal: {value}"
+ );
+ value
+ .parse()
+ .expect("corpus revision fits the client fence")
+ }
+
+ fn corpus_target() -> PlatformAttentionTarget {
+ PlatformAttentionTarget {
+ project: ProjectId::new("project-conformance").unwrap(),
+ user_workspace: UserWorkspaceId::new("workspace-conformance").unwrap(),
+ }
+ }
+
+ /// The graph the corpus target implies: one workspace in its project, one
+ /// attempt under it, and one session bound to an exact Platform session.
+ fn corpus_inventory() -> AttentionSourceInventory {
+ let workspace = record(
+ WorkContextIdentity::UserWorkspace(
+ UserWorkspaceId::new("workspace-conformance").unwrap(),
+ ),
+ WorkContextLifecycle::Active,
+ vec![
+ relation(
+ WorkContextRelationKind::UserWorkspaceProject,
+ WorkContextIdentity::Project(ProjectId::new("project-conformance").unwrap()),
+ ),
+ relation(
+ WorkContextRelationKind::UserWorkspaceCheckout,
+ WorkContextIdentity::Checkout(CheckoutId::new("checkout-conformance").unwrap()),
+ ),
+ ],
+ );
+ let attempt = record(
+ WorkContextIdentity::AttemptWorkspace(
+ AttemptWorkspaceId::new("attempt-conformance").unwrap(),
+ ),
+ WorkContextLifecycle::Running,
+ vec![relation(
+ WorkContextRelationKind::AttemptUserWorkspace,
+ WorkContextIdentity::UserWorkspace(
+ UserWorkspaceId::new("workspace-conformance").unwrap(),
+ ),
+ )],
+ );
+ let platform_session = V1SessionRef::new(ResourceCoordinate::new(
+ ResourceAuthority::Automonique,
+ ResourceKind::Session,
+ ResourceId::new("platform-session-conformance").unwrap(),
+ ))
+ .unwrap();
+ let session = record(
+ WorkContextIdentity::Session(WorkSessionId::new("session-conformance").unwrap()),
+ WorkContextLifecycle::Active,
+ vec![
+ relation(
+ WorkContextRelationKind::SessionAttemptWorkspace,
+ WorkContextIdentity::AttemptWorkspace(
+ AttemptWorkspaceId::new("attempt-conformance").unwrap(),
+ ),
+ ),
+ relation(
+ WorkContextRelationKind::SessionPlatformSession,
+ WorkContextIdentity::PlatformSession(platform_session),
+ ),
+ ],
+ );
+ AttentionSourceInventory::from_authoritative_records(
+ corpus_target(),
+ &[workspace, attempt, session],
+ ReviewAttentionPresence::Present,
+ )
+ .unwrap()
+ }
+
+ fn corpus_source(value: &CorpusSource) -> AttentionSource {
+ let kind = match value.kind.as_str() {
+ "review" => AttentionSourceKind::Review,
+ "orchestration" => AttentionSourceKind::Orchestration,
+ "provider_session" => AttentionSourceKind::ProviderSession,
+ other => panic!("corpus source kind {other} is unknown"),
+ };
+ AttentionSource::new(kind, AttentionSourceId::new(value.id.clone()).unwrap())
+ }
+
+ fn corpus_state(value: &str) -> AttentionItemState {
+ match value {
+ "needs_you" => AttentionItemState::NeedsYou,
+ "working" => AttentionItemState::Working,
+ "blocked" => AttentionItemState::Blocked,
+ "done" => AttentionItemState::Done,
+ other => panic!("corpus item state {other} is unknown"),
+ }
+ }
+
+ fn corpus_reason(value: &str) -> AttentionItemReason {
+ match value {
+ "review_requested" => AttentionItemReason::ReviewRequested,
+ "comment_reply" => AttentionItemReason::CommentReply,
+ "approval_required" => AttentionItemReason::ApprovalRequired,
+ "agent_working" => AttentionItemReason::AgentWorking,
+ "check_running" => AttentionItemReason::CheckRunning,
+ "delivery_pending" => AttentionItemReason::DeliveryPending,
+ "complete" => AttentionItemReason::Complete,
+ "conflict" => AttentionItemReason::Conflict,
+ "check_failed" => AttentionItemReason::CheckFailed,
+ "external_blocker" => AttentionItemReason::ExternalBlocker,
+ other => panic!("corpus item reason {other} is unknown"),
+ }
+ }
+
+ fn corpus_snapshot(value: &CorpusSnapshot) -> AttentionSourceSnapshot {
+ let items = value
+ .items
+ .iter()
+ .map(|item| {
+ let session = item.platform_session.as_ref().map(|coordinate| {
+ assert_eq!(coordinate.authority, "automonique");
+ assert_eq!(coordinate.kind, "session");
+ V1SessionRef::new(ResourceCoordinate::new(
+ ResourceAuthority::Automonique,
+ ResourceKind::Session,
+ ResourceId::new(coordinate.id.clone()).unwrap(),
+ ))
+ .unwrap()
+ });
+ AttentionItem::new(
+ AttentionItemId::new(item.id.clone()).unwrap(),
+ Revision::new(corpus_decimal(&item.revision)).unwrap(),
+ corpus_decimal(&item.observed_at_ms),
+ corpus_state(&item.state),
+ corpus_reason(&item.reason),
+ item.unread,
+ item.nested_agent_path
+ .iter()
+ .map(|agent| {
+ automonique_protocol::platform_v2_attention::AttentionAgentId::new(
+ agent.clone(),
+ )
+ .unwrap()
+ })
+ .collect(),
+ session,
+ )
+ .unwrap()
+ })
+ .collect();
+ AttentionSourceSnapshot::new(
+ corpus_source(&value.source),
+ ProjectId::new(value.project.clone()).unwrap(),
+ UserWorkspaceId::new(value.user_workspace.clone()).unwrap(),
+ Revision::new(corpus_decimal(&value.revision)).unwrap(),
+ value
+ .previous_revision
+ .as_ref()
+ .map(|previous| Revision::new(corpus_decimal(previous)).unwrap()),
+ corpus_decimal(&value.observed_at_ms),
+ items,
+ )
+ .unwrap()
+ }
+
+ fn corpus_unavailable(reason: &str) -> AttentionUnavailableReason {
+ match reason {
+ "transport" => AttentionUnavailableReason::Transport,
+ "inventory_incomplete" => AttentionUnavailableReason::InventoryIncomplete,
+ other => panic!("corpus unavailable reason {other} is unknown"),
+ }
+ }
+
+ /// Assert this board reached exactly what the corpus records for the read.
+ fn assert_corpus_outcome(
+ case: &str,
+ outcome: &str,
+ actual: &Result,
+ ) {
+ let matched = match outcome {
+ "inserted" => matches!(actual, Ok(AttentionApplyOutcome::Inserted)),
+ "replaced" => matches!(actual, Ok(AttentionApplyOutcome::Replaced)),
+ "exact_replay" => matches!(actual, Ok(AttentionApplyOutcome::ExactReplay)),
+ "availability_restored" => {
+ matches!(actual, Ok(AttentionApplyOutcome::AvailabilityRestored))
+ }
+ "initial_revision_required" => {
+ matches!(actual, Err(AttentionError::InitialRevisionRequired))
+ }
+ "invalid_successor" => matches!(actual, Err(AttentionError::InvalidSuccessor)),
+ "conflicting_replay" => matches!(actual, Err(AttentionError::ConflictingReplay)),
+ "baseline_invalid" => matches!(actual, Err(AttentionError::InvalidBaseline)),
+ other => panic!("corpus outcome {other} is unknown"),
+ };
+ assert!(
+ matched,
+ "case {case} expected {outcome}, board reached {actual:?}"
+ );
+ }
+
+ // SDTEST-1856 — SDUC-493
+ #[test]
+ fn sdtest_1856_chronology_follows_authoritative_observation_not_source_order() {
+ fn observed(id: &str, revision: u64, observed_at_ms: u64) -> AttentionItem {
+ AttentionItem::new(
+ AttentionItemId::new(id.to_owned()).unwrap(),
+ Revision::new(revision).unwrap(),
+ observed_at_ms,
+ AttentionItemState::Blocked,
+ AttentionItemReason::ExternalBlocker,
+ true,
+ Vec::new(),
+ None,
+ )
+ .unwrap()
+ }
+
+ let review = source(AttentionSourceKind::Review, "workspace-1");
+ let orchestration = source(AttentionSourceKind::Orchestration, "workspace-1");
+ let mut board = PlatformAttentionBoard::new(inventory(ReviewAttentionPresence::Present));
+ board
+ .replace_source(
+ AttentionSourceSnapshot::new(
+ review.clone(),
+ target().project,
+ target().user_workspace,
+ Revision::FIRST,
+ None,
+ 900,
+ vec![observed("review-old", 1, 100)],
+ )
+ .unwrap(),
+ )
+ .unwrap();
+ board
+ .apply_authenticated_baseline_read(
+ &orchestration,
+ AttentionReadResult::Snapshot(Box::new(
+ AttentionSourceSnapshot::new(
+ orchestration.clone(),
+ target().project,
+ target().user_workspace,
+ Revision::new(3).unwrap(),
+ Some(Revision::new(2).unwrap()),
+ 900,
+ vec![
+ observed("orchestration-newest", 3, 800),
+ observed("orchestration-tie-high", 3, 400),
+ observed("orchestration-tie-low", 2, 400),
+ ],
+ )
+ .unwrap(),
+ )),
+ )
+ .unwrap();
+
+ // Key order alone would put the review source first: it is the lowest
+ // source kind. Chronology must contradict that.
+ assert_eq!(
+ board
+ .visible_items()
+ .map(|item| item.key().item().as_str().to_owned())
+ .collect::>(),
+ vec![
+ "review-old",
+ "orchestration-newest",
+ "orchestration-tie-high",
+ "orchestration-tie-low",
+ ]
+ );
+ assert_eq!(
+ board
+ .chronology()
+ .iter()
+ .map(|item| item.key().item().as_str().to_owned())
+ .collect::>(),
+ vec![
+ "orchestration-newest",
+ // Equal observation falls back to the higher item revision.
+ "orchestration-tie-high",
+ "orchestration-tie-low",
+ "review-old",
+ ]
+ );
+
+ // A hidden source contributes no chronology, and hiding it never
+ // reorders what survives.
+ board
+ .mark_unavailable(&orchestration, AttentionUnavailableReason::Transport)
+ .unwrap();
+ assert_eq!(
+ board
+ .chronology()
+ .iter()
+ .map(|item| item.key().item().as_str().to_owned())
+ .collect::>(),
+ vec!["review-old"]
+ );
+ }
+
+ // SDTEST-1842 — SDUC-493
+ #[test]
+ fn sdtest_1842_shared_attention_corpus_replays_to_the_recorded_outcomes() {
+ let corpus: CorpusFile = serde_json::from_slice(ATTENTION_CORPUS).unwrap();
+ assert_eq!(corpus.schema, "automonique.attention-conformance/v1");
+ assert_eq!(corpus.version, "1");
+ assert_eq!(corpus.target.project, "project-conformance");
+ assert_eq!(corpus.target.user_workspace, "workspace-conformance");
+ assert!(!corpus.cases.is_empty());
+
+ for case in &corpus.cases {
+ let source = corpus_source(&case.source);
+ let mut board = PlatformAttentionBoard::new(corpus_inventory());
+ assert!(
+ board.inventory().contains(&source),
+ "case {} names a source the corpus target does not inventory",
+ case.id
+ );
+
+ for read in &case.reads {
+ match read {
+ CorpusRead::Snapshot {
+ mode,
+ outcome,
+ snapshot,
+ } => {
+ let value = corpus_snapshot(snapshot);
+ let result = match mode.as_str() {
+ "continuous" => board.apply_read(
+ &source,
+ AttentionReadResult::Snapshot(Box::new(value)),
+ ),
+ "baseline" => board.apply_authenticated_baseline_read(
+ &source,
+ AttentionReadResult::Snapshot(Box::new(value)),
+ ),
+ other => panic!("corpus read mode {other} is unknown"),
+ };
+ assert_corpus_outcome(&case.id, outcome, &result);
+ }
+ CorpusRead::Refusal { category } => {
+ let refusal = PlatformV2Refusal::new(category.clone(), "corpus").unwrap();
+ board.mark_refused(&source, &refusal).unwrap();
+ }
+ CorpusRead::Unavailable { reason } => {
+ board
+ .mark_unavailable(&source, corpus_unavailable(reason))
+ .unwrap();
+ }
+ }
+ }
+
+ let available = matches!(
+ board.status(&source),
+ Some(AttentionSourceStatus::Available)
+ );
+ assert_eq!(
+ available, case.expected.available,
+ "case {} reached the wrong availability",
+ case.id
+ );
+ let visible = board
+ .visible_items()
+ .filter(|item| item.key().source() == &source)
+ .map(|item| item.value().id().as_str().to_owned())
+ .collect::>();
+ assert_eq!(
+ visible, case.expected.visible_items,
+ "case {} rendered the wrong items",
+ case.id
+ );
+ // Hiding a source must not discard its revision chain: a client
+ // that forgot where it was could only resynchronize by trusting
+ // whatever the next read claims.
+ let ever_accepted = case.reads.iter().any(|read| {
+ matches!(
+ read,
+ CorpusRead::Snapshot { outcome, .. }
+ if outcome == "inserted" || outcome == "replaced"
+ )
+ });
+ assert_eq!(
+ board.retained_snapshot(&source).is_some(),
+ ever_accepted,
+ "case {} disagrees about whether the revision chain survived",
+ case.id
+ );
+ }
+ }
+}
diff --git a/crates/shelldeck-core/src/config/platform_review.rs b/crates/shelldeck-core/src/config/platform_review.rs
index 0c058ac7..f8bef462 100644
--- a/crates/shelldeck-core/src/config/platform_review.rs
+++ b/crates/shelldeck-core/src/config/platform_review.rs
@@ -5,6 +5,7 @@
//! observations. Mutation previews retain their exact snapshot coordinates;
//! only the server can resolve them against separately configured authority.
+pub use automonique_platform_client::platform_v2_client::ReviewActionConfirmation;
use automonique_protocol::platform::IdempotencyKey;
use automonique_protocol::platform_v2::{ProjectId, WorkContextIdentity, WorkContextTargetKind};
pub use automonique_protocol::platform_v2_review::{
@@ -16,6 +17,12 @@ pub use automonique_protocol::platform_v2_review::{
ReviewProposalId, ReviewProposalKind, ReviewReceiptOutcome, ReviewReconciliation,
ReviewSchemaVersion, ReviewSnapshot, ReviewText, WorktreeFileState,
};
+pub use automonique_protocol::platform_v2_transport::{
+ ReviewAgentDeliveryCapability, ReviewCapabilities, ReviewCheckRerunCapability,
+ ReviewConfirmationDigest, ReviewConflictResolutionCapability, ReviewGitStagingCapabilities,
+ ReviewIndexDigest, ReviewPullRequestCapabilities, ReviewPullRequestMergeCapability,
+ ReviewReceiptCorrelationDigest, ReviewStagingCapability,
+};
use automonique_protocol::primitives::Revision;
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicU64, Ordering};
@@ -23,6 +30,31 @@ use std::time::{SystemTime, UNIX_EPOCH};
use super::workspace_catalog::PlatformV2Mapping;
+mod custody;
+mod delivery;
+mod notes;
+pub use custody::{
+ PlatformReviewCustodyStore, ReviewCustodyError, ReviewCustodyPresentation,
+ ReviewCustodyRecovery,
+};
+pub use delivery::{
+ advertised_agent_deliveries, review_delivery_control, AdvertisedReviewDelivery,
+ ReviewAgentDeliveryProjection, ReviewDeliveryWithheld,
+};
+pub use notes::{PlatformReviewNote, PlatformReviewNoteStore, ReviewNoteError};
+
+mod worktree;
+pub use worktree::{
+ advertised_conflict_resolutions, advertised_staging_proposals, review_safe_preview,
+ review_safe_text, review_staging_control, AdvertisedReviewConflictResolution,
+ AdvertisedReviewStaging, ReviewPreviewWithheld, ReviewSafeHtml, ReviewSafeImage,
+ ReviewSafePreview, ReviewSafeText, ReviewStagingProposal, ReviewStagingWithheld,
+ ReviewWorktreeFile, ReviewWorktreeHunk, ReviewWorktreeLane, ReviewWorktreeLaneGroup,
+ ReviewWorktreeObservation, ReviewWorktreeProjection, MAX_SAFE_PREVIEW_BYTES,
+ MAX_SAFE_PREVIEW_EDGE, MAX_SAFE_PREVIEW_LINES, MAX_SAFE_PREVIEW_LINE_CHARS,
+ MAX_SAFE_PREVIEW_PIXELS, SAFE_PREVIEW_BOX_EDGE,
+};
+
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct PlatformReviewTarget {
pub project: ProjectId,
@@ -95,6 +127,7 @@ pub struct PlatformReviewActionPreview {
expected_revision: Revision,
action: ReviewAction,
idempotency_key: IdempotencyKey,
+ confirmation: Option,
}
impl PlatformReviewActionPreview {
@@ -122,7 +155,7 @@ impl PlatformReviewActionPreview {
body: ReviewText::new(body.trim().to_owned())
.map_err(|_| "review comment is invalid")?,
};
- Self::review_action(target, review.revision, action, &nonce)
+ Self::review_action(target, review.revision, action, &nonce, None)
}
pub fn approve(
@@ -141,76 +174,194 @@ impl PlatformReviewActionPreview {
expected_review_revision: review.review.freshness.observed_revision,
},
&nonce,
+ None,
)
}
+ /// Prepare the unconfirmed batch delivery of advertised review comments.
+ ///
+ /// Every coordinate that crosses the network is taken verbatim from the
+ /// capability: the comment ids and their `expected_comment_revision` come
+ /// from `agent_deliverable_comments`, and the request is pinned to the
+ /// capability's own `snapshot_revision`. The snapshot is consulted only to
+ /// prove the two reads agree; it never supplies a value.
+ ///
+ /// This is the unconfirmed lane. It deliberately carries no confirmation
+ /// digest and no receipt correlation — see [`delivery`] for why both would
+ /// be wrong here, and why minting the second would actively break receipt
+ /// recovery. The advertisement is the fence, which is why an unadvertised
+ /// selection refuses here instead of being posted and refused remotely.
pub fn batch_send_comments(
target: PlatformReviewTarget,
review: &PlatformReviewSemantic,
+ capabilities: &ReviewCapabilities,
comment_ids: &[String],
) -> Result {
validate_review_target(&target, review)?;
- if review.review.freshness.state != ReviewFreshnessState::Fresh || comment_ids.is_empty() {
- return Err("review comments are not actionable");
+ if comment_ids.is_empty() {
+ return Err("review comment selection is empty");
}
let selected = comment_ids.iter().collect::>();
if selected.len() != comment_ids.len() {
return Err("review comment selection is duplicated");
}
- let comments = comment_ids
+ let advertised = advertised_agent_deliveries(Some(capabilities), &target, review);
+ if advertised.is_empty() {
+ return Err("review agent delivery is not advertised");
+ }
+ // Walk the advertisement rather than the selection. The server sorts
+ // the advertised list by comment id and the protocol requires a batch
+ // to be strictly ordered by that same id, so filtering here yields
+ // wire order for free; walking the selection would not.
+ let comments = advertised
.iter()
- .map(|id| {
- let comment = review
- .comments
- .iter()
- .find(|comment| &comment.id == id)
- .filter(|comment| review.comment_is_batch_actionable(&comment.id))
- .ok_or("review comment is not actionable")?;
+ .filter(|entry| selected.contains(&entry.comment_id))
+ .map(|entry| {
Ok(ReviewCommentTarget::new(
- ReviewCommentId::new(comment.id.clone())
+ ReviewCommentId::new(entry.comment_id.clone())
.map_err(|_| "review comment identity is invalid")?,
- comment.revision,
+ entry.expected_comment_revision,
))
})
.collect::, &'static str>>()?;
+ if comments.len() != selected.len() {
+ return Err("review comment is not advertised as deliverable");
+ }
let nonce = review_nonce();
Self::review_action(
target,
- review.revision,
+ capabilities.snapshot_revision(),
ReviewAction::BatchSendCommentsToAgent { comments },
&nonce,
+ None,
)
}
- pub fn apply_proposal(
+ /// Prepare one confirmed index-level staging transition.
+ ///
+ /// The *kind* is taken from the capability, never from the snapshot's
+ /// proposal. Both agree on a coherent read — [`advertised_staging_proposals`]
+ /// checks that — but the value the confirmation digest was minted over is
+ /// this one, so this is the one that crosses the wire.
+ ///
+ /// This is the confirmed lane. `Stage`, `Unstage` and `Commit` now require
+ /// a server-minted confirmation, and their unconfirmed spelling cannot be
+ /// encoded at all, so an unadvertised proposal refuses here rather than
+ /// being posted and refused remotely. A deployment withholding the commit
+ /// grant advertises no `Commit` entry, so this refuses for it while
+ /// admitting its `Stage` and `Unstage` siblings — the withholding is the
+ /// absence, and nothing here can invent the missing entry.
+ pub fn stage_proposal(
target: PlatformReviewTarget,
review: &PlatformReviewSemantic,
+ capabilities: &ReviewCapabilities,
proposal_id: &str,
) -> Result {
validate_review_target(&target, review)?;
- let proposal = review
- .proposals
+ let advertised = advertised_staging_proposals(Some(capabilities), &target, review);
+ if !advertised
.iter()
- .find(|proposal| proposal.id == proposal_id)
- .filter(|proposal| review.proposal_is_actionable(&proposal.id))
- .ok_or("review proposal is not actionable")?;
- let proposal_id = ReviewProposalId::new(proposal.id.clone())
+ .any(|entry| entry.proposal_id == proposal_id)
+ {
+ return Err("review staging proposal is not advertised");
+ }
+ let capability = capabilities
+ .staging()
+ .iter()
+ .find(|capability| capability.proposal_id().as_str() == proposal_id)
+ .ok_or("review staging capability is unavailable or stale")?;
+ let id = ReviewProposalId::new(proposal_id.to_owned())
.map_err(|_| "review proposal identity is invalid")?;
- let action = match proposal.kind {
- ReviewProposalKind::Stage => ReviewAction::Stage { proposal_id },
- ReviewProposalKind::Unstage => ReviewAction::Unstage { proposal_id },
- ReviewProposalKind::Commit => ReviewAction::Commit { proposal_id },
+ let action = match capability.kind() {
+ ReviewProposalKind::Stage => ReviewAction::Stage { proposal_id: id },
+ ReviewProposalKind::Unstage => ReviewAction::Unstage { proposal_id: id },
+ ReviewProposalKind::Commit => ReviewAction::Commit { proposal_id: id },
+ // Unreachable: `ReviewStagingCapability::new` refuses this kind,
+ // because collapsing a conflict writes worktree bytes rather than
+ // index entries and carries its own capability.
ReviewProposalKind::ResolveConflict => {
return Err("conflict resolution requires an explicit resolution")
}
};
+ let confirmation = ReviewActionConfirmation::new(
+ capability.confirmation_digest().clone(),
+ capabilities.workspace_revision(),
+ capability.receipt_correlation_digest().clone(),
+ );
+ let nonce = review_nonce();
+ Self::review_action(
+ target,
+ capabilities.snapshot_revision(),
+ action,
+ &nonce,
+ Some(confirmation),
+ )
+ }
+
+ /// Prepare one confirmed collapse of a conflicted file to a recorded side.
+ ///
+ /// `resolution` is not a free choice: the server advertises one entry per
+ /// side git actually recorded, and this refuses any other. That is why the
+ /// side has to arrive as an argument rather than be defaulted — the
+ /// confirmation digest commits to it, so a control that guessed would
+ /// carry a digest for the other blob.
+ ///
+ /// No content crosses this wire in either direction. What lands is exactly
+ /// the blob git is already holding as stage 2 (`keep_current`) or stage 3
+ /// (`keep_incoming`) for the one path the action names.
+ pub fn resolve_conflict(
+ target: PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ capabilities: &ReviewCapabilities,
+ proposal_id: &str,
+ file_id: &str,
+ resolution: ConflictResolution,
+ ) -> Result {
+ validate_review_target(&target, review)?;
+ let advertised = advertised_conflict_resolutions(Some(capabilities), &target, review);
+ if !advertised.iter().any(|entry| {
+ entry.proposal_id == proposal_id
+ && entry.file_id == file_id
+ && entry.resolution == resolution
+ }) {
+ return Err("review conflict resolution is not advertised");
+ }
+ let capability = capabilities
+ .conflict_resolutions()
+ .iter()
+ .find(|capability| {
+ capability.proposal_id().as_str() == proposal_id
+ && capability.file_id().as_str() == file_id
+ && capability.resolution() == resolution
+ })
+ .ok_or("review conflict resolution capability is unavailable or stale")?;
+ let confirmation = ReviewActionConfirmation::new(
+ capability.confirmation_digest().clone(),
+ capabilities.workspace_revision(),
+ capability.receipt_correlation_digest().clone(),
+ );
let nonce = review_nonce();
- Self::review_action(target, review.revision, action, &nonce)
+ Self::review_action(
+ target,
+ capabilities.snapshot_revision(),
+ ReviewAction::ResolveConflict {
+ proposal_id: ReviewProposalId::new(proposal_id.to_owned())
+ .map_err(|_| "review proposal identity is invalid")?,
+ file_id: automonique_protocol::platform_v2_review::ReviewFileId::new(
+ file_id.to_owned(),
+ )
+ .map_err(|_| "review file identity is invalid")?,
+ resolution,
+ },
+ &nonce,
+ Some(confirmation),
+ )
}
pub fn rerun_check(
target: PlatformReviewTarget,
review: &PlatformReviewSemantic,
+ capabilities: &ReviewCapabilities,
check_id: &str,
) -> Result {
validate_review_target(&target, review)?;
@@ -220,6 +371,27 @@ impl PlatformReviewActionPreview {
.find(|check| check.id == check_id)
.filter(|check| review.check_is_rerunnable(&check.id))
.ok_or("review check is not actionable")?;
+ if capabilities.project() != &target.project
+ || capabilities.workspace() != &target.workspace
+ || capabilities.snapshot_revision() != review.revision
+ {
+ return Err("review capabilities are stale or belong to another workspace");
+ }
+ let capability = capabilities
+ .rerunnable_checks()
+ .iter()
+ .find(|capability| capability.check_id().as_str() == check.id)
+ .filter(|capability| {
+ capability.expected_check_revision() == check.freshness.observed_revision
+ && capability.authority().kind() == check.authority.kind
+ && capability.authority().id().as_str() == check.authority.id
+ })
+ .ok_or("review check capability is unavailable or stale")?;
+ let confirmation = ReviewActionConfirmation::new(
+ capability.confirmation_digest().clone(),
+ capabilities.workspace_revision(),
+ capability.receipt_correlation_digest().clone(),
+ );
let nonce = review_nonce();
Self::review_action(
target,
@@ -230,38 +402,67 @@ impl PlatformReviewActionPreview {
expected_check_revision: check.freshness.observed_revision,
},
&nonce,
+ Some(confirmation),
)
}
+ /// Prepare the confirmed merge of the advertised pull request.
+ ///
+ /// This became a confirmed lane with the same protocol revision that
+ /// earned the staging capability: `MergePullRequest` is now inside
+ /// `requires_confirmation`, so its unconfirmed spelling no longer encodes
+ /// and the previous constructor minted a preview the transport could not
+ /// have sent. Every coordinate is therefore taken from the capability the
+ /// server minted, and the snapshot is consulted only to prove the two
+ /// reads agree.
+ ///
+ /// The server ships this slot empty for every workspace today — no
+ /// provider adapter can preflight a pull-request write — so this refuses
+ /// in production, which is the honest answer rather than a silent one.
pub fn merge_pull_request(
target: PlatformReviewTarget,
review: &PlatformReviewSemantic,
+ capabilities: &ReviewCapabilities,
) -> Result {
validate_review_target(&target, review)?;
- let pull = &review.pull_request;
if !review.pull_request_is_mergeable() {
return Err("pull request is not mergeable");
}
- let id = pull
- .id
- .as_ref()
- .ok_or("pull request identity is unavailable")?;
- let head = pull
- .head_revision
- .as_ref()
- .ok_or("pull request head revision is unavailable")?;
+ if capabilities.project() != &target.project
+ || capabilities.workspace() != &target.workspace
+ || capabilities.snapshot_revision() != review.revision
+ {
+ return Err("review capabilities are stale or belong to another workspace");
+ }
+ let pull = &review.pull_request;
+ let capability = capabilities
+ .merge_pull_request()
+ .filter(|capability| {
+ Some(capability.pull_request_id().as_str()) == pull.id.as_deref()
+ && capability.expected_pull_request_revision()
+ == pull.freshness.observed_revision
+ && Some(capability.expected_head_revision().as_str())
+ == pull.head_revision.as_deref()
+ && capability.authority().kind() == pull.authority.kind
+ && capability.authority().id().as_str() == pull.authority.id
+ })
+ .ok_or("pull request merge capability is unavailable or stale")?;
+ let confirmation = ReviewActionConfirmation::new(
+ capability.confirmation_digest().clone(),
+ capabilities.workspace_revision(),
+ capability.receipt_correlation_digest().clone(),
+ );
let nonce = review_nonce();
Self::review_action(
target,
- review.revision,
+ capabilities.snapshot_revision(),
ReviewAction::MergePullRequest {
- pull_request_id: PullRequestId::new(id.clone())
- .map_err(|_| "pull request identity is invalid")?,
- expected_pull_request_revision: pull.freshness.observed_revision,
- expected_head_revision: ReviewField::new(head.clone())
- .map_err(|_| "pull request head revision is invalid")?,
+ pull_request_id: capability.pull_request_id().clone(),
+ expected_pull_request_revision: capability.expected_pull_request_revision(),
+ expected_head_revision: capability.expected_head_revision().clone(),
},
&nonce,
+ Some(confirmation),
)
}
@@ -270,10 +471,19 @@ impl PlatformReviewActionPreview {
expected_revision: Revision,
action: ReviewAction,
nonce: &str,
+ confirmation: Option,
) -> Result {
action
.validate_client_shape()
.map_err(|_| "review action is invalid")?;
+ // Ask the contract rather than restate it. The confirmed set grew from
+ // the check rerun alone to the three pull-request writes and the four
+ // staging writes, and a local `matches!` would have kept accepting the
+ // unconfirmed spelling of each new member long after the transport
+ // stopped encoding it.
+ if action.requires_confirmation() != confirmation.is_some() {
+ return Err("review action confirmation does not match its action");
+ }
let idempotency_key = IdempotencyKey::new(format!("shelldeck-review-{nonce}"))
.map_err(|_| "review idempotency key is invalid")?;
Ok(Self {
@@ -281,6 +491,7 @@ impl PlatformReviewActionPreview {
expected_revision,
action,
idempotency_key,
+ confirmation,
})
}
@@ -304,6 +515,168 @@ impl PlatformReviewActionPreview {
&self.idempotency_key
}
+ #[must_use]
+ pub const fn confirmation(&self) -> Option<&ReviewActionConfirmation> {
+ self.confirmation.as_ref()
+ }
+
+ /// Whether this preview must be revalidated against the latest
+ /// authoritative capability generation before it crosses the durable
+ /// dispatch fence.
+ ///
+ /// A confirmed rerun must, because its digest names a plan the server
+ /// preflighted. A batch delivery must for the opposite reason: it carries
+ /// no digest at all, so the advertisement *is* its fence, and a list that
+ /// has since been retracted has to refuse locally rather than be posted.
+ #[must_use]
+ pub const fn requires_capability_revalidation(&self) -> bool {
+ self.confirmation.is_some()
+ || matches!(self.action, ReviewAction::BatchSendCommentsToAgent { .. })
+ }
+
+ /// Revalidate a confirmed rerun or an unconfirmed batch delivery against
+ /// the latest authoritative capability generation before crossing the
+ /// durable dispatch fence.
+ #[must_use]
+ pub fn matches_capabilities(&self, capabilities: &ReviewCapabilities) -> bool {
+ match (&self.action, &self.confirmation) {
+ (
+ ReviewAction::RerunCheck {
+ check_id,
+ expected_check_revision,
+ },
+ Some(confirmation),
+ ) => {
+ self.capability_coordinates_match(capabilities)
+ && capabilities.workspace_revision()
+ == confirmation.expected_workspace_revision()
+ && capabilities.rerunnable_checks().iter().any(|capability| {
+ capability.check_id() == check_id
+ && capability.expected_check_revision() == *expected_check_revision
+ && capability.confirmation_digest()
+ == confirmation.confirmation_digest()
+ && capability.receipt_correlation_digest()
+ == confirmation.receipt_correlation_digest()
+ })
+ }
+ // The unconfirmed delivery lane has no digest to compare, so the
+ // comparison is the advertisement itself: every comment must still
+ // be listed, at exactly the revision this preview committed to.
+ //
+ // That is what makes a reused list refuse here instead of reaching
+ // the server. Settling a delivery moves the comment out of
+ // `not_sent`/`refused` and bumps both the snapshot revision and the
+ // comment's own revision, so a re-read after settlement cannot
+ // still advertise it — whatever fresh idempotency key a caller
+ // attaches.
+ (ReviewAction::BatchSendCommentsToAgent { comments }, None) => {
+ self.capability_coordinates_match(capabilities)
+ && comments.iter().all(|comment| {
+ capabilities
+ .agent_deliverable_comments()
+ .iter()
+ .any(|capability| {
+ capability.comment_id() == comment.comment_id()
+ && capability.expected_comment_revision()
+ == comment.expected_revision()
+ })
+ })
+ }
+ // The staging lane is fenced by its digest, and the digest is what
+ // makes a moved worktree refuse *here* rather than at the daemon.
+ // The server mints it over the commit `HEAD` resolved to, the
+ // whole index, and every named path's objects and stat identity,
+ // so any of those moving between two capability reads yields a
+ // different digest and no match. Comparing the exposed
+ // `expected_head_revision` and `expected_index_digest` separately
+ // would restate a subset of the same commitment.
+ //
+ // Only a fresh capability document can supply that comparison,
+ // which is why every staging preview reports
+ // `requires_capability_revalidation`.
+ (
+ ReviewAction::Stage { proposal_id }
+ | ReviewAction::Unstage { proposal_id }
+ | ReviewAction::Commit { proposal_id },
+ Some(confirmation),
+ ) => {
+ let kind = match &self.action {
+ ReviewAction::Stage { .. } => ReviewProposalKind::Stage,
+ ReviewAction::Unstage { .. } => ReviewProposalKind::Unstage,
+ _ => ReviewProposalKind::Commit,
+ };
+ self.capability_coordinates_match(capabilities)
+ && capabilities.workspace_revision()
+ == confirmation.expected_workspace_revision()
+ && capabilities.staging().iter().any(|capability| {
+ capability.proposal_id() == proposal_id
+ && capability.kind() == kind
+ && capability.confirmation_digest()
+ == confirmation.confirmation_digest()
+ && capability.receipt_correlation_digest()
+ == confirmation.receipt_correlation_digest()
+ })
+ }
+ // The side is part of the identity, not a parameter of it: two
+ // entries for one file differ only by `resolution`, and each
+ // carries the digest minted over the blob that side would write.
+ (
+ ReviewAction::ResolveConflict {
+ proposal_id,
+ file_id,
+ resolution,
+ },
+ Some(confirmation),
+ ) => {
+ self.capability_coordinates_match(capabilities)
+ && capabilities.workspace_revision()
+ == confirmation.expected_workspace_revision()
+ && capabilities
+ .conflict_resolutions()
+ .iter()
+ .any(|capability| {
+ capability.proposal_id() == proposal_id
+ && capability.file_id() == file_id
+ && capability.resolution() == *resolution
+ && capability.confirmation_digest()
+ == confirmation.confirmation_digest()
+ && capability.receipt_correlation_digest()
+ == confirmation.receipt_correlation_digest()
+ })
+ }
+ (
+ ReviewAction::MergePullRequest {
+ pull_request_id,
+ expected_pull_request_revision,
+ expected_head_revision,
+ },
+ Some(confirmation),
+ ) => {
+ self.capability_coordinates_match(capabilities)
+ && capabilities.workspace_revision()
+ == confirmation.expected_workspace_revision()
+ && capabilities.merge_pull_request().is_some_and(|capability| {
+ capability.pull_request_id() == pull_request_id
+ && capability.expected_pull_request_revision()
+ == *expected_pull_request_revision
+ && capability.expected_head_revision() == expected_head_revision
+ && capability.confirmation_digest()
+ == confirmation.confirmation_digest()
+ && capability.receipt_correlation_digest()
+ == confirmation.receipt_correlation_digest()
+ })
+ }
+ (_, None) => true,
+ _ => false,
+ }
+ }
+
+ fn capability_coordinates_match(&self, capabilities: &ReviewCapabilities) -> bool {
+ capabilities.project() == &self.target.project
+ && capabilities.workspace() == &self.target.workspace
+ && capabilities.snapshot_revision() == self.expected_revision
+ }
+
#[must_use]
pub const fn kind(&self) -> &'static str {
match self.action {
@@ -458,6 +831,28 @@ pub enum PlatformReviewLoad {
Unavailable(PlatformReviewUnavailable),
}
+/// Authenticated server-advertised review mutation capabilities for one exact
+/// project/workspace snapshot. An unavailable capability load grants nothing.
+///
+/// The available payload is boxed because `ReviewCapabilities` now carries the
+/// agent-delivery list and three pull-request slots, which makes it far larger
+/// than a refusal's two strings.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum PlatformReviewCapabilitiesLoad {
+ Available(Box),
+ Unavailable(PlatformReviewUnavailable),
+}
+
+impl PlatformReviewCapabilitiesLoad {
+ #[must_use]
+ pub fn available(&self) -> Option<&ReviewCapabilities> {
+ match self {
+ Self::Available(capabilities) => Some(capabilities),
+ Self::Unavailable(_) => None,
+ }
+ }
+}
+
impl PlatformReviewLoad {
#[must_use]
pub fn needs_user_action(&self) -> bool {
@@ -538,6 +933,37 @@ impl PlatformReviewSemantic {
})
}
+ /// Whether one conflicted file inside one proposal may be collapsed to a
+ /// side git recorded.
+ ///
+ /// Deliberately not folded into [`Self::proposal_is_actionable`], which
+ /// answers a different question: staging moves index entries for a whole
+ /// proposal and is refused outright while any of its files is unmerged,
+ /// whereas this is admissible *only* while the one named file is unmerged.
+ /// The two predicates are near-inverses, so sharing one would make each
+ /// caller re-derive which half it meant.
+ ///
+ /// Which side may be written is never decided here: the server advertises
+ /// one entry per side it actually recorded, and the client may name no
+ /// other.
+ #[must_use]
+ pub fn conflict_resolution_is_actionable(&self, proposal_id: &str, file_id: &str) -> bool {
+ self.proposals
+ .iter()
+ .find(|proposal| proposal.id == proposal_id)
+ .is_some_and(|proposal| {
+ proposal.kind == ReviewProposalKind::ResolveConflict
+ && proposal
+ .authority
+ .as_ref()
+ .is_some_and(|authority| authority.kind == ReviewAuthorityKind::Git)
+ && proposal.files.iter().any(|id| id == file_id)
+ && self.files.iter().any(|file| {
+ file.id == file_id && file.conflict == ConflictState::Unresolved
+ })
+ })
+ }
+
#[must_use]
pub fn check_is_rerunnable(&self, check_id: &str) -> bool {
self.checks.iter().any(|check| {
@@ -976,6 +1402,221 @@ mod tests {
const RENDER_CORPUS: &[u8] =
include_bytes!("../../tests/fixtures/platform-v2-render-conformance-v1.json");
+ /// Mint the advertisement a coherent server would return for `comment_ids`
+ /// at this review's exact snapshot revision.
+ ///
+ /// Every value comes from the review itself, so the helper cannot advertise
+ /// a comment revision the snapshot disagrees with — the tests that need
+ /// that divergence build it explicitly.
+ fn delivery_capabilities(
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ comment_ids: &[&str],
+ ) -> ReviewCapabilities {
+ let authority = ReviewAuthority::new(
+ ReviewAuthorityKind::Review,
+ automonique_protocol::platform_v2_review::ReviewAuthorityId::new(
+ review.review.authority.id.clone(),
+ )
+ .unwrap(),
+ );
+ let advertised = comment_ids
+ .iter()
+ .map(|id| {
+ let comment = review.comments.iter().find(|c| c.id == *id).unwrap();
+ ReviewAgentDeliveryCapability::new(
+ ReviewCommentId::new(comment.id.clone()).unwrap(),
+ comment.revision,
+ authority.clone(),
+ )
+ .unwrap()
+ })
+ .collect();
+ ReviewCapabilities::new(
+ target.project.clone(),
+ target.workspace.clone(),
+ review.revision,
+ Revision::new(91).unwrap(),
+ Vec::new(),
+ advertised,
+ ReviewPullRequestCapabilities::default(),
+ ReviewGitStagingCapabilities::default(),
+ )
+ .unwrap()
+ }
+
+ /// Mirror the server's own eligibility rule for `agent_deliverable_comments`.
+ ///
+ /// The daemon advertises a comment only when the caller holds the review
+ /// authority the snapshot names, the review is fresh, and the comment is
+ /// still in `not_sent` or `refused`. Reproducing that rule here — rather
+ /// than hand-writing each expected list — is what lets the delivery tests
+ /// *show* an advertisement retracting instead of asserting that it does.
+ fn advertise(
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ ) -> ReviewCapabilities {
+ let eligible = if review.review.authority.kind == ReviewAuthorityKind::Review
+ && review.review.freshness.state == ReviewFreshnessState::Fresh
+ {
+ review
+ .comments
+ .iter()
+ .filter(|comment| {
+ matches!(
+ comment.agent_state,
+ CommentAgentState::NotSent | CommentAgentState::Refused
+ )
+ })
+ .map(|comment| comment.id.as_str())
+ .collect::>()
+ } else {
+ Vec::new()
+ };
+ delivery_capabilities(target, review, &eligible)
+ }
+
+ /// Mirror the settlement the daemon performs when a delivery completes:
+ /// the delivered comment leaves the sendable states and bumps its own
+ /// revision, and the snapshot revision bumps with it.
+ fn settle(review: &PlatformReviewSemantic, delivered: &[&str]) -> PlatformReviewSemantic {
+ let mut settled = review.clone();
+ settled.revision = Revision::new(review.revision.get() + 1).unwrap();
+ for comment in &mut settled.comments {
+ if delivered.contains(&comment.id.as_str()) {
+ comment.agent_state = CommentAgentState::Sent;
+ comment.revision = Revision::new(comment.revision.get() + 1).unwrap();
+ }
+ }
+ settled
+ }
+
+ /// The canonical fixture carries one already-sent comment. Batch delivery
+ /// needs at least two sendable ones, and needs their snapshot order to
+ /// differ from their sorted order, so the wire ordering requirement is
+ /// exercised rather than satisfied by accident.
+ fn deliverable_review() -> PlatformReviewSemantic {
+ let snapshot = decode_review_snapshot(CANONICAL_FIXTURE).unwrap();
+ let mut review = PlatformReviewSemantic::from(&snapshot);
+ review.comments[0].agent_state = CommentAgentState::NotSent;
+ let mut second = review.comments[0].clone();
+ second.id = "comment-0".to_owned();
+ second.revision = Revision::new(review.comments[0].revision.get() + 3).unwrap();
+ second.agent_state = CommentAgentState::Refused;
+ review.comments.push(second);
+ review
+ }
+
+ fn rerun_capabilities(
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ ) -> ReviewCapabilities {
+ let check = &review.checks[0];
+ ReviewCapabilities::new(
+ target.project.clone(),
+ target.workspace.clone(),
+ review.revision,
+ Revision::new(91).unwrap(),
+ vec![ReviewCheckRerunCapability::new(
+ ReviewCheckId::new(check.id.clone()).unwrap(),
+ check.freshness.observed_revision,
+ ReviewAuthority::new(
+ check.authority.kind,
+ automonique_protocol::platform_v2_review::ReviewAuthorityId::new(
+ check.authority.id.clone(),
+ )
+ .unwrap(),
+ ),
+ ReviewConfirmationDigest::new("a".repeat(64)).unwrap(),
+ ReviewReceiptCorrelationDigest::new("b".repeat(64)).unwrap(),
+ )
+ .unwrap()],
+ Vec::new(),
+ ReviewPullRequestCapabilities::default(),
+ ReviewGitStagingCapabilities::default(),
+ )
+ .unwrap()
+ }
+
+ /// The advertisement a coherent server would mint for this snapshot's
+ /// first proposal, at the `HEAD` and index its preflight read.
+ fn staging_capabilities(
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ ) -> ReviewCapabilities {
+ let proposal = &review.proposals[0];
+ ReviewCapabilities::new(
+ target.project.clone(),
+ target.workspace.clone(),
+ review.revision,
+ Revision::new(91).unwrap(),
+ Vec::new(),
+ Vec::new(),
+ ReviewPullRequestCapabilities::default(),
+ ReviewGitStagingCapabilities {
+ staging: vec![ReviewStagingCapability::new(
+ ReviewProposalId::new(proposal.id.clone()).unwrap(),
+ proposal.kind,
+ ReviewField::new("4".repeat(40)).unwrap(),
+ ReviewIndexDigest::new("a".repeat(64)).unwrap(),
+ ReviewAuthority::new(
+ ReviewAuthorityKind::Git,
+ automonique_protocol::platform_v2_review::ReviewAuthorityId::new(
+ proposal.authority.as_ref().unwrap().id.clone(),
+ )
+ .unwrap(),
+ ),
+ ReviewConfirmationDigest::new("c".repeat(64)).unwrap(),
+ ReviewReceiptCorrelationDigest::new("d".repeat(64)).unwrap(),
+ )
+ .unwrap()],
+ conflict_resolutions: Vec::new(),
+ },
+ )
+ .unwrap()
+ }
+
+ /// The merge slot the server ships empty in production. It exists here
+ /// because `MergePullRequest` is a confirmed lane now, so the unconfirmed
+ /// spelling cannot be constructed at all.
+ fn merge_capabilities(
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ ) -> ReviewCapabilities {
+ let pull = &review.pull_request;
+ ReviewCapabilities::new(
+ target.project.clone(),
+ target.workspace.clone(),
+ review.revision,
+ Revision::new(91).unwrap(),
+ Vec::new(),
+ Vec::new(),
+ ReviewPullRequestCapabilities {
+ merge: Some(
+ ReviewPullRequestMergeCapability::new(
+ PullRequestId::new(pull.id.clone().unwrap()).unwrap(),
+ pull.freshness.observed_revision,
+ ReviewField::new(pull.head_revision.clone().unwrap()).unwrap(),
+ pull.readiness,
+ ReviewAuthority::new(
+ pull.authority.kind,
+ automonique_protocol::platform_v2_review::ReviewAuthorityId::new(
+ pull.authority.id.clone(),
+ )
+ .unwrap(),
+ ),
+ ReviewConfirmationDigest::new("e".repeat(64)).unwrap(),
+ ReviewReceiptCorrelationDigest::new("f".repeat(64)).unwrap(),
+ )
+ .unwrap(),
+ ),
+ ..ReviewPullRequestCapabilities::default()
+ },
+ ReviewGitStagingCapabilities::default(),
+ )
+ .unwrap()
+ }
+
#[derive(Deserialize)]
struct RenderCorpus {
schema: String,
@@ -1338,6 +1979,7 @@ mod tests {
let batch = PlatformReviewActionPreview::batch_send_comments(
target.clone(),
&semantic,
+ &delivery_capabilities(&target, &semantic, &[&semantic.comments[0].id]),
&[semantic.comments[0].id.clone()],
)
.unwrap();
@@ -1366,9 +2008,10 @@ mod tests {
})
);
- let proposal = PlatformReviewActionPreview::apply_proposal(
+ let proposal = PlatformReviewActionPreview::stage_proposal(
target.clone(),
&semantic,
+ &staging_capabilities(&target, &semantic),
&semantic.proposals[0].id,
)
.unwrap();
@@ -1389,10 +2032,21 @@ mod tests {
proposal_id: semantic.proposals[0].id.clone(),
})
);
+ // Staging is a confirmed lane: the digests are the server's, verbatim.
+ let staging_confirmation = proposal.confirmation().unwrap();
+ assert_eq!(
+ staging_confirmation.confirmation_digest().as_str(),
+ "c".repeat(64)
+ );
+ assert_eq!(
+ staging_confirmation.receipt_correlation_digest().as_str(),
+ "d".repeat(64)
+ );
let rerun = PlatformReviewActionPreview::rerun_check(
target.clone(),
&semantic,
+ &rerun_capabilities(&target, &semantic),
&semantic.checks[0].id,
)
.unwrap();
@@ -1419,8 +2073,12 @@ mod tests {
})
);
- let merge =
- PlatformReviewActionPreview::merge_pull_request(target.clone(), &semantic).unwrap();
+ let merge = PlatformReviewActionPreview::merge_pull_request(
+ target.clone(),
+ &semantic,
+ &merge_capabilities(&target, &semantic),
+ )
+ .unwrap();
assert_eq!(merge.target(), &target);
assert_eq!(merge.expected_revision(), semantic.revision);
assert_eq!(
@@ -1456,14 +2114,30 @@ mod tests {
})
);
+ let merge_confirmation = merge.confirmation().unwrap();
+ assert_eq!(
+ merge_confirmation.confirmation_digest().as_str(),
+ "e".repeat(64)
+ );
+ assert_eq!(
+ merge_confirmation.receipt_correlation_digest().as_str(),
+ "f".repeat(64)
+ );
+
let mut stale = semantic.clone();
stale.pull_request.freshness.state = ReviewFreshnessState::Stale;
- assert!(PlatformReviewActionPreview::merge_pull_request(target.clone(), &stale).is_err());
+ assert!(PlatformReviewActionPreview::merge_pull_request(
+ target.clone(),
+ &stale,
+ &merge_capabilities(&target, &semantic),
+ )
+ .is_err());
stale = semantic.clone();
stale.checks[0].freshness.state = ReviewFreshnessState::Stale;
assert!(PlatformReviewActionPreview::rerun_check(
target.clone(),
&stale,
+ &rerun_capabilities(&target, &semantic),
&stale.checks[0].id,
)
.is_err());
@@ -1475,9 +2149,10 @@ mod tests {
.find(|file| file.id == proposal_file)
.unwrap()
.conflict = ConflictState::Unresolved;
- assert!(PlatformReviewActionPreview::apply_proposal(
+ assert!(PlatformReviewActionPreview::stage_proposal(
target.clone(),
&conflicted,
+ &staging_capabilities(&target, &semantic),
&conflicted.proposals[0].id,
)
.is_err());
@@ -1491,28 +2166,36 @@ mod tests {
wrong_authority.checks[0].authority.kind = ReviewAuthorityKind::Review;
wrong_authority.pull_request.authority.kind = ReviewAuthorityKind::Review;
assert!(!wrong_authority.approval_is_actionable());
+ // The advertisement is minted from the review the snapshot names, so a
+ // Git review authority cannot produce one at all: the capability's own
+ // constructor refuses a non-review authority.
assert!(PlatformReviewActionPreview::batch_send_comments(
target.clone(),
&wrong_authority,
+ &delivery_capabilities(&target, &semantic, &[&semantic.comments[0].id]),
&[wrong_authority.comments[0].id.clone()],
)
.is_err());
- assert!(PlatformReviewActionPreview::apply_proposal(
+ assert!(PlatformReviewActionPreview::stage_proposal(
target.clone(),
&wrong_authority,
+ &staging_capabilities(&target, &semantic),
&wrong_authority.proposals[0].id,
)
.is_err());
assert!(PlatformReviewActionPreview::rerun_check(
target.clone(),
&wrong_authority,
+ &rerun_capabilities(&target, &semantic),
&wrong_authority.checks[0].id,
)
.is_err());
- assert!(
- PlatformReviewActionPreview::merge_pull_request(target.clone(), &wrong_authority,)
- .is_err()
- );
+ assert!(PlatformReviewActionPreview::merge_pull_request(
+ target.clone(),
+ &wrong_authority,
+ &merge_capabilities(&target, &semantic),
+ )
+ .is_err());
let foreign_target = PlatformReviewTarget {
project: target.project.clone(),
workspace: WorkContextIdentity::parse_local(
@@ -1521,24 +2204,30 @@ mod tests {
)
.unwrap(),
};
- assert!(
- PlatformReviewActionPreview::merge_pull_request(foreign_target, &semantic).is_err()
- );
- assert!(PlatformReviewActionPreview::apply_proposal(
+ assert!(PlatformReviewActionPreview::merge_pull_request(
+ foreign_target,
+ &semantic,
+ &merge_capabilities(&target, &semantic),
+ )
+ .is_err());
+ assert!(PlatformReviewActionPreview::stage_proposal(
target.clone(),
&semantic,
+ &staging_capabilities(&target, &semantic),
"proposal-missing",
)
.is_err());
assert!(PlatformReviewActionPreview::rerun_check(
target.clone(),
&semantic,
+ &rerun_capabilities(&target, &semantic),
"check-missing",
)
.is_err());
assert!(PlatformReviewActionPreview::batch_send_comments(
- target,
+ target.clone(),
&semantic,
+ &delivery_capabilities(&target, &semantic, &[&semantic.comments[0].id]),
&[
semantic.comments[0].id.clone(),
semantic.comments[0].id.clone()
@@ -1547,6 +2236,158 @@ mod tests {
.is_err());
}
+ // SDTEST-1863
+ #[test]
+ fn advertised_delivery_is_sent_verbatim_and_a_reused_advertisement_is_refused() {
+ let review = deliverable_review();
+ let snapshot = decode_review_snapshot(CANONICAL_FIXTURE).unwrap();
+ let target = PlatformReviewTarget {
+ project: ProjectId::new("project-1").unwrap(),
+ workspace: snapshot.workspace().clone(),
+ };
+
+ // The server advertises both sendable comments, sorted by id, which is
+ // the opposite of their order inside the snapshot.
+ let advertised = advertise(&target, &review);
+ let ids = advertised
+ .agent_deliverable_comments()
+ .iter()
+ .map(|capability| capability.comment_id().as_str())
+ .collect::>();
+ assert_eq!(ids, vec!["comment-0", "comment-1"]);
+ assert_ne!(
+ ids,
+ review
+ .comments
+ .iter()
+ .map(|comment| comment.id.as_str())
+ .collect::>()
+ );
+
+ let preview = PlatformReviewActionPreview::batch_send_comments(
+ target.clone(),
+ &review,
+ &advertised,
+ &["comment-1".to_owned(), "comment-0".to_owned()],
+ )
+ .unwrap();
+ // Every coordinate is the capability's, and the request is pinned to
+ // the capability's snapshot revision.
+ assert_eq!(preview.expected_revision(), advertised.snapshot_revision());
+ assert!(preview.confirmation().is_none());
+ let ReviewAction::BatchSendCommentsToAgent { comments } = preview.action() else {
+ panic!("batch preview changed action family");
+ };
+ assert_eq!(
+ comments
+ .iter()
+ .map(|comment| (comment.comment_id().as_str(), comment.expected_revision()))
+ .collect::>(),
+ advertised
+ .agent_deliverable_comments()
+ .iter()
+ .map(|capability| (
+ capability.comment_id().as_str(),
+ capability.expected_comment_revision()
+ ))
+ .collect::>()
+ );
+ assert!(preview.requires_capability_revalidation());
+ assert!(preview.matches_capabilities(&advertised));
+
+ // A capability response minted against another snapshot revision must
+ // construct nothing, or the preview would pin itself to a revision the
+ // active review never observed.
+ let superseded = advertise(&target, &settle(&review, &[]));
+ assert_ne!(superseded.snapshot_revision(), review.revision);
+ assert!(PlatformReviewActionPreview::batch_send_comments(
+ target.clone(),
+ &review,
+ &superseded,
+ &["comment-1".to_owned()],
+ )
+ .is_err());
+ // A comment that exists and is sendable, but was not advertised, is
+ // refused locally rather than posted for the server to refuse.
+ let partial = delivery_capabilities(&target, &review, &["comment-0"]);
+ assert!(PlatformReviewActionPreview::batch_send_comments(
+ target.clone(),
+ &review,
+ &partial,
+ &["comment-0".to_owned(), "comment-1".to_owned()],
+ )
+ .is_err());
+
+ // One delivery settles. Re-read the advertisement rather than reusing
+ // the list: the delivered comment left `refused`, its own revision
+ // bumped, and the snapshot revision bumped with it.
+ let settled = settle(&review, &["comment-0"]);
+ let readvertised = advertise(&target, &settled);
+ assert_eq!(
+ readvertised
+ .agent_deliverable_comments()
+ .iter()
+ .map(|capability| capability.comment_id().as_str())
+ .collect::>(),
+ vec!["comment-1"],
+ "settling one delivery must retract only that comment"
+ );
+ assert_eq!(readvertised.snapshot_revision(), settled.revision);
+
+ // The preview built from the retracted list is now refused locally, so
+ // it never reaches the server.
+ assert!(!preview.matches_capabilities(&readvertised));
+
+ // And a fresh preview built from that same stale list — the exact
+ // request a duplicate-delivery bug would have to make: same batch,
+ // same advertised revisions, a brand new idempotency key — is refused
+ // for the same reason, which is why no digest is needed here.
+ let replay = PlatformReviewActionPreview::batch_send_comments(
+ target.clone(),
+ &review,
+ &advertised,
+ &["comment-1".to_owned(), "comment-0".to_owned()],
+ )
+ .unwrap();
+ assert_ne!(replay.idempotency_key(), preview.idempotency_key());
+ assert!(!replay.matches_capabilities(&readvertised));
+
+ // Preparing against the settled snapshot cannot reconstruct it either:
+ // the delivered comment is no longer advertised at all.
+ assert!(PlatformReviewActionPreview::batch_send_comments(
+ target.clone(),
+ &settled,
+ &readvertised,
+ &["comment-0".to_owned()],
+ )
+ .is_err());
+
+ // The survivor is still deliverable, at the new snapshot revision.
+ let next = PlatformReviewActionPreview::batch_send_comments(
+ target.clone(),
+ &settled,
+ &readvertised,
+ &["comment-1".to_owned()],
+ )
+ .unwrap();
+ assert_eq!(next.expected_revision(), settled.revision);
+ assert!(next.matches_capabilities(&readvertised));
+
+ // Settling the last one empties the advertisement entirely, and an
+ // empty advertisement can construct nothing.
+ let drained = settle(&settled, &["comment-1"]);
+ let empty = advertise(&target, &drained);
+ assert!(empty.agent_deliverable_comments().is_empty());
+ assert!(!next.matches_capabilities(&empty));
+ assert!(PlatformReviewActionPreview::batch_send_comments(
+ target,
+ &drained,
+ &empty,
+ &["comment-1".to_owned()],
+ )
+ .is_err());
+ }
+
// SDTEST-1778
#[test]
fn canonical_render_corpus_projects_every_cross_client_semantic_key() {
diff --git a/crates/shelldeck-core/src/config/platform_review/custody.rs b/crates/shelldeck-core/src/config/platform_review/custody.rs
new file mode 100644
index 00000000..7199cb90
--- /dev/null
+++ b/crates/shelldeck-core/src/config/platform_review/custody.rs
@@ -0,0 +1,1376 @@
+//! Durable at-most-once custody for native Platform v2 review mutations.
+//!
+//! A prepared preview is inert. Before any network dispatch it is advanced to
+//! `dispatched` under the cross-process lock. Every restart treats dispatched
+//! and accepted effects as lookup-only; ShellDeck never reconstructs a POST
+//! decision from a provider session, chronology, or other observation.
+
+use std::path::{Path, PathBuf};
+
+use automonique_protocol::platform::ReceiptId;
+use automonique_protocol::platform_v2_review::{
+ ReviewActionId, ReviewActorId, ReviewFileId, ReviewHunkId, MAX_REVIEW_COMMENTS,
+};
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use super::*;
+use crate::config::app_config::AppConfig;
+use crate::workspace_review::storage::{
+ bounded_descriptor_read, ensure_private_directory_io, lock_path, open_lock_file,
+ secure_atomic_write,
+};
+
+const REVIEW_CUSTODY_SCHEMA: u16 = 1;
+const MAX_REVIEW_CUSTODY_RECORDS: usize = 64;
+const MAX_REVIEW_CUSTODY_FILE_BYTES: u64 = 1024 * 1024;
+const MAX_REFUSAL_BYTES: usize = 16 * 1024;
+static REVIEW_CUSTODY_PROCESS_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewCustodyPresentation {
+ pub outcome: String,
+ pub detail: String,
+ pub actor: Option,
+}
+
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ReviewCustodyRecovery {
+ NeverStarted(PlatformReviewActionPreview),
+ LookupOnly(PlatformReviewActionPreview),
+ Terminal(ReviewCustodyPresentation),
+}
+
+#[derive(Clone, Debug)]
+pub struct PlatformReviewCustodyStore {
+ path: PathBuf,
+}
+
+impl PlatformReviewCustodyStore {
+ pub fn open_default() -> Result {
+ Self::open(
+ AppConfig::config_dir()
+ .join("platform-review")
+ .join("custody")
+ .join("effects-v1.json"),
+ )
+ }
+
+ pub fn open(path: PathBuf) -> Result {
+ let store = Self { path };
+ let _guard = REVIEW_CUSTODY_PROCESS_LOCK.lock();
+ store.prepare_storage()?;
+ let lock = open_lock_file(&lock_path(&store.path))?;
+ fs2::FileExt::lock_exclusive(&lock)?;
+ let _ = load_document(&store.path)?;
+ Ok(store)
+ }
+
+ /// Persist one inert confirmation preview. A target may own only one
+ /// non-terminal effect at a time; terminal presentation is replaced by a
+ /// later explicit preview for that same exact target.
+ pub fn prepare(&self, preview: &PlatformReviewActionPreview) -> Result<(), ReviewCustodyError> {
+ self.transact(|document| {
+ let key = preview.idempotency_key().as_str();
+ if document.records.iter().any(|record| record.key == key) {
+ return Err(ReviewCustodyError::DuplicateEffect);
+ }
+ if document.records.iter().any(|record| {
+ record.matches_target(preview.target()) && !record.state.is_terminal()
+ }) {
+ return Err(ReviewCustodyError::EffectAlreadyPending);
+ }
+ document.records.retain(|record| {
+ !record.matches_target(preview.target()) || !record.state.is_terminal()
+ });
+ if document.records.len() >= MAX_REVIEW_CUSTODY_RECORDS {
+ return Err(ReviewCustodyError::CapacityExceeded);
+ }
+ document.records.push(ReviewCustodyRecord::from_preview(
+ preview,
+ ReviewCustodyState::Prepared,
+ )?);
+ Ok(())
+ })
+ }
+
+ /// Establish the at-most-once boundary before the provider call begins.
+ pub fn mark_dispatched(
+ &self,
+ preview: &PlatformReviewActionPreview,
+ ) -> Result<(), ReviewCustodyError> {
+ self.transact(|document| {
+ let record = exact_record_mut(document, preview)?;
+ match record.state {
+ ReviewCustodyState::Prepared => {
+ record.state = ReviewCustodyState::Dispatched;
+ Ok(())
+ }
+ ReviewCustodyState::Dispatched | ReviewCustodyState::Receipt { .. } => Ok(()),
+ ReviewCustodyState::Refused { .. } => Err(ReviewCustodyError::EffectTerminal),
+ }
+ })
+ }
+
+ pub fn record_receipt(
+ &self,
+ preview: &PlatformReviewActionPreview,
+ receipt: &ReviewActionReceipt,
+ ) -> Result<(), ReviewCustodyError> {
+ if receipt.idempotency_key() != preview.idempotency_key() {
+ return Err(ReviewCustodyError::ReceiptMismatch);
+ }
+ self.transact(|document| {
+ let record = exact_record_mut(document, preview)?;
+ if matches!(record.state, ReviewCustodyState::Prepared) {
+ return Err(ReviewCustodyError::EffectNeverDispatched);
+ }
+ record.state = ReviewCustodyState::Receipt {
+ receipt: ReviewReceiptDisk::from_receipt(receipt),
+ };
+ Ok(())
+ })
+ }
+
+ pub fn record_refusal(
+ &self,
+ preview: &PlatformReviewActionPreview,
+ category: &str,
+ explanation: &str,
+ ) -> Result<(), ReviewCustodyError> {
+ if category.is_empty()
+ || category.len() > MAX_REFUSAL_BYTES
+ || explanation.len() > MAX_REFUSAL_BYTES
+ {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ self.transact(|document| {
+ let record = exact_record_mut(document, preview)?;
+ if matches!(record.state, ReviewCustodyState::Prepared) {
+ return Err(ReviewCustodyError::EffectNeverDispatched);
+ }
+ record.state = ReviewCustodyState::Refused {
+ category: category.to_owned(),
+ explanation: explanation.to_owned(),
+ };
+ Ok(())
+ })
+ }
+
+ /// Cancel only a preview that provably never crossed the dispatch fence.
+ pub fn cancel_prepared(
+ &self,
+ preview: &PlatformReviewActionPreview,
+ ) -> Result<(), ReviewCustodyError> {
+ self.transact(|document| {
+ let index = exact_record_index(document, preview)?;
+ if !matches!(document.records[index].state, ReviewCustodyState::Prepared) {
+ return Err(ReviewCustodyError::EffectAlreadyPending);
+ }
+ document.records.remove(index);
+ Ok(())
+ })
+ }
+
+ pub fn recovery(
+ &self,
+ target: &PlatformReviewTarget,
+ ) -> Result, ReviewCustodyError> {
+ let _guard = REVIEW_CUSTODY_PROCESS_LOCK.lock();
+ self.prepare_storage()?;
+ let lock = open_lock_file(&lock_path(&self.path))?;
+ fs2::FileExt::lock_exclusive(&lock)?;
+ let document = load_document(&self.path)?;
+ let mut matching = document
+ .records
+ .iter()
+ .filter(|record| record.matches_target(target));
+ let Some(record) = matching.next() else {
+ return Ok(None);
+ };
+ if matching.next().is_some() {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ let preview = record.to_preview()?;
+ Ok(Some(match &record.state {
+ ReviewCustodyState::Prepared => ReviewCustodyRecovery::NeverStarted(preview),
+ ReviewCustodyState::Dispatched => ReviewCustodyRecovery::LookupOnly(preview),
+ ReviewCustodyState::Receipt { receipt } if receipt.requires_lookup()? => {
+ ReviewCustodyRecovery::LookupOnly(preview)
+ }
+ ReviewCustodyState::Receipt { receipt } => {
+ ReviewCustodyRecovery::Terminal(receipt.presentation()?)
+ }
+ ReviewCustodyState::Refused {
+ category,
+ explanation,
+ } => ReviewCustodyRecovery::Terminal(ReviewCustodyPresentation {
+ outcome: category.clone(),
+ detail: explanation.clone(),
+ actor: None,
+ }),
+ }))
+ }
+
+ fn transact(
+ &self,
+ update: impl FnOnce(&mut ReviewCustodyDocument) -> Result,
+ ) -> Result {
+ let _guard = REVIEW_CUSTODY_PROCESS_LOCK.lock();
+ self.prepare_storage()?;
+ let lock = open_lock_file(&lock_path(&self.path))?;
+ fs2::FileExt::lock_exclusive(&lock)?;
+ let mut document = load_document(&self.path)?;
+ let outcome = update(&mut document)?;
+ document.revision = document
+ .revision
+ .checked_add(1)
+ .ok_or(ReviewCustodyError::DocumentInvalid)?;
+ persist_document(&self.path, &document)?;
+ Ok(outcome)
+ }
+
+ fn prepare_storage(&self) -> Result<(), ReviewCustodyError> {
+ let parent = self.path.parent().ok_or(ReviewCustodyError::PathInvalid)?;
+ ensure_private_directory_io(parent)?;
+ Ok(())
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReviewCustodyDocument {
+ schema: u16,
+ revision: u64,
+ records: Vec,
+}
+
+impl Default for ReviewCustodyDocument {
+ fn default() -> Self {
+ Self {
+ schema: REVIEW_CUSTODY_SCHEMA,
+ revision: 0,
+ records: Vec::new(),
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReviewCustodyRecord {
+ project: String,
+ workspace_kind: String,
+ workspace: String,
+ expected_revision: u64,
+ key: String,
+ action: ReviewActionDisk,
+ confirmation: Option,
+ state: ReviewCustodyState,
+}
+
+impl ReviewCustodyRecord {
+ fn from_preview(
+ preview: &PlatformReviewActionPreview,
+ state: ReviewCustodyState,
+ ) -> Result {
+ Ok(Self {
+ project: preview.target.project.as_str().to_owned(),
+ workspace_kind: preview.target.workspace.kind().as_str().to_owned(),
+ workspace: preview.target.workspace.id().to_owned(),
+ expected_revision: preview.expected_revision.get(),
+ key: preview.idempotency_key.as_str().to_owned(),
+ action: ReviewActionDisk::from_action(&preview.action)?,
+ confirmation: preview
+ .confirmation
+ .as_ref()
+ .map(ReviewConfirmationDisk::from_confirmation),
+ state,
+ })
+ }
+
+ fn matches_target(&self, target: &PlatformReviewTarget) -> bool {
+ self.project == target.project.as_str()
+ && self.workspace_kind == target.workspace.kind().as_str()
+ && self.workspace == target.workspace.id()
+ }
+
+ fn to_preview(&self) -> Result {
+ let project = ProjectId::new(self.project.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?;
+ let kind = WorkContextTargetKind::parse(&self.workspace_kind)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?;
+ let workspace = WorkContextIdentity::parse_local(kind, &self.workspace)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?;
+ if kind != WorkContextTargetKind::UserWorkspace {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ let expected_revision = Revision::new(self.expected_revision)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?;
+ let action = self.action.to_action()?;
+ let idempotency_key = IdempotencyKey::new(self.key.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?;
+ let confirmation = self
+ .confirmation
+ .as_ref()
+ .map(ReviewConfirmationDisk::to_confirmation)
+ .transpose()?;
+ // Same contract predicate the preview constructor uses, for the same
+ // reason: a record whose confirmation no longer matches its family is
+ // a document that cannot be replayed onto the wire, so it is rejected
+ // as invalid rather than recovered into an unsendable preview.
+ if action.requires_confirmation() != confirmation.is_some() {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ action
+ .validate_client_shape()
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?;
+ Ok(PlatformReviewActionPreview {
+ target: PlatformReviewTarget { project, workspace },
+ expected_revision,
+ action,
+ idempotency_key,
+ confirmation,
+ })
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
+enum ReviewActionDisk {
+ AddComment {
+ comment_id: String,
+ file_id: String,
+ hunk_id: String,
+ side: String,
+ line: u32,
+ body: String,
+ },
+ ApproveReview {
+ expected_review_revision: u64,
+ },
+ RerunCheck {
+ check_id: String,
+ expected_check_revision: u64,
+ },
+ /// The unconfirmed batch delivery lane.
+ ///
+ /// It carries no `confirmation` — the server advertises no digest for it —
+ /// but it is still an exposed mutation, so it takes the same durable
+ /// at-most-once record as every other one. Without it a restart between
+ /// the POST and the receipt would leave a delivered batch indistinguishable
+ /// from one that never left, and re-preparing would mint a fresh
+ /// idempotency key.
+ BatchSendCommentsToAgent {
+ comments: Vec,
+ },
+ /// The three confirmed index-level staging lanes.
+ ///
+ /// Each is a separate variant rather than one variant carrying a `kind`
+ /// string, because the three are separately granted: an operator withholds
+ /// committing independently of index writes, and a recovered plan that
+ /// could be re-read as another kind would let the narrower grant replay as
+ /// the wider one.
+ ///
+ /// The disk format did not understand these before this slice, exactly as
+ /// it did not understand the batch delivery before PR #161: preparation
+ /// would have returned "unsupported action" and the dispatch would have
+ /// died at the custody fence.
+ Stage {
+ proposal_id: String,
+ },
+ Unstage {
+ proposal_id: String,
+ },
+ Commit {
+ proposal_id: String,
+ },
+ /// Collapsing one conflicted path to one side git already recorded.
+ ///
+ /// The side is persisted because it is part of the effect's identity: it
+ /// decides which blob lands, and the confirmation digest was minted over
+ /// it. A record that lost it could be replayed as the other side.
+ ResolveConflict {
+ proposal_id: String,
+ file_id: String,
+ resolution: String,
+ },
+ /// The confirmed pull-request merge.
+ ///
+ /// Unreachable in production — the server advertises no merge capability —
+ /// but the record has to exist for the same reason as the others: the
+ /// action is now confirmed, so it crosses the durable fence, and a family
+ /// the format cannot spell fails at `prepare` rather than at the wire.
+ MergePullRequest {
+ pull_request_id: String,
+ expected_pull_request_revision: u64,
+ expected_head_revision: String,
+ },
+}
+
+/// One comment target inside a persisted batch delivery.
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReviewCommentTargetDisk {
+ comment_id: String,
+ expected_revision: u64,
+}
+
+impl ReviewActionDisk {
+ fn from_action(action: &ReviewAction) -> Result {
+ Ok(match action {
+ ReviewAction::AddComment {
+ comment_id,
+ anchor,
+ body,
+ } => Self::AddComment {
+ comment_id: comment_id.as_str().to_owned(),
+ file_id: anchor.file_id().as_str().to_owned(),
+ hunk_id: anchor.hunk_id().as_str().to_owned(),
+ side: anchor.side().as_str().to_owned(),
+ line: anchor.line(),
+ body: body.as_str().to_owned(),
+ },
+ ReviewAction::ApproveReview {
+ expected_review_revision,
+ } => Self::ApproveReview {
+ expected_review_revision: expected_review_revision.get(),
+ },
+ ReviewAction::RerunCheck {
+ check_id,
+ expected_check_revision,
+ } => Self::RerunCheck {
+ check_id: check_id.as_str().to_owned(),
+ expected_check_revision: expected_check_revision.get(),
+ },
+ ReviewAction::BatchSendCommentsToAgent { comments } => Self::BatchSendCommentsToAgent {
+ comments: comments
+ .iter()
+ .map(|comment| ReviewCommentTargetDisk {
+ comment_id: comment.comment_id().as_str().to_owned(),
+ expected_revision: comment.expected_revision().get(),
+ })
+ .collect(),
+ },
+ ReviewAction::Stage { proposal_id } => Self::Stage {
+ proposal_id: proposal_id.as_str().to_owned(),
+ },
+ ReviewAction::Unstage { proposal_id } => Self::Unstage {
+ proposal_id: proposal_id.as_str().to_owned(),
+ },
+ ReviewAction::Commit { proposal_id } => Self::Commit {
+ proposal_id: proposal_id.as_str().to_owned(),
+ },
+ ReviewAction::ResolveConflict {
+ proposal_id,
+ file_id,
+ resolution,
+ } => Self::ResolveConflict {
+ proposal_id: proposal_id.as_str().to_owned(),
+ file_id: file_id.as_str().to_owned(),
+ resolution: resolution.as_str().to_owned(),
+ },
+ ReviewAction::MergePullRequest {
+ pull_request_id,
+ expected_pull_request_revision,
+ expected_head_revision,
+ } => Self::MergePullRequest {
+ pull_request_id: pull_request_id.as_str().to_owned(),
+ expected_pull_request_revision: expected_pull_request_revision.get(),
+ expected_head_revision: expected_head_revision.as_str().to_owned(),
+ },
+ _ => return Err(ReviewCustodyError::UnsupportedAction),
+ })
+ }
+
+ fn to_action(&self) -> Result {
+ Ok(match self {
+ Self::AddComment {
+ comment_id,
+ file_id,
+ hunk_id,
+ side,
+ line,
+ body,
+ } => ReviewAction::AddComment {
+ comment_id: ReviewCommentId::new(comment_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ anchor: ReviewAnchor::new(
+ ReviewFileId::new(file_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ ReviewHunkId::new(hunk_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ DiffSide::parse(side).map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ *line,
+ )
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ body: ReviewText::new(body.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ },
+ Self::ApproveReview {
+ expected_review_revision,
+ } => ReviewAction::ApproveReview {
+ expected_review_revision: Revision::new(*expected_review_revision)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ },
+ Self::RerunCheck {
+ check_id,
+ expected_check_revision,
+ } => ReviewAction::RerunCheck {
+ check_id: ReviewCheckId::new(check_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ expected_check_revision: Revision::new(*expected_check_revision)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ },
+ Self::BatchSendCommentsToAgent { comments } => {
+ // Bound before allocating. `validate_client_shape` would also
+ // reject an oversized batch, but only after building the whole
+ // vector out of a document this process has not yet trusted.
+ if comments.len() > MAX_REVIEW_COMMENTS {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ ReviewAction::BatchSendCommentsToAgent {
+ comments: comments
+ .iter()
+ .map(|comment| {
+ Ok(ReviewCommentTarget::new(
+ ReviewCommentId::new(comment.comment_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ Revision::new(comment.expected_revision)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ ))
+ })
+ .collect::, ReviewCustodyError>>()?,
+ }
+ }
+ Self::Stage { proposal_id } => ReviewAction::Stage {
+ proposal_id: disk_proposal_id(proposal_id)?,
+ },
+ Self::Unstage { proposal_id } => ReviewAction::Unstage {
+ proposal_id: disk_proposal_id(proposal_id)?,
+ },
+ Self::Commit { proposal_id } => ReviewAction::Commit {
+ proposal_id: disk_proposal_id(proposal_id)?,
+ },
+ Self::ResolveConflict {
+ proposal_id,
+ file_id,
+ resolution,
+ } => ReviewAction::ResolveConflict {
+ proposal_id: disk_proposal_id(proposal_id)?,
+ file_id: ReviewFileId::new(file_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ resolution: ConflictResolution::parse(resolution)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ },
+ Self::MergePullRequest {
+ pull_request_id,
+ expected_pull_request_revision,
+ expected_head_revision,
+ } => ReviewAction::MergePullRequest {
+ pull_request_id: PullRequestId::new(pull_request_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ expected_pull_request_revision: Revision::new(*expected_pull_request_revision)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ expected_head_revision: ReviewField::new(expected_head_revision.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ },
+ })
+ }
+}
+
+/// Re-parse a persisted proposal id through the protocol's own grammar.
+///
+/// The looser of two spellings is the one a recovered plan replays through, so
+/// a document this process has not yet trusted is never allowed to widen what
+/// a proposal id may be.
+fn disk_proposal_id(value: &str) -> Result {
+ ReviewProposalId::new(value.to_owned()).map_err(|_| ReviewCustodyError::DocumentInvalid)
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReviewConfirmationDisk {
+ confirmation_digest: String,
+ expected_workspace_revision: u64,
+ receipt_correlation_digest: String,
+}
+
+impl ReviewConfirmationDisk {
+ fn from_confirmation(confirmation: &ReviewActionConfirmation) -> Self {
+ Self {
+ confirmation_digest: confirmation.confirmation_digest().as_str().to_owned(),
+ expected_workspace_revision: confirmation.expected_workspace_revision().get(),
+ receipt_correlation_digest: confirmation
+ .receipt_correlation_digest()
+ .as_str()
+ .to_owned(),
+ }
+ }
+
+ fn to_confirmation(&self) -> Result {
+ Ok(ReviewActionConfirmation::new(
+ ReviewConfirmationDigest::new(self.confirmation_digest.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ Revision::new(self.expected_workspace_revision)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ ReviewReceiptCorrelationDigest::new(self.receipt_correlation_digest.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ ))
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(tag = "state", rename_all = "snake_case", deny_unknown_fields)]
+enum ReviewCustodyState {
+ Prepared,
+ Dispatched,
+ Receipt {
+ receipt: ReviewReceiptDisk,
+ },
+ Refused {
+ category: String,
+ explanation: String,
+ },
+}
+
+impl ReviewCustodyState {
+ fn is_terminal(&self) -> bool {
+ match self {
+ Self::Prepared | Self::Dispatched => false,
+ Self::Receipt { receipt } => receipt.requires_lookup().map_or(true, |value| !value),
+ Self::Refused { .. } => true,
+ }
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReviewReceiptDisk {
+ receipt_id: String,
+ key: String,
+ action_id: String,
+ actor: String,
+ outcome: String,
+ revision: Option,
+ current_revision: Option,
+ reconciliation: String,
+}
+
+impl ReviewReceiptDisk {
+ fn from_receipt(receipt: &ReviewActionReceipt) -> Self {
+ Self {
+ receipt_id: receipt.receipt_id().as_str().to_owned(),
+ key: receipt.idempotency_key().as_str().to_owned(),
+ action_id: receipt.action_id().as_str().to_owned(),
+ actor: receipt.actor().as_str().to_owned(),
+ outcome: receipt.outcome().as_str().to_owned(),
+ revision: receipt.revision().map(Revision::get),
+ current_revision: receipt.current_revision().map(Revision::get),
+ reconciliation: receipt.reconciliation().as_str().to_owned(),
+ }
+ }
+
+ fn to_receipt(&self) -> Result {
+ ReviewActionReceipt::new(
+ ReceiptId::new(self.receipt_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ IdempotencyKey::new(self.key.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ ReviewActionId::new(self.action_id.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ ReviewActorId::new(self.actor.clone())
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ ReviewReceiptOutcome::parse(&self.outcome)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ self.revision
+ .map(Revision::new)
+ .transpose()
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ self.current_revision
+ .map(Revision::new)
+ .transpose()
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ ReviewReconciliation::parse(&self.reconciliation)
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)?,
+ )
+ .map_err(|_| ReviewCustodyError::DocumentInvalid)
+ }
+
+ fn requires_lookup(&self) -> Result {
+ let receipt = self.to_receipt()?;
+ Ok(
+ receipt.reconciliation() == ReviewReconciliation::PollReceipt
+ || matches!(
+ receipt.outcome(),
+ ReviewReceiptOutcome::Accepted | ReviewReceiptOutcome::Unknown
+ ),
+ )
+ }
+
+ fn presentation(&self) -> Result {
+ let receipt = self.to_receipt()?;
+ Ok(ReviewCustodyPresentation {
+ outcome: receipt.outcome().as_str().to_owned(),
+ detail: format!(
+ "{} · {}",
+ receipt.action_id().as_str(),
+ receipt.reconciliation().as_str()
+ ),
+ actor: Some(receipt.actor().as_str().to_owned()),
+ })
+ }
+}
+
+fn exact_record_index(
+ document: &ReviewCustodyDocument,
+ preview: &PlatformReviewActionPreview,
+) -> Result {
+ let mut matching = document.records.iter().enumerate().filter(|(_, record)| {
+ record.key == preview.idempotency_key().as_str() && record.matches_target(preview.target())
+ });
+ let Some((index, record)) = matching.next() else {
+ return Err(ReviewCustodyError::UnknownEffect);
+ };
+ if matching.next().is_some() || record.to_preview()? != *preview {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ Ok(index)
+}
+
+fn exact_record_mut<'a>(
+ document: &'a mut ReviewCustodyDocument,
+ preview: &PlatformReviewActionPreview,
+) -> Result<&'a mut ReviewCustodyRecord, ReviewCustodyError> {
+ let index = exact_record_index(document, preview)?;
+ Ok(&mut document.records[index])
+}
+
+fn load_document(path: &Path) -> Result {
+ let Some(bytes) = bounded_descriptor_read(path, MAX_REVIEW_CUSTODY_FILE_BYTES)? else {
+ return Ok(ReviewCustodyDocument::default());
+ };
+ if bytes.len() as u64 > MAX_REVIEW_CUSTODY_FILE_BYTES {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ let document: ReviewCustodyDocument = serde_json::from_slice(&bytes)?;
+ validate_document(&document)?;
+ Ok(document)
+}
+
+fn validate_document(document: &ReviewCustodyDocument) -> Result<(), ReviewCustodyError> {
+ if document.schema != REVIEW_CUSTODY_SCHEMA
+ || document.records.len() > MAX_REVIEW_CUSTODY_RECORDS
+ {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ let mut keys = std::collections::BTreeSet::new();
+ let mut active_targets = std::collections::BTreeSet::new();
+ for record in &document.records {
+ let preview = record.to_preview()?;
+ if !keys.insert(record.key.as_str()) {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ if !record.state.is_terminal()
+ && !active_targets.insert((
+ preview.target.project.as_str().to_owned(),
+ preview.target.workspace.kind().as_str().to_owned(),
+ preview.target.workspace.id().to_owned(),
+ ))
+ {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ if let ReviewCustodyState::Receipt { receipt } = &record.state {
+ let value = receipt.to_receipt()?;
+ if value.idempotency_key() != preview.idempotency_key() {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ }
+ if let ReviewCustodyState::Refused {
+ category,
+ explanation,
+ } = &record.state
+ {
+ if category.is_empty()
+ || category.len() > MAX_REFUSAL_BYTES
+ || explanation.len() > MAX_REFUSAL_BYTES
+ {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ }
+ }
+ Ok(())
+}
+
+fn persist_document(
+ path: &Path,
+ document: &ReviewCustodyDocument,
+) -> Result<(), ReviewCustodyError> {
+ validate_document(document)?;
+ let bytes = serde_json::to_vec_pretty(document)?;
+ if bytes.len() as u64 > MAX_REVIEW_CUSTODY_FILE_BYTES {
+ return Err(ReviewCustodyError::DocumentInvalid);
+ }
+ secure_atomic_write(path, &bytes)?;
+ Ok(())
+}
+
+#[derive(Debug, Error)]
+pub enum ReviewCustodyError {
+ #[error("review custody storage failed: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("review custody document is not valid JSON: {0}")]
+ Json(#[from] serde_json::Error),
+ #[error("review custody path is invalid")]
+ PathInvalid,
+ #[error("review custody document is invalid or exceeds its bound")]
+ DocumentInvalid,
+ #[error("review custody has reached its bounded capacity")]
+ CapacityExceeded,
+ #[error("review custody already contains this effect")]
+ DuplicateEffect,
+ #[error("another review effect is already pending for this workspace")]
+ EffectAlreadyPending,
+ #[error("review effect is not in custody")]
+ UnknownEffect,
+ #[error("review effect is already terminal")]
+ EffectTerminal,
+ #[error("review effect was never dispatched")]
+ EffectNeverDispatched,
+ #[error("review receipt does not match the exact effect")]
+ ReceiptMismatch,
+ #[error("review action is outside this custody slice")]
+ UnsupportedAction,
+}
+
+#[cfg(test)]
+mod tests {
+ use std::sync::{Arc, Barrier};
+
+ use super::*;
+ use automonique_protocol::platform_v2_review_api::decode_review_snapshot;
+
+ const CANONICAL_FIXTURE: &[u8] =
+ include_bytes!("../../../tests/fixtures/platform-v2-review-v2.json");
+
+ fn semantic() -> PlatformReviewSemantic {
+ PlatformReviewSemantic::from(&decode_review_snapshot(CANONICAL_FIXTURE).unwrap())
+ }
+
+ fn target(review: &PlatformReviewSemantic) -> PlatformReviewTarget {
+ PlatformReviewTarget {
+ project: ProjectId::new("project-1").unwrap(),
+ workspace: WorkContextIdentity::parse_local(
+ WorkContextTargetKind::UserWorkspace,
+ &review.workspace_id,
+ )
+ .unwrap(),
+ }
+ }
+
+ fn approval() -> PlatformReviewActionPreview {
+ let review = semantic();
+ PlatformReviewActionPreview::approve(target(&review), &review).unwrap()
+ }
+
+ fn comment(body: &str) -> PlatformReviewActionPreview {
+ let review = semantic();
+ let file = review
+ .files
+ .iter()
+ .find(|file| !file.hunks.is_empty())
+ .unwrap();
+ let hunk = &file.hunks[0];
+ let (side, line) = if hunk.new_lines > 0 {
+ (DiffSide::New, hunk.new_start)
+ } else {
+ (DiffSide::Old, hunk.old_start)
+ };
+ let anchor = ReviewAnchorSemantic {
+ file_id: file.id.clone(),
+ hunk_id: hunk.id.clone(),
+ side,
+ line,
+ };
+ PlatformReviewActionPreview::add_comment(target(&review), &review, &anchor, body).unwrap()
+ }
+
+ struct TempRoot(PathBuf);
+
+ impl TempRoot {
+ fn new() -> Self {
+ let path = std::env::temp_dir().join(format!(
+ "shelldeck-review-custody-{}-{}",
+ std::process::id(),
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(&path).unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
+ }
+ Self(path)
+ }
+
+ fn path(&self) -> &Path {
+ &self.0
+ }
+ }
+
+ impl Drop for TempRoot {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+ }
+
+ fn store_path(directory: &TempRoot) -> PathBuf {
+ directory
+ .path()
+ .join("platform-review")
+ .join("custody")
+ .join("effects-v1.json")
+ }
+
+ fn receipt(
+ preview: &PlatformReviewActionPreview,
+ outcome: ReviewReceiptOutcome,
+ actor: &str,
+ ) -> ReviewActionReceipt {
+ let (revision, reconciliation) = match outcome {
+ ReviewReceiptOutcome::Accepted | ReviewReceiptOutcome::Unknown => {
+ (None, ReviewReconciliation::PollReceipt)
+ }
+ ReviewReceiptOutcome::Completed => (
+ Some(Revision::new(43).unwrap()),
+ ReviewReconciliation::Final,
+ ),
+ ReviewReceiptOutcome::Refused | ReviewReceiptOutcome::Conflict => {
+ (None, ReviewReconciliation::Final)
+ }
+ };
+ ReviewActionReceipt::new(
+ ReceiptId::new("receipt-1").unwrap(),
+ preview.idempotency_key().clone(),
+ ReviewActionId::new("action-1").unwrap(),
+ ReviewActorId::new(actor).unwrap(),
+ outcome,
+ revision,
+ (outcome == ReviewReceiptOutcome::Conflict).then(|| Revision::new(44).unwrap()),
+ reconciliation,
+ )
+ .unwrap()
+ }
+
+ // SDTEST-1822 — an inert preview survives a process restart without ever
+ // becoming provider work; only the durable dispatch fence enables lookup.
+ #[test]
+ fn prepared_restart_is_never_started_and_dispatched_restart_is_lookup_only() {
+ let directory = TempRoot::new();
+ let preview = approval();
+ let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ store.prepare(&preview).unwrap();
+
+ let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ assert_eq!(
+ restarted.recovery(preview.target()).unwrap(),
+ Some(ReviewCustodyRecovery::NeverStarted(preview.clone()))
+ );
+
+ restarted.mark_dispatched(&preview).unwrap();
+ let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ assert_eq!(
+ restarted.recovery(preview.target()).unwrap(),
+ Some(ReviewCustodyRecovery::LookupOnly(preview))
+ );
+ }
+
+ // SDTEST-1823 — Accepted remains durable across restart, retains its actor,
+ // and advances to a terminal presentation only after the correlated poll.
+ #[test]
+ fn accepted_restart_then_completed_retains_actor_and_never_reopens_dispatch() {
+ let directory = TempRoot::new();
+ let preview = approval();
+ let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ store.prepare(&preview).unwrap();
+ store.mark_dispatched(&preview).unwrap();
+ store
+ .record_receipt(
+ &preview,
+ &receipt(&preview, ReviewReceiptOutcome::Accepted, "actor-1"),
+ )
+ .unwrap();
+
+ let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ assert!(matches!(
+ restarted.recovery(preview.target()).unwrap(),
+ Some(ReviewCustodyRecovery::LookupOnly(value)) if value == preview
+ ));
+ restarted
+ .record_receipt(
+ &preview,
+ &receipt(&preview, ReviewReceiptOutcome::Completed, "actor-1"),
+ )
+ .unwrap();
+ assert_eq!(
+ restarted.recovery(preview.target()).unwrap(),
+ Some(ReviewCustodyRecovery::Terminal(ReviewCustodyPresentation {
+ outcome: "completed".to_owned(),
+ detail: "action-1 · final".to_owned(),
+ actor: Some("actor-1".to_owned()),
+ }))
+ );
+ }
+
+ // SDTEST-1824 — separate store instances serialize through the OS lock, so
+ // two processes cannot reserve two effects for the same workspace.
+ #[test]
+ fn concurrent_stores_reserve_only_one_effect_for_an_exact_target() {
+ let directory = TempRoot::new();
+ let path = store_path(&directory);
+ PlatformReviewCustodyStore::open(path.clone()).unwrap();
+ let first = approval();
+ let second = approval();
+ let barrier = Arc::new(Barrier::new(3));
+ let handles = [first, second]
+ .into_iter()
+ .map(|preview| {
+ let path = path.clone();
+ let barrier = barrier.clone();
+ std::thread::spawn(move || {
+ let store = PlatformReviewCustodyStore::open(path).unwrap();
+ barrier.wait();
+ store.prepare(&preview)
+ })
+ })
+ .collect::>();
+ barrier.wait();
+ let results = handles
+ .into_iter()
+ .map(|handle| handle.join().unwrap())
+ .collect::>();
+ assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
+ assert_eq!(
+ results
+ .iter()
+ .filter(|result| matches!(result, Err(ReviewCustodyError::EffectAlreadyPending)))
+ .count(),
+ 1
+ );
+ }
+
+ // SDTEST-1825 — invalid and oversized documents fail closed before any
+ // candidate state is admitted or rewritten.
+ #[test]
+ fn oversized_document_is_rejected_without_replacement() {
+ let directory = TempRoot::new();
+ let path = store_path(&directory);
+ let store = PlatformReviewCustodyStore::open(path.clone()).unwrap();
+ store.prepare(&approval()).unwrap();
+ let oversized = vec![b'x'; MAX_REVIEW_CUSTODY_FILE_BYTES as usize + 1];
+ std::fs::write(&path, &oversized).unwrap();
+ assert!(matches!(
+ PlatformReviewCustodyStore::open(path.clone()),
+ Err(ReviewCustodyError::DocumentInvalid)
+ ));
+ assert_eq!(std::fs::read(path).unwrap(), oversized);
+ }
+
+ // SDTEST-1858 — the unconfirmed comment family crosses the same durable
+ // fence as a confirmed rerun: restart never reposts it, its receipt keeps
+ // the server actor, and a second comment cannot open a parallel effect.
+ #[test]
+ fn unconfirmed_comment_is_fenced_restart_safe_and_actor_attributed() {
+ let directory = TempRoot::new();
+ let preview = comment("borne exacte");
+ assert!(preview.confirmation().is_none());
+ let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ store.prepare(&preview).unwrap();
+
+ // A second comment for the same workspace cannot reserve its own
+ // effect while the first is still non-terminal.
+ assert!(matches!(
+ store.prepare(&comment("seconde borne")),
+ Err(ReviewCustodyError::EffectAlreadyPending)
+ ));
+
+ store.mark_dispatched(&preview).unwrap();
+ let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ assert_eq!(
+ restarted.recovery(preview.target()).unwrap(),
+ Some(ReviewCustodyRecovery::LookupOnly(preview.clone()))
+ );
+ restarted
+ .record_receipt(
+ &preview,
+ &receipt(&preview, ReviewReceiptOutcome::Completed, "reviewer-7"),
+ )
+ .unwrap();
+ assert_eq!(
+ PlatformReviewCustodyStore::open(store_path(&directory))
+ .unwrap()
+ .recovery(preview.target())
+ .unwrap(),
+ Some(ReviewCustodyRecovery::Terminal(ReviewCustodyPresentation {
+ outcome: "completed".to_owned(),
+ detail: "action-1 · final".to_owned(),
+ actor: Some("reviewer-7".to_owned()),
+ }))
+ );
+ }
+
+ /// The advertisement a coherent server would mint for this snapshot's one
+ /// sendable comment, and the preview built verbatim from it.
+ fn batch_delivery() -> PlatformReviewActionPreview {
+ let mut review = semantic();
+ review.comments[0].agent_state = CommentAgentState::NotSent;
+ let target = target(&review);
+ let capabilities = ReviewCapabilities::new(
+ target.project.clone(),
+ target.workspace.clone(),
+ review.revision,
+ Revision::new(91).unwrap(),
+ Vec::new(),
+ vec![ReviewAgentDeliveryCapability::new(
+ ReviewCommentId::new(review.comments[0].id.clone()).unwrap(),
+ review.comments[0].revision,
+ ReviewAuthority::new(
+ ReviewAuthorityKind::Review,
+ automonique_protocol::platform_v2_review::ReviewAuthorityId::new(
+ review.review.authority.id.clone(),
+ )
+ .unwrap(),
+ ),
+ )
+ .unwrap()],
+ ReviewPullRequestCapabilities::default(),
+ ReviewGitStagingCapabilities::default(),
+ )
+ .unwrap();
+ PlatformReviewActionPreview::batch_send_comments(
+ target,
+ &review,
+ &capabilities,
+ &[review.comments[0].id.clone()],
+ )
+ .unwrap()
+ }
+
+ // SDTEST-1865 — the unconfirmed batch delivery takes the same durable
+ // at-most-once lane as every other exposed mutation, and survives the
+ // round trip through disk with its exact comment set intact.
+ #[test]
+ fn unconfirmed_batch_delivery_is_fenced_and_survives_the_disk_round_trip() {
+ let directory = TempRoot::new();
+ let preview = batch_delivery();
+ // No confirmation digest: this lane is fenced by the advertisement and
+ // the domain state machine, never by a digest.
+ assert!(preview.confirmation().is_none());
+ // It must still be revalidated before dispatch, exactly like a
+ // confirmed rerun and unlike a comment or an approval.
+ assert!(preview.requires_capability_revalidation());
+
+ // Before this slice the disk format knew only AddComment,
+ // ApproveReview and RerunCheck, so a batch delivery could not be
+ // recorded at all and would have refused at the fence.
+ let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ store.prepare(&preview).unwrap();
+ assert!(matches!(
+ store.prepare(&batch_delivery()),
+ Err(ReviewCustodyError::EffectAlreadyPending)
+ ));
+
+ // A restart before dispatch reports the preview as never started, and
+ // the exact action survives serialization: same comments, same
+ // revisions, same order.
+ let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ assert_eq!(
+ restarted.recovery(preview.target()).unwrap(),
+ Some(ReviewCustodyRecovery::NeverStarted(preview.clone()))
+ );
+
+ restarted.mark_dispatched(&preview).unwrap();
+ assert_eq!(
+ PlatformReviewCustodyStore::open(store_path(&directory))
+ .unwrap()
+ .recovery(preview.target())
+ .unwrap(),
+ Some(ReviewCustodyRecovery::LookupOnly(preview.clone())),
+ "a restart mid-delivery must look the receipt up, never re-post"
+ );
+
+ let reopened = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ reopened
+ .record_receipt(
+ &preview,
+ &receipt(&preview, ReviewReceiptOutcome::Completed, "reviewer-9"),
+ )
+ .unwrap();
+ assert_eq!(
+ PlatformReviewCustodyStore::open(store_path(&directory))
+ .unwrap()
+ .recovery(preview.target())
+ .unwrap(),
+ Some(ReviewCustodyRecovery::Terminal(ReviewCustodyPresentation {
+ outcome: "completed".to_owned(),
+ detail: "action-1 · final".to_owned(),
+ actor: Some("reviewer-9".to_owned()),
+ }))
+ );
+ }
+
+ /// One confirmed staging preview, built from the advertisement a coherent
+ /// server would mint for this snapshot's `Commit` proposal.
+ fn staging_preview() -> PlatformReviewActionPreview {
+ let review = semantic();
+ let target = target(&review);
+ let proposal = &review.proposals[0];
+ let capabilities = ReviewCapabilities::new(
+ target.project.clone(),
+ target.workspace.clone(),
+ review.revision,
+ Revision::new(91).unwrap(),
+ Vec::new(),
+ Vec::new(),
+ ReviewPullRequestCapabilities::default(),
+ ReviewGitStagingCapabilities {
+ staging: vec![ReviewStagingCapability::new(
+ ReviewProposalId::new(proposal.id.clone()).unwrap(),
+ proposal.kind,
+ ReviewField::new("7".repeat(40)).unwrap(),
+ ReviewIndexDigest::new("a".repeat(64)).unwrap(),
+ ReviewAuthority::new(
+ ReviewAuthorityKind::Git,
+ automonique_protocol::platform_v2_review::ReviewAuthorityId::new(
+ proposal.authority.as_ref().unwrap().id.clone(),
+ )
+ .unwrap(),
+ ),
+ ReviewConfirmationDigest::new("c".repeat(64)).unwrap(),
+ ReviewReceiptCorrelationDigest::new("d".repeat(64)).unwrap(),
+ )
+ .unwrap()],
+ conflict_resolutions: Vec::new(),
+ },
+ )
+ .unwrap();
+ PlatformReviewActionPreview::stage_proposal(target, &review, &capabilities, &proposal.id)
+ .unwrap()
+ }
+
+ // SDTEST-1870 — the confirmed staging families take the same durable
+ // at-most-once lane as every other exposed mutation, and survive the round
+ // trip through disk with their exact coordinates and digests intact.
+ //
+ // The disk format did not know these actions before this slice, exactly as
+ // it did not know the batch delivery before PR #161: `prepare` would have
+ // returned `UnsupportedAction` and the dispatch would have died at the
+ // fence. That assumption was wrong once already, which is why this test
+ // drives the whole store rather than only the enum.
+ #[test]
+ fn confirmed_staging_is_fenced_and_survives_the_disk_round_trip() {
+ let directory = TempRoot::new();
+ let preview = staging_preview();
+ // Staging is a confirmed lane now: the unconfirmed spelling cannot be
+ // constructed at all, so a record without a confirmation would be an
+ // unsendable document.
+ let confirmation = preview.confirmation().expect("staging carries a digest");
+ assert_eq!(confirmation.confirmation_digest().as_str(), "c".repeat(64));
+ assert_eq!(
+ confirmation.receipt_correlation_digest().as_str(),
+ "d".repeat(64)
+ );
+ assert!(preview.requires_capability_revalidation());
+
+ let store = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ store.prepare(&preview).unwrap();
+ assert!(matches!(
+ store.prepare(&staging_preview()),
+ Err(ReviewCustodyError::EffectAlreadyPending)
+ ));
+
+ // A restart before dispatch reports it as never started, and the exact
+ // action plus both digests survive serialization.
+ let restarted = PlatformReviewCustodyStore::open(store_path(&directory)).unwrap();
+ assert_eq!(
+ restarted.recovery(preview.target()).unwrap(),
+ Some(ReviewCustodyRecovery::NeverStarted(preview.clone()))
+ );
+
+ restarted.mark_dispatched(&preview).unwrap();
+ assert_eq!(
+ PlatformReviewCustodyStore::open(store_path(&directory))
+ .unwrap()
+ .recovery(preview.target())
+ .unwrap(),
+ Some(ReviewCustodyRecovery::LookupOnly(preview.clone())),
+ "a restart mid-write must look the receipt up through its correlation, never re-post"
+ );
+ }
+
+ // SDTEST-1871 — every confirmed staging family round-trips through the
+ // disk enum, and the side of a conflict resolution is part of the persisted
+ // identity rather than a value recomputed on recovery.
+ #[test]
+ fn every_staging_family_round_trips_and_a_resolution_keeps_its_side() {
+ for action in [
+ ReviewAction::Stage {
+ proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(),
+ },
+ ReviewAction::Unstage {
+ proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(),
+ },
+ ReviewAction::Commit {
+ proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(),
+ },
+ ReviewAction::ResolveConflict {
+ proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(),
+ file_id: ReviewFileId::new("file-1".to_owned()).unwrap(),
+ resolution: ConflictResolution::KeepCurrent,
+ },
+ ReviewAction::ResolveConflict {
+ proposal_id: ReviewProposalId::new("proposal-1".to_owned()).unwrap(),
+ file_id: ReviewFileId::new("file-1".to_owned()).unwrap(),
+ resolution: ConflictResolution::KeepIncoming,
+ },
+ ] {
+ let disk = ReviewActionDisk::from_action(&action).expect("family is spelled on disk");
+ let json = serde_json::to_string(&disk).expect("serializable");
+ let decoded: ReviewActionDisk = serde_json::from_str(&json).expect("decodable");
+ assert_eq!(
+ decoded.to_action().expect("re-parses through the grammar"),
+ action,
+ "a persisted staging action must replay as exactly itself"
+ );
+ }
+
+ // A document whose confirmation no longer matches its family cannot be
+ // replayed onto the wire, so it is rejected as invalid rather than
+ // recovered into an unsendable preview.
+ let mut record =
+ ReviewCustodyRecord::from_preview(&staging_preview(), ReviewCustodyState::Prepared)
+ .unwrap();
+ record.confirmation = None;
+ assert!(matches!(
+ record.to_preview(),
+ Err(ReviewCustodyError::DocumentInvalid)
+ ));
+ }
+
+ #[cfg(unix)]
+ // SDTEST-1826 — linked/replaced custody paths and lock files are never
+ // followed outside ShellDeck's private directory boundary.
+ #[test]
+ fn linked_custody_path_is_rejected() {
+ use std::os::unix::fs::symlink;
+
+ let directory = TempRoot::new();
+ let path = store_path(&directory);
+ let parent = path.parent().unwrap();
+ std::fs::create_dir_all(parent).unwrap();
+ let outside = directory.path().join("outside.json");
+ std::fs::write(&outside, b"outside").unwrap();
+ symlink(&outside, &path).unwrap();
+ assert!(PlatformReviewCustodyStore::open(path).is_err());
+ assert_eq!(std::fs::read(outside).unwrap(), b"outside");
+ }
+}
diff --git a/crates/shelldeck-core/src/config/platform_review/delivery.rs b/crates/shelldeck-core/src/config/platform_review/delivery.rs
new file mode 100644
index 00000000..bf93c297
--- /dev/null
+++ b/crates/shelldeck-core/src/config/platform_review/delivery.rs
@@ -0,0 +1,506 @@
+//! Server-advertised batch delivery of review comments to the authorized session.
+//!
+//! This module is a read-side projection, exactly like [`super::worktree`]. It
+//! grants nothing: it reports which comments the server has *proven* it can
+//! deliver to the retained agent session bound to this review coordinate, and
+//! otherwise reports which fence is missing so the surface can say so instead
+//! of offering a control that would refuse.
+//!
+//! # Why this lane carries no confirmation digest
+//!
+//! A confirmed check rerun needs one because the client names the target and
+//! the effect fires in a system the daemon does not own, so only a digest can
+//! bind the executed write to the preflighted plan. Delivery to a retained
+//! session differs on every axis that motivated the digest:
+//!
+//! * The target is never on the wire. Provider and session identity come from
+//! the operator-owned registry, keyed by the review coordinate, so a client
+//! can neither name nor redirect the session it reaches.
+//! * The snapshot revision is fenced twice server-side, by the store's
+//! current-revision check and again by `resolve_action`.
+//! * The comment set is fenced by the batch arm of `resolve_action`, which
+//! requires every comment to exist at exactly the advertised revision and to
+//! still be in `not_sent` or `refused`.
+//!
+//! Exactly-once therefore falls out of the domain state machine rather than a
+//! digest: settling a delivery moves the comment out of those two states and
+//! bumps both the snapshot revision and the comment's own revision, so the
+//! advertisement that authorized a send cannot construct a second one.
+//!
+//! Minting a receipt correlation here would be worse than redundant: the
+//! host's receipt lookup *skips* a retained action whenever the request
+//! carries a correlation digest, so advertising one would hand this client a
+//! token that makes its own receipt unfindable. A fence that breaks recovery
+//! is not a fence.
+//!
+//! The client consequence is the one rule this module exists to enforce: the
+//! advertisement is the fence, so it must be re-read after every settlement
+//! rather than reused. See [`super::PlatformReviewActionPreview::matches_capabilities`].
+
+use super::{
+ PlatformReviewSemantic, PlatformReviewTarget, ReviewAuthorityKind, ReviewCapabilities,
+};
+use automonique_protocol::primitives::Revision;
+
+/// Why the projection offers no batch send-to-agent control.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReviewDeliveryWithheld {
+ /// No capability response is attributed to this exact project, workspace
+ /// and snapshot revision, so nothing has been advertised at all.
+ NoServerCapability,
+ /// The capability response is exact and its advertised list is empty.
+ /// That is the server's honest fail-closed answer — no registry binding,
+ /// no reachable delivery adapter, a stale review, or no comment currently
+ /// in a sendable state — and it must produce no control.
+ NoDeliverableComment,
+ /// No durable at-most-once custody store is available in this process.
+ NoCustodyLane,
+}
+
+impl ReviewDeliveryWithheld {
+ /// Every reason a surface must be able to explain.
+ pub const ALL: [Self; 3] = [
+ Self::NoServerCapability,
+ Self::NoDeliverableComment,
+ Self::NoCustodyLane,
+ ];
+
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::NoServerCapability => "no_server_capability",
+ Self::NoDeliverableComment => "no_deliverable_comment",
+ Self::NoCustodyLane => "no_custody_lane",
+ }
+ }
+
+ /// Canonical cross-client presentation key, carrying no localized copy.
+ #[must_use]
+ pub fn semantic_key(self) -> String {
+ format!("delivery_withheld.{}", self.as_str())
+ }
+}
+
+/// One comment the server advertised as deliverable, carried verbatim.
+///
+/// `expected_comment_revision` is the server's value, never the snapshot's.
+/// The two agree on a coherent read — that is what [`advertised_agent_deliveries`]
+/// checks — but the value that crosses the network is this one, because it is
+/// the one the server committed to accepting.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AdvertisedReviewDelivery {
+ pub comment_id: String,
+ pub expected_comment_revision: Revision,
+ pub authority_id: String,
+}
+
+/// The exact advertised delivery set for one active snapshot.
+///
+/// Every entry survives three checks: the capability response is attributed to
+/// this exact project, workspace and snapshot revision; its authority is the
+/// review authority the snapshot itself names; and the comment is present in
+/// that same snapshot, at the advertised revision, in a sendable state.
+///
+/// The third check is a torn-read guard, not a second source of authority. The
+/// capability list and the snapshot are two separate reads, and a coherent
+/// server mints them from one projection, so a disagreement means they
+/// straddled a change. Refusing the entry is the fail-closed answer; it can
+/// never *add* an entry the server did not advertise.
+#[must_use]
+pub fn advertised_agent_deliveries(
+ capabilities: Option<&ReviewCapabilities>,
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+) -> Vec {
+ let Some(capabilities) = capabilities else {
+ return Vec::new();
+ };
+ if capabilities.project() != &target.project
+ || capabilities.workspace() != &target.workspace
+ || capabilities.snapshot_revision() != review.revision
+ || review.workspace_kind != target.workspace.kind()
+ || review.workspace_id != target.workspace.id()
+ {
+ return Vec::new();
+ }
+ capabilities
+ .agent_deliverable_comments()
+ .iter()
+ .filter(|capability| {
+ capability.authority().kind() == ReviewAuthorityKind::Review
+ && capability.authority().id().as_str() == review.review.authority.id
+ && review.review.authority.kind == ReviewAuthorityKind::Review
+ })
+ .filter(|capability| {
+ review.comments.iter().any(|comment| {
+ comment.id == capability.comment_id().as_str()
+ && comment.revision == capability.expected_comment_revision()
+ }) && review.comment_is_batch_actionable(capability.comment_id().as_str())
+ })
+ .map(|capability| AdvertisedReviewDelivery {
+ comment_id: capability.comment_id().as_str().to_owned(),
+ expected_comment_revision: capability.expected_comment_revision(),
+ authority_id: capability.authority().id().as_str().to_owned(),
+ })
+ .collect()
+}
+
+/// Decide whether a batch send-to-agent control may be rendered at all.
+///
+/// Both fences must hold, exactly like the confirmed check rerun: the server
+/// must have advertised at least one deliverable comment for this exact
+/// snapshot, and the durable custody store must be able to record the
+/// at-most-once boundary before dispatch. This lane needs no *confirmation
+/// digest*, but it still needs the durable record every other exposed mutation
+/// gets: a restart between the POST and the receipt must not make a delivered
+/// batch indistinguishable from one that never left.
+///
+/// A missing fence makes the control absent, never optimistically disabled.
+///
+/// # Errors
+///
+/// Returns the first unmet fence.
+pub const fn review_delivery_control(
+ exact_capability: bool,
+ advertised: bool,
+ custody_available: bool,
+) -> Result<(), ReviewDeliveryWithheld> {
+ if !exact_capability {
+ Err(ReviewDeliveryWithheld::NoServerCapability)
+ } else if !advertised {
+ Err(ReviewDeliveryWithheld::NoDeliverableComment)
+ } else if !custody_available {
+ Err(ReviewDeliveryWithheld::NoCustodyLane)
+ } else {
+ Ok(())
+ }
+}
+
+/// Everything a surface needs to render the delivery lane for one snapshot.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewAgentDeliveryProjection {
+ advertised: Vec,
+ /// Why no control may be offered, when none may.
+ pub control: Result<(), ReviewDeliveryWithheld>,
+}
+
+impl ReviewAgentDeliveryProjection {
+ #[must_use]
+ pub fn new(
+ review: &PlatformReviewSemantic,
+ target: &PlatformReviewTarget,
+ capabilities: Option<&ReviewCapabilities>,
+ custody_available: bool,
+ ) -> Self {
+ // "Exact" is about attribution, not content: a capability response for
+ // this project, workspace and snapshot revision was received. An empty
+ // advertised list inside it is a different, more specific answer, and
+ // the surface must be able to say which one it got.
+ let exact_capability = capabilities.is_some_and(|capabilities| {
+ capabilities.project() == &target.project
+ && capabilities.workspace() == &target.workspace
+ && capabilities.snapshot_revision() == review.revision
+ });
+ let advertised = advertised_agent_deliveries(capabilities, target, review);
+ let control =
+ review_delivery_control(exact_capability, !advertised.is_empty(), custody_available);
+ Self {
+ advertised,
+ control,
+ }
+ }
+
+ /// The projection for a surface that has no exactly attributed target.
+ ///
+ /// Nothing was advertised because nothing could have been, so this is the
+ /// same fail-closed answer as an absent capability response.
+ #[must_use]
+ pub const fn unavailable() -> Self {
+ Self {
+ advertised: Vec::new(),
+ control: Err(ReviewDeliveryWithheld::NoServerCapability),
+ }
+ }
+
+ /// The advertised set, in the capability's own order.
+ ///
+ /// The server sorts it by comment id, and the protocol requires a batch to
+ /// be strictly ordered by that same id, so a selection filtered out of
+ /// this slice is already in wire order.
+ #[must_use]
+ pub fn advertised(&self) -> &[AdvertisedReviewDelivery] {
+ &self.advertised
+ }
+
+ /// Whether this exact comment may carry a send control.
+ #[must_use]
+ pub fn advertises(&self, comment_id: &str) -> bool {
+ self.advertised
+ .iter()
+ .any(|entry| entry.comment_id == comment_id)
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::super::{
+ CommentAgentState, PlatformReviewSemantic, ReviewAgentDeliveryCapability, ReviewAuthority,
+ ReviewCommentId, ReviewFreshnessState, ReviewGitStagingCapabilities,
+ ReviewPullRequestCapabilities,
+ };
+ use super::*;
+ use automonique_protocol::platform_v2::{ProjectId, WorkContextIdentity};
+ use automonique_protocol::platform_v2_review::ReviewAuthorityId;
+ use automonique_protocol::platform_v2_review_api::decode_review_snapshot;
+
+ const CANONICAL_FIXTURE: &[u8] =
+ include_bytes!("../../../tests/fixtures/platform-v2-review-v2.json");
+
+ fn fixture() -> (PlatformReviewTarget, PlatformReviewSemantic) {
+ let snapshot = decode_review_snapshot(CANONICAL_FIXTURE).unwrap();
+ let mut review = PlatformReviewSemantic::from(&snapshot);
+ review.comments[0].agent_state = CommentAgentState::NotSent;
+ let target = PlatformReviewTarget {
+ project: ProjectId::new("project-1").unwrap(),
+ workspace: snapshot.workspace().clone(),
+ };
+ (target, review)
+ }
+
+ /// Build a capability response with arbitrary coordinates, so each fence
+ /// can be broken one at a time.
+ fn capabilities(
+ project: &ProjectId,
+ workspace: &WorkContextIdentity,
+ snapshot_revision: Revision,
+ entries: Vec<(&str, Revision, &str)>,
+ ) -> ReviewCapabilities {
+ let advertised = entries
+ .into_iter()
+ .map(|(id, revision, authority)| {
+ ReviewAgentDeliveryCapability::new(
+ ReviewCommentId::new(id.to_owned()).unwrap(),
+ revision,
+ ReviewAuthority::new(
+ ReviewAuthorityKind::Review,
+ ReviewAuthorityId::new(authority.to_owned()).unwrap(),
+ ),
+ )
+ .unwrap()
+ })
+ .collect();
+ ReviewCapabilities::new(
+ project.clone(),
+ workspace.clone(),
+ snapshot_revision,
+ Revision::new(91).unwrap(),
+ Vec::new(),
+ advertised,
+ ReviewPullRequestCapabilities::default(),
+ ReviewGitStagingCapabilities::default(),
+ )
+ .unwrap()
+ }
+
+ fn exact(target: &PlatformReviewTarget, review: &PlatformReviewSemantic) -> ReviewCapabilities {
+ let comment = &review.comments[0];
+ capabilities(
+ &target.project,
+ &target.workspace,
+ review.revision,
+ vec![(
+ comment.id.as_str(),
+ comment.revision,
+ review.review.authority.id.as_str(),
+ )],
+ )
+ }
+
+ // SDTEST-1864
+ #[test]
+ fn every_delivery_fence_fails_closed_with_its_own_distinct_reason() {
+ let (target, review) = fixture();
+ let exact_capabilities = exact(&target, &review);
+
+ let granted =
+ ReviewAgentDeliveryProjection::new(&review, &target, Some(&exact_capabilities), true);
+ assert_eq!(granted.control, Ok(()));
+ assert!(granted.advertises(&review.comments[0].id));
+ assert_eq!(
+ granted.advertised(),
+ &[AdvertisedReviewDelivery {
+ comment_id: review.comments[0].id.clone(),
+ expected_comment_revision: review.comments[0].revision,
+ authority_id: review.review.authority.id.clone(),
+ }]
+ );
+
+ // No capability response at all.
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&review, &target, None, true).control,
+ Err(ReviewDeliveryWithheld::NoServerCapability)
+ );
+ assert_eq!(
+ ReviewAgentDeliveryProjection::unavailable().control,
+ Err(ReviewDeliveryWithheld::NoServerCapability)
+ );
+
+ // A response for another project, another workspace, or another
+ // snapshot revision is not this snapshot's answer.
+ let foreign_project = capabilities(
+ &ProjectId::new("project-2").unwrap(),
+ &target.workspace,
+ review.revision,
+ vec![(
+ review.comments[0].id.as_str(),
+ review.comments[0].revision,
+ review.review.authority.id.as_str(),
+ )],
+ );
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&review, &target, Some(&foreign_project), true)
+ .control,
+ Err(ReviewDeliveryWithheld::NoServerCapability)
+ );
+ let stale_snapshot = capabilities(
+ &target.project,
+ &target.workspace,
+ Revision::new(review.revision.get() + 1).unwrap(),
+ vec![(
+ review.comments[0].id.as_str(),
+ review.comments[0].revision,
+ review.review.authority.id.as_str(),
+ )],
+ );
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&review, &target, Some(&stale_snapshot), true)
+ .control,
+ Err(ReviewDeliveryWithheld::NoServerCapability)
+ );
+
+ // `advertised_agent_deliveries` is asserted directly, not only through
+ // the projection: the action preview calls it on its own, so a fence
+ // that lived only in `ReviewAgentDeliveryProjection::new` would let a
+ // preview pin itself to a foreign or superseded snapshot revision.
+ assert!(
+ advertised_agent_deliveries(Some(&exact_capabilities), &target, &review).len() == 1
+ );
+ for hole in [&foreign_project, &stale_snapshot] {
+ assert!(
+ advertised_agent_deliveries(Some(hole), &target, &review).is_empty(),
+ "a capability response for another coordinate advertises nothing"
+ );
+ }
+ assert!(advertised_agent_deliveries(None, &target, &review).is_empty());
+ let foreign_workspace = PlatformReviewTarget {
+ project: target.project.clone(),
+ workspace: WorkContextIdentity::parse_local(target.workspace.kind(), "wc_user_foreign")
+ .unwrap(),
+ };
+ assert!(advertised_agent_deliveries(
+ Some(&exact_capabilities),
+ &foreign_workspace,
+ &review
+ )
+ .is_empty());
+
+ // An exact response advertising nothing is the server's honest
+ // fail-closed answer, and must read differently from an absent one.
+ let empty = capabilities(
+ &target.project,
+ &target.workspace,
+ review.revision,
+ Vec::new(),
+ );
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&review, &target, Some(&empty), true).control,
+ Err(ReviewDeliveryWithheld::NoDeliverableComment)
+ );
+
+ // Custody is still required. This lane needs no confirmation digest,
+ // but it is an exposed mutation and takes the same durable record.
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&review, &target, Some(&exact_capabilities), false)
+ .control,
+ Err(ReviewDeliveryWithheld::NoCustodyLane)
+ );
+
+ // Torn reads. The capability list and the snapshot are two separate
+ // reads; a coherent server mints them from one projection, so any
+ // disagreement means they straddled a change and the entry is dropped.
+ let wrong_comment_revision = capabilities(
+ &target.project,
+ &target.workspace,
+ review.revision,
+ vec![(
+ review.comments[0].id.as_str(),
+ Revision::new(review.comments[0].revision.get() + 1).unwrap(),
+ review.review.authority.id.as_str(),
+ )],
+ );
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(
+ &review,
+ &target,
+ Some(&wrong_comment_revision),
+ true
+ )
+ .control,
+ Err(ReviewDeliveryWithheld::NoDeliverableComment)
+ );
+ let unknown_comment = capabilities(
+ &target.project,
+ &target.workspace,
+ review.revision,
+ vec![(
+ "comment-absent",
+ review.comments[0].revision,
+ review.review.authority.id.as_str(),
+ )],
+ );
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&review, &target, Some(&unknown_comment), true)
+ .control,
+ Err(ReviewDeliveryWithheld::NoDeliverableComment)
+ );
+ let foreign_authority = capabilities(
+ &target.project,
+ &target.workspace,
+ review.revision,
+ vec![(
+ review.comments[0].id.as_str(),
+ review.comments[0].revision,
+ "authority-other",
+ )],
+ );
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&review, &target, Some(&foreign_authority), true)
+ .control,
+ Err(ReviewDeliveryWithheld::NoDeliverableComment)
+ );
+
+ // A comment already delivered, and a review that is no longer fresh,
+ // are both unsendable however the advertisement reads.
+ let mut sent = review.clone();
+ sent.comments[0].agent_state = CommentAgentState::Sent;
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&sent, &target, Some(&exact_capabilities), true)
+ .control,
+ Err(ReviewDeliveryWithheld::NoDeliverableComment)
+ );
+ let mut stale = review.clone();
+ stale.review.freshness.state = ReviewFreshnessState::Stale;
+ assert_eq!(
+ ReviewAgentDeliveryProjection::new(&stale, &target, Some(&exact_capabilities), true)
+ .control,
+ Err(ReviewDeliveryWithheld::NoDeliverableComment)
+ );
+
+ // Every reason a surface must explain carries its own key.
+ let keys = ReviewDeliveryWithheld::ALL
+ .iter()
+ .map(|reason| reason.semantic_key())
+ .collect::>();
+ assert_eq!(keys.len(), ReviewDeliveryWithheld::ALL.len());
+ }
+}
diff --git a/crates/shelldeck-core/src/config/platform_review/notes.rs b/crates/shelldeck-core/src/config/platform_review/notes.rs
new file mode 100644
index 00000000..f9036f5c
--- /dev/null
+++ b/crates/shelldeck-core/src/config/platform_review/notes.rs
@@ -0,0 +1,591 @@
+//! Durable line-anchored review notes for the native Platform v2 lane.
+//!
+//! A note is a local draft, never an observation and never authority. It
+//! retains the exact snapshot coordinates it was written against — file,
+//! hunk, side, line and the captured snapshot revision — so a later snapshot
+//! can never silently re-anchor it. Selection is persisted next to the note
+//! so a batch delivery decision survives restart, but the store itself
+//! dispatches nothing: only a typed preview may cross the network.
+
+use std::path::{Path, PathBuf};
+
+use serde::{Deserialize, Serialize};
+use thiserror::Error;
+
+use super::*;
+use crate::config::app_config::AppConfig;
+use crate::workspace_review::storage::{
+ bounded_descriptor_read, ensure_private_directory_io, lock_path, open_lock_file,
+ secure_atomic_write,
+};
+
+const REVIEW_NOTE_SCHEMA: u16 = 1;
+const MAX_REVIEW_NOTE_TARGETS: usize = 16;
+const MAX_REVIEW_NOTES_PER_TARGET: usize = 64;
+const MAX_REVIEW_NOTE_BODY_BYTES: usize = 4 * 1024;
+const MAX_REVIEW_NOTE_FILE_BYTES: u64 = 1024 * 1024;
+static REVIEW_NOTE_PROCESS_LOCK: parking_lot::Mutex<()> = parking_lot::Mutex::new(());
+
+/// One persisted line-anchored draft, bound to the snapshot it was written on.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct PlatformReviewNote {
+ pub id: String,
+ pub anchor: ReviewAnchorSemantic,
+ pub body: String,
+ pub captured_revision: Revision,
+ pub selected: bool,
+ pub created_at_ms: u64,
+}
+
+impl PlatformReviewNote {
+ /// A note may be prepared or delivered only while the snapshot it was
+ /// anchored against is still the exact active one and its coordinates
+ /// still resolve inside that snapshot.
+ #[must_use]
+ pub fn is_actionable(&self, review: &PlatformReviewSemantic) -> bool {
+ self.captured_revision == review.revision && validate_anchor(review, &self.anchor).is_ok()
+ }
+}
+
+#[derive(Clone, Debug)]
+pub struct PlatformReviewNoteStore {
+ path: PathBuf,
+}
+
+impl PlatformReviewNoteStore {
+ pub fn open_default() -> Result {
+ Self::open(
+ AppConfig::config_dir()
+ .join("platform-review")
+ .join("notes")
+ .join("notes-v1.json"),
+ )
+ }
+
+ pub fn open(path: PathBuf) -> Result {
+ let store = Self { path };
+ let _guard = REVIEW_NOTE_PROCESS_LOCK.lock();
+ store.prepare_storage()?;
+ let lock = open_lock_file(&lock_path(&store.path))?;
+ fs2::FileExt::lock_exclusive(&lock)?;
+ let _ = load_document(&store.path)?;
+ Ok(store)
+ }
+
+ /// Persist one note anchored inside the exact current snapshot.
+ pub fn add(
+ &self,
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ anchor: &ReviewAnchorSemantic,
+ body: &str,
+ ) -> Result {
+ validate_review_target(target, review).map_err(|_| ReviewNoteError::ForeignSnapshot)?;
+ validate_anchor(review, anchor).map_err(|_| ReviewNoteError::AnchorInvalid)?;
+ let body = body.trim().to_owned();
+ if body.is_empty() || body.len() > MAX_REVIEW_NOTE_BODY_BYTES {
+ return Err(ReviewNoteError::BodyInvalid);
+ }
+ let note = PlatformReviewNote {
+ id: format!("shelldeck-note-{}", review_nonce()),
+ anchor: anchor.clone(),
+ body,
+ captured_revision: review.revision,
+ selected: false,
+ created_at_ms: now_ms(),
+ };
+ let stored = note.clone();
+ self.transact(|document| {
+ let record = document.target_mut(target)?;
+ if record.notes.len() >= MAX_REVIEW_NOTES_PER_TARGET {
+ return Err(ReviewNoteError::CapacityExceeded);
+ }
+ if record.notes.iter().any(|value| value.id == stored.id) {
+ return Err(ReviewNoteError::DuplicateNote);
+ }
+ record.notes.push(ReviewNoteDisk::from_note(&stored));
+ Ok(())
+ })?;
+ Ok(note)
+ }
+
+ /// Mark or unmark one note for batch delivery.
+ pub fn set_selected(
+ &self,
+ target: &PlatformReviewTarget,
+ id: &str,
+ selected: bool,
+ ) -> Result<(), ReviewNoteError> {
+ self.transact(|document| {
+ let record = document.target_mut(target)?;
+ let note = record
+ .notes
+ .iter_mut()
+ .find(|note| note.id == id)
+ .ok_or(ReviewNoteError::UnknownNote)?;
+ note.selected = selected;
+ Ok(())
+ })
+ }
+
+ pub fn remove(&self, target: &PlatformReviewTarget, id: &str) -> Result<(), ReviewNoteError> {
+ self.transact(|document| {
+ let record = document.target_mut(target)?;
+ let before = record.notes.len();
+ record.notes.retain(|note| note.id != id);
+ if record.notes.len() == before {
+ return Err(ReviewNoteError::UnknownNote);
+ }
+ document.records.retain(|record| !record.notes.is_empty());
+ Ok(())
+ })
+ }
+
+ /// Every retained note for this exact target, oldest first.
+ pub fn notes(
+ &self,
+ target: &PlatformReviewTarget,
+ ) -> Result, ReviewNoteError> {
+ let document = self.read()?;
+ let Some(record) = document
+ .records
+ .iter()
+ .find(|record| record.matches_target(target))
+ else {
+ return Ok(Vec::new());
+ };
+ let mut notes = record
+ .notes
+ .iter()
+ .map(ReviewNoteDisk::to_note)
+ .collect::, _>>()?;
+ notes.sort_by(|left, right| {
+ left.created_at_ms
+ .cmp(&right.created_at_ms)
+ .then_with(|| left.id.cmp(&right.id))
+ });
+ Ok(notes)
+ }
+
+ /// Selected notes that the exact active snapshot still supports. A note
+ /// captured against a superseded revision is retained but never returned
+ /// here: it must be re-anchored explicitly, never silently.
+ pub fn selected_actionable(
+ &self,
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ ) -> Result, ReviewNoteError> {
+ validate_review_target(target, review).map_err(|_| ReviewNoteError::ForeignSnapshot)?;
+ Ok(self
+ .notes(target)?
+ .into_iter()
+ .filter(|note| note.selected && note.is_actionable(review))
+ .collect())
+ }
+
+ fn read(&self) -> Result {
+ let _guard = REVIEW_NOTE_PROCESS_LOCK.lock();
+ self.prepare_storage()?;
+ let lock = open_lock_file(&lock_path(&self.path))?;
+ fs2::FileExt::lock_exclusive(&lock)?;
+ load_document(&self.path)
+ }
+
+ fn transact(
+ &self,
+ update: impl FnOnce(&mut ReviewNoteDocument) -> Result,
+ ) -> Result {
+ let _guard = REVIEW_NOTE_PROCESS_LOCK.lock();
+ self.prepare_storage()?;
+ let lock = open_lock_file(&lock_path(&self.path))?;
+ fs2::FileExt::lock_exclusive(&lock)?;
+ let mut document = load_document(&self.path)?;
+ let outcome = update(&mut document)?;
+ document.revision = document
+ .revision
+ .checked_add(1)
+ .ok_or(ReviewNoteError::DocumentInvalid)?;
+ persist_document(&self.path, &document)?;
+ Ok(outcome)
+ }
+
+ fn prepare_storage(&self) -> Result<(), ReviewNoteError> {
+ let parent = self.path.parent().ok_or(ReviewNoteError::PathInvalid)?;
+ ensure_private_directory_io(parent)?;
+ Ok(())
+ }
+}
+
+fn now_ms() -> u64 {
+ u64::try_from(
+ SystemTime::now()
+ .duration_since(UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_millis(),
+ )
+ .unwrap_or(u64::MAX)
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReviewNoteDocument {
+ schema: u16,
+ revision: u64,
+ records: Vec,
+}
+
+impl Default for ReviewNoteDocument {
+ fn default() -> Self {
+ Self {
+ schema: REVIEW_NOTE_SCHEMA,
+ revision: 0,
+ records: Vec::new(),
+ }
+ }
+}
+
+impl ReviewNoteDocument {
+ fn target_mut(
+ &mut self,
+ target: &PlatformReviewTarget,
+ ) -> Result<&mut ReviewNoteTargetDisk, ReviewNoteError> {
+ if let Some(index) = self
+ .records
+ .iter()
+ .position(|record| record.matches_target(target))
+ {
+ return Ok(&mut self.records[index]);
+ }
+ if self.records.len() >= MAX_REVIEW_NOTE_TARGETS {
+ return Err(ReviewNoteError::CapacityExceeded);
+ }
+ self.records.push(ReviewNoteTargetDisk {
+ project: target.project.as_str().to_owned(),
+ workspace_kind: target.workspace.kind().as_str().to_owned(),
+ workspace: target.workspace.id().to_owned(),
+ notes: Vec::new(),
+ });
+ self.records
+ .last_mut()
+ .ok_or(ReviewNoteError::DocumentInvalid)
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReviewNoteTargetDisk {
+ project: String,
+ workspace_kind: String,
+ workspace: String,
+ notes: Vec,
+}
+
+impl ReviewNoteTargetDisk {
+ fn matches_target(&self, target: &PlatformReviewTarget) -> bool {
+ self.project == target.project.as_str()
+ && self.workspace_kind == target.workspace.kind().as_str()
+ && self.workspace == target.workspace.id()
+ }
+
+ fn validate_identity(&self) -> Result<(), ReviewNoteError> {
+ ProjectId::new(self.project.clone()).map_err(|_| ReviewNoteError::DocumentInvalid)?;
+ let kind = WorkContextTargetKind::parse(&self.workspace_kind)
+ .map_err(|_| ReviewNoteError::DocumentInvalid)?;
+ if kind != WorkContextTargetKind::UserWorkspace {
+ return Err(ReviewNoteError::DocumentInvalid);
+ }
+ WorkContextIdentity::parse_local(kind, &self.workspace)
+ .map_err(|_| ReviewNoteError::DocumentInvalid)?;
+ Ok(())
+ }
+}
+
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+#[serde(deny_unknown_fields)]
+struct ReviewNoteDisk {
+ id: String,
+ file_id: String,
+ hunk_id: String,
+ side: String,
+ line: u32,
+ body: String,
+ captured_revision: u64,
+ selected: bool,
+ created_at_ms: u64,
+}
+
+impl ReviewNoteDisk {
+ fn from_note(note: &PlatformReviewNote) -> Self {
+ Self {
+ id: note.id.clone(),
+ file_id: note.anchor.file_id.clone(),
+ hunk_id: note.anchor.hunk_id.clone(),
+ side: note.anchor.side.as_str().to_owned(),
+ line: note.anchor.line,
+ body: note.body.clone(),
+ captured_revision: note.captured_revision.get(),
+ selected: note.selected,
+ created_at_ms: note.created_at_ms,
+ }
+ }
+
+ fn to_note(&self) -> Result {
+ if self.id.is_empty()
+ || self.file_id.is_empty()
+ || self.hunk_id.is_empty()
+ || self.body.is_empty()
+ || self.body.len() > MAX_REVIEW_NOTE_BODY_BYTES
+ {
+ return Err(ReviewNoteError::DocumentInvalid);
+ }
+ Ok(PlatformReviewNote {
+ id: self.id.clone(),
+ anchor: ReviewAnchorSemantic {
+ file_id: self.file_id.clone(),
+ hunk_id: self.hunk_id.clone(),
+ side: DiffSide::parse(&self.side).map_err(|_| ReviewNoteError::DocumentInvalid)?,
+ line: self.line,
+ },
+ body: self.body.clone(),
+ captured_revision: Revision::new(self.captured_revision)
+ .map_err(|_| ReviewNoteError::DocumentInvalid)?,
+ selected: self.selected,
+ created_at_ms: self.created_at_ms,
+ })
+ }
+}
+
+fn load_document(path: &Path) -> Result {
+ let Some(bytes) = bounded_descriptor_read(path, MAX_REVIEW_NOTE_FILE_BYTES)? else {
+ return Ok(ReviewNoteDocument::default());
+ };
+ if bytes.len() as u64 > MAX_REVIEW_NOTE_FILE_BYTES {
+ return Err(ReviewNoteError::DocumentInvalid);
+ }
+ let document: ReviewNoteDocument = serde_json::from_slice(&bytes)?;
+ validate_document(&document)?;
+ Ok(document)
+}
+
+fn validate_document(document: &ReviewNoteDocument) -> Result<(), ReviewNoteError> {
+ if document.schema != REVIEW_NOTE_SCHEMA || document.records.len() > MAX_REVIEW_NOTE_TARGETS {
+ return Err(ReviewNoteError::DocumentInvalid);
+ }
+ let mut targets = std::collections::BTreeSet::new();
+ for record in &document.records {
+ record.validate_identity()?;
+ if !targets.insert((
+ record.project.as_str(),
+ record.workspace_kind.as_str(),
+ record.workspace.as_str(),
+ )) {
+ return Err(ReviewNoteError::DocumentInvalid);
+ }
+ if record.notes.len() > MAX_REVIEW_NOTES_PER_TARGET {
+ return Err(ReviewNoteError::DocumentInvalid);
+ }
+ let mut ids = std::collections::BTreeSet::new();
+ for note in &record.notes {
+ let _ = note.to_note()?;
+ if !ids.insert(note.id.as_str()) {
+ return Err(ReviewNoteError::DocumentInvalid);
+ }
+ }
+ }
+ Ok(())
+}
+
+fn persist_document(path: &Path, document: &ReviewNoteDocument) -> Result<(), ReviewNoteError> {
+ validate_document(document)?;
+ let bytes = serde_json::to_vec_pretty(document)?;
+ if bytes.len() as u64 > MAX_REVIEW_NOTE_FILE_BYTES {
+ return Err(ReviewNoteError::DocumentInvalid);
+ }
+ secure_atomic_write(path, &bytes)?;
+ Ok(())
+}
+
+#[derive(Debug, Error)]
+pub enum ReviewNoteError {
+ #[error("review note storage failed: {0}")]
+ Io(#[from] std::io::Error),
+ #[error("review note document is not valid JSON: {0}")]
+ Json(#[from] serde_json::Error),
+ #[error("review note path is invalid")]
+ PathInvalid,
+ #[error("review note document is invalid or exceeds its bound")]
+ DocumentInvalid,
+ #[error("review notes have reached their bounded capacity")]
+ CapacityExceeded,
+ #[error("review note already exists")]
+ DuplicateNote,
+ #[error("review note is not retained for this target")]
+ UnknownNote,
+ #[error("review note body is empty or exceeds its bound")]
+ BodyInvalid,
+ #[error("review note anchor is not in the exact snapshot")]
+ AnchorInvalid,
+ #[error("review snapshot belongs to another workspace")]
+ ForeignSnapshot,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use automonique_protocol::platform_v2_review_api::decode_review_snapshot;
+
+ const CANONICAL_FIXTURE: &[u8] =
+ include_bytes!("../../../tests/fixtures/platform-v2-review-v2.json");
+
+ fn semantic() -> PlatformReviewSemantic {
+ PlatformReviewSemantic::from(&decode_review_snapshot(CANONICAL_FIXTURE).unwrap())
+ }
+
+ fn target(review: &PlatformReviewSemantic) -> PlatformReviewTarget {
+ PlatformReviewTarget {
+ project: ProjectId::new("project-1").unwrap(),
+ workspace: WorkContextIdentity::parse_local(
+ WorkContextTargetKind::UserWorkspace,
+ &review.workspace_id,
+ )
+ .unwrap(),
+ }
+ }
+
+ fn first_anchor(review: &PlatformReviewSemantic) -> ReviewAnchorSemantic {
+ let file = review
+ .files
+ .iter()
+ .find(|file| !file.hunks.is_empty())
+ .unwrap();
+ let hunk = &file.hunks[0];
+ let (side, start) = if hunk.new_lines > 0 {
+ (DiffSide::New, hunk.new_start)
+ } else {
+ (DiffSide::Old, hunk.old_start)
+ };
+ ReviewAnchorSemantic {
+ file_id: file.id.clone(),
+ hunk_id: hunk.id.clone(),
+ side,
+ line: start,
+ }
+ }
+
+ struct TempRoot(PathBuf);
+
+ impl TempRoot {
+ fn new() -> Self {
+ let path = std::env::temp_dir().join(format!(
+ "shelldeck-review-notes-{}-{}",
+ std::process::id(),
+ uuid::Uuid::new_v4()
+ ));
+ std::fs::create_dir_all(&path).unwrap();
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o700)).unwrap();
+ }
+ Self(path)
+ }
+
+ fn store_path(&self) -> PathBuf {
+ self.0
+ .join("platform-review")
+ .join("notes")
+ .join("notes-v1.json")
+ }
+ }
+
+ impl Drop for TempRoot {
+ fn drop(&mut self) {
+ let _ = std::fs::remove_dir_all(&self.0);
+ }
+ }
+
+ // SDTEST-1853 — a line-anchored note survives restart with its exact
+ // captured coordinates and its selection decision.
+ #[test]
+ fn note_and_selection_survive_restart_with_exact_anchor() {
+ let directory = TempRoot::new();
+ let review = semantic();
+ let target = target(&review);
+ let anchor = first_anchor(&review);
+ let store = PlatformReviewNoteStore::open(directory.store_path()).unwrap();
+ let note = store
+ .add(&target, &review, &anchor, " revoir cette borne ")
+ .unwrap();
+ store.set_selected(&target, ¬e.id, true).unwrap();
+
+ let restarted = PlatformReviewNoteStore::open(directory.store_path()).unwrap();
+ let notes = restarted.notes(&target).unwrap();
+ assert_eq!(notes.len(), 1);
+ assert_eq!(notes[0].anchor, anchor);
+ assert_eq!(notes[0].body, "revoir cette borne");
+ assert_eq!(notes[0].captured_revision, review.revision);
+ assert!(notes[0].selected);
+ assert_eq!(
+ restarted.selected_actionable(&target, &review).unwrap(),
+ notes
+ );
+ }
+
+ // SDTEST-1854 — a note is never re-anchored onto a newer snapshot and a
+ // coordinate outside the exact snapshot is refused before persistence.
+ #[test]
+ fn superseded_revision_and_foreign_anchor_are_never_actionable() {
+ let directory = TempRoot::new();
+ let review = semantic();
+ let target = target(&review);
+ let anchor = first_anchor(&review);
+ let store = PlatformReviewNoteStore::open(directory.store_path()).unwrap();
+ let note = store.add(&target, &review, &anchor, "borne").unwrap();
+ store.set_selected(&target, ¬e.id, true).unwrap();
+
+ let mut advanced = review.clone();
+ advanced.revision = Revision::new(review.revision.get() + 1).unwrap();
+ assert!(!note.is_actionable(&advanced));
+ assert!(store
+ .selected_actionable(&target, &advanced)
+ .unwrap()
+ .is_empty());
+ // The note itself is retained, not silently dropped or moved.
+ assert_eq!(store.notes(&target).unwrap().len(), 1);
+
+ let outside = ReviewAnchorSemantic {
+ line: anchor.line.saturating_add(9_999),
+ ..anchor
+ };
+ assert!(matches!(
+ store.add(&target, &review, &outside, "hors borne"),
+ Err(ReviewNoteError::AnchorInvalid)
+ ));
+ assert!(matches!(
+ store.add(&target, &review, &first_anchor(&review), " "),
+ Err(ReviewNoteError::BodyInvalid)
+ ));
+ assert_eq!(store.notes(&target).unwrap().len(), 1);
+ }
+
+ // SDTEST-1855 — an invalid or oversized note document fails closed and
+ // never replaces what is already on disk.
+ #[test]
+ fn oversized_note_document_is_rejected_without_replacement() {
+ let directory = TempRoot::new();
+ let review = semantic();
+ let target = target(&review);
+ let path = directory.store_path();
+ let store = PlatformReviewNoteStore::open(path.clone()).unwrap();
+ store
+ .add(&target, &review, &first_anchor(&review), "borne")
+ .unwrap();
+ let oversized = vec![b'x'; MAX_REVIEW_NOTE_FILE_BYTES as usize + 1];
+ std::fs::write(&path, &oversized).unwrap();
+ assert!(matches!(
+ PlatformReviewNoteStore::open(path.clone()),
+ Err(ReviewNoteError::DocumentInvalid)
+ ));
+ assert_eq!(std::fs::read(path).unwrap(), oversized);
+ }
+}
diff --git a/crates/shelldeck-core/src/config/platform_review/worktree.rs b/crates/shelldeck-core/src/config/platform_review/worktree.rs
new file mode 100644
index 00000000..79ba257e
--- /dev/null
+++ b/crates/shelldeck-core/src/config/platform_review/worktree.rs
@@ -0,0 +1,1878 @@
+//! Combined worktree lanes and bounded safe previews for one review snapshot.
+//!
+//! This module is a pure read-side projection of [`PlatformReviewSemantic`].
+//! It answers two questions and nothing else: which combined
+//! staged/unstaged/untracked/conflicted lane every observed file belongs to,
+//! and what — if anything — may be painted for that file's preview.
+//!
+//! It grants no authority of its own. Staging is a Git mutation, so a control
+//! exists only where the server advertised a slot for that exact proposal, and
+//! the projection otherwise reports which fence is missing so the surface can
+//! say so instead of offering a control that would refuse.
+//!
+//! # What the server has to prove before a control exists
+//!
+//! A worktree is shared substrate: any other process running as the daemon uid
+//! can move `HEAD` and rewrite the index between an advertisement and the
+//! action arriving, and the snapshot revision does not help — it tracks what
+//! the projection observed, not what the repository now is. So every entry in
+//! `staging` and `conflict_resolutions` names both mutable things the write
+//! depends on, and its confirmation digest commits to them. This module
+//! carries that observation verbatim ([`ReviewWorktreeObservation`]) so a
+//! reader can see what the server read rather than having to trust that it
+//! read anything.
+//!
+//! # Absence is the withholding
+//!
+//! The three staging kinds share one capability type but not one grant: an
+//! operator withholds committing separately from index writes, and conflict
+//! resolution separately again. A deployment that grants stage and unstage but
+//! not commit therefore produces a `staging` list with `Stage` and `Unstage`
+//! entries and no `Commit` entry — and the commit control must simply not
+//! exist. A disabled button with a tooltip would claim the grant is present
+//! and merely unavailable, which is the opposite of what the list says.
+//!
+//! # No hunk granularity
+//!
+//! [`super::ReviewProposalId`] names a proposal and a proposal lists file ids;
+//! `ReviewAnchor`'s hunk id exists for comments only. File-level staging is
+//! all the contract can express, so it is all this module offers. A hunk
+//! control here would be advertising something no action can name.
+
+use super::{
+ ConflictResolution, ConflictState, DiffChangeKind, DiffSide, PlatformReviewSemantic,
+ PlatformReviewTarget, PreviewKind, ReviewAnchorSemantic, ReviewAuthorityKind,
+ ReviewCapabilities, ReviewFileSemantic, ReviewProposalKind, WorktreeFileState,
+};
+use automonique_protocol::primitives::Revision;
+
+/// Largest declared preview payload ShellDeck will describe at all.
+///
+/// The declared size is the server's claim, not a measurement: refusing above
+/// this ceiling keeps an inflated claim from becoming a rendering budget.
+pub const MAX_SAFE_PREVIEW_BYTES: u64 = 512 * 1024;
+
+/// Largest declared raster area ShellDeck will describe.
+pub const MAX_SAFE_PREVIEW_PIXELS: u64 = 16 * 1024 * 1024;
+
+/// Largest declared raster edge ShellDeck will describe.
+pub const MAX_SAFE_PREVIEW_EDGE: u32 = 16_384;
+
+/// Longest edge, in logical pixels, of the bounded image placeholder box.
+pub const SAFE_PREVIEW_BOX_EDGE: u32 = 200;
+
+/// Most preview lines retained for one file or one hunk.
+pub const MAX_SAFE_PREVIEW_LINES: usize = 12;
+
+/// Most characters retained per preview line.
+pub const MAX_SAFE_PREVIEW_LINE_CHARS: usize = 160;
+
+/// Invisible formatting scalars that can reorder or hide neighbouring text.
+///
+/// `char::is_control` — the protocol's own bound — does not cover these, so a
+/// bidirectional override smuggled into a path or hunk preview would still
+/// render as a convincingly different line (the "trojan source" class).
+const INVISIBLE_FORMATTING: [char; 13] = [
+ '\u{061c}', // ARABIC LETTER MARK
+ '\u{200b}', // ZERO WIDTH SPACE
+ '\u{200c}', // ZERO WIDTH NON-JOINER
+ '\u{200d}', // ZERO WIDTH JOINER
+ '\u{200e}', // LEFT-TO-RIGHT MARK
+ '\u{200f}', // RIGHT-TO-LEFT MARK
+ '\u{202a}', // LEFT-TO-RIGHT EMBEDDING
+ '\u{202b}', // RIGHT-TO-LEFT EMBEDDING
+ '\u{202d}', // LEFT-TO-RIGHT OVERRIDE
+ '\u{202e}', // RIGHT-TO-LEFT OVERRIDE
+ '\u{2066}', // LEFT-TO-RIGHT ISOLATE
+ '\u{2067}', // RIGHT-TO-LEFT ISOLATE
+ '\u{2068}', // FIRST STRONG ISOLATE
+];
+
+/// One combined lane of the review's worktree state.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub enum ReviewWorktreeLane {
+ /// Unresolved merge conflicts. Listed first: nothing else can proceed.
+ Conflicted,
+ Staged,
+ Unstaged,
+ Untracked,
+}
+
+impl ReviewWorktreeLane {
+ /// Presentation order, most blocking first.
+ pub const ALL: [Self; 4] = [
+ Self::Conflicted,
+ Self::Staged,
+ Self::Unstaged,
+ Self::Untracked,
+ ];
+
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::Conflicted => "conflicted",
+ Self::Staged => "staged",
+ Self::Unstaged => "unstaged",
+ Self::Untracked => "untracked",
+ }
+ }
+
+ /// Canonical cross-client presentation key, carrying no localized copy.
+ #[must_use]
+ pub fn semantic_key(self) -> String {
+ format!("worktree_lane.{}", self.as_str())
+ }
+}
+
+/// Why the projection offers no Git staging control.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReviewStagingWithheld {
+ /// No capability response is attributed to this exact project, workspace
+ /// and snapshot revision, so nothing has been advertised at all.
+ NoServerCapability,
+ /// The capability response is exact and both its git lists are empty.
+ /// That is the server's honest fail-closed answer — no registry binding
+ /// granting index writes, a repository the preflight could not read, an
+ /// unborn `HEAD`, or a worktree in a state no staging write can be fenced
+ /// against — and it must produce no control.
+ NoStageableProposal,
+ /// No durable at-most-once custody store is available in this process.
+ NoCustodyLane,
+}
+
+impl ReviewStagingWithheld {
+ /// Every reason a surface must be able to explain.
+ pub const ALL: [Self; 3] = [
+ Self::NoServerCapability,
+ Self::NoStageableProposal,
+ Self::NoCustodyLane,
+ ];
+
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::NoServerCapability => "no_server_capability",
+ Self::NoStageableProposal => "no_stageable_proposal",
+ Self::NoCustodyLane => "no_custody_lane",
+ }
+ }
+
+ #[must_use]
+ pub fn semantic_key(self) -> String {
+ format!("staging_withheld.{}", self.as_str())
+ }
+}
+
+/// The one repository read every git capability in a response was minted from.
+///
+/// `ReviewCapabilities::new` refuses a response whose git entries disagree on
+/// either field, so this is a property of the whole document rather than of an
+/// entry. It is carried here for the reason the server carries it at all: a
+/// client must be able to see the `HEAD` and index the preflight observed, and
+/// decline to offer a control once it holds a document that read a different
+/// worktree.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewWorktreeObservation {
+ /// The commit `HEAD` resolved to when the server read the repository.
+ pub head_revision: String,
+ /// A digest over the whole index as the preflight read it.
+ pub index_digest: String,
+}
+
+/// One staging transition the server proved it can perform, carried verbatim.
+///
+/// The two confirmation digests are deliberately absent. They are minted per
+/// entry and must cross the wire exactly as the server spelled them, so
+/// [`super::PlatformReviewActionPreview`] reads them straight off the
+/// capability rather than through a render-side copy that could drift.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AdvertisedReviewStaging {
+ pub proposal_id: String,
+ pub kind: ReviewProposalKind,
+ pub expected_head_revision: String,
+ pub expected_index_digest: String,
+ pub authority_id: String,
+}
+
+/// One conflicted file the server proved it can collapse to one recorded side.
+///
+/// The server lists **one entry per admissible side**, not one per file with
+/// the side left to the client: the side decides which bytes land, so it is
+/// inside the confirmation digest, and a digest cannot commit to a choice not
+/// yet made. A file with both stage 2 and stage 3 recorded therefore yields
+/// two entries here, and the surface renders two controls. A delete/modify
+/// conflict has one side recorded and yields one.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct AdvertisedReviewConflictResolution {
+ pub proposal_id: String,
+ pub file_id: String,
+ pub resolution: ConflictResolution,
+ pub expected_head_revision: String,
+ pub expected_index_digest: String,
+ pub authority_id: String,
+}
+
+/// Whether a capability response is attributed to this exact review read.
+fn exact_capability_attribution(
+ capabilities: &ReviewCapabilities,
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+) -> bool {
+ capabilities.project() == &target.project
+ && capabilities.workspace() == &target.workspace
+ && capabilities.snapshot_revision() == review.revision
+ && review.workspace_kind == target.workspace.kind()
+ && review.workspace_id == target.workspace.id()
+}
+
+/// The exact advertised staging set for one active snapshot.
+///
+/// Every entry survives three checks, exactly like the delivery lane: the
+/// capability response is attributed to this project, workspace and snapshot
+/// revision; its authority is a Git authority the proposal itself names; and
+/// the proposal is present in that same snapshot, at the same kind, and
+/// locally actionable.
+///
+/// The third check is a torn-read guard, not a second source of authority. The
+/// capability list and the snapshot are two separate reads; a disagreement
+/// means they straddled a change, and refusing the entry is the fail-closed
+/// answer. It can never *add* an entry the server did not advertise, which is
+/// what makes a withheld `commit` grant render as absence.
+#[must_use]
+pub fn advertised_staging_proposals(
+ capabilities: Option<&ReviewCapabilities>,
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+) -> Vec {
+ let Some(capabilities) = capabilities else {
+ return Vec::new();
+ };
+ if !exact_capability_attribution(capabilities, target, review) {
+ return Vec::new();
+ }
+ capabilities
+ .staging()
+ .iter()
+ .filter(|capability| capability.authority().kind() == ReviewAuthorityKind::Git)
+ .filter(|capability| {
+ review.proposals.iter().any(|proposal| {
+ proposal.id == capability.proposal_id().as_str()
+ && proposal.kind == capability.kind()
+ && proposal.authority.as_ref().is_some_and(|authority| {
+ authority.kind == ReviewAuthorityKind::Git
+ && authority.id == capability.authority().id().as_str()
+ })
+ }) && review.proposal_is_actionable(capability.proposal_id().as_str())
+ })
+ .map(|capability| AdvertisedReviewStaging {
+ proposal_id: capability.proposal_id().as_str().to_owned(),
+ kind: capability.kind(),
+ expected_head_revision: capability.expected_head_revision().as_str().to_owned(),
+ expected_index_digest: capability.expected_index_digest().as_str().to_owned(),
+ authority_id: capability.authority().id().as_str().to_owned(),
+ })
+ .collect()
+}
+
+/// The exact advertised conflict-resolution set for one active snapshot.
+///
+/// The snapshot-side guard differs from the staging one because the domain
+/// does: the proposal must be a `ResolveConflict` on a Git authority, the file
+/// must be one the proposal names, and that file must still be unmerged in
+/// this snapshot. A file git no longer reports as conflicted cannot be
+/// collapsed to a side, whatever an older capability read said.
+#[must_use]
+pub fn advertised_conflict_resolutions(
+ capabilities: Option<&ReviewCapabilities>,
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+) -> Vec {
+ let Some(capabilities) = capabilities else {
+ return Vec::new();
+ };
+ if !exact_capability_attribution(capabilities, target, review) {
+ return Vec::new();
+ }
+ capabilities
+ .conflict_resolutions()
+ .iter()
+ .filter(|capability| capability.authority().kind() == ReviewAuthorityKind::Git)
+ .filter(|capability| {
+ review.conflict_resolution_is_actionable(
+ capability.proposal_id().as_str(),
+ capability.file_id().as_str(),
+ ) && review.proposals.iter().any(|proposal| {
+ proposal.id == capability.proposal_id().as_str()
+ && proposal.authority.as_ref().is_some_and(|authority| {
+ authority.id == capability.authority().id().as_str()
+ })
+ })
+ })
+ .map(|capability| AdvertisedReviewConflictResolution {
+ proposal_id: capability.proposal_id().as_str().to_owned(),
+ file_id: capability.file_id().as_str().to_owned(),
+ resolution: capability.resolution(),
+ expected_head_revision: capability.expected_head_revision().as_str().to_owned(),
+ expected_index_digest: capability.expected_index_digest().as_str().to_owned(),
+ authority_id: capability.authority().id().as_str().to_owned(),
+ })
+ .collect()
+}
+
+/// Decide whether any Git staging control may be rendered at all.
+///
+/// Three fences must hold, exactly like the batch delivery lane: a capability
+/// response attributed to this exact snapshot, at least one entry inside it,
+/// and a durable custody store able to record the at-most-once boundary before
+/// dispatch. A missing fence makes the control absent, never optimistically
+/// disabled.
+///
+/// This is the lane-level answer. It says a control *may* exist, never that
+/// any particular one does: each proposal still needs its own advertised slot,
+/// which is how a deployment withholding `commit` while granting `stage` and
+/// `unstage` produces two controls and not three.
+///
+/// # Errors
+///
+/// Returns the first unmet fence.
+pub const fn review_staging_control(
+ exact_capability: bool,
+ advertised: bool,
+ custody_available: bool,
+) -> Result<(), ReviewStagingWithheld> {
+ if !exact_capability {
+ Err(ReviewStagingWithheld::NoServerCapability)
+ } else if !advertised {
+ Err(ReviewStagingWithheld::NoStageableProposal)
+ } else if !custody_available {
+ Err(ReviewStagingWithheld::NoCustodyLane)
+ } else {
+ Ok(())
+ }
+}
+
+/// Bounded, control-free text safe to paint as plain characters.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct ReviewSafeText {
+ pub lines: Vec,
+ /// At least one line or one character was dropped by the bounds.
+ pub truncated: bool,
+}
+
+impl ReviewSafeText {
+ #[must_use]
+ pub fn is_empty(&self) -> bool {
+ self.lines.is_empty()
+ }
+}
+
+/// Server-sanitized raster metadata plus the bounded box that may be painted.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewSafeImage {
+ pub media_type: String,
+ pub width: u32,
+ pub height: u32,
+ pub byte_size: u64,
+ /// Aspect-preserving placeholder width, never above [`SAFE_PREVIEW_BOX_EDGE`].
+ pub box_width: u32,
+ /// Aspect-preserving placeholder height, never above [`SAFE_PREVIEW_BOX_EDGE`].
+ pub box_height: u32,
+}
+
+/// Server-sanitized HTML, described only.
+///
+/// This deliberately carries no markup and no source text: the review contract
+/// ships no HTML body, and ShellDeck would neither interpret one as markup nor
+/// re-emit one as source.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewSafeHtml {
+ pub media_type: String,
+ pub byte_size: u64,
+}
+
+/// Why a file's preview is not painted.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ReviewPreviewWithheld {
+ /// The snapshot declares no preview, or the declared text carries none.
+ NoContent,
+ /// Opaque binary content is never previewed.
+ Binary,
+ /// The server did not mark the payload sanitized.
+ Unsanitized,
+ /// The declared payload is missing or above the render budget.
+ Oversized,
+ /// The declared raster is degenerate or above the decode budget.
+ OversizedRaster,
+ /// The declared kind, media type, and payload disagree.
+ Incoherent,
+}
+
+impl ReviewPreviewWithheld {
+ /// Every reason a surface must be able to explain.
+ pub const ALL: [Self; 6] = [
+ Self::NoContent,
+ Self::Binary,
+ Self::Unsanitized,
+ Self::Oversized,
+ Self::OversizedRaster,
+ Self::Incoherent,
+ ];
+
+ #[must_use]
+ pub const fn as_str(self) -> &'static str {
+ match self {
+ Self::NoContent => "no_content",
+ Self::Binary => "binary",
+ Self::Unsanitized => "unsanitized",
+ Self::Oversized => "oversized",
+ Self::OversizedRaster => "oversized_raster",
+ Self::Incoherent => "incoherent",
+ }
+ }
+
+ #[must_use]
+ pub fn semantic_key(self) -> String {
+ format!("preview_withheld.{}", self.as_str())
+ }
+}
+
+/// What may be painted for one reviewed file.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub enum ReviewSafePreview {
+ Text(ReviewSafeText),
+ Image(ReviewSafeImage),
+ Html(ReviewSafeHtml),
+ Withheld(ReviewPreviewWithheld),
+}
+
+impl ReviewSafePreview {
+ #[must_use]
+ pub const fn withheld(&self) -> Option {
+ match self {
+ Self::Withheld(reason) => Some(*reason),
+ _ => None,
+ }
+ }
+}
+
+/// One server-proposed staging transition covering an exact file.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewStagingProposal {
+ pub proposal_id: String,
+ pub kind: ReviewProposalKind,
+ /// The snapshot's own admissibility: a Git authority, a supported kind,
+ /// and no unresolved conflict among the proposal's files.
+ pub admissible: bool,
+}
+
+/// One reviewable hunk with its exact anchor and bounded text.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewWorktreeHunk {
+ pub id: String,
+ pub anchor: ReviewAnchorSemantic,
+ pub text: ReviewSafeText,
+}
+
+/// One reviewed file as it appears inside a lane.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewWorktreeFile {
+ pub id: String,
+ pub path: ReviewSafeText,
+ pub change: DiffChangeKind,
+ pub worktree: WorktreeFileState,
+ pub conflict: ConflictState,
+ /// The file is listed in more than one lane because only part of its
+ /// content is staged.
+ pub partial: bool,
+ pub preview: ReviewSafePreview,
+ pub hunks: Vec,
+ pub staging: Vec,
+}
+
+/// One lane of the combined worktree review.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewWorktreeLaneGroup {
+ pub lane: ReviewWorktreeLane,
+ pub semantic_key: String,
+ pub files: Vec,
+}
+
+/// The combined staged/unstaged/untracked/conflicted review projection.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct ReviewWorktreeProjection {
+ pub source_revision: Revision,
+ pub lanes: Vec,
+ /// `Ok` only when an exact server capability, at least one advertised
+ /// entry, and a durable custody lane all back a staging mutation.
+ /// Otherwise it names the exact missing fence instead of a control.
+ pub staging: Result<(), ReviewStagingWithheld>,
+ advertised_staging: Vec,
+ advertised_conflict_resolutions: Vec,
+ observation: Option,
+}
+
+impl ReviewWorktreeProjection {
+ /// Project one review snapshot into its combined lanes.
+ ///
+ /// `custody_available` is the caller's durable at-most-once store; it is
+ /// never inferred from the snapshot.
+ /// `target` is optional because the lanes are not: a surface with no
+ /// exactly reconciled target still renders the combined read, it simply
+ /// has nothing a capability response could be attributed to, so every
+ /// control is withheld for want of a server capability.
+ #[must_use]
+ pub fn new(
+ review: &PlatformReviewSemantic,
+ target: Option<&PlatformReviewTarget>,
+ capabilities: Option<&ReviewCapabilities>,
+ custody_available: bool,
+ ) -> Self {
+ // "Exact" is about attribution, not content: a capability response for
+ // this project, workspace and snapshot revision was received. Two
+ // empty git lists inside it is a different, more specific answer, and
+ // the surface must be able to say which one it got.
+ let exact_capability = target
+ .zip(capabilities)
+ .is_some_and(|(target, capabilities)| {
+ exact_capability_attribution(capabilities, target, review)
+ });
+ let advertised_staging = target.map_or_else(Vec::new, |target| {
+ advertised_staging_proposals(capabilities, target, review)
+ });
+ let advertised_conflict_resolutions = target.map_or_else(Vec::new, |target| {
+ advertised_conflict_resolutions(capabilities, target, review)
+ });
+ let staging = review_staging_control(
+ exact_capability,
+ !advertised_staging.is_empty() || !advertised_conflict_resolutions.is_empty(),
+ custody_available,
+ );
+ // Every git entry in one response shares one repository read, so the
+ // first admitted entry carries the whole document's observation.
+ let observation = advertised_staging
+ .first()
+ .map(|entry| ReviewWorktreeObservation {
+ head_revision: entry.expected_head_revision.clone(),
+ index_digest: entry.expected_index_digest.clone(),
+ })
+ .or_else(|| {
+ advertised_conflict_resolutions
+ .first()
+ .map(|entry| ReviewWorktreeObservation {
+ head_revision: entry.expected_head_revision.clone(),
+ index_digest: entry.expected_index_digest.clone(),
+ })
+ });
+ let lanes = ReviewWorktreeLane::ALL
+ .into_iter()
+ .map(|lane| ReviewWorktreeLaneGroup {
+ lane,
+ semantic_key: lane.semantic_key(),
+ files: review
+ .files
+ .iter()
+ .filter(|file| file_lanes(file).contains(&lane))
+ .map(|file| worktree_file(review, file))
+ .collect(),
+ })
+ .collect();
+ Self {
+ source_revision: review.revision,
+ lanes,
+ staging,
+ advertised_staging,
+ advertised_conflict_resolutions,
+ observation,
+ }
+ }
+
+ /// Files listed in at least one lane, counting a partially staged file
+ /// once rather than once per lane.
+ #[must_use]
+ pub fn distinct_file_count(&self) -> usize {
+ let mut ids = self
+ .lanes
+ .iter()
+ .flat_map(|group| group.files.iter().map(|file| file.id.as_str()))
+ .collect::>();
+ ids.sort_unstable();
+ ids.dedup();
+ ids.len()
+ }
+
+ /// The advertised staging set, in the capability's own order.
+ #[must_use]
+ pub fn advertised_staging(&self) -> &[AdvertisedReviewStaging] {
+ &self.advertised_staging
+ }
+
+ /// The advertised conflict-resolution set, in the capability's own order.
+ #[must_use]
+ pub fn advertised_conflict_resolutions(&self) -> &[AdvertisedReviewConflictResolution] {
+ &self.advertised_conflict_resolutions
+ }
+
+ /// The `HEAD` and index the server read, when it advertised anything.
+ #[must_use]
+ pub const fn observation(&self) -> Option<&ReviewWorktreeObservation> {
+ self.observation.as_ref()
+ }
+
+ /// Whether this exact proposal may carry a staging control.
+ ///
+ /// This is the whole of the withholding rule at the render site: a
+ /// `Commit` proposal the server did not advertise answers `false` here and
+ /// simply grows no button, while its `Stage` and `Unstage` siblings do.
+ #[must_use]
+ pub fn advertises_staging(&self, proposal_id: &str) -> bool {
+ self.advertised_staging
+ .iter()
+ .any(|entry| entry.proposal_id == proposal_id)
+ }
+
+ /// Every admissible side for one conflicted file, in advertisement order.
+ ///
+ /// The server lists one entry per side it actually recorded, so this
+ /// returns two for a file holding both and one for a delete/modify pair.
+ #[must_use]
+ pub fn advertised_sides(&self, proposal_id: &str, file_id: &str) -> Vec {
+ self.advertised_conflict_resolutions
+ .iter()
+ .filter(|entry| entry.proposal_id == proposal_id && entry.file_id == file_id)
+ .map(|entry| entry.resolution)
+ .collect()
+ }
+}
+
+/// The lanes one file belongs to, in presentation order.
+///
+/// An unresolved conflict owns the file outright: git reports it as unmerged,
+/// and staging it is refused, so listing it under staged or unstaged would
+/// claim a state the repository does not have. `partially_staged` genuinely
+/// has content on both sides and is therefore listed twice.
+fn file_lanes(file: &ReviewFileSemantic) -> Vec {
+ if file.conflict == ConflictState::Unresolved {
+ return vec![ReviewWorktreeLane::Conflicted];
+ }
+ match file.worktree {
+ WorktreeFileState::Staged => vec![ReviewWorktreeLane::Staged],
+ WorktreeFileState::Unstaged => vec![ReviewWorktreeLane::Unstaged],
+ WorktreeFileState::Untracked => vec![ReviewWorktreeLane::Untracked],
+ WorktreeFileState::PartiallyStaged => {
+ vec![ReviewWorktreeLane::Staged, ReviewWorktreeLane::Unstaged]
+ }
+ }
+}
+
+fn worktree_file(review: &PlatformReviewSemantic, file: &ReviewFileSemantic) -> ReviewWorktreeFile {
+ ReviewWorktreeFile {
+ id: file.id.clone(),
+ path: review_safe_text(&file.path),
+ change: file.change,
+ worktree: file.worktree,
+ conflict: file.conflict,
+ partial: file.worktree == WorktreeFileState::PartiallyStaged
+ && file.conflict != ConflictState::Unresolved,
+ preview: review_safe_preview(file),
+ hunks: file
+ .hunks
+ .iter()
+ .map(|hunk| ReviewWorktreeHunk {
+ id: hunk.id.clone(),
+ anchor: ReviewAnchorSemantic {
+ file_id: file.id.clone(),
+ hunk_id: hunk.id.clone(),
+ side: if hunk.new_lines > 0 {
+ DiffSide::New
+ } else {
+ DiffSide::Old
+ },
+ line: if hunk.new_lines > 0 {
+ hunk.new_start
+ } else {
+ hunk.old_start
+ },
+ },
+ text: review_safe_text(&hunk.preview),
+ })
+ .collect(),
+ staging: review
+ .proposals
+ .iter()
+ .filter(|proposal| proposal.files.iter().any(|id| id == &file.id))
+ .map(|proposal| ReviewStagingProposal {
+ proposal_id: proposal.id.clone(),
+ kind: proposal.kind,
+ admissible: review.proposal_is_actionable(&proposal.id),
+ })
+ .collect(),
+ }
+}
+
+/// Bound and neutralize one server-supplied string before it is painted.
+///
+/// The protocol already refuses control characters, but this is the last stop
+/// before pixels: line structure, invisible reordering scalars, line length,
+/// and line count are all bounded here rather than trusted upstream.
+#[must_use]
+pub fn review_safe_text(raw: &str) -> ReviewSafeText {
+ let mut lines: Vec = Vec::new();
+ let mut truncated = false;
+ for raw_line in raw.split(['\n', '\r']) {
+ if lines.len() == MAX_SAFE_PREVIEW_LINES {
+ truncated = true;
+ break;
+ }
+ let mut line = String::new();
+ for (characters, character) in raw_line.chars().enumerate() {
+ if characters == MAX_SAFE_PREVIEW_LINE_CHARS {
+ truncated = true;
+ line.push('…');
+ break;
+ }
+ if character.is_control() || INVISIBLE_FORMATTING.contains(&character) {
+ line.push('\u{fffd}');
+ } else {
+ line.push(character);
+ }
+ }
+ lines.push(line);
+ }
+ while lines.last().is_some_and(String::is_empty) {
+ lines.pop();
+ }
+ ReviewSafeText { lines, truncated }
+}
+
+/// Decide what may be painted for one file, from its declared preview only.
+///
+/// The snapshot carries preview *metadata*, never a payload: text arrives as
+/// bounded hunk previews and everything else arrives as a description. Every
+/// declared quantity is therefore treated as a claim to bound, not a budget to
+/// honour.
+#[must_use]
+pub fn review_safe_preview(file: &ReviewFileSemantic) -> ReviewSafePreview {
+ let preview = &file.preview;
+ // The contract admits hunks only for a text preview. A snapshot that
+ // disagrees with itself is described, never painted.
+ if preview.kind != PreviewKind::Text && !file.hunks.is_empty() {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Incoherent);
+ }
+ match preview.kind {
+ PreviewKind::None => ReviewSafePreview::Withheld(ReviewPreviewWithheld::NoContent),
+ PreviewKind::Binary => ReviewSafePreview::Withheld(ReviewPreviewWithheld::Binary),
+ PreviewKind::Text => {
+ if !preview.sanitized {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Unsanitized);
+ }
+ if preview
+ .byte_size
+ .is_some_and(|size| size > MAX_SAFE_PREVIEW_BYTES)
+ {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Oversized);
+ }
+ if preview.width.is_some() || preview.height.is_some() {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Incoherent);
+ }
+ let text = review_safe_text(
+ &file
+ .hunks
+ .iter()
+ .map(|hunk| hunk.preview.as_str())
+ .collect::>()
+ .join("\n"),
+ );
+ if text.is_empty() {
+ ReviewSafePreview::Withheld(ReviewPreviewWithheld::NoContent)
+ } else {
+ ReviewSafePreview::Text(text)
+ }
+ }
+ PreviewKind::Image => {
+ if !preview.sanitized {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Unsanitized);
+ }
+ let Some(media_type) = preview
+ .media_type
+ .as_deref()
+ .filter(|value| value.starts_with("image/"))
+ else {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Incoherent);
+ };
+ let Some(byte_size) = preview
+ .byte_size
+ .filter(|size| *size <= MAX_SAFE_PREVIEW_BYTES)
+ else {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Oversized);
+ };
+ let (Some(width), Some(height)) = (preview.width, preview.height) else {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::OversizedRaster);
+ };
+ if width == 0
+ || height == 0
+ || width > MAX_SAFE_PREVIEW_EDGE
+ || height > MAX_SAFE_PREVIEW_EDGE
+ || u64::from(width) * u64::from(height) > MAX_SAFE_PREVIEW_PIXELS
+ {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::OversizedRaster);
+ }
+ let (box_width, box_height) = bounded_box(width, height);
+ ReviewSafePreview::Image(ReviewSafeImage {
+ media_type: media_type.to_owned(),
+ width,
+ height,
+ byte_size,
+ box_width,
+ box_height,
+ })
+ }
+ PreviewKind::Html => {
+ if !preview.sanitized {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Unsanitized);
+ }
+ if preview.media_type.as_deref() != Some("text/html")
+ || preview.width.is_some()
+ || preview.height.is_some()
+ {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Incoherent);
+ }
+ let Some(byte_size) = preview
+ .byte_size
+ .filter(|size| *size <= MAX_SAFE_PREVIEW_BYTES)
+ else {
+ return ReviewSafePreview::Withheld(ReviewPreviewWithheld::Oversized);
+ };
+ ReviewSafePreview::Html(ReviewSafeHtml {
+ media_type: "text/html".to_owned(),
+ byte_size,
+ })
+ }
+ }
+}
+
+/// Fit a declared raster into the placeholder box without ever exceeding it.
+fn bounded_box(width: u32, height: u32) -> (u32, u32) {
+ if width <= SAFE_PREVIEW_BOX_EDGE && height <= SAFE_PREVIEW_BOX_EDGE {
+ return (width, height);
+ }
+ let long_edge = u64::from(width.max(height));
+ let scaled = |edge: u32| -> u32 {
+ let value = u64::from(edge) * u64::from(SAFE_PREVIEW_BOX_EDGE) / long_edge;
+ u32::try_from(value).unwrap_or(SAFE_PREVIEW_BOX_EDGE).max(1)
+ };
+ (scaled(width), scaled(height))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::config::platform_review::{
+ AttentionState, DeliverySemantic, DeliveryState, MergeReadiness,
+ PlatformReviewActionPreview, PlatformReviewTarget, PullRequestSemantic, PullRequestState,
+ ReviewAction, ReviewAttentionSemantic, ReviewAuthority, ReviewAuthorityKind,
+ ReviewAuthoritySemantic, ReviewConfirmationDigest, ReviewConflictResolutionCapability,
+ ReviewDecision, ReviewField, ReviewFreshnessSemantic, ReviewFreshnessState,
+ ReviewGitStagingCapabilities, ReviewHunkSemantic, ReviewIndexDigest, ReviewPreviewSemantic,
+ ReviewProposalId, ReviewProposalSemantic, ReviewPullRequestCapabilities,
+ ReviewReceiptCorrelationDigest, ReviewSchemaVersion, ReviewStagingCapability,
+ ReviewStatusSemantic,
+ };
+ use crate::config::workspace_catalog::{
+ PlatformContextRef, PlatformMappingReconciliation, PlatformV2Mapping,
+ };
+ use automonique_protocol::platform_v2::WorkContextTargetKind;
+ use automonique_protocol::platform_v2_review::{ReviewAuthorityId, ReviewFileId};
+ use automonique_protocol::platform_v2_review_api::decode_review_snapshot;
+
+ const CANONICAL_FIXTURE: &[u8] =
+ include_bytes!("../../../tests/fixtures/platform-v2-review-v2.json");
+
+ fn revision(value: u64) -> Revision {
+ Revision::new(value).expect("revision")
+ }
+
+ fn freshness() -> ReviewFreshnessSemantic {
+ ReviewFreshnessSemantic {
+ state: ReviewFreshnessState::Fresh,
+ observed_revision: revision(9),
+ observed_at_ms: 1_800_000_000_000,
+ }
+ }
+
+ fn authority(kind: ReviewAuthorityKind) -> ReviewAuthoritySemantic {
+ ReviewAuthoritySemantic {
+ kind,
+ id: "authority-1".to_owned(),
+ }
+ }
+
+ fn preview(kind: PreviewKind) -> ReviewPreviewSemantic {
+ ReviewPreviewSemantic {
+ kind,
+ media_type: None,
+ byte_size: None,
+ width: None,
+ height: None,
+ sanitized: kind == PreviewKind::Text,
+ }
+ }
+
+ fn text_file(
+ id: &str,
+ worktree: WorktreeFileState,
+ conflict: ConflictState,
+ ) -> ReviewFileSemantic {
+ ReviewFileSemantic {
+ id: id.to_owned(),
+ path: format!("src/{id}.rs"),
+ change: DiffChangeKind::Modified,
+ worktree,
+ conflict,
+ preview: preview(PreviewKind::Text),
+ hunks: vec![ReviewHunkSemantic {
+ id: format!("{id}-hunk-1"),
+ old_start: 10,
+ old_lines: 2,
+ new_start: 12,
+ new_lines: 3,
+ preview: "@@ -10,2 +12,3 @@ typed".to_owned(),
+ }],
+ }
+ }
+
+ fn review_with(
+ files: Vec,
+ proposals: Vec,
+ ) -> PlatformReviewSemantic {
+ PlatformReviewSemantic {
+ schema: ReviewSchemaVersion::V2,
+ workspace_kind: WorkContextTargetKind::UserWorkspace,
+ workspace_id: "wc_user_1".to_owned(),
+ revision: revision(9),
+ attention: ReviewAttentionSemantic {
+ state: AttentionState::NeedsYou,
+ reason: None,
+ source_revision: None,
+ unread: 0,
+ needs_user_action: false,
+ },
+ attention_events: Vec::new(),
+ review: ReviewStatusSemantic {
+ decision: ReviewDecision::Pending,
+ authority: authority(ReviewAuthorityKind::Review),
+ freshness: freshness(),
+ },
+ checks: Vec::new(),
+ pull_request: PullRequestSemantic {
+ id: None,
+ state: PullRequestState::Absent,
+ readiness: MergeReadiness::Unknown,
+ head_revision: None,
+ authority: authority(ReviewAuthorityKind::PullRequest),
+ freshness: freshness(),
+ },
+ delivery: DeliverySemantic {
+ id: None,
+ state: DeliveryState::NotDelivered,
+ authority: authority(ReviewAuthorityKind::Delivery),
+ freshness: freshness(),
+ },
+ files,
+ comments: Vec::new(),
+ proposals,
+ }
+ }
+
+ fn target() -> PlatformReviewTarget {
+ PlatformReviewTarget::from_exact_mapping(&PlatformV2Mapping {
+ reconciliation_revision: 1,
+ project: PlatformContextRef {
+ id: "project-1".to_owned(),
+ revision: 1,
+ },
+ checkout: PlatformContextRef {
+ id: "checkout-1".to_owned(),
+ revision: 1,
+ },
+ user_workspace: PlatformContextRef {
+ id: "wc_user_1".to_owned(),
+ revision: 1,
+ },
+ reconciliation: PlatformMappingReconciliation::Exact {
+ reconciled_at_millis: 1,
+ },
+ })
+ .expect("exact mapping")
+ }
+
+ fn lane<'a>(
+ projection: &'a ReviewWorktreeProjection,
+ lane: ReviewWorktreeLane,
+ ) -> &'a [ReviewWorktreeFile] {
+ &projection
+ .lanes
+ .iter()
+ .find(|group| group.lane == lane)
+ .expect("lane group")
+ .files
+ }
+
+ // SDTEST-1843 — one snapshot, four lanes. A partially staged file has real
+ // content on both sides and must appear twice; an unresolved conflict is
+ // unmerged and must not be claimed as staged or unstaged.
+ #[test]
+ fn sdtest_1843_combined_lanes_place_every_file_where_git_actually_has_it() {
+ let review = review_with(
+ vec![
+ text_file(
+ "file-staged",
+ WorktreeFileState::Staged,
+ ConflictState::None,
+ ),
+ text_file(
+ "file-partial",
+ WorktreeFileState::PartiallyStaged,
+ ConflictState::None,
+ ),
+ text_file(
+ "file-untracked",
+ WorktreeFileState::Untracked,
+ ConflictState::None,
+ ),
+ text_file(
+ "file-conflict",
+ WorktreeFileState::Unstaged,
+ ConflictState::Unresolved,
+ ),
+ text_file(
+ "file-resolved",
+ WorktreeFileState::Staged,
+ ConflictState::Resolved,
+ ),
+ ],
+ Vec::new(),
+ );
+ let projection = ReviewWorktreeProjection::new(&review, None, None, true);
+
+ assert_eq!(
+ projection
+ .lanes
+ .iter()
+ .map(|group| group.lane)
+ .collect::>(),
+ ReviewWorktreeLane::ALL.to_vec(),
+ "lanes are presented most blocking first"
+ );
+ assert_eq!(
+ lane(&projection, ReviewWorktreeLane::Conflicted)
+ .iter()
+ .map(|file| file.id.as_str())
+ .collect::>(),
+ ["file-conflict"]
+ );
+ assert_eq!(
+ lane(&projection, ReviewWorktreeLane::Staged)
+ .iter()
+ .map(|file| file.id.as_str())
+ .collect::>(),
+ ["file-staged", "file-partial", "file-resolved"],
+ "a resolved conflict returns to its own worktree lane"
+ );
+ assert_eq!(
+ lane(&projection, ReviewWorktreeLane::Unstaged)
+ .iter()
+ .map(|file| file.id.as_str())
+ .collect::>(),
+ ["file-partial"]
+ );
+ assert_eq!(
+ lane(&projection, ReviewWorktreeLane::Untracked)
+ .iter()
+ .map(|file| file.id.as_str())
+ .collect::>(),
+ ["file-untracked"]
+ );
+ assert!(lane(&projection, ReviewWorktreeLane::Staged)[1].partial);
+ assert!(!lane(&projection, ReviewWorktreeLane::Staged)[0].partial);
+ assert_eq!(
+ projection.distinct_file_count(),
+ 5,
+ "a file listed twice is still one file"
+ );
+ }
+
+ // SDTEST-1844 — the lane projection derives each hunk's review anchor. If
+ // that derivation drifted, the comment composer would offer a line the
+ // snapshot refuses, so the anchors are proved against the real preview
+ // constructor rather than against a restatement of the same arithmetic.
+ #[test]
+ fn sdtest_1844_every_projected_hunk_anchor_is_admitted_by_the_comment_constructor() {
+ let mut only_old = text_file("file-old", WorktreeFileState::Staged, ConflictState::None);
+ only_old.hunks[0].new_lines = 0;
+ only_old.hunks[0].new_start = 0;
+ let review = review_with(
+ vec![
+ text_file("file-new", WorktreeFileState::Staged, ConflictState::None),
+ only_old,
+ ],
+ Vec::new(),
+ );
+ let projection = ReviewWorktreeProjection::new(&review, None, None, true);
+ let anchors = projection
+ .lanes
+ .iter()
+ .flat_map(|group| group.files.iter())
+ .flat_map(|file| file.hunks.iter())
+ .map(|hunk| hunk.anchor.clone())
+ .collect::>();
+
+ assert_eq!(anchors.len(), 2);
+ assert_eq!(anchors[0].side, DiffSide::New);
+ assert_eq!(anchors[0].line, 12);
+ assert_eq!(anchors[1].side, DiffSide::Old);
+ assert_eq!(anchors[1].line, 10);
+ for anchor in &anchors {
+ PlatformReviewActionPreview::add_comment(target(), &review, anchor, "typed")
+ .unwrap_or_else(|error| panic!("anchor {anchor:?} refused: {error}"));
+ }
+ }
+
+ // SDTEST-1845 — the protocol bounds hunk previews to 512 control-free
+ // bytes, which still admits a right-to-left override that repaints a diff
+ // line as something else. This is the last stop before pixels.
+ #[test]
+ fn sdtest_1845_safe_text_neutralizes_invisible_reordering_and_bounds_the_shape() {
+ let bidi = review_safe_text("fn drop(\u{202e}) // safe\u{202d}");
+ assert_eq!(bidi.lines, ["fn drop(\u{fffd}) // safe\u{fffd}"]);
+ assert!(!bidi.truncated);
+
+ let vertical = review_safe_text("first\nsecond\r\nthird");
+ assert_eq!(vertical.lines, ["first", "second", "", "third"]);
+
+ let long = review_safe_text(&"a".repeat(MAX_SAFE_PREVIEW_LINE_CHARS + 40));
+ assert!(long.truncated);
+ assert_eq!(long.lines.len(), 1);
+ assert_eq!(
+ long.lines[0].chars().count(),
+ MAX_SAFE_PREVIEW_LINE_CHARS + 1,
+ "the ellipsis is the only character added past the bound"
+ );
+
+ let tall = review_safe_text(&"x\n".repeat(MAX_SAFE_PREVIEW_LINES + 5));
+ assert!(tall.truncated);
+ assert_eq!(tall.lines.len(), MAX_SAFE_PREVIEW_LINES);
+
+ assert!(review_safe_text("").is_empty());
+ assert!(review_safe_text("\n\n").is_empty());
+ }
+
+ // SDTEST-1846 — declared raster dimensions are a claim. A sanitized image
+ // is described inside a clamped aspect-preserving box; a degenerate or
+ // enormous claim is refused instead of becoming a layout or decode budget.
+ #[test]
+ fn sdtest_1846_image_previews_are_clamped_and_absurd_rasters_are_refused() {
+ let image = |width: Option, height: Option, bytes: Option| {
+ let mut file = text_file("file-1", WorktreeFileState::Staged, ConflictState::None);
+ file.hunks.clear();
+ file.preview = ReviewPreviewSemantic {
+ kind: PreviewKind::Image,
+ media_type: Some("image/png".to_owned()),
+ byte_size: bytes,
+ width,
+ height,
+ sanitized: true,
+ };
+ file
+ };
+
+ let ReviewSafePreview::Image(small) =
+ review_safe_preview(&image(Some(120), Some(60), Some(4_096)))
+ else {
+ panic!("a bounded sanitized raster is describable");
+ };
+ assert_eq!((small.box_width, small.box_height), (120, 60));
+
+ let ReviewSafePreview::Image(large) =
+ review_safe_preview(&image(Some(4_000), Some(1_000), Some(4_096)))
+ else {
+ panic!("a bounded sanitized raster is describable");
+ };
+ assert_eq!(large.box_width, SAFE_PREVIEW_BOX_EDGE);
+ assert_eq!(large.box_height, SAFE_PREVIEW_BOX_EDGE / 4);
+ assert_eq!((large.width, large.height), (4_000, 1_000));
+
+ let ReviewSafePreview::Image(sliver) =
+ review_safe_preview(&image(Some(4_000), Some(1), Some(4_096)))
+ else {
+ panic!("a bounded sanitized raster is describable");
+ };
+ assert_eq!(
+ (sliver.box_width, sliver.box_height),
+ (SAFE_PREVIEW_BOX_EDGE, 1),
+ "an extreme aspect ratio still fits the box"
+ );
+
+ for absurd in [
+ image(Some(0), Some(10), Some(4_096)),
+ image(Some(MAX_SAFE_PREVIEW_EDGE + 1), Some(2), Some(4_096)),
+ image(Some(8_192), Some(8_192), Some(4_096)),
+ image(Some(10), None, Some(4_096)),
+ ] {
+ assert_eq!(
+ review_safe_preview(&absurd).withheld(),
+ Some(ReviewPreviewWithheld::OversizedRaster),
+ "{:?}",
+ absurd.preview
+ );
+ }
+ assert_eq!(
+ review_safe_preview(&image(Some(10), Some(10), None)).withheld(),
+ Some(ReviewPreviewWithheld::Oversized)
+ );
+ assert_eq!(
+ review_safe_preview(&image(Some(10), Some(10), Some(MAX_SAFE_PREVIEW_BYTES + 1)))
+ .withheld(),
+ Some(ReviewPreviewWithheld::Oversized)
+ );
+ }
+
+ // SDTEST-1847 — HTML is described, never painted as markup and never
+ // re-emitted as source; unsanitized, incoherent, binary and empty previews
+ // are withheld with the reason the surface must show.
+ #[test]
+ fn sdtest_1847_html_and_opaque_previews_are_described_but_never_rendered() {
+ let mut html = text_file("file-1", WorktreeFileState::Staged, ConflictState::None);
+ html.hunks.clear();
+ html.preview = ReviewPreviewSemantic {
+ kind: PreviewKind::Html,
+ media_type: Some("text/html".to_owned()),
+ byte_size: Some(2_048),
+ width: None,
+ height: None,
+ sanitized: true,
+ };
+ let ReviewSafePreview::Html(described) = review_safe_preview(&html) else {
+ panic!("a sanitized bounded HTML preview is describable");
+ };
+ assert_eq!(described.media_type, "text/html");
+ assert_eq!(described.byte_size, 2_048);
+
+ let mut unsanitized = html.clone();
+ unsanitized.preview.sanitized = false;
+ assert_eq!(
+ review_safe_preview(&unsanitized).withheld(),
+ Some(ReviewPreviewWithheld::Unsanitized)
+ );
+
+ let mut wrong_media = html.clone();
+ wrong_media.preview.media_type = Some("text/plain".to_owned());
+ assert_eq!(
+ review_safe_preview(&wrong_media).withheld(),
+ Some(ReviewPreviewWithheld::Incoherent)
+ );
+
+ let mut with_hunks = html.clone();
+ with_hunks.hunks = text_file("x", WorktreeFileState::Staged, ConflictState::None).hunks;
+ assert_eq!(
+ review_safe_preview(&with_hunks).withheld(),
+ Some(ReviewPreviewWithheld::Incoherent),
+ "only a text preview may carry hunks"
+ );
+
+ let mut binary = html.clone();
+ binary.preview = ReviewPreviewSemantic {
+ kind: PreviewKind::Binary,
+ media_type: Some("application/octet-stream".to_owned()),
+ byte_size: Some(64),
+ width: None,
+ height: None,
+ sanitized: false,
+ };
+ assert_eq!(
+ review_safe_preview(&binary).withheld(),
+ Some(ReviewPreviewWithheld::Binary)
+ );
+
+ let mut none = html.clone();
+ none.preview = preview(PreviewKind::None);
+ assert_eq!(
+ review_safe_preview(&none).withheld(),
+ Some(ReviewPreviewWithheld::NoContent)
+ );
+
+ let mut unsanitized_text =
+ text_file("file-2", WorktreeFileState::Staged, ConflictState::None);
+ unsanitized_text.preview.sanitized = false;
+ assert_eq!(
+ review_safe_preview(&unsanitized_text).withheld(),
+ Some(ReviewPreviewWithheld::Unsanitized),
+ "an unsanitized payload is never shown as source text"
+ );
+
+ let mut empty_text = text_file("file-3", WorktreeFileState::Staged, ConflictState::None);
+ empty_text.hunks.clear();
+ assert_eq!(
+ review_safe_preview(&empty_text).withheld(),
+ Some(ReviewPreviewWithheld::NoContent)
+ );
+ }
+
+ const OBSERVED_HEAD: &str = "1f0e4b8c2a6d9e3f7051c8b4a2d6e9f30517b2c4";
+ const OTHER_HEAD: &str = "9a8b7c6d5e4f30219a8b7c6d5e4f30219a8b7c6d";
+
+ fn index_digest(seed: char) -> ReviewIndexDigest {
+ ReviewIndexDigest::new(seed.to_string().repeat(64)).expect("index digest")
+ }
+
+ /// Two distinct lowercase-hex digests per seed, so a test can assert that
+ /// the confirmation and the correlation are carried separately rather than
+ /// one being reused for both.
+ fn digests(seed: char) -> (ReviewConfirmationDigest, ReviewReceiptCorrelationDigest) {
+ let confirmation = format!("{}{}", seed.to_string().repeat(63), '0');
+ let correlation = format!("{}{}", seed.to_string().repeat(63), '1');
+ (
+ ReviewConfirmationDigest::new(confirmation).expect("confirmation"),
+ ReviewReceiptCorrelationDigest::new(correlation).expect("correlation"),
+ )
+ }
+
+ fn git_authority() -> ReviewAuthority {
+ ReviewAuthority::new(
+ ReviewAuthorityKind::Git,
+ ReviewAuthorityId::new("authority-1".to_owned()).expect("authority id"),
+ )
+ }
+
+ fn staging_capability(
+ proposal_id: &str,
+ kind: ReviewProposalKind,
+ head: &str,
+ index: char,
+ seed: char,
+ ) -> ReviewStagingCapability {
+ let (confirmation, correlation) = digests(seed);
+ ReviewStagingCapability::new(
+ ReviewProposalId::new(proposal_id.to_owned()).expect("proposal id"),
+ kind,
+ ReviewField::new(head.to_owned()).expect("head revision"),
+ index_digest(index),
+ git_authority(),
+ confirmation,
+ correlation,
+ )
+ .expect("staging capability")
+ }
+
+ fn conflict_capability(
+ proposal_id: &str,
+ file_id: &str,
+ resolution: ConflictResolution,
+ index: char,
+ seed: char,
+ ) -> ReviewConflictResolutionCapability {
+ let (confirmation, correlation) = digests(seed);
+ ReviewConflictResolutionCapability::new(
+ ReviewProposalId::new(proposal_id.to_owned()).expect("proposal id"),
+ ReviewFileId::new(file_id.to_owned()).expect("file id"),
+ resolution,
+ ReviewField::new(OBSERVED_HEAD.to_owned()).expect("head revision"),
+ index_digest(index),
+ git_authority(),
+ confirmation,
+ correlation,
+ )
+ .expect("conflict capability")
+ }
+
+ fn capabilities(
+ target: &PlatformReviewTarget,
+ review: &PlatformReviewSemantic,
+ git: ReviewGitStagingCapabilities,
+ ) -> ReviewCapabilities {
+ ReviewCapabilities::new(
+ target.project.clone(),
+ target.workspace.clone(),
+ review.revision,
+ revision(91),
+ Vec::new(),
+ Vec::new(),
+ ReviewPullRequestCapabilities::default(),
+ git,
+ )
+ .expect("capability response")
+ }
+
+ /// One unstaged file with a `Stage` proposal, one with an `Unstage`, one
+ /// with a `Commit`, and one unmerged file carrying a `ResolveConflict`
+ /// proposal over both sides.
+ fn staging_review() -> PlatformReviewSemantic {
+ review_with(
+ vec![
+ text_file("file-1", WorktreeFileState::Unstaged, ConflictState::None),
+ text_file("file-2", WorktreeFileState::Staged, ConflictState::None),
+ text_file("file-3", WorktreeFileState::Staged, ConflictState::None),
+ text_file(
+ "file-conflict",
+ WorktreeFileState::Unstaged,
+ ConflictState::Unresolved,
+ ),
+ ],
+ vec![
+ ReviewProposalSemantic {
+ id: "proposal-stage".to_owned(),
+ kind: ReviewProposalKind::Stage,
+ authority: Some(authority(ReviewAuthorityKind::Git)),
+ files: vec!["file-1".to_owned()],
+ subject: None,
+ },
+ ReviewProposalSemantic {
+ id: "proposal-unstage".to_owned(),
+ kind: ReviewProposalKind::Unstage,
+ authority: Some(authority(ReviewAuthorityKind::Git)),
+ files: vec!["file-2".to_owned()],
+ subject: None,
+ },
+ ReviewProposalSemantic {
+ id: "proposal-commit".to_owned(),
+ kind: ReviewProposalKind::Commit,
+ authority: Some(authority(ReviewAuthorityKind::Git)),
+ files: vec!["file-3".to_owned()],
+ subject: Some("record the reviewed change".to_owned()),
+ },
+ ReviewProposalSemantic {
+ id: "proposal-conflict".to_owned(),
+ kind: ReviewProposalKind::ResolveConflict,
+ authority: Some(authority(ReviewAuthorityKind::Git)),
+ files: vec!["file-conflict".to_owned()],
+ subject: None,
+ },
+ ],
+ )
+ }
+
+ // SDTEST-1867 — the withholding demonstration. A deployment that installs
+ // `index_write` but not `commit` advertises `Stage` and `Unstage` and no
+ // `Commit`. The commit control must be *absent*, not disabled: the three
+ // kinds share one capability type but not one grant, so the only honest
+ // rendering of a missing grant is a missing control. The two granted kinds
+ // must go all the way through to a dispatchable confirmed preview in the
+ // same run, or the test would prove nothing about the ones that are there.
+ #[test]
+ fn sdtest_1867_withheld_commit_grant_renders_no_control_while_its_siblings_go_end_to_end() {
+ let review = staging_review();
+ let target = target();
+ let granted = capabilities(
+ &target,
+ &review,
+ ReviewGitStagingCapabilities {
+ staging: vec![
+ staging_capability(
+ "proposal-stage",
+ ReviewProposalKind::Stage,
+ OBSERVED_HEAD,
+ 'a',
+ 'c',
+ ),
+ staging_capability(
+ "proposal-unstage",
+ ReviewProposalKind::Unstage,
+ OBSERVED_HEAD,
+ 'a',
+ 'd',
+ ),
+ ],
+ conflict_resolutions: Vec::new(),
+ },
+ );
+
+ let projection =
+ ReviewWorktreeProjection::new(&review, Some(&target), Some(&granted), true);
+ assert_eq!(projection.staging, Ok(()));
+ assert_eq!(
+ projection
+ .advertised_staging()
+ .iter()
+ .map(|entry| (entry.proposal_id.as_str(), entry.kind))
+ .collect::>(),
+ [
+ ("proposal-stage", ReviewProposalKind::Stage),
+ ("proposal-unstage", ReviewProposalKind::Unstage),
+ ]
+ );
+ assert!(
+ !projection.advertises_staging("proposal-commit"),
+ "a grant the server withheld must produce no control at all"
+ );
+ // The commit proposal is still *observed* — the read side never lost
+ // it — which is exactly what distinguishes absence-of-control from
+ // absence-of-proposal.
+ let staged = lane(&projection, ReviewWorktreeLane::Staged);
+ assert!(staged.iter().any(|file| file
+ .staging
+ .iter()
+ .any(|proposal| proposal.proposal_id == "proposal-commit" && proposal.admissible)));
+
+ // The withheld kind refuses at the preview constructor too, so nothing
+ // downstream can reconstruct it from the snapshot's own proposal.
+ assert!(PlatformReviewActionPreview::stage_proposal(
+ target.clone(),
+ &review,
+ &granted,
+ "proposal-commit",
+ )
+ .is_err());
+
+ // The two granted kinds go end to end: a confirmed preview carrying the
+ // server's own digests verbatim, which the fresh capability document
+ // still admits.
+ for (proposal_id, seed) in [("proposal-stage", 'c'), ("proposal-unstage", 'd')] {
+ let preview = PlatformReviewActionPreview::stage_proposal(
+ target.clone(),
+ &review,
+ &granted,
+ proposal_id,
+ )
+ .expect("granted staging proposal is dispatchable");
+ let (confirmation, correlation) = digests(seed);
+ let carried = preview.confirmation().expect("staging is a confirmed lane");
+ assert_eq!(carried.confirmation_digest(), &confirmation);
+ assert_eq!(carried.receipt_correlation_digest(), &correlation);
+ assert_eq!(carried.expected_workspace_revision(), revision(91));
+ assert!(preview.requires_capability_revalidation());
+ assert!(preview.matches_capabilities(&granted));
+ }
+
+ // Grant the commit too and the third control appears, from the same
+ // projection code and with no other change.
+ let full = capabilities(
+ &target,
+ &review,
+ ReviewGitStagingCapabilities {
+ staging: vec![
+ staging_capability(
+ "proposal-stage",
+ ReviewProposalKind::Stage,
+ OBSERVED_HEAD,
+ 'a',
+ 'c',
+ ),
+ staging_capability(
+ "proposal-unstage",
+ ReviewProposalKind::Unstage,
+ OBSERVED_HEAD,
+ 'a',
+ 'd',
+ ),
+ staging_capability(
+ "proposal-commit",
+ ReviewProposalKind::Commit,
+ OBSERVED_HEAD,
+ 'a',
+ 'e',
+ ),
+ ],
+ conflict_resolutions: Vec::new(),
+ },
+ );
+ let projection = ReviewWorktreeProjection::new(&review, Some(&target), Some(&full), true);
+ assert!(projection.advertises_staging("proposal-commit"));
+ assert!(PlatformReviewActionPreview::stage_proposal(
+ target.clone(),
+ &review,
+ &full,
+ "proposal-commit",
+ )
+ .is_ok());
+ }
+
+ // SDTEST-1868 — every other way the lane can be withheld, and the fence
+ // that a worktree moving under the daemon has to trip.
+ #[test]
+ fn sdtest_1868_staging_lane_names_its_missing_fence_and_refuses_a_moved_worktree() {
+ let review = staging_review();
+ let target = target();
+ let granted = capabilities(
+ &target,
+ &review,
+ ReviewGitStagingCapabilities {
+ staging: vec![staging_capability(
+ "proposal-stage",
+ ReviewProposalKind::Stage,
+ OBSERVED_HEAD,
+ 'a',
+ 'c',
+ )],
+ conflict_resolutions: Vec::new(),
+ },
+ );
+
+ assert_eq!(
+ ReviewWorktreeProjection::new(&review, Some(&target), None, true).staging,
+ Err(ReviewStagingWithheld::NoServerCapability)
+ );
+ assert_eq!(
+ ReviewWorktreeProjection::new(&review, None, Some(&granted), true).staging,
+ Err(ReviewStagingWithheld::NoServerCapability),
+ "no exactly reconciled target means nothing can be attributed"
+ );
+ // An exact response with two empty git lists is a different, more
+ // specific answer than no response at all, and the surface must be
+ // able to say which one it got.
+ let empty = capabilities(&target, &review, ReviewGitStagingCapabilities::default());
+ assert_eq!(
+ ReviewWorktreeProjection::new(&review, Some(&target), Some(&empty), true).staging,
+ Err(ReviewStagingWithheld::NoStageableProposal)
+ );
+ assert_eq!(
+ ReviewWorktreeProjection::new(&review, Some(&target), Some(&granted), false).staging,
+ Err(ReviewStagingWithheld::NoCustodyLane),
+ "the durable lane is reported last, once the server has proved a slot"
+ );
+ // The lanes render in every one of those states; only the control is
+ // withheld.
+ for projection in [
+ ReviewWorktreeProjection::new(&review, Some(&target), None, true),
+ ReviewWorktreeProjection::new(&review, Some(&target), Some(&empty), true),
+ ] {
+ assert_eq!(projection.distinct_file_count(), 4);
+ assert!(projection.advertised_staging().is_empty());
+ assert!(projection.observation().is_none());
+ }
+
+ // The observation is exposed so a reader can see the fence.
+ let projection =
+ ReviewWorktreeProjection::new(&review, Some(&target), Some(&granted), true);
+ let observation = projection
+ .observation()
+ .expect("granted lane names its read");
+ assert_eq!(observation.head_revision, OBSERVED_HEAD);
+ assert_eq!(observation.index_digest, "a".repeat(64));
+
+ // A preview minted against one worktree read must stop matching once a
+ // fresher document reports a different `HEAD` or a different index.
+ // The server mints the digest over both, so the comparison is the
+ // digest — and the refusal is local, before anything reaches the
+ // daemon.
+ let preview = PlatformReviewActionPreview::stage_proposal(
+ target.clone(),
+ &review,
+ &granted,
+ "proposal-stage",
+ )
+ .expect("advertised proposal");
+ for moved in [
+ staging_capability(
+ "proposal-stage",
+ ReviewProposalKind::Stage,
+ OTHER_HEAD,
+ 'a',
+ 'f',
+ ),
+ staging_capability(
+ "proposal-stage",
+ ReviewProposalKind::Stage,
+ OBSERVED_HEAD,
+ 'b',
+ 'f',
+ ),
+ ] {
+ let refreshed = capabilities(
+ &target,
+ &review,
+ ReviewGitStagingCapabilities {
+ staging: vec![moved],
+ conflict_resolutions: Vec::new(),
+ },
+ );
+ assert!(
+ !preview.matches_capabilities(&refreshed),
+ "a worktree that moved must withdraw the control locally"
+ );
+ }
+
+ // A capability whose kind disagrees with the snapshot's proposal is a
+ // torn read between two server-side reads, and is refused rather than
+ // letting the client pick which one to believe.
+ let torn = capabilities(
+ &target,
+ &review,
+ ReviewGitStagingCapabilities {
+ staging: vec![staging_capability(
+ "proposal-stage",
+ ReviewProposalKind::Commit,
+ OBSERVED_HEAD,
+ 'a',
+ 'c',
+ )],
+ conflict_resolutions: Vec::new(),
+ },
+ );
+ assert!(
+ ReviewWorktreeProjection::new(&review, Some(&target), Some(&torn), true)
+ .advertised_staging()
+ .is_empty()
+ );
+ }
+
+ // SDTEST-1869 — `conflict_resolutions` lists one entry per admissible
+ // side, so a file with both sides recorded yields two controls and a
+ // delete/modify pair yields one. The side is part of the identity, not a
+ // parameter chosen after the fact: each entry carries the digest minted
+ // over the blob that side would write.
+ #[test]
+ fn sdtest_1869_both_recorded_sides_render_two_controls_and_a_single_side_renders_one() {
+ let review = staging_review();
+ let target = target();
+ let both = capabilities(
+ &target,
+ &review,
+ ReviewGitStagingCapabilities {
+ staging: Vec::new(),
+ conflict_resolutions: vec![
+ conflict_capability(
+ "proposal-conflict",
+ "file-conflict",
+ ConflictResolution::KeepCurrent,
+ 'a',
+ 'c',
+ ),
+ conflict_capability(
+ "proposal-conflict",
+ "file-conflict",
+ ConflictResolution::KeepIncoming,
+ 'a',
+ 'd',
+ ),
+ ],
+ },
+ );
+ let projection = ReviewWorktreeProjection::new(&review, Some(&target), Some(&both), true);
+ assert_eq!(projection.staging, Ok(()));
+ assert_eq!(
+ projection.advertised_sides("proposal-conflict", "file-conflict"),
+ [
+ ConflictResolution::KeepCurrent,
+ ConflictResolution::KeepIncoming
+ ],
+ "one entry per admissible side means two buttons, not one with a choice"
+ );
+
+ for (resolution, seed) in [
+ (ConflictResolution::KeepCurrent, 'c'),
+ (ConflictResolution::KeepIncoming, 'd'),
+ ] {
+ let preview = PlatformReviewActionPreview::resolve_conflict(
+ target.clone(),
+ &review,
+ &both,
+ "proposal-conflict",
+ "file-conflict",
+ resolution,
+ )
+ .expect("advertised side");
+ let (confirmation, correlation) = digests(seed);
+ let carried = preview.confirmation().expect("confirmed lane");
+ assert_eq!(
+ carried.confirmation_digest(),
+ &confirmation,
+ "each side carries its own digest, so they can never be swapped"
+ );
+ assert_eq!(carried.receipt_correlation_digest(), &correlation);
+ assert!(matches!(
+ preview.action(),
+ ReviewAction::ResolveConflict { resolution: sent, .. } if *sent == resolution
+ ));
+ assert!(preview.matches_capabilities(&both));
+ }
+
+ // A delete/modify conflict has one side recorded, so only that side is
+ // ever offered and the other refuses locally.
+ let one_side = capabilities(
+ &target,
+ &review,
+ ReviewGitStagingCapabilities {
+ staging: Vec::new(),
+ conflict_resolutions: vec![conflict_capability(
+ "proposal-conflict",
+ "file-conflict",
+ ConflictResolution::KeepCurrent,
+ 'a',
+ 'c',
+ )],
+ },
+ );
+ let projection =
+ ReviewWorktreeProjection::new(&review, Some(&target), Some(&one_side), true);
+ assert_eq!(
+ projection.advertised_sides("proposal-conflict", "file-conflict"),
+ [ConflictResolution::KeepCurrent]
+ );
+ assert!(PlatformReviewActionPreview::resolve_conflict(
+ target.clone(),
+ &review,
+ &one_side,
+ "proposal-conflict",
+ "file-conflict",
+ ConflictResolution::KeepIncoming,
+ )
+ .is_err());
+
+ // A file the snapshot no longer reports as unmerged cannot be
+ // collapsed, whatever an older capability read said.
+ let mut resolved = review.clone();
+ resolved
+ .files
+ .iter_mut()
+ .find(|file| file.id == "file-conflict")
+ .expect("conflicted file")
+ .conflict = ConflictState::Resolved;
+ assert!(
+ ReviewWorktreeProjection::new(&resolved, Some(&target), Some(&both), true)
+ .advertised_conflict_resolutions()
+ .is_empty()
+ );
+ }
+
+ // SDTEST-1849 — the shipped canonical snapshot, not a hand-built one,
+ // reaches the combined lanes, the safe text preview, and the staging
+ // observation the surface renders.
+ #[test]
+ fn sdtest_1849_canonical_snapshot_projects_to_lanes_previews_and_observations() {
+ let snapshot = decode_review_snapshot(CANONICAL_FIXTURE).expect("canonical fixture");
+ let review = PlatformReviewSemantic::from(&snapshot);
+ let projection = ReviewWorktreeProjection::new(&review, None, None, true);
+
+ assert_eq!(projection.source_revision, review.revision);
+ assert_eq!(projection.distinct_file_count(), 1);
+ for lane_kind in [ReviewWorktreeLane::Staged, ReviewWorktreeLane::Unstaged] {
+ let files = lane(&projection, lane_kind);
+ assert_eq!(files.len(), 1, "{lane_kind:?}");
+ assert_eq!(files[0].path.lines, ["src/review.rs"]);
+ assert!(files[0].partial);
+ assert_eq!(files[0].hunks.len(), 1);
+ assert_eq!(files[0].hunks[0].anchor.side, DiffSide::New);
+ assert_eq!(files[0].hunks[0].anchor.line, 10);
+ assert_eq!(
+ files[0].preview,
+ ReviewSafePreview::Text(ReviewSafeText {
+ lines: vec!["@@ -10,2 +10,3 @@ · sanitized preview".to_owned()],
+ truncated: false,
+ })
+ );
+ assert_eq!(
+ files[0].staging,
+ vec![ReviewStagingProposal {
+ proposal_id: "proposal-1".to_owned(),
+ kind: ReviewProposalKind::Commit,
+ admissible: true,
+ }]
+ );
+ }
+ assert!(lane(&projection, ReviewWorktreeLane::Conflicted).is_empty());
+ assert!(lane(&projection, ReviewWorktreeLane::Untracked).is_empty());
+ assert_eq!(
+ projection.staging,
+ Err(ReviewStagingWithheld::NoServerCapability)
+ );
+ }
+}
diff --git a/crates/shelldeck-core/src/lib.rs b/crates/shelldeck-core/src/lib.rs
index c23c05f5..27b98080 100644
--- a/crates/shelldeck-core/src/lib.rs
+++ b/crates/shelldeck-core/src/lib.rs
@@ -1,5 +1,6 @@
pub mod acp;
pub mod agent_runtime;
+pub mod agent_session;
pub mod ai;
pub mod companion;
pub mod config;
diff --git a/crates/shelldeck-core/src/workspace_navigation.rs b/crates/shelldeck-core/src/workspace_navigation.rs
index 38943f89..3f3c69e0 100644
--- a/crates/shelldeck-core/src/workspace_navigation.rs
+++ b/crates/shelldeck-core/src/workspace_navigation.rs
@@ -80,6 +80,15 @@ pub struct ProviderSessionBinding {
pub run_id: Option,
}
+/// Binding for a local CLI-agent runtime retained by a workspace. Runtime
+/// existence/liveness belongs to the UI session collection; this DTO only
+/// carries stable navigation identity and checkout authority.
+#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
+pub struct AgentSessionBinding {
+ pub checkout_id: CatalogCheckoutId,
+ pub session_id: Uuid,
+}
+
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
pub struct TerminalViewport {
pub scrollback_offset_lines: usize,
@@ -113,6 +122,7 @@ pub enum WorkspaceTabContent {
Browser {
location: String,
},
+ AgentSession(AgentSessionBinding),
ProviderSession(ProviderSessionBinding),
}
@@ -326,6 +336,13 @@ impl WorkspaceSurfaceState {
return Err(SurfaceValidationError::PlatformWorkspaceMismatch);
}
}
+ WorkspaceTabContent::AgentSession(binding)
+ if binding.checkout_id != workspace.checkout_id() =>
+ {
+ return Err(SurfaceValidationError::CheckoutAuthorityMismatch(
+ binding.checkout_id,
+ ));
+ }
_ => {}
}
}
@@ -1594,4 +1611,41 @@ mod tests {
.validate_for(&catalog, id)
.expect("exact provider mapping");
}
+
+ // SDTEST-1884
+ #[test]
+ fn local_agent_session_is_bound_to_the_workspace_checkout() {
+ let catalog = catalog();
+ let id = workspace(10);
+ let pane = PaneId::from_uuid(uuid(70));
+ let tab = WorkspaceTabId::from_uuid(uuid(71));
+ let surface = |checkout_id| WorkspaceSurfaceState {
+ root: Some(PaneNode::Leaf(PaneLeaf {
+ id: pane,
+ tabs: vec![WorkspaceTab {
+ id: tab,
+ title: "Implement cockpit".into(),
+ content: WorkspaceTabContent::AgentSession(AgentSessionBinding {
+ checkout_id,
+ session_id: uuid(72),
+ }),
+ }],
+ active_tab: Some(tab),
+ })),
+ focus: Some(WorkspaceFocus {
+ pane_id: pane,
+ tab_id: tab,
+ }),
+ };
+
+ surface(checkout(2))
+ .validate_for(&catalog, id)
+ .expect("workspace checkout owns the local agent session");
+ assert_eq!(
+ surface(checkout(3)).validate_for(&catalog, id),
+ Err(SurfaceValidationError::CheckoutAuthorityMismatch(checkout(
+ 3
+ )))
+ );
+ }
}
diff --git a/crates/shelldeck-core/src/workspace_review.rs b/crates/shelldeck-core/src/workspace_review.rs
index 5fd2ce86..4681bd50 100644
--- a/crates/shelldeck-core/src/workspace_review.rs
+++ b/crates/shelldeck-core/src/workspace_review.rs
@@ -17,7 +17,7 @@ use crate::config::workspace_catalog::{
};
use crate::workspace_navigation::{
PaneId, PaneNode, WorkspaceFocus, WorkspaceNavigationState, WorkspaceSurfaceState,
- WorkspaceTabContent,
+ WorkspaceTabContent, WorkspaceTabId,
};
#[path = "workspace_review_preview.rs"]
@@ -30,7 +30,7 @@ use validation::{
validate_pending_record, validate_preview_bounds, validate_provider_evidence,
};
#[path = "workspace_review_storage.rs"]
-mod storage;
+pub(crate) mod storage;
use storage::{
bounded_read, ensure_private_directory, lock_path, open_lock_file, read_disk_identity,
secure_atomic_write, workflow_bounded_read, workflow_disk_revision, workspace_review_root,
@@ -696,12 +696,38 @@ pub enum ApprovalDecision {
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
pub enum ReviewMutationKind {
- StageHunks {
- hunks: Vec,
- },
- UnstageHunks {
- hunks: Vec,
- },
+ /// Modelled, and deliberately without an adapter. Do not give it one as
+ /// written; `benfavre/shelldeck#163` records the investigation, and the
+ /// short version is that nothing here can close a git write.
+ ///
+ /// * A hunk has no identity to close over. It is not a property of the
+ /// repository but of a rendering: the same pair of blobs decomposes into
+ /// a different number of hunks at a different context width, moves when
+ /// `diff.indentHeuristic` is toggled, yields a different set under `-w`,
+ /// and produces no hunks at all under a `.gitattributes` textconv driver.
+ /// Two hunks in one file can also have byte-identical bodies, context
+ /// included, so content addressing alone is ambiguous within one file.
+ /// * `ReviewHunkId` is a v4 UUID and nothing outside tests produces a
+ /// [`ReviewHunk`], so it labels a projection rather than naming anything
+ /// a worktree holds. The platform contract agrees: its own hunk id exists
+ /// for comment anchors, and its `DiffHunk` carries positions plus a
+ /// bounded preview, never the hunk's content.
+ /// * [`MutationTargetFence::LocalReview`] pins the checkout and this
+ /// crate's `review_revision`. Neither moves when another process running
+ /// as the same uid moves `HEAD`, the index or the file, so it would fence
+ /// a staging write against nothing. `bext-stack/automonique#225` is the
+ /// standard a local git write has to meet, and `config::platform_review`
+ /// is where the file-level controls that meet it live: a confirmation
+ /// minted over the observed `HEAD` object id, a digest of the whole
+ /// index, and each named path's objects.
+ /// * Git offers no fenced mechanism anyway. `git add -p` is `git diff-files
+ /// -p` followed by `git apply --cached`, and `git apply` matches context
+ /// at any line offset: it exits 0 against an index the patch was not
+ /// minted from, and it will write into a different region of the file
+ /// when the reviewed one is gone.
+ StageHunks { hunks: Vec },
+ /// Unwired for the same reasons as [`ReviewMutationKind::StageHunks`].
+ UnstageHunks { hunks: Vec },
SendComments {
session_id: String,
comments: Vec,
@@ -1758,6 +1784,11 @@ pub enum AttentionState {
pub struct AttentionTarget {
pub workspace: CatalogWorkspaceId,
pub pane: PaneId,
+ /// Exact retained native tab coordinate. Provider-only projections may
+ /// instead use `session_id`, which is resolved without consulting the
+ /// pane's mutable active-tab state.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub tab_id: Option,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option