M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
||||
//! Disk-backed, co-located checkpoint store.
|
||||
//!
|
||||
//! Each finalized [`RewindCheckpoint`] is mirrored to a small on-disk store that
|
||||
//! lives *inside* the session working tree (the snapshotted rootfs), so the
|
||||
//! per-turn rootfs snapshot carries serialized checkpoints across a sandbox
|
||||
//! restore. A restored session rehydrates them into the in-memory cache (see
|
||||
//! [`CheckpointStore::with_cap`]); the cache is the hot read path, disk the
|
||||
//! durable copy. Re-seeding the *live* trackers from the cache is not yet wired.
|
||||
//!
|
||||
//! The store is a durability **mirror**, not the restore mechanism: in-session
|
||||
//! [`rewind_to`](crate::handle::WorkspaceHandle::rewind_to) always reverts
|
||||
//! in-process, never via a rootfs rollback. All disk I/O is gated by
|
||||
//! `workspace_rewind_durable` ([`rewind_durable_enabled`](super::checkpoint::rewind_durable_enabled));
|
||||
//! off ⇒ the legacy in-memory-only path.
|
||||
//!
|
||||
//! **On-disk layout** (under the session `cwd`):
|
||||
//!
|
||||
//! ```text
|
||||
//! <cwd>/.kigi/rewind-checkpoints/
|
||||
//! .gitignore # "*" — blobs are never committed
|
||||
//! <session_id>/
|
||||
//! checkpoint-<prompt_index>.json # one RewindCheckpoint per prompt
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::session::checkpoint::RewindCheckpoint;
|
||||
|
||||
/// Directory (under `<cwd>/.kigi`) holding every session's checkpoint store.
|
||||
const STORE_SUBDIR: &str = "rewind-checkpoints";
|
||||
|
||||
/// Default cap on retained checkpoints per session. Bounds on-disk and in-memory
|
||||
/// size; the oldest (lowest `prompt_index`) are evicted beyond this.
|
||||
const DEFAULT_CHECKPOINT_CAP: usize = 64;
|
||||
|
||||
/// Monotonic counter making each checkpoint temp-file name unique within the
|
||||
/// process, so concurrent writers for the same `prompt_index` never collide.
|
||||
static TMP_WRITE_SEQ: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
|
||||
|
||||
/// Disk-backed, co-located checkpoint store fronted by an in-memory cache.
|
||||
///
|
||||
/// See the [module docs](self) for the on-disk layout and durability rationale.
|
||||
pub(crate) struct CheckpointStore {
|
||||
/// Per-session store directory: `<cwd>/.kigi/rewind-checkpoints/<session_id>`.
|
||||
dir: PathBuf,
|
||||
/// Max retained checkpoints; the oldest are evicted beyond this.
|
||||
cap: usize,
|
||||
/// In-memory cache fronting disk (the hot read path). A `BTreeMap` keeps keys
|
||||
/// ordered, so the oldest prompt (smallest key) is cheap to find for eviction.
|
||||
cache: Mutex<BTreeMap<usize, RewindCheckpoint>>,
|
||||
/// Serializes `persist` against `truncate_from` so a finalize and a rewind
|
||||
/// can't interleave their disk + cache mutations and drift out of sync.
|
||||
io_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl CheckpointStore {
|
||||
/// Build a store for `session_id` rooted at the session `cwd`.
|
||||
///
|
||||
/// With the durable flag **off** this does no disk I/O. With it **on** it
|
||||
/// rehydrates the cache from any blobs the rootfs snapshot carried (see
|
||||
/// [`with_cap`](Self::with_cap)).
|
||||
pub(crate) fn new(cwd: &Path, session_id: &str) -> Self {
|
||||
Self::with_cap(cwd, session_id, DEFAULT_CHECKPOINT_CAP)
|
||||
}
|
||||
|
||||
/// Like [`new`](Self::new) but with an explicit retention cap (clamped to ≥1).
|
||||
/// With the durable flag on, rehydrates the cache from the blobs the rootfs
|
||||
/// snapshot carried and enforces the cap against them; flag off ⇒ empty, no I/O.
|
||||
pub(crate) fn with_cap(cwd: &Path, session_id: &str, cap: usize) -> Self {
|
||||
// `session_id` is RPC-controlled: never join it verbatim (a `../../etc`
|
||||
// would escape the store root). Map it to a safe, collision-free name first.
|
||||
let dir = cwd
|
||||
.join(".kigi")
|
||||
.join(STORE_SUBDIR)
|
||||
.join(session_store_dir_name(session_id));
|
||||
let cap = cap.max(1);
|
||||
let cache = if super::checkpoint::rewind_durable_enabled() {
|
||||
rehydrate_off_runtime(&dir, cap)
|
||||
} else {
|
||||
BTreeMap::new()
|
||||
};
|
||||
Self {
|
||||
dir,
|
||||
cap,
|
||||
cache: Mutex::new(cache),
|
||||
io_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk path for a single checkpoint.
|
||||
fn checkpoint_path(&self, prompt_index: usize) -> PathBuf {
|
||||
checkpoint_file_path(&self.dir, prompt_index)
|
||||
}
|
||||
|
||||
/// Write `checkpoint` through to disk and the cache (last-write-wins per
|
||||
/// `prompt_index`), then evict the oldest beyond the cap. Takes the checkpoint
|
||||
/// by value to avoid cloning its (potentially large) contents on the hot path.
|
||||
pub(crate) async fn persist(&self, checkpoint: RewindCheckpoint) {
|
||||
// Serialize against `truncate_from` so a finalize and a rewind can't
|
||||
// interleave and leave the cache and disk inconsistent.
|
||||
let _io = self.io_lock.lock().await;
|
||||
|
||||
let prompt_index = checkpoint.prompt_index;
|
||||
// Skip a checkpoint below the retention window: it would be evicted the
|
||||
// instant it's inserted (write-then-delete). Overwrites of an existing
|
||||
// index, or any index while under the cap, are always retained.
|
||||
{
|
||||
let cache = self.cache.lock().await;
|
||||
let below_window = cache.len() >= self.cap
|
||||
&& !cache.contains_key(&prompt_index)
|
||||
&& cache
|
||||
.keys()
|
||||
.next()
|
||||
.is_some_and(|&oldest| prompt_index < oldest);
|
||||
if below_window {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.ensure_store_dir().await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
dir = %self.dir.display(),
|
||||
"rewind checkpoint store: mkdir failed; skipping persist"
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(e) = self.write_checkpoint_file(&checkpoint).await {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
prompt_index,
|
||||
"rewind checkpoint store: write failed; skipping persist"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Pick cap victims under the cache lock, then release it before the file
|
||||
// deletions (never hold the mutex across I/O). The just-inserted index is
|
||||
// guaranteed to survive eviction by the retention-window check above.
|
||||
let evicted = {
|
||||
let mut cache = self.cache.lock().await;
|
||||
cache.insert(prompt_index, checkpoint);
|
||||
let mut evicted = Vec::new();
|
||||
while cache.len() > self.cap {
|
||||
// `pop_first` removes the smallest key — the oldest prompt.
|
||||
let Some((oldest, _)) = cache.pop_first() else {
|
||||
break;
|
||||
};
|
||||
evicted.push(oldest);
|
||||
}
|
||||
evicted
|
||||
};
|
||||
for idx in evicted {
|
||||
let _ = tokio::fs::remove_file(self.checkpoint_path(idx)).await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop persisted checkpoints `>= target` from cache and disk. Scans the
|
||||
/// directory (not just the cache) so it stays correct when the cache is cold
|
||||
/// (e.g. after a sandbox restore). Missing store dir is a no-op.
|
||||
pub(crate) async fn truncate_from(&self, target: usize) {
|
||||
// Serialize against `persist` (see [`persist`](Self::persist)) so a rewind
|
||||
// and a concurrent finalize can't interleave their cache + disk mutations.
|
||||
let _io = self.io_lock.lock().await;
|
||||
|
||||
// Open the disk scan *before* pruning the cache so the two can't diverge:
|
||||
// if the dir can't be opened while it still holds `>= target` blobs, a pruned
|
||||
// cache would let a later rehydrate resurrect the just-rewound checkpoints.
|
||||
let mut entries = match tokio::fs::read_dir(&self.dir).await {
|
||||
Ok(entries) => entries,
|
||||
// Dir absent ⇒ no on-disk blobs to diverge from; safe to prune the cache.
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
self.cache.lock().await.retain(|&idx, _| idx < target);
|
||||
return;
|
||||
}
|
||||
// Unscannable dir may still hold `>= target` blobs: keep the cache so it
|
||||
// stays consistent with disk (the rewound checkpoints aren't resurrected).
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
dir = %self.dir.display(),
|
||||
"rewind checkpoint store: truncate scan failed; keeping cache to stay consistent with disk"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
// Scan opened: prune the cache, then delete the `>= target` blobs below.
|
||||
self.cache.lock().await.retain(|&idx, _| idx < target);
|
||||
// Explicit loop (not `while let Ok(Some(..))`) so a transient mid-scan
|
||||
// `read_dir` error continues instead of ending early: aborting would leave
|
||||
// a `>= target` blob after the cache was pruned, and a later rehydrate would
|
||||
// resurrect that rewound checkpoint. `remove_file` failures are logged.
|
||||
loop {
|
||||
match entries.next_entry().await {
|
||||
Ok(Some(entry)) => {
|
||||
let name = entry.file_name();
|
||||
if let Some(idx) = parse_checkpoint_index(&name)
|
||||
&& idx >= target
|
||||
&& let Err(e) = tokio::fs::remove_file(entry.path()).await
|
||||
{
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
path = %entry.path().display(),
|
||||
"rewind checkpoint store: failed to remove checkpoint on truncate; \
|
||||
it may be resurrected on a later rehydrate"
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
dir = %self.dir.display(),
|
||||
"rewind checkpoint store: truncate scan read_dir error; \
|
||||
continuing to scan remaining entries"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Create the store dir and its `.gitignore` (idempotent). The `.gitignore`
|
||||
/// at the `rewind-checkpoints` root uses `*` so no blob is ever committed.
|
||||
async fn ensure_store_dir(&self) -> std::io::Result<()> {
|
||||
tokio::fs::create_dir_all(&self.dir).await?;
|
||||
if let Some(root) = self.dir.parent() {
|
||||
let gitignore = root.join(".gitignore");
|
||||
// `Path::exists` is a blocking `stat` on the async runtime thread; use
|
||||
// the async probe to stay consistent with the surrounding tokio::fs I/O.
|
||||
if !tokio::fs::try_exists(&gitignore).await.unwrap_or(false) {
|
||||
tokio::fs::write(&gitignore, "*\n").await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serialize `checkpoint` via temp-file + rename, so a crash mid-write can't
|
||||
/// leave a torn JSON blob at the final path. The temp path carries a per-write
|
||||
/// unique suffix (pid + counter), not just `prompt_index`: two overlapping
|
||||
/// persists of the same prompt would otherwise share one temp file and tear.
|
||||
async fn write_checkpoint_file(&self, checkpoint: &RewindCheckpoint) -> std::io::Result<()> {
|
||||
let json = serde_json::to_vec(checkpoint).map_err(std::io::Error::other)?;
|
||||
let final_path = self.checkpoint_path(checkpoint.prompt_index);
|
||||
let unique = TMP_WRITE_SEQ.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||
let tmp_path = self.dir.join(format!(
|
||||
"checkpoint-{}.json.tmp.{}.{}",
|
||||
checkpoint.prompt_index,
|
||||
std::process::id(),
|
||||
unique,
|
||||
));
|
||||
// Flush the blob to disk *before* the rename: atomic rename gives visibility,
|
||||
// not data persistence, so without this fsync the durability mechanism (a
|
||||
// rootfs snapshot carrying these files) could capture a zero-length/short
|
||||
// blob. `sync_all` fsyncs contents + metadata.
|
||||
{
|
||||
let mut f = tokio::fs::File::create(&tmp_path).await?;
|
||||
f.write_all(&json).await?;
|
||||
f.sync_all().await?;
|
||||
}
|
||||
tokio::fs::rename(&tmp_path, &final_path).await?;
|
||||
// Best-effort dir fsync so the rename (the new dir entry) is itself durable;
|
||||
// async open keeps this off the blocking path. Ignored where unsupported.
|
||||
if let Ok(dir) = tokio::fs::File::open(&self.dir).await {
|
||||
let _ = dir.sync_all().await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// On-disk path for a single checkpoint under `dir`.
|
||||
fn checkpoint_file_path(dir: &Path, prompt_index: usize) -> PathBuf {
|
||||
dir.join(format!("checkpoint-{prompt_index}.json"))
|
||||
}
|
||||
|
||||
/// Derive the on-disk store directory name for a caller-controlled `session_id`.
|
||||
/// Must be (1) a single traversal-safe component (`../../etc` must not escape the
|
||||
/// root) and (2) collision-free across distinct raw ids. A readable sanitized
|
||||
/// prefix (`[^A-Za-z0-9_-]` → `_`, length-bounded) plus a short hash of the *raw*
|
||||
/// id; deterministic so a restored session reads back the same directory.
|
||||
fn session_store_dir_name(session_id: &str) -> String {
|
||||
const PREFIX_MAX: usize = 48;
|
||||
let prefix: String = session_id
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
|
||||
c
|
||||
} else {
|
||||
'_'
|
||||
}
|
||||
})
|
||||
.take(PREFIX_MAX)
|
||||
.collect();
|
||||
// The hash makes the name collision-resistant; appended unconditionally so
|
||||
// the result always contains `-<hex>` and can never be empty, `.`, or `..`.
|
||||
format!("{prefix}-{:016x}", fnv1a_64(session_id.as_bytes()))
|
||||
}
|
||||
|
||||
/// FNV-1a 64-bit hash. Small and fully specified, so the digest is stable across
|
||||
/// platforms and toolchains — required since the store dir name depends on it.
|
||||
fn fnv1a_64(bytes: &[u8]) -> u64 {
|
||||
const OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
const PRIME: u64 = 0x0000_0100_0000_01b3;
|
||||
let mut hash = OFFSET_BASIS;
|
||||
for &b in bytes {
|
||||
hash ^= b as u64;
|
||||
hash = hash.wrapping_mul(PRIME);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
/// Run the blocking disk rehydrate without starving the async runtime.
|
||||
/// [`load_capped_from_disk`] uses blocking `std::fs` but the constructor is sync
|
||||
/// and reached from async paths: on a multi-thread runtime hand it to
|
||||
/// `block_in_place`; otherwise (current-thread, where that panics) run inline.
|
||||
fn rehydrate_off_runtime(dir: &Path, cap: usize) -> BTreeMap<usize, RewindCheckpoint> {
|
||||
use tokio::runtime::{Handle, RuntimeFlavor};
|
||||
match Handle::try_current() {
|
||||
Ok(handle) if handle.runtime_flavor() == RuntimeFlavor::MultiThread => {
|
||||
tokio::task::block_in_place(|| load_capped_from_disk(dir, cap))
|
||||
}
|
||||
_ => load_capped_from_disk(dir, cap),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load persisted checkpoints from `dir`, trimmed to the newest `cap` (older
|
||||
/// blobs are deleted, bounding on-disk size). Blocking `std::fs`, run once at
|
||||
/// construction. Missing dir ⇒ empty; unreadable/corrupt blobs are skipped.
|
||||
fn load_capped_from_disk(dir: &Path, cap: usize) -> BTreeMap<usize, RewindCheckpoint> {
|
||||
let mut loaded = BTreeMap::new();
|
||||
let entries = match std::fs::read_dir(dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return loaded,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
dir = %dir.display(),
|
||||
"rewind checkpoint store: rehydrate scan failed"
|
||||
);
|
||||
return loaded;
|
||||
}
|
||||
};
|
||||
for entry in entries {
|
||||
// Don't `flatten()` away per-entry errors: a dropped entry would omit a blob
|
||||
// from the cache while leaving it on disk, diverging the two. Log and skip.
|
||||
let entry = match entry {
|
||||
Ok(entry) => entry,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = %e,
|
||||
dir = %dir.display(),
|
||||
"rewind checkpoint store: skipping unreadable dir entry on rehydrate"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let file_name = entry.file_name();
|
||||
let Some(idx) = parse_checkpoint_index(&file_name) else {
|
||||
// Sweep orphaned temp files from a crashed `write_checkpoint_file`:
|
||||
// rehydrate runs once at construction before this instance writes, so
|
||||
// removing them is safe and bounds clutter. Best-effort.
|
||||
if is_orphan_checkpoint_tmp(&file_name) {
|
||||
let _ = std::fs::remove_file(entry.path());
|
||||
}
|
||||
continue;
|
||||
};
|
||||
match std::fs::read(entry.path()) {
|
||||
Ok(bytes) => match serde_json::from_slice::<RewindCheckpoint>(&bytes) {
|
||||
Ok(checkpoint) => {
|
||||
loaded.insert(idx, checkpoint);
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
path = %entry.path().display(),
|
||||
"rewind checkpoint store: skipping unparseable blob on rehydrate"
|
||||
),
|
||||
},
|
||||
Err(e) => tracing::warn!(
|
||||
error = %e,
|
||||
path = %entry.path().display(),
|
||||
"rewind checkpoint store: skipping unreadable blob on rehydrate"
|
||||
),
|
||||
}
|
||||
}
|
||||
while loaded.len() > cap {
|
||||
let Some((oldest, _)) = loaded.pop_first() else {
|
||||
break;
|
||||
};
|
||||
let _ = std::fs::remove_file(checkpoint_file_path(dir, oldest));
|
||||
}
|
||||
loaded
|
||||
}
|
||||
|
||||
/// Parse `checkpoint-<n>.json` → `n`. `None` for anything else (including the
|
||||
/// in-flight `checkpoint-<n>.json.tmp` written by `write_checkpoint_file`).
|
||||
fn parse_checkpoint_index(file_name: &std::ffi::OsStr) -> Option<usize> {
|
||||
file_name
|
||||
.to_str()?
|
||||
.strip_prefix("checkpoint-")?
|
||||
.strip_suffix(".json")?
|
||||
.parse()
|
||||
.ok()
|
||||
}
|
||||
|
||||
/// Whether `file_name` is an orphaned checkpoint temp file
|
||||
/// (`checkpoint-<idx>.json.tmp[...]`) left by a crashed `write_checkpoint_file`,
|
||||
/// swept on rehydrate (`parse_checkpoint_index` deliberately skips them).
|
||||
fn is_orphan_checkpoint_tmp(file_name: &std::ffi::OsStr) -> bool {
|
||||
file_name
|
||||
.to_str()
|
||||
.is_some_and(|n| n.starts_with("checkpoint-") && n.contains(".json.tmp"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl CheckpointStore {
|
||||
/// The per-session store directory.
|
||||
pub(crate) fn dir(&self) -> &Path {
|
||||
&self.dir
|
||||
}
|
||||
|
||||
/// Read a checkpoint, preferring the cache and falling back to a disk read
|
||||
/// (warming the cache). Test-only: the prod consumer that re-seeds live
|
||||
/// trackers from these blobs after a sandbox restore is not yet wired.
|
||||
pub(crate) async fn get(&self, prompt_index: usize) -> Option<RewindCheckpoint> {
|
||||
if let Some(cp) = self.cache.lock().await.get(&prompt_index).cloned() {
|
||||
return Some(cp);
|
||||
}
|
||||
let bytes = tokio::fs::read(self.checkpoint_path(prompt_index))
|
||||
.await
|
||||
.ok()?;
|
||||
let checkpoint: RewindCheckpoint = serde_json::from_slice(&bytes).ok()?;
|
||||
self.cache
|
||||
.lock()
|
||||
.await
|
||||
.insert(prompt_index, checkpoint.clone());
|
||||
Some(checkpoint)
|
||||
}
|
||||
|
||||
/// Number of checkpoints currently held in the in-memory cache.
|
||||
async fn cached_len(&self) -> usize {
|
||||
self.cache.lock().await.len()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::session::file_state::{FileSnapshot, RewindPoint};
|
||||
use kigi_paths::RelPathBuf;
|
||||
|
||||
/// A minimal FS-only checkpoint (no hunk delta) for store-mechanics tests.
|
||||
/// Distinct content per prompt so a disk round-trip is meaningfully checked.
|
||||
fn fs_only_checkpoint(prompt_index: usize) -> RewindCheckpoint {
|
||||
let mut fs = RewindPoint::new(prompt_index);
|
||||
fs.add_snapshot(FileSnapshot::new(
|
||||
RelPathBuf::new("a.rs").unwrap(),
|
||||
Some(format!("content for prompt {prompt_index}")),
|
||||
));
|
||||
RewindCheckpoint {
|
||||
prompt_index,
|
||||
fs,
|
||||
hunks: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_writes_under_cwd_and_gitignores_blobs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = CheckpointStore::new(tmp.path(), "sess-1");
|
||||
|
||||
// The store is co-located inside the working tree (the snapshotted rootfs).
|
||||
assert!(
|
||||
store.dir().starts_with(tmp.path()),
|
||||
"store dir must live inside the session working tree, got {}",
|
||||
store.dir().display()
|
||||
);
|
||||
|
||||
store.persist(fs_only_checkpoint(0)).await;
|
||||
|
||||
// The checkpoint blob is on disk...
|
||||
assert!(store.checkpoint_path(0).exists(), "checkpoint blob written");
|
||||
// ...and a `.gitignore` ignores the whole store so blobs are never committed.
|
||||
let gitignore = tmp
|
||||
.path()
|
||||
.join(".kigi")
|
||||
.join(STORE_SUBDIR)
|
||||
.join(".gitignore");
|
||||
let body = std::fs::read_to_string(&gitignore).expect("gitignore written");
|
||||
assert_eq!(body.trim(), "*", "store .gitignore must ignore all blobs");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cap_eviction_drops_oldest_checkpoint_and_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = CheckpointStore::with_cap(tmp.path(), "sess-1", 2);
|
||||
|
||||
for idx in 0..3 {
|
||||
store.persist(fs_only_checkpoint(idx)).await;
|
||||
}
|
||||
|
||||
// The oldest (0) is evicted from both cache and disk; the last `cap` stay.
|
||||
assert!(
|
||||
!store.checkpoint_path(0).exists(),
|
||||
"evicted checkpoint's file must be removed"
|
||||
);
|
||||
assert!(store.checkpoint_path(1).exists());
|
||||
assert!(store.checkpoint_path(2).exists());
|
||||
assert!(store.get(0).await.is_none(), "evicted checkpoint is gone");
|
||||
assert!(store.get(2).await.is_some());
|
||||
assert_eq!(store.cached_len().await, 2, "cache is bounded to the cap");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn persist_below_retention_window_is_skipped_not_self_deleted() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = CheckpointStore::with_cap(tmp.path(), "sess-1", 2);
|
||||
store.persist(fs_only_checkpoint(5)).await;
|
||||
store.persist(fs_only_checkpoint(10)).await;
|
||||
|
||||
// An index below the retained window would be evicted the moment it's
|
||||
// inserted, so it must be skipped entirely — not written-then-deleted.
|
||||
store.persist(fs_only_checkpoint(3)).await;
|
||||
assert!(
|
||||
!store.checkpoint_path(3).exists(),
|
||||
"below-window write must be skipped, not left dangling or self-deleted"
|
||||
);
|
||||
assert!(
|
||||
store.checkpoint_path(5).exists(),
|
||||
"existing in-window checkpoint must survive"
|
||||
);
|
||||
assert!(store.checkpoint_path(10).exists());
|
||||
assert!(store.get(3).await.is_none());
|
||||
assert!(store.get(5).await.is_some());
|
||||
assert_eq!(store.cached_len().await, 2);
|
||||
|
||||
// A newer index correctly evicts the oldest and survives itself.
|
||||
store.persist(fs_only_checkpoint(11)).await;
|
||||
assert!(
|
||||
store.checkpoint_path(11).exists(),
|
||||
"the newest write must survive eviction"
|
||||
);
|
||||
assert!(
|
||||
!store.checkpoint_path(5).exists(),
|
||||
"the oldest in-window entry is evicted by the newer write"
|
||||
);
|
||||
assert!(store.checkpoint_path(10).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_rehydrates_from_disk_when_cache_is_cold() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
{
|
||||
let store = CheckpointStore::new(tmp.path(), "sess-1");
|
||||
store.persist(fs_only_checkpoint(7)).await;
|
||||
}
|
||||
|
||||
// A fresh store models a sandbox restore: cold cache, blobs on disk.
|
||||
let restored = CheckpointStore::new(tmp.path(), "sess-1");
|
||||
assert_eq!(restored.cached_len().await, 0, "fresh store starts cold");
|
||||
|
||||
let cp = restored
|
||||
.get(7)
|
||||
.await
|
||||
.expect("checkpoint survives store re-creation (carried by rootfs snapshot)");
|
||||
assert_eq!(cp.prompt_index, 7);
|
||||
assert_eq!(
|
||||
cp.fs.file_snapshots.len(),
|
||||
1,
|
||||
"fs snapshot content survives the disk round-trip"
|
||||
);
|
||||
// The disk read warms the cache for subsequent hot reads.
|
||||
assert_eq!(restored.cached_len().await, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncate_from_drops_target_and_later_checkpoints() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = CheckpointStore::new(tmp.path(), "sess-1");
|
||||
for idx in 0..4 {
|
||||
store.persist(fs_only_checkpoint(idx)).await;
|
||||
}
|
||||
|
||||
store.truncate_from(2).await;
|
||||
|
||||
assert!(store.checkpoint_path(0).exists());
|
||||
assert!(store.checkpoint_path(1).exists());
|
||||
assert!(
|
||||
!store.checkpoint_path(2).exists(),
|
||||
"target checkpoint file removed"
|
||||
);
|
||||
assert!(
|
||||
!store.checkpoint_path(3).exists(),
|
||||
"later checkpoint file removed"
|
||||
);
|
||||
assert!(store.get(3).await.is_none());
|
||||
assert!(store.get(1).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn truncate_on_missing_store_dir_is_noop() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let store = CheckpointStore::new(tmp.path(), "sess-1");
|
||||
// Nothing persisted yet (dir absent) — truncate must not error or create it.
|
||||
store.truncate_from(0).await;
|
||||
assert!(!store.dir().exists());
|
||||
}
|
||||
|
||||
/// Rehydrate (post-restore): blobs the rootfs snapshot carried load back into
|
||||
/// the cache, and the cap is enforced against the on-disk set (oldest deleted).
|
||||
#[tokio::test]
|
||||
async fn rehydrate_loads_capped_set_from_disk() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
// Write 4 blobs to disk (uncapped) to model a snapshot carrying history.
|
||||
let writer = CheckpointStore::with_cap(tmp.path(), "sess-1", 100);
|
||||
for idx in 0..4 {
|
||||
writer.persist(fs_only_checkpoint(idx)).await;
|
||||
}
|
||||
|
||||
// Rehydrate with cap 2: only the newest 2 load, older blobs are deleted.
|
||||
let loaded = load_capped_from_disk(&writer.dir, 2);
|
||||
assert_eq!(loaded.len(), 2, "rehydrate keeps only the newest `cap`");
|
||||
assert!(loaded.contains_key(&2));
|
||||
assert!(loaded.contains_key(&3));
|
||||
assert!(
|
||||
!checkpoint_file_path(&writer.dir, 0).exists(),
|
||||
"cap is enforced against on-disk blobs, not just the cache"
|
||||
);
|
||||
assert!(!checkpoint_file_path(&writer.dir, 1).exists());
|
||||
assert!(checkpoint_file_path(&writer.dir, 2).exists());
|
||||
assert!(checkpoint_file_path(&writer.dir, 3).exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rehydrate_sweeps_orphan_tmp_files() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let writer = CheckpointStore::with_cap(tmp.path(), "sess-1", 100);
|
||||
writer.persist(fs_only_checkpoint(0)).await;
|
||||
|
||||
// Simulate a crash / failed rename leaving an orphan temp file behind.
|
||||
let orphan = writer.dir.join("checkpoint-9.json.tmp.4242.0");
|
||||
std::fs::write(&orphan, b"partial").unwrap();
|
||||
assert!(orphan.exists());
|
||||
|
||||
// Rehydrate sweeps the orphan and still loads the real checkpoint.
|
||||
let loaded = load_capped_from_disk(&writer.dir, 100);
|
||||
assert!(loaded.contains_key(&0), "real checkpoint still rehydrates");
|
||||
assert!(
|
||||
!orphan.exists(),
|
||||
"orphaned *.json.tmp* file must be swept on rehydrate"
|
||||
);
|
||||
// The committed checkpoint blob is untouched by the sweep.
|
||||
assert!(checkpoint_file_path(&writer.dir, 0).exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_orphan_checkpoint_tmp_matches_only_temp_files() {
|
||||
use std::ffi::OsStr;
|
||||
assert!(is_orphan_checkpoint_tmp(OsStr::new(
|
||||
"checkpoint-3.json.tmp.123.0"
|
||||
)));
|
||||
assert!(is_orphan_checkpoint_tmp(OsStr::new(
|
||||
"checkpoint-3.json.tmp"
|
||||
)));
|
||||
assert!(!is_orphan_checkpoint_tmp(OsStr::new("checkpoint-3.json")));
|
||||
assert!(!is_orphan_checkpoint_tmp(OsStr::new(".gitignore")));
|
||||
assert!(!is_orphan_checkpoint_tmp(OsStr::new("other.json.tmp.1.2")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_store_dir_name_is_safe_and_collision_free() {
|
||||
let root = Path::new("/store/root");
|
||||
for raw in [
|
||||
"../../etc/passwd",
|
||||
"a/b",
|
||||
"..",
|
||||
".",
|
||||
"",
|
||||
"a\\b",
|
||||
"/abs",
|
||||
"./../x",
|
||||
] {
|
||||
let s = session_store_dir_name(raw);
|
||||
assert!(!s.is_empty(), "never empty for {raw:?}");
|
||||
assert!(
|
||||
!s.contains('/') && !s.contains('\\'),
|
||||
"no separators for {raw:?}: {s:?}"
|
||||
);
|
||||
assert!(s != "." && s != "..", "not a traversal component: {s:?}");
|
||||
// Joining must stay within the store root and add exactly one path
|
||||
// component (no `..` escape).
|
||||
let joined = root.join(&s);
|
||||
assert!(joined.starts_with(root), "stays in root: {joined:?}");
|
||||
assert_eq!(
|
||||
joined.components().count(),
|
||||
root.components().count() + 1,
|
||||
"exactly one extra component for {raw:?}: {joined:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Distinct raw ids sharing a sanitized prefix must still map to distinct
|
||||
// directories (hash suffix differs), so sessions can't clobber each other.
|
||||
assert_ne!(
|
||||
session_store_dir_name("foo/bar"),
|
||||
session_store_dir_name("foo_bar"),
|
||||
"distinct raw ids must not share a store directory"
|
||||
);
|
||||
assert_ne!(session_store_dir_name("a/b"), session_store_dir_name("a-b"));
|
||||
|
||||
// Deterministic: the same raw id always maps to the same directory, so a
|
||||
// restored session rehydrates from the right place.
|
||||
assert_eq!(
|
||||
session_store_dir_name("session-123"),
|
||||
session_store_dir_name("session-123")
|
||||
);
|
||||
|
||||
// The readable prefix is preserved for typical ids.
|
||||
assert!(session_store_dir_name("main").starts_with("main-"));
|
||||
assert!(session_store_dir_name("a1b2-c3d4_e5").starts_with("a1b2-c3d4_e5-"));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,226 @@
|
||||
//! Jujutsu (jj) operations for colocated repos.
|
||||
//!
|
||||
//! Mirrors the git operations in [`super::git`] but uses the `jj` CLI.
|
||||
//! All read-only calls use `--ignore-working-copy`; mutating calls use
|
||||
//! [`super::git::jj_cli_mut`].
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
use super::git::{
|
||||
ChangeType, CommitData, CommitResult, GitBranchEntry, GitBranchListData, GitFileChange,
|
||||
GitInfoData, GitStatusData, VcsKind, git_cli, jj_cli, jj_cli_mut,
|
||||
};
|
||||
|
||||
/// Query bookmarks attached to a revision (returns `None` if empty).
|
||||
async fn bookmarks_at(cwd: &Path, revset: &str) -> Option<String> {
|
||||
jj_cli(
|
||||
cwd,
|
||||
&[
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
revset,
|
||||
"-T",
|
||||
r#"bookmarks.join(", ")"#,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
/// Repo info (reuses `GitInfoData` for ACP compatibility).
|
||||
pub async fn info(cwd: &Path) -> Result<GitInfoData> {
|
||||
let root = jj_cli(cwd, &["workspace", "root"]).await?;
|
||||
let current_branch = bookmarks_at(cwd, "@-").await;
|
||||
|
||||
// Remote URLs via colocated git
|
||||
let remotes = git_cli(cwd, &["remote", "-v"])
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
(parts.len() >= 2 && line.contains("(fetch)")).then(|| parts[1].to_string())
|
||||
})
|
||||
.collect::<BTreeSet<_>>()
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
Ok(GitInfoData {
|
||||
root,
|
||||
remotes,
|
||||
current_branch,
|
||||
default_branch: None,
|
||||
vcs_kind: Some(VcsKind::JujutsuColocated),
|
||||
})
|
||||
}
|
||||
|
||||
/// Status mapped to `GitStatusData` (all changes in `unstaged` — jj has no index).
|
||||
pub async fn status(cwd: &Path) -> Result<GitStatusData> {
|
||||
let root = jj_cli(cwd, &["workspace", "root"]).await.ok();
|
||||
let commit = jj_cli(
|
||||
cwd,
|
||||
&[
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@",
|
||||
"-T",
|
||||
"commit_id.shortest(12)",
|
||||
],
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
let branch = bookmarks_at(cwd, "@-").await;
|
||||
|
||||
let diff_output = jj_cli(cwd, &["diff", "--summary"])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let unstaged: Vec<GitFileChange> = diff_output
|
||||
.lines()
|
||||
.filter_map(|line| {
|
||||
let line = line.trim();
|
||||
let (change_type, path) = if let Some(rest) = line.strip_prefix("M ") {
|
||||
(ChangeType::Edit, rest.trim())
|
||||
} else if let Some(rest) = line.strip_prefix("A ") {
|
||||
(ChangeType::Create, rest.trim())
|
||||
} else if let Some(rest) = line.strip_prefix("D ") {
|
||||
(ChangeType::Delete, rest.trim())
|
||||
} else {
|
||||
let rest = line.strip_prefix("R ")?;
|
||||
(ChangeType::Rename, rest.trim())
|
||||
};
|
||||
Some(GitFileChange {
|
||||
path: path.to_string(),
|
||||
old_path: None,
|
||||
change_type,
|
||||
staged: Some(false),
|
||||
additions: 0,
|
||||
deletions: 0,
|
||||
patch: None,
|
||||
patch_bytes: None,
|
||||
patch_lines: None,
|
||||
old_text: None,
|
||||
new_text: None,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(GitStatusData {
|
||||
root,
|
||||
main_root: None,
|
||||
is_worktree: None,
|
||||
branch,
|
||||
commit,
|
||||
upstream: None,
|
||||
remote_url: None,
|
||||
ahead: None,
|
||||
behind: None,
|
||||
staged: Vec::new(),
|
||||
unstaged,
|
||||
})
|
||||
}
|
||||
|
||||
/// Current commit id: the working-copy commit (`@`), matching the `commit`
|
||||
/// field reported by [`status`].
|
||||
///
|
||||
/// In a colocated repo, git HEAD points at `@-` (the parent of the working-copy
|
||||
/// commit), so reading git HEAD would return a different revision than jj's
|
||||
/// current commit. Returns `Ok(None)` if the id can't be determined, mirroring
|
||||
/// the lenient behavior of `git::get_current_commit`.
|
||||
pub async fn current_commit(cwd: &Path) -> Result<Option<String>> {
|
||||
let commit = jj_cli(cwd, &["log", "--no-graph", "-r", "@", "-T", "commit_id"])
|
||||
.await
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty());
|
||||
Ok(commit)
|
||||
}
|
||||
|
||||
/// Commit: describe the current change and start a new one.
|
||||
pub async fn commit(cwd: &Path, message: &str) -> Result<CommitResult> {
|
||||
jj_cli_mut(cwd, &["describe", "-m", message]).await?;
|
||||
let commit_hash = jj_cli(
|
||||
cwd,
|
||||
&[
|
||||
"log",
|
||||
"--no-graph",
|
||||
"-r",
|
||||
"@",
|
||||
"-T",
|
||||
"commit_id.shortest(12)",
|
||||
],
|
||||
)
|
||||
.await
|
||||
.ok();
|
||||
jj_cli_mut(cwd, &["new"]).await?;
|
||||
|
||||
Ok(CommitResult {
|
||||
data: CommitData {
|
||||
commit_hash,
|
||||
output: Some("Commit described and new change started".to_string()),
|
||||
},
|
||||
warning: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Discard: restore working copy from parent.
|
||||
pub async fn discard(cwd: &Path, paths: Option<Vec<String>>) -> Result<()> {
|
||||
match paths {
|
||||
Some(paths) if !paths.is_empty() => {
|
||||
let mut args: Vec<&str> = vec!["restore"];
|
||||
let refs: Vec<&str> = paths.iter().map(|s| s.as_str()).collect();
|
||||
args.extend(refs);
|
||||
jj_cli_mut(cwd, &args).await?;
|
||||
}
|
||||
_ => {
|
||||
jj_cli_mut(cwd, &["restore"]).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bookmark list, mapped to `GitBranchListData` for ACP compatibility.
|
||||
pub async fn list_bookmarks(cwd: &Path) -> Result<GitBranchListData> {
|
||||
let root = jj_cli(cwd, &["workspace", "root"]).await?;
|
||||
|
||||
let bookmark_output = jj_cli(
|
||||
cwd,
|
||||
&[
|
||||
"bookmark",
|
||||
"list",
|
||||
"--all",
|
||||
"-T",
|
||||
r#"name ++ if(remote, "@" ++ remote, "") ++ "\n""#,
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let current_bookmark = bookmarks_at(cwd, "@-").await;
|
||||
|
||||
let branches: Vec<GitBranchEntry> = bookmark_output
|
||||
.lines()
|
||||
.filter(|l| !l.is_empty())
|
||||
.map(|name| {
|
||||
let name = name.to_string();
|
||||
let is_remote = name.contains('@');
|
||||
let is_current = !is_remote && current_bookmark.as_deref().is_some_and(|cb| cb == name);
|
||||
GitBranchEntry {
|
||||
name,
|
||||
current: is_current,
|
||||
remote: is_remote,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(GitBranchListData {
|
||||
current_branch: current_bookmark,
|
||||
repo_root: root,
|
||||
branches,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,971 @@
|
||||
pub(crate) mod checkpoint;
|
||||
pub(crate) mod checkpoint_store;
|
||||
pub mod file_state;
|
||||
pub mod git;
|
||||
pub mod jj;
|
||||
pub(crate) mod swap_policy;
|
||||
pub mod tool_config;
|
||||
use crate::capability::CapabilityMode;
|
||||
use crate::config::{MemoryConfig, SessionContextFactory};
|
||||
use crate::file_system::{AsyncFsWrapper, LocalFs};
|
||||
use crate::hub::{HubConfig, HubHandle};
|
||||
use crate::session::file_state::FileStateTracker;
|
||||
use kigi_computer_hub_mcp_adapter::McpBridgeHandle;
|
||||
use kigi_hunk_tracker::HunkTrackerHandle;
|
||||
use kigi_mcp::servers::McpState;
|
||||
use kigi_tool_protocol::ToolId;
|
||||
use kigi_tool_runtime::WorkspaceViewerContext;
|
||||
use kigi_tools::notification::types::{ToolNotification, ToolNotificationHandle};
|
||||
use kigi_tools::registry::types::{FinalizedToolset, ToolConfig, ToolServerConfig};
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
/// Minimal result types for git error reporting (duplicated from shell session/result).
|
||||
pub mod result {
|
||||
use serde::Serialize;
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ExtMethodError {
|
||||
pub code: i32,
|
||||
pub message: String,
|
||||
pub data: Option<serde_json::Value>,
|
||||
}
|
||||
impl ExtMethodError {
|
||||
pub fn with_data(code: i32, msg: String, data: impl Serialize) -> Self {
|
||||
Self {
|
||||
code,
|
||||
message: msg,
|
||||
data: serde_json::to_value(data).ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
#[derive(Debug)]
|
||||
pub struct ExtMethodResult<T> {
|
||||
pub result: Option<T>,
|
||||
pub error: Option<serde_json::Value>,
|
||||
}
|
||||
}
|
||||
/// Per-session state held in [`WorkspaceShared::sessions`].
|
||||
///
|
||||
/// The `effective_tool_config` baseline and the resolved `toolset` are
|
||||
/// kept under a single `RwLock` so a hot reload swaps both atomically.
|
||||
pub struct WorkspaceSession {
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) cwd: PathBuf,
|
||||
pub(crate) session_env: Arc<HashMap<String, String>>,
|
||||
pub(crate) capability_mode: CapabilityMode,
|
||||
pub(crate) depth: u32,
|
||||
pub(crate) fork_budget: u32,
|
||||
pub(crate) hunk_tracker: HunkTrackerHandle,
|
||||
pub(crate) file_state_tracker: Arc<FileStateTracker>,
|
||||
/// Per-turn hunk deltas keyed by `prompt_index`, captured at finalize and
|
||||
/// replayed on rewind (only when `workspace_rewind_hunks` is on). The live
|
||||
/// restore source; the durable on-disk mirror is the [`checkpoint_store`] field.
|
||||
///
|
||||
/// [`checkpoint_store`]: WorkspaceSession::checkpoint_store
|
||||
pub(crate) hunk_checkpoints:
|
||||
Arc<tokio::sync::Mutex<HashMap<usize, kigi_hunk_tracker::HunkTurnDelta>>>,
|
||||
/// Git domain of the per-prompt rewind checkpoints (HEAD + staged set).
|
||||
pub(crate) git_checkpoints: crate::session::git::GitCheckpointStore,
|
||||
/// Disk-backed durability mirror for finalized checkpoints, fronted by an
|
||||
/// in-memory cache. Gated by `workspace_rewind_durable` (off = no disk I/O,
|
||||
/// legacy path). Co-located in the working tree so the rootfs snapshot carries
|
||||
/// it and a restored session rehydrates the cache. Mirror only — restore stays in-process.
|
||||
pub(crate) checkpoint_store: crate::session::checkpoint_store::CheckpointStore,
|
||||
pub(crate) async_fs: AsyncFsWrapper,
|
||||
inner: RwLock<WorkspaceSessionInner>,
|
||||
/// Per-session lock that serialises `update_tool_config` calls.
|
||||
pub(crate) update_lock: tokio::sync::Mutex<()>,
|
||||
/// Per-session MCP state (owned clients, etc.).
|
||||
pub(crate) mcp_state: Arc<tokio::sync::Mutex<McpState>>,
|
||||
/// MCP bridges kept alive for the session lifetime.
|
||||
pub(crate) mcp_bridges: tokio::sync::Mutex<Vec<McpBridgeHandle>>,
|
||||
/// Qualified tool IDs registered on the server for this session's MCP tools.
|
||||
pub(crate) mcp_tool_ids: tokio::sync::Mutex<Vec<ToolId>>,
|
||||
/// Per-user feature-flag bag resolved at session-bind time, frozen for
|
||||
/// the session lifetime. `None` → tools use their safe defaults.
|
||||
pub(crate) viewer_ctx: Option<WorkspaceViewerContext>,
|
||||
/// Auto-approve (YOLO) state. Seeded from `session.bind` metadata,
|
||||
/// refreshed by each before-turn hook.
|
||||
pub(crate) yolo_mode: std::sync::atomic::AtomicBool,
|
||||
/// Session-lifetime terminal backend (background-task registry +
|
||||
/// persistent shell). Created once at session construction; every toolset
|
||||
/// re-resolve reuses it, so background tasks and shell state survive
|
||||
/// toolset swaps. Its child processes die only via `kill_task`,
|
||||
/// [`Self::shutdown_terminal_backend`] (`drop_session`/evict), or process
|
||||
/// exit.
|
||||
///
|
||||
/// Local-mode exception: `bind_local_session` installs an externally
|
||||
/// built toolset via plain [`Self::replace`], so that toolset's
|
||||
/// `Terminal` resource is the shell's own backend while this one sits
|
||||
/// idle as the sole safe teardown target. Never adopt an externally
|
||||
/// owned backend into this field (drop/evict would SIGKILL a backend
|
||||
/// shared with the shell) and never query this field for the live task
|
||||
/// table — the toolset's `Terminal` resource is the source of truth.
|
||||
terminal_backend: crate::config::SessionTerminalBackend,
|
||||
/// Canonical JSON of the explicit `session.bind` toolset this session was
|
||||
/// created (or last rebound) with. `None` when the session was resolved
|
||||
/// from the workspace default (no explicit toolset in the bind metadata).
|
||||
/// Lets a rebind detect a config change and re-resolve instead of silently
|
||||
/// reusing a stale toolset (e.g. a session created by a metadata-less
|
||||
/// hub revive bind that a config-carrying client rebind must correct).
|
||||
bind_tool_config_fingerprint: std::sync::Mutex<Option<serde_json::Value>>,
|
||||
/// The last snapshot-driven rebuild failed and kept a stale toolset;
|
||||
/// cleared by any successful install. While set, an identical-config
|
||||
/// re-apply (update RPC or owner rebind) heals instead of reusing.
|
||||
stale_resolve: std::sync::atomic::AtomicBool,
|
||||
/// Whether this session forwards `BackgroundTaskCompleted` system notifications.
|
||||
#[allow(dead_code)]
|
||||
system_notifications: bool,
|
||||
/// Per-session notification sender, re-applied across toolset re-resolves.
|
||||
system_notify_handle: Option<ToolNotificationHandle>,
|
||||
/// Receiver paired with `system_notify_handle`, taken once by the forwarder.
|
||||
#[allow(dead_code)]
|
||||
pending_notif_rx:
|
||||
tokio::sync::Mutex<Option<tokio::sync::mpsc::UnboundedReceiver<ToolNotification>>>,
|
||||
/// Spawned forwarder handle; aborted on teardown. Sync mutex so the sync
|
||||
/// teardown path can abort without an await.
|
||||
system_notify_forwarder: std::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
|
||||
}
|
||||
struct WorkspaceSessionInner {
|
||||
effective_tool_config: Arc<ToolServerConfig>,
|
||||
toolset: Arc<FinalizedToolset>,
|
||||
}
|
||||
impl std::fmt::Debug for WorkspaceSession {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("WorkspaceSession")
|
||||
.field("session_id", &self.session_id)
|
||||
.field("cwd", &self.cwd)
|
||||
.field("capability_mode", &self.capability_mode)
|
||||
.field("depth", &self.depth)
|
||||
.field("fork_budget", &self.fork_budget)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
impl WorkspaceSession {
|
||||
pub(crate) fn new(
|
||||
session_id: String,
|
||||
cwd: PathBuf,
|
||||
session_env: Arc<HashMap<String, String>>,
|
||||
capability_mode: CapabilityMode,
|
||||
depth: u32,
|
||||
fork_budget: u32,
|
||||
effective_tool_config: Arc<ToolServerConfig>,
|
||||
toolset: Arc<FinalizedToolset>,
|
||||
terminal_backend: crate::config::SessionTerminalBackend,
|
||||
hunk_tracker: HunkTrackerHandle,
|
||||
viewer_ctx: Option<WorkspaceViewerContext>,
|
||||
#[allow(dead_code)] system_notifications: bool,
|
||||
system_notify_channel: Option<(
|
||||
ToolNotificationHandle,
|
||||
tokio::sync::mpsc::UnboundedReceiver<ToolNotification>,
|
||||
)>,
|
||||
) -> Self {
|
||||
let (system_notify_handle, pending_notif_rx) = match system_notify_channel {
|
||||
Some((handle, rx)) => (Some(handle), Some(rx)),
|
||||
None => (None, None),
|
||||
};
|
||||
let async_fs = AsyncFsWrapper::new(Arc::new(LocalFs::new(cwd.clone())));
|
||||
let file_state_tracker = Arc::new(FileStateTracker::new());
|
||||
let checkpoint_store =
|
||||
crate::session::checkpoint_store::CheckpointStore::new(&cwd, &session_id);
|
||||
Self {
|
||||
session_id,
|
||||
cwd,
|
||||
session_env,
|
||||
capability_mode,
|
||||
depth,
|
||||
fork_budget,
|
||||
hunk_tracker,
|
||||
file_state_tracker,
|
||||
hunk_checkpoints: Arc::new(tokio::sync::Mutex::new(HashMap::new())),
|
||||
git_checkpoints: crate::session::git::GitCheckpointStore::new(),
|
||||
checkpoint_store,
|
||||
async_fs,
|
||||
inner: RwLock::new(WorkspaceSessionInner {
|
||||
effective_tool_config,
|
||||
toolset,
|
||||
}),
|
||||
terminal_backend,
|
||||
update_lock: tokio::sync::Mutex::new(()),
|
||||
bind_tool_config_fingerprint: std::sync::Mutex::new(None),
|
||||
stale_resolve: std::sync::atomic::AtomicBool::new(false),
|
||||
mcp_state: Arc::new(tokio::sync::Mutex::new(McpState::new(vec![]))),
|
||||
mcp_bridges: tokio::sync::Mutex::new(Vec::new()),
|
||||
mcp_tool_ids: tokio::sync::Mutex::new(Vec::new()),
|
||||
viewer_ctx,
|
||||
yolo_mode: std::sync::atomic::AtomicBool::new(false),
|
||||
system_notifications,
|
||||
system_notify_handle,
|
||||
#[allow(dead_code)]
|
||||
pending_notif_rx: tokio::sync::Mutex::new(pending_notif_rx),
|
||||
system_notify_forwarder: std::sync::Mutex::new(None),
|
||||
}
|
||||
}
|
||||
/// Whether this session opted into `BackgroundTaskCompleted` system
|
||||
/// notifications.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn system_notifications(&self) -> bool {
|
||||
self.system_notifications
|
||||
}
|
||||
/// The per-session notification sender, re-applied on every toolset
|
||||
/// re-resolve so notifications keep flowing to the forwarder's channel.
|
||||
pub(crate) fn system_notify_handle(&self) -> Option<ToolNotificationHandle> {
|
||||
self.system_notify_handle.clone()
|
||||
}
|
||||
/// Take the stashed notification receiver (once) for the per-session
|
||||
/// forwarder to own.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) async fn take_pending_notif_rx(
|
||||
&self,
|
||||
) -> Option<tokio::sync::mpsc::UnboundedReceiver<ToolNotification>> {
|
||||
self.pending_notif_rx.lock().await.take()
|
||||
}
|
||||
/// Store the spawned forwarder handle, aborting any previous one.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn set_system_notify_forwarder(&self, handle: tokio::task::JoinHandle<()>) {
|
||||
let mut guard = self
|
||||
.system_notify_forwarder
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if let Some(old) = guard.replace(handle) {
|
||||
old.abort();
|
||||
}
|
||||
}
|
||||
/// Abort the per-session system-notify forwarder on teardown.
|
||||
pub(crate) fn abort_system_notify_forwarder(&self) {
|
||||
if let Some(handle) = self
|
||||
.system_notify_forwarder
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner())
|
||||
.take()
|
||||
{
|
||||
handle.abort();
|
||||
}
|
||||
}
|
||||
pub fn session_id(&self) -> &str {
|
||||
&self.session_id
|
||||
}
|
||||
pub fn cwd(&self) -> &Path {
|
||||
&self.cwd
|
||||
}
|
||||
pub fn session_env(&self) -> &Arc<HashMap<String, String>> {
|
||||
&self.session_env
|
||||
}
|
||||
pub fn capability_mode(&self) -> CapabilityMode {
|
||||
self.capability_mode
|
||||
}
|
||||
pub fn depth(&self) -> u32 {
|
||||
self.depth
|
||||
}
|
||||
pub fn fork_budget(&self) -> u32 {
|
||||
self.fork_budget
|
||||
}
|
||||
pub fn hunk_tracker(&self) -> &HunkTrackerHandle {
|
||||
&self.hunk_tracker
|
||||
}
|
||||
/// Per-user feature-flag bag resolved at session-bind time.
|
||||
pub fn viewer_ctx(&self) -> Option<&WorkspaceViewerContext> {
|
||||
self.viewer_ctx.as_ref()
|
||||
}
|
||||
pub fn yolo_mode(&self) -> bool {
|
||||
self.yolo_mode.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
pub fn set_yolo_mode(&self, enabled: bool) {
|
||||
self.yolo_mode
|
||||
.store(enabled, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
pub fn file_state_tracker(&self) -> &Arc<FileStateTracker> {
|
||||
&self.file_state_tracker
|
||||
}
|
||||
/// Git domain of the per-prompt rewind checkpoints.
|
||||
pub fn git_checkpoints(&self) -> &crate::session::git::GitCheckpointStore {
|
||||
&self.git_checkpoints
|
||||
}
|
||||
pub fn async_fs(&self) -> &AsyncFsWrapper {
|
||||
&self.async_fs
|
||||
}
|
||||
/// The session-lifetime terminal backend, injected into every toolset
|
||||
/// re-resolve so background tasks and shell state survive swaps.
|
||||
pub(crate) fn terminal_backend(
|
||||
&self,
|
||||
) -> &Arc<dyn kigi_tools::computer::types::TerminalBackend> {
|
||||
self.terminal_backend.backend()
|
||||
}
|
||||
/// Explicitly shut the session's terminal backend down (kills all of its
|
||||
/// child process groups and stops its actor). Called by
|
||||
/// `drop_session`/evict so task teardown does not depend on when the last
|
||||
/// toolset `Arc` drops.
|
||||
pub(crate) fn shutdown_terminal_backend(&self) {
|
||||
self.terminal_backend.shutdown();
|
||||
}
|
||||
/// Return the current resolved toolset (snapshot).
|
||||
pub fn toolset(&self) -> Arc<FinalizedToolset> {
|
||||
self.inner.read().toolset.clone()
|
||||
}
|
||||
/// Whether the current toolset's `Terminal` resource is the session-owned
|
||||
/// backend. `false` means the toolset is externally owned — the local
|
||||
/// (shell) mode shape installed by `bind_local_session`, where the shell's
|
||||
/// own backend rides the toolset and the session-owned backend is an idle
|
||||
/// decoy. Rebuild paths must skip such sessions: finalizing around
|
||||
/// [`Self::terminal_backend`] would swap the decoy into the toolset and
|
||||
/// detach tools from the shell's live task table. A toolset with no
|
||||
/// `Terminal` resource counts as session-owned (nothing to detach).
|
||||
pub(crate) async fn toolset_terminal_is_session_owned(&self) -> bool {
|
||||
let toolset = self.toolset();
|
||||
let res = toolset.resources.lock().await;
|
||||
match res.get::<kigi_tools::types::resources::Terminal>() {
|
||||
Some(t) => Arc::ptr_eq(&t.0, self.terminal_backend()),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
/// Return the current effective tool config baseline.
|
||||
pub fn effective_tool_config(&self) -> Arc<ToolServerConfig> {
|
||||
self.inner.read().effective_tool_config.clone()
|
||||
}
|
||||
/// Whether `fingerprint` matches the explicit bind toolset this session
|
||||
/// was created (or last rebound) with. `None` = default resolution.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn bind_tool_config_matches(&self, fingerprint: Option<&serde_json::Value>) -> bool {
|
||||
let guard = self
|
||||
.bind_tool_config_fingerprint
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
guard.as_ref() == fingerprint
|
||||
}
|
||||
/// Whether the last snapshot-driven rebuild failed and left the live
|
||||
/// toolset stale w.r.t. the current MCP/hub snapshots.
|
||||
pub(crate) fn stale_resolve(&self) -> bool {
|
||||
self.stale_resolve
|
||||
.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// Mark the live toolset stale: a snapshot-driven rebuild failed and the
|
||||
/// previous toolset was kept.
|
||||
pub(crate) fn mark_stale_resolve(&self) {
|
||||
self.stale_resolve
|
||||
.store(true, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// Clear the stale marker: a freshly resolved toolset was installed.
|
||||
pub(crate) fn clear_stale_resolve(&self) {
|
||||
self.stale_resolve
|
||||
.store(false, std::sync::atomic::Ordering::Relaxed);
|
||||
}
|
||||
/// Record the explicit bind toolset (or `None` for a default resolution)
|
||||
/// this session's toolset was resolved from.
|
||||
///
|
||||
/// Unconditional: callers must pair this with the toolset swap under the
|
||||
/// session's `update_lock` so fingerprint and live toolset cannot diverge.
|
||||
pub(crate) fn set_bind_tool_config_fingerprint(&self, fingerprint: Option<serde_json::Value>) {
|
||||
*self
|
||||
.bind_tool_config_fingerprint
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner()) = fingerprint;
|
||||
}
|
||||
/// [`Self::set_bind_tool_config_fingerprint`], but only when no
|
||||
/// fingerprint was recorded yet. The `session.bind` create path uses this
|
||||
/// (outside `update_lock`): a concurrent rebind can race between session
|
||||
/// insertion and this call, swap in its own toolset, and record its
|
||||
/// fingerprint under the lock — which this set-if-unset then must not
|
||||
/// clobber, or the stored fingerprint would describe a toolset that is no
|
||||
/// longer live.
|
||||
pub(crate) fn set_bind_tool_config_fingerprint_if_unset(
|
||||
&self,
|
||||
fingerprint: Option<serde_json::Value>,
|
||||
) {
|
||||
let mut guard = self
|
||||
.bind_tool_config_fingerprint
|
||||
.lock()
|
||||
.unwrap_or_else(|e| e.into_inner());
|
||||
if guard.is_none() {
|
||||
*guard = fingerprint;
|
||||
}
|
||||
}
|
||||
/// Replace both the baseline config and the resolved toolset atomically.
|
||||
///
|
||||
/// TOOL-STATE CAVEAT: the outgoing toolset is not flushed here, so an
|
||||
/// in-process rebuild can drop up to one debounce window (≤500 ms) of
|
||||
/// unpersisted state. Intentionally not "fixed" with a flush-before-rebuild:
|
||||
/// tool `call()` does not hold `update_lock`, so a concurrent call would
|
||||
/// still race. Restart/snapshot scenarios are unaffected.
|
||||
pub(crate) fn replace(
|
||||
&self,
|
||||
new_effective_tool_config: Arc<ToolServerConfig>,
|
||||
new_toolset: Arc<FinalizedToolset>,
|
||||
) {
|
||||
let mut w = self.inner.write();
|
||||
w.effective_tool_config = new_effective_tool_config;
|
||||
w.toolset = new_toolset;
|
||||
}
|
||||
/// [`Self::replace`], but first carries the session's
|
||||
/// `BrowserServiceHandle` from the old toolset into the new one.
|
||||
/// Rebuilds produce a fresh `FinalizedToolset`, so the browser service
|
||||
/// seeded post-finalize (`finalize_session_setup`) must be carried
|
||||
/// forward or the session's live browser state is lost.
|
||||
///
|
||||
/// Without the optional browser backend there is no browser service to carry;
|
||||
/// only the terminal-orphan diagnostic runs before the swap.
|
||||
///
|
||||
/// Callers must hold the session's `update_lock` so the read-then-swap
|
||||
/// cannot interleave with another rebuild.
|
||||
pub(crate) async fn replace_carrying_browser_service(
|
||||
&self,
|
||||
new_effective_tool_config: Arc<ToolServerConfig>,
|
||||
new_toolset: Arc<FinalizedToolset>,
|
||||
) {
|
||||
let old_toolset = self.toolset();
|
||||
let old_terminal = {
|
||||
let res = old_toolset.resources.lock().await;
|
||||
res.get::<kigi_tools::types::resources::Terminal>()
|
||||
.map(|t| t.0.clone())
|
||||
};
|
||||
if let Some(old_terminal) = old_terminal
|
||||
&& !Arc::ptr_eq(&old_terminal, self.terminal_backend())
|
||||
{
|
||||
crate::handle::WORKSPACE_TERMINAL_BACKEND_ORPHANED_TOTAL
|
||||
.with_label_values(&["swap"])
|
||||
.inc();
|
||||
tracing::error!(
|
||||
session_id = % self.session_id,
|
||||
"toolset swap: outgoing toolset's terminal backend is not the \
|
||||
session-owned one — its background tasks die with the old toolset"
|
||||
);
|
||||
}
|
||||
self.replace(new_effective_tool_config, new_toolset);
|
||||
}
|
||||
}
|
||||
/// Sink for delivering a workspace-originated ext-notification (method +
|
||||
/// params JSON) to the client. The shell installs the concrete delivery:
|
||||
/// the agent gateway in local mode, the server transport in proxy mode.
|
||||
pub type ClientExtSink = std::sync::Arc<dyn Fn(String, serde_json::Value) + Send + Sync>;
|
||||
/// Workspace-wide shared state.
|
||||
pub struct WorkspaceShared {
|
||||
pub(crate) default_tool_config: ToolServerConfig,
|
||||
/// Require an explicit toolset on every `session.bind`; see
|
||||
/// [`crate::config::WorkspaceConfig::require_explicit_toolset`].
|
||||
pub(crate) require_explicit_toolset: bool,
|
||||
/// See [`crate::config::WorkspaceConfig::confine_fs_to_workspace_root`].
|
||||
/// Default `false`; enabled only for remote-sandbox workspace servers.
|
||||
pub(crate) confine_fs_to_workspace_root: bool,
|
||||
/// Workspace root directory. Independent of any session — stored
|
||||
/// here so it survives session creation/deletion.
|
||||
pub(crate) root_cwd: std::path::PathBuf,
|
||||
pub(crate) sessions: RwLock<HashMap<String, Arc<WorkspaceSession>>>,
|
||||
pub(crate) session_factory: Arc<dyn SessionContextFactory>,
|
||||
pub(crate) mcp_tools_snapshot: arc_swap::ArcSwap<Vec<ToolConfig>>,
|
||||
pub(crate) events: tokio::sync::broadcast::Sender<kigi_workspace_types::WorkspaceEvent>,
|
||||
pub(crate) respect_gitignore: bool,
|
||||
pub(crate) memory_config: Option<MemoryConfig>,
|
||||
pub(crate) hook_registry: Arc<parking_lot::RwLock<kigi_hooks::discovery::HookRegistry>>,
|
||||
pub(crate) hook_load_errors: Vec<kigi_hooks::error::HookError>,
|
||||
/// Skill discovery configuration (extra paths, ignore prefixes).
|
||||
/// Used by `discover_skills` via the `discovery` module.
|
||||
pub(crate) skills_config: crate::discovery::SkillsConfig,
|
||||
/// Plugin discovery configuration (CLI dirs, config paths,
|
||||
/// disabled/enabled lists). Used by `discover_plugins` via the
|
||||
/// `discovery` module.
|
||||
pub(crate) plugin_discovery_config: crate::discovery::PluginDiscoveryConfig,
|
||||
/// Live server connection handle. `None` until
|
||||
/// [`WorkspaceHandle::connect_hub`](crate::handle::WorkspaceHandle::connect_hub)
|
||||
/// is called (or if no [`HubConfig`] was provided).
|
||||
///
|
||||
/// Uses `tokio::sync::Mutex` so the guard can be held across the
|
||||
/// async `HubHandle::connect()` call, preventing TOCTOU races.
|
||||
pub(crate) hub_handle: tokio::sync::Mutex<Option<HubHandle>>,
|
||||
/// Remote-origin tool configs (consumer direction), updated by the
|
||||
/// notification listener.
|
||||
pub(crate) hub_tools_snapshot: arc_swap::ArcSwap<Vec<ToolConfig>>,
|
||||
/// Server config stashed at construction time for deferred connect.
|
||||
pub(crate) hub_config: Option<HubConfig>,
|
||||
/// Auth provider for xAI service calls.
|
||||
pub(crate) auth_provider: Option<kigi_computer_hub_sdk::SharedAuthProvider>,
|
||||
/// Connection-level sink feeding the `ActivityTracker` (drained by
|
||||
/// `run_activity_feed`); not a network egress. `None` until `connect_hub()` sets it.
|
||||
pub(crate) activity_notify_handle:
|
||||
arc_swap::ArcSwap<Option<kigi_tools::notification::types::ToolNotificationHandle>>,
|
||||
/// Sink for workspace-originated ext-notifications to the client (e.g.
|
||||
/// `x.ai/search/fuzzy/status`). Mode-agnostic: the shell wires it to the
|
||||
/// agent gateway in local mode, and to the server in proxy mode. `None` until
|
||||
/// set via [`WorkspaceHandle::set_client_ext_sink`](crate::handle::WorkspaceHandle::set_client_ext_sink).
|
||||
pub(crate) client_ext_sink: arc_swap::ArcSwap<Option<ClientExtSink>>,
|
||||
pub(crate) local_registry: kigi_computer_hub_sdk::LocalRegistry,
|
||||
pub(crate) activity_tracker: std::sync::Arc<crate::activity::ActivityTracker>,
|
||||
/// Runtime-tunable timing/threshold config for the tool server.
|
||||
/// Read by the status publisher task and at shutdown.
|
||||
pub(crate) status_config: crate::status_config::StatusConfig,
|
||||
/// Opaque metadata for the tool server registration, forwarded verbatim to
|
||||
/// the server; structured access goes through
|
||||
/// [`WorkspaceShared::server_metadata_typed`].
|
||||
pub(crate) server_metadata: Option<serde_json::Value>,
|
||||
/// Workspace-level fuzzy search manager. Separate from the shell's
|
||||
/// own `FuzzySearchManager` — this instance serves remote (hub/RPC)
|
||||
/// clients.
|
||||
pub(crate) fuzzy_searches:
|
||||
std::sync::Arc<tokio::sync::Mutex<crate::file_system::FuzzySearchManager>>,
|
||||
pub(crate) lsp: Option<std::sync::Arc<dyn kigi_tools::implementations::lsp::LspBackend>>,
|
||||
pub(crate) codebase_indexes:
|
||||
std::sync::Arc<parking_lot::Mutex<crate::file_system::CodebaseIndexManager>>,
|
||||
/// Finalize the FS rewind checkpoint on non-`Completed` turn-end outcomes
|
||||
/// (from `KIGI_WORKSPACE_REWIND_ALL_OUTCOMES`, default off).
|
||||
pub(crate) workspace_rewind_all_outcomes: bool,
|
||||
/// Resolved `$KIGI_WORKSPACE_HOME` — the workspace-owned on-disk state root
|
||||
/// (`<kigi_home>/workspace` by default).
|
||||
pub(crate) workspace_home: std::path::PathBuf,
|
||||
/// Whether per-session `events.jsonl` recording is enabled
|
||||
/// (`KIGI_WORKSPACE_EVENTS_ENABLED=true`). When `false`, every
|
||||
/// [`session_event_writer`](Self::session_event_writer) hands back an
|
||||
/// [`EventWriter::noop()`](kigi_file_utils::events::EventWriter::noop) and
|
||||
/// no session directory or `events.jsonl` is ever created — the legacy
|
||||
/// behaviour, preserved bit-for-bit.
|
||||
pub(crate) events_enabled: bool,
|
||||
/// Per-session `events.jsonl` writers, keyed by `session_id`. Lazily opened
|
||||
/// on first use under `workspace_home/sessions/{session_id}/`. Held in an
|
||||
/// `Arc` shared with [`ActivityTracker`](crate::activity::ActivityTracker) so
|
||||
/// `Tool*` events resolve the right writer without a back-reference to
|
||||
/// `WorkspaceShared`. Stays empty whenever `events_enabled` is `false`.
|
||||
pub(crate) session_event_writers:
|
||||
Arc<dashmap::DashMap<String, kigi_file_utils::events::EventWriter>>,
|
||||
/// `(path, size, mtime_ms) → sha256` memo for the client-facing
|
||||
/// `workspace.client_fs_*` ops, so unchanged files hash once per
|
||||
/// workspace instead of per stat/read.
|
||||
/// Test-only seam: runs after the toolset re-resolve returns and before
|
||||
/// the post-resolve turn re-check / install in
|
||||
/// `resolve_and_swap_session_toolset_locked`, so tests can interleave a
|
||||
/// turn start inside the check→install window deterministically.
|
||||
#[cfg(test)]
|
||||
pub(crate) post_resolve_test_hook: parking_lot::Mutex<Option<Box<dyn Fn() + Send + Sync>>>,
|
||||
pub(crate) client_fs_hash_memo: crate::file_system::client_fs::FileHashMemo,
|
||||
}
|
||||
impl WorkspaceShared {
|
||||
/// Workspace root directory.
|
||||
pub fn root_cwd(&self) -> &std::path::Path {
|
||||
&self.root_cwd
|
||||
}
|
||||
/// Resolved `$KIGI_WORKSPACE_HOME` — the workspace-owned on-disk state root.
|
||||
pub fn workspace_home(&self) -> &std::path::Path {
|
||||
&self.workspace_home
|
||||
}
|
||||
/// Return the per-session `events.jsonl` writer for `session_id`, opening
|
||||
/// (and caching) it on first use under
|
||||
/// `workspace_home/sessions/{session_id}/`.
|
||||
///
|
||||
/// When `events_enabled` is `false` this returns
|
||||
/// [`EventWriter::noop()`](kigi_file_utils::events::EventWriter::noop)
|
||||
/// WITHOUT touching the cache or the filesystem, so the flag-off path stays
|
||||
/// byte-for-byte identical to the legacy behaviour. The returned handle is
|
||||
/// `Clone + Send + Sync`; callers emit through it directly.
|
||||
pub(crate) fn session_event_writer(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> kigi_file_utils::events::EventWriter {
|
||||
get_or_open_session_writer(
|
||||
self.events_enabled,
|
||||
&self.session_event_writers,
|
||||
&self.workspace_home,
|
||||
session_id,
|
||||
)
|
||||
}
|
||||
/// Like `session_event_writer` but never opens a new writer. Returns `None`
|
||||
/// if the session was never opened or already evicted.
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn session_event_writer_cached(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Option<kigi_file_utils::events::EventWriter> {
|
||||
if !self.events_enabled {
|
||||
return None;
|
||||
}
|
||||
self.session_event_writers
|
||||
.get(session_id)
|
||||
.map(|w| w.value().clone())
|
||||
}
|
||||
/// Stable hub server id (`--server-id`), if a hub config is present.
|
||||
pub(crate) fn server_id(&self) -> Option<String> {
|
||||
self.hub_config.as_ref().and_then(|c| c.server_id.clone())
|
||||
}
|
||||
/// Auth provider used for xAI service calls.
|
||||
pub fn auth_provider(&self) -> Option<&kigi_computer_hub_sdk::SharedAuthProvider> {
|
||||
self.auth_provider.as_ref()
|
||||
}
|
||||
/// Parse the opaque [`server_metadata`](Self::server_metadata) blob into
|
||||
/// the typed subset the workspace needs (currently `sandbox_id`);
|
||||
/// unknown/missing fields default cleanly. A present-but-malformed blob is
|
||||
/// logged and salvaged field-by-field (a bad sibling field must not
|
||||
/// silently drop `sandbox_id` from every environment artifact).
|
||||
pub(crate) fn server_metadata_typed(&self) -> crate::config::WorkspaceServerMetadata {
|
||||
let Some(v) = self.server_metadata.as_ref() else {
|
||||
return Default::default();
|
||||
};
|
||||
match serde_json::from_value(v.clone()) {
|
||||
Ok(typed) => typed,
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = % e,
|
||||
"workspace: malformed server_metadata; salvaging sandbox_id field-wise"
|
||||
);
|
||||
crate::config::WorkspaceServerMetadata {
|
||||
sandbox_id: v
|
||||
.get("sandbox_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
pub fn default_tool_config(&self) -> &ToolServerConfig {
|
||||
&self.default_tool_config
|
||||
}
|
||||
pub fn respect_gitignore(&self) -> bool {
|
||||
self.respect_gitignore
|
||||
}
|
||||
pub fn memory_config(&self) -> Option<&MemoryConfig> {
|
||||
self.memory_config.as_ref()
|
||||
}
|
||||
pub fn mcp_tools_snapshot(&self) -> Arc<Vec<ToolConfig>> {
|
||||
self.mcp_tools_snapshot.load_full()
|
||||
}
|
||||
/// The tool server, if a server connection is active.
|
||||
///
|
||||
/// Returns a clone of the [`ToolServer`](kigi_computer_hub_sdk::ToolServer)
|
||||
/// which is cheap (`Arc` bump). Uses `try_lock` to avoid blocking
|
||||
/// on the async mutex from synchronous contexts. Returns `None` if
|
||||
/// the lock is held (i.e. a `connect_hub` call is in progress).
|
||||
pub fn hub_server(&self) -> Option<kigi_computer_hub_sdk::ToolServer> {
|
||||
self.hub_handle
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|guard| guard.as_ref().map(|h| h.server.clone()))
|
||||
}
|
||||
/// Like [`Self::hub_server`] but awaits the `hub_handle` lock instead of
|
||||
/// returning `None` on contention. Use from async contexts that must not
|
||||
/// confuse a transient `connect_hub` lock-hold with "no hub connected";
|
||||
/// `None` means no hub is connected.
|
||||
pub async fn hub_server_blocking(&self) -> Option<kigi_computer_hub_sdk::ToolServer> {
|
||||
self.hub_handle
|
||||
.lock()
|
||||
.await
|
||||
.as_ref()
|
||||
.map(|h| h.server.clone())
|
||||
}
|
||||
/// Current snapshot of hub-provided tool configs (consumer direction).
|
||||
pub fn hub_tools_snapshot(&self) -> Arc<Vec<ToolConfig>> {
|
||||
self.hub_tools_snapshot.load_full()
|
||||
}
|
||||
/// Compose a session's tool `ctx.notification_handle` as a fan-out of the
|
||||
/// connection-level activity feed (internal tracker accounting) and the
|
||||
/// opt-in per-session `system.notify` sender. Only the `system.notify` leg
|
||||
/// reaches a client, so the fan-out can't double-wake. `None` → factory default.
|
||||
pub(crate) fn compose_session_notification_handle(
|
||||
&self,
|
||||
system_notify_handle: Option<ToolNotificationHandle>,
|
||||
) -> Option<ToolNotificationHandle> {
|
||||
let activity = self.activity_notify_handle.load_full().as_ref().clone();
|
||||
match (activity, system_notify_handle) {
|
||||
(None, None) => None,
|
||||
(Some(a), None) => Some(a),
|
||||
(None, Some(s)) => Some(s),
|
||||
(Some(a), Some(s)) => Some(ToolNotificationHandle::tee(vec![a, s])),
|
||||
}
|
||||
}
|
||||
pub fn activity_tracker(&self) -> &std::sync::Arc<crate::activity::ActivityTracker> {
|
||||
&self.activity_tracker
|
||||
}
|
||||
pub fn fuzzy_searches(
|
||||
&self,
|
||||
) -> &std::sync::Arc<tokio::sync::Mutex<crate::file_system::FuzzySearchManager>> {
|
||||
&self.fuzzy_searches
|
||||
}
|
||||
pub fn subscribe_events(
|
||||
&self,
|
||||
) -> tokio::sync::broadcast::Receiver<kigi_workspace_types::WorkspaceEvent> {
|
||||
self.events.subscribe()
|
||||
}
|
||||
pub fn codebase_indexes(
|
||||
&self,
|
||||
) -> &std::sync::Arc<parking_lot::Mutex<crate::file_system::CodebaseIndexManager>> {
|
||||
&self.codebase_indexes
|
||||
}
|
||||
/// Skill discovery configuration (extra paths and ignore
|
||||
/// prefixes). Used by the `discovery` module when the channel's
|
||||
/// `discover_skills` method is called.
|
||||
pub fn skills_config(&self) -> &crate::discovery::SkillsConfig {
|
||||
&self.skills_config
|
||||
}
|
||||
/// Plugin discovery configuration (CLI dirs, config paths,
|
||||
/// disabled/enabled lists). Used by the `discovery` module when
|
||||
/// the channel's `discover_plugins` method is called.
|
||||
pub fn plugin_discovery_config(&self) -> &crate::discovery::PluginDiscoveryConfig {
|
||||
&self.plugin_discovery_config
|
||||
}
|
||||
/// Re-resolve every session's toolset and emit `ToolsChanged` events.
|
||||
///
|
||||
/// Shared implementation used by `on_mcp_snapshot_changed`,
|
||||
/// `on_hub_tools_changed`, and the server notification listener.
|
||||
///
|
||||
/// When `use_async_lock` is true, uses `.lock().await` on each
|
||||
/// session's `update_lock` (appropriate for spawned async tasks
|
||||
/// where notifications must not be silently lost). When false,
|
||||
/// uses `try_lock()` and skips sessions whose lock is held.
|
||||
pub(crate) async fn re_resolve_all_sessions(
|
||||
self: &Arc<Self>,
|
||||
source: &str,
|
||||
use_async_lock: bool,
|
||||
) -> usize {
|
||||
use crate::session::swap_policy::{
|
||||
SessionSnapshot, SwapAction, SwapDecision, SwapPolicy, SwapTrigger,
|
||||
record_swap_decision,
|
||||
};
|
||||
let trigger = SwapTrigger::from_rebuild_source(source);
|
||||
let mcp_snap = self.mcp_tools_snapshot.load_full();
|
||||
let hub_snap = self.hub_tools_snapshot.load_full();
|
||||
let sessions: Vec<(String, Arc<WorkspaceSession>)> = {
|
||||
let guard = self.sessions.read();
|
||||
guard
|
||||
.iter()
|
||||
.map(|(id, s)| (id.clone(), s.clone()))
|
||||
.collect()
|
||||
};
|
||||
let mut rebuilt = 0usize;
|
||||
for (sid, session) in sessions {
|
||||
let guard = if use_async_lock {
|
||||
session.update_lock.lock().await
|
||||
} else {
|
||||
match session.update_lock.try_lock() {
|
||||
Ok(g) => g,
|
||||
Err(_) => {
|
||||
tracing::trace!(
|
||||
session = % sid, source = % source,
|
||||
"skipping rebuild: session update_lock held"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
};
|
||||
let snapshot =
|
||||
SessionSnapshot::capture_for_rebuild(&session, &self.activity_tracker).await;
|
||||
match SwapPolicy::evaluate(&snapshot, trigger) {
|
||||
SwapDecision::Apply => {}
|
||||
SwapDecision::Skip(reason) => {
|
||||
record_swap_decision(
|
||||
&self.activity_tracker,
|
||||
trigger,
|
||||
&sid,
|
||||
SwapAction::Skipped(reason),
|
||||
);
|
||||
tracing::warn!(
|
||||
session = % sid, source = % source,
|
||||
"skipping rebuild: toolset terminal backend is externally \
|
||||
owned (local bind)"
|
||||
);
|
||||
drop(guard);
|
||||
continue;
|
||||
}
|
||||
decision @ (SwapDecision::Reuse | SwapDecision::Defer(_)) => {
|
||||
debug_assert!(
|
||||
false,
|
||||
"snapshot rebuild produced a non-rebuild decision: {decision:?}"
|
||||
);
|
||||
tracing::error!(
|
||||
session = % sid, source = % source, ? decision,
|
||||
"skipping rebuild: snapshot rebuild policy returned a \
|
||||
non-rebuild decision (policy regression)"
|
||||
);
|
||||
drop(guard);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let baseline = (*session.effective_tool_config()).clone();
|
||||
match crate::session::tool_config::resolve_session_toolset_rebuild(
|
||||
baseline,
|
||||
session.capability_mode(),
|
||||
&mcp_snap,
|
||||
&hub_snap,
|
||||
session.cwd().to_path_buf(),
|
||||
session.session_env().clone(),
|
||||
&sid,
|
||||
self.session_factory.as_ref(),
|
||||
Some(self.local_registry.clone()),
|
||||
self.lsp.clone(),
|
||||
session.viewer_ctx().cloned(),
|
||||
self.compose_session_notification_handle(session.system_notify_handle()),
|
||||
session.terminal_backend().clone(),
|
||||
) {
|
||||
Ok((effective, toolset)) => {
|
||||
session
|
||||
.replace_carrying_browser_service(Arc::new(effective), toolset)
|
||||
.await;
|
||||
session.clear_stale_resolve();
|
||||
record_swap_decision(
|
||||
&self.activity_tracker,
|
||||
trigger,
|
||||
&sid,
|
||||
SwapAction::Applied,
|
||||
);
|
||||
let _ = self
|
||||
.events
|
||||
.send(kigi_workspace_types::WorkspaceEvent::ToolsChanged {
|
||||
session_id: sid,
|
||||
});
|
||||
rebuilt += 1;
|
||||
}
|
||||
Err(e) => {
|
||||
session.mark_stale_resolve();
|
||||
record_swap_decision(
|
||||
&self.activity_tracker,
|
||||
trigger,
|
||||
&sid,
|
||||
SwapAction::ApplyFailed,
|
||||
);
|
||||
tracing::warn!(
|
||||
session = % sid, source = % source, error = % e,
|
||||
"snapshot rebuild failed for session"
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(guard);
|
||||
}
|
||||
rebuilt
|
||||
}
|
||||
}
|
||||
/// Core get-or-open logic for a session's `events.jsonl` writer, factored out of
|
||||
/// [`WorkspaceShared::session_event_writer`] so the `enabled` gate can be
|
||||
/// unit-tested without touching process environment.
|
||||
///
|
||||
/// - `enabled == false` → [`EventWriter::noop()`]; the `writers` map and the
|
||||
/// filesystem are left untouched (legacy behaviour preserved).
|
||||
/// - `enabled == true` → returns the cached writer for `session_id`, opening a
|
||||
/// fresh one (and creating `workspace_home/sessions/{session_id}/`) on first
|
||||
/// use. [`EventWriter::open`] uses `create(true).append(true)`, so a writer
|
||||
/// re-opened for the same directory after a workspace restart APPENDS to the
|
||||
/// existing `events.jsonl` rather than truncating it.
|
||||
pub(crate) fn get_or_open_session_writer(
|
||||
enabled: bool,
|
||||
writers: &dashmap::DashMap<String, kigi_file_utils::events::EventWriter>,
|
||||
workspace_home: &Path,
|
||||
session_id: &str,
|
||||
) -> kigi_file_utils::events::EventWriter {
|
||||
use kigi_file_utils::events::EventWriter;
|
||||
if !enabled {
|
||||
return EventWriter::noop();
|
||||
}
|
||||
if let Some(existing) = writers.get(session_id) {
|
||||
return existing.value().clone();
|
||||
}
|
||||
let dir = workspace_home.join("sessions").join(session_id);
|
||||
if let Err(e) = std::fs::create_dir_all(&dir) {
|
||||
tracing::warn!(
|
||||
session_id = % session_id, dir = % dir.display(), error = % e,
|
||||
"failed to create session event dir; events.jsonl disabled for this session (will retry on next use)"
|
||||
);
|
||||
return EventWriter::noop();
|
||||
}
|
||||
let writer = EventWriter::open(&dir);
|
||||
writers
|
||||
.entry(session_id.to_owned())
|
||||
.or_insert(writer)
|
||||
.clone()
|
||||
}
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::get_or_open_session_writer;
|
||||
use dashmap::DashMap;
|
||||
use kigi_file_utils::events::{Event, EventWriter};
|
||||
fn count_lines(path: &std::path::Path) -> usize {
|
||||
std::fs::read_to_string(path)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.lines()
|
||||
.count()
|
||||
}
|
||||
#[test]
|
||||
fn flag_off_returns_noop_and_creates_nothing() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let writers: DashMap<String, EventWriter> = DashMap::new();
|
||||
let w = get_or_open_session_writer(false, &writers, home.path(), "sess-a");
|
||||
w.emit(Event::ToolStarted {
|
||||
tool_name: "read_file".into(),
|
||||
});
|
||||
assert!(writers.is_empty(), "flag-off must not cache a writer");
|
||||
let sess_dir = home.path().join("sessions").join("sess-a");
|
||||
assert!(
|
||||
!sess_dir.exists(),
|
||||
"flag-off must not create the session dir or events.jsonl"
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn flag_on_opens_and_writes_real_content() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let writers: DashMap<String, EventWriter> = DashMap::new();
|
||||
let w = get_or_open_session_writer(true, &writers, home.path(), "sess-b");
|
||||
w.emit(Event::YoloToggled { enabled: true });
|
||||
assert_eq!(writers.len(), 1, "flag-on must cache the opened writer");
|
||||
let path = home
|
||||
.path()
|
||||
.join("sessions")
|
||||
.join("sess-b")
|
||||
.join("events.jsonl");
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let v: serde_json::Value = serde_json::from_str(text.trim()).unwrap();
|
||||
assert_eq!(v["type"], "yolo_toggled");
|
||||
assert_eq!(v["enabled"], true);
|
||||
assert!(v["ts"].as_str().is_some());
|
||||
}
|
||||
#[test]
|
||||
fn second_call_reuses_one_cache_entry() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let writers: DashMap<String, EventWriter> = DashMap::new();
|
||||
get_or_open_session_writer(true, &writers, home.path(), "sess-c").emit(
|
||||
Event::ToolStarted {
|
||||
tool_name: "a".into(),
|
||||
},
|
||||
);
|
||||
get_or_open_session_writer(true, &writers, home.path(), "sess-c").emit(
|
||||
Event::ToolStarted {
|
||||
tool_name: "b".into(),
|
||||
},
|
||||
);
|
||||
assert_eq!(writers.len(), 1, "same session must reuse one cache entry");
|
||||
let path = home
|
||||
.path()
|
||||
.join("sessions")
|
||||
.join("sess-c")
|
||||
.join("events.jsonl");
|
||||
assert_eq!(count_lines(&path), 2);
|
||||
}
|
||||
#[test]
|
||||
fn reopen_after_restart_appends() {
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
{
|
||||
let writers: DashMap<String, EventWriter> = DashMap::new();
|
||||
get_or_open_session_writer(true, &writers, home.path(), "sess-d").emit(
|
||||
Event::ToolStarted {
|
||||
tool_name: "before-restart".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let writers: DashMap<String, EventWriter> = DashMap::new();
|
||||
get_or_open_session_writer(true, &writers, home.path(), "sess-d").emit(
|
||||
Event::ToolStarted {
|
||||
tool_name: "after-restart".into(),
|
||||
},
|
||||
);
|
||||
}
|
||||
let path = home
|
||||
.path()
|
||||
.join("sessions")
|
||||
.join("sess-d")
|
||||
.join("events.jsonl");
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
let lines: Vec<&str> = text.trim().lines().collect();
|
||||
assert_eq!(
|
||||
lines.len(),
|
||||
2,
|
||||
"re-open after restart must append, preserving the earlier line"
|
||||
);
|
||||
let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap();
|
||||
assert_eq!(first["tool_name"], "before-restart");
|
||||
let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap();
|
||||
assert_eq!(second["tool_name"], "after-restart");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,779 @@
|
||||
//! Toolset-swap guard policy: every trigger evaluates the one decision table,
|
||||
//! [`SwapPolicy::evaluate`] over a [`SessionSnapshot`]. The exhaustive match
|
||||
//! is the spec; the matrix test's `expected_decision` mirrors it row by row.
|
||||
|
||||
use prometheus::{IntCounterVec, register_int_counter_vec};
|
||||
|
||||
use crate::activity::ActivityTracker;
|
||||
use crate::session::WorkspaceSession;
|
||||
|
||||
/// Toolset installs/swaps by trigger (`create`/`fork`/`owner_rebind`/
|
||||
/// `update_tool_config`/`mcp_snapshot`/`hub_tools`/`other`) plus the guard
|
||||
/// state at swap time; record via [`record_toolset_swap`] only.
|
||||
pub(crate) static WORKSPACE_TOOLSET_SWAP_TOTAL: std::sync::LazyLock<IntCounterVec> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"grok_workspace_toolset_swap_total",
|
||||
"Session toolset installs and swaps, by trigger and guard state",
|
||||
&["trigger", "turn_active", "in_flight"]
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
/// Toolset swaps rejected by the turn-safety guards, by reason
|
||||
/// (`turn_active` = RPC entry check, `turn_active_late` = post-resolve
|
||||
/// re-check, `in_flight` = owner-rebind keep-old) and trigger.
|
||||
pub(crate) static WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL: std::sync::LazyLock<IntCounterVec> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"grok_workspace_toolset_swap_rejected_total",
|
||||
"Session toolset swaps rejected by the turn-safety guards, by reason and trigger",
|
||||
&["reason", "trigger"]
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
/// Rebinds (`session.bind` against an existing session) that carried a changed
|
||||
/// explicit toolset and re-resolved it, by result. A steady `ok` stream is the
|
||||
/// resume path correcting sessions that were created by metadata-less binds.
|
||||
static WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL: std::sync::LazyLock<IntCounterVec> =
|
||||
std::sync::LazyLock::new(|| {
|
||||
register_int_counter_vec!(
|
||||
"grok_workspace_bind_rebind_reresolve_total",
|
||||
"session.bind rebinds that re-resolved a changed explicit toolset, by result",
|
||||
&["result"]
|
||||
)
|
||||
.unwrap()
|
||||
});
|
||||
|
||||
/// Zero-init this module's metric families. See [`crate::init_metrics`].
|
||||
pub(crate) fn init_metrics() {
|
||||
const TRIGGERS: &[SwapTrigger] = &[
|
||||
SwapTrigger::OwnerRebind,
|
||||
SwapTrigger::UpdateRpc,
|
||||
SwapTrigger::McpSnapshot,
|
||||
SwapTrigger::HubTools,
|
||||
SwapTrigger::Other,
|
||||
];
|
||||
for trigger in TRIGGERS {
|
||||
for turn_active in ["true", "false"] {
|
||||
for in_flight in ["true", "false"] {
|
||||
WORKSPACE_TOOLSET_SWAP_TOTAL
|
||||
.with_label_values(&[trigger.metric_label(), turn_active, in_flight])
|
||||
.inc_by(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Only these reason/trigger pairs are reachable: in-flight guards owner
|
||||
// rebinds; the two turn-active guards fire on the update RPC.
|
||||
for (reason, trigger) in [
|
||||
(DeferReason::InFlightCalls, SwapTrigger::OwnerRebind),
|
||||
(DeferReason::TurnActive, SwapTrigger::UpdateRpc),
|
||||
(DeferReason::TurnActiveLate, SwapTrigger::UpdateRpc),
|
||||
] {
|
||||
WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL
|
||||
.with_label_values(&[reason.metric_reason(), trigger.metric_label()])
|
||||
.inc_by(0);
|
||||
}
|
||||
for result in ["skipped_externally_owned", "ok", "error"] {
|
||||
WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL
|
||||
.with_label_values(&[result])
|
||||
.inc_by(0);
|
||||
}
|
||||
}
|
||||
|
||||
/// Record a toolset install/swap on [`WORKSPACE_TOOLSET_SWAP_TOTAL`],
|
||||
/// stamping the session's turn/in-flight state at swap time.
|
||||
pub(crate) fn record_toolset_swap(tracker: &ActivityTracker, trigger: &str, session_id: &str) {
|
||||
let turn_active = bool_label(tracker.is_turn_active(session_id));
|
||||
let in_flight = bool_label(tracker.session_active_tool_calls(session_id) > 0);
|
||||
WORKSPACE_TOOLSET_SWAP_TOTAL
|
||||
.with_label_values(&[trigger, turn_active, in_flight])
|
||||
.inc();
|
||||
}
|
||||
|
||||
fn bool_label(v: bool) -> &'static str {
|
||||
if v { "true" } else { "false" }
|
||||
}
|
||||
|
||||
/// What initiated a toolset swap attempt. The trigger fixes both the metric
|
||||
/// `trigger` label and the guard set the policy applies (see the module table).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SwapTrigger {
|
||||
/// Hub `session.bind` against an existing session that carried a changed
|
||||
/// explicit toolset (`WorkspaceHandle::rebind_existing_hub_session`).
|
||||
OwnerRebind,
|
||||
/// The `workspace.update_tool_config` RPC.
|
||||
UpdateRpc,
|
||||
/// `re_resolve_all_sessions` after an MCP snapshot change.
|
||||
McpSnapshot,
|
||||
/// `re_resolve_all_sessions` after a remote tools change/notification.
|
||||
HubTools,
|
||||
/// `re_resolve_all_sessions` from an unrecognized source (test callers
|
||||
/// only today). Snapshot-rebuild policy, `other` metric label.
|
||||
Other,
|
||||
}
|
||||
|
||||
impl SwapTrigger {
|
||||
pub(crate) fn from_rebuild_source(source: &str) -> Self {
|
||||
match source {
|
||||
"mcp_snapshot_changed" => Self::McpSnapshot,
|
||||
"hub_tools_changed" | "hub_notification" => Self::HubTools,
|
||||
_ => Self::Other,
|
||||
}
|
||||
}
|
||||
|
||||
/// The `trigger` label on [`WORKSPACE_TOOLSET_SWAP_TOTAL`] and
|
||||
/// [`WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL`]. Dashboards depend on these
|
||||
/// exact values.
|
||||
pub(crate) fn metric_label(self) -> &'static str {
|
||||
match self {
|
||||
Self::OwnerRebind => "owner_rebind",
|
||||
Self::UpdateRpc => "update_tool_config",
|
||||
Self::McpSnapshot => "mcp_snapshot",
|
||||
Self::HubTools => "hub_tools",
|
||||
Self::Other => "other",
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the apply path re-evaluates post-resolve, pre-install. Only the
|
||||
/// update RPC: a turn can start mid-resolve (turn hooks are lock-free);
|
||||
/// owner rebinds must answer inside the server's ack budget, so they don't.
|
||||
pub(crate) fn rechecks_after_resolve(self) -> bool {
|
||||
self == Self::UpdateRpc
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a swap was skipped (nothing resolved, toolset and fingerprint kept).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SkipReason {
|
||||
/// The toolset `Terminal` is not the session-owned backend (local/shell
|
||||
/// bind): a rebuild would detach tools from the shell's live task table.
|
||||
ExternallyOwned,
|
||||
}
|
||||
|
||||
/// Why a swap was deferred (existing toolset kept; a later attempt applies).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum DeferReason {
|
||||
/// Owner rebind arrived while the session had tool calls in flight, with
|
||||
/// either an `explicit → different-explicit` change or a stale-heal
|
||||
/// identical re-apply.
|
||||
InFlightCalls,
|
||||
/// The session's turn is active and the config differs (update-RPC entry
|
||||
/// check); retryable at the turn boundary.
|
||||
TurnActive,
|
||||
/// [`Self::TurnActive`] detected by the post-resolve re-check: the turn
|
||||
/// started during the re-resolve and the resolved toolset was discarded.
|
||||
TurnActiveLate,
|
||||
}
|
||||
|
||||
impl DeferReason {
|
||||
/// The `reason` label on [`WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL`].
|
||||
pub(crate) fn metric_reason(self) -> &'static str {
|
||||
match self {
|
||||
Self::InFlightCalls => "in_flight",
|
||||
Self::TurnActive => "turn_active",
|
||||
Self::TurnActiveLate => "turn_active_late",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a trigger should do with its candidate config, per the module table.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
#[must_use = "a non-Apply decision means the config must NOT be installed"]
|
||||
pub(crate) enum SwapDecision {
|
||||
/// Resolve and install the candidate config.
|
||||
Apply,
|
||||
/// Identical fingerprint: the live toolset already reflects the candidate.
|
||||
Reuse,
|
||||
/// Deliberate skip: leave toolset AND fingerprint untouched.
|
||||
Skip(SkipReason),
|
||||
/// Keep the existing toolset for now; a later attempt applies.
|
||||
Defer(DeferReason),
|
||||
}
|
||||
|
||||
/// How the candidate config's fingerprint relates to the session's stored one.
|
||||
/// Produced under a single lock acquisition (see [`classify`]).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum BindFingerprintTransition {
|
||||
/// Candidate fingerprint equals the stored one.
|
||||
Unchanged,
|
||||
/// Stored fingerprint is `None` (default resolution) and the candidate
|
||||
/// differs.
|
||||
FromDefault,
|
||||
/// Stored fingerprint is explicit and the candidate differs.
|
||||
FromExplicit,
|
||||
}
|
||||
|
||||
/// Classify `candidate` against the stored bind fingerprint in one poison-safe
|
||||
/// lock acquisition, so the decision cannot straddle a concurrent
|
||||
/// fingerprint write (`set_if_unset` runs outside `update_lock`).
|
||||
fn classify(
|
||||
stored: &std::sync::Mutex<Option<serde_json::Value>>,
|
||||
candidate: Option<&serde_json::Value>,
|
||||
) -> BindFingerprintTransition {
|
||||
let guard = stored.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if guard.as_ref() == candidate {
|
||||
BindFingerprintTransition::Unchanged
|
||||
} else if guard.is_some() {
|
||||
BindFingerprintTransition::FromExplicit
|
||||
} else {
|
||||
BindFingerprintTransition::FromDefault
|
||||
}
|
||||
}
|
||||
|
||||
/// One coherent read (under `update_lock`) of the session state the policy
|
||||
/// keys on. Turn/in-flight reads are tracker-side lock-free, so a decision
|
||||
/// can go stale during a long resolve — see `rechecks_after_resolve`.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub(crate) struct SessionSnapshot {
|
||||
/// `None` = no candidate config: an in-place rebuild of the session's
|
||||
/// current baseline (snapshot-driven triggers), never Reuse-exempt.
|
||||
transition: Option<BindFingerprintTransition>,
|
||||
turn_active: bool,
|
||||
in_flight_calls: u32,
|
||||
toolset_terminal_session_owned: bool,
|
||||
/// The last snapshot-driven rebuild failed and kept a stale toolset
|
||||
/// ([`WorkspaceSession::stale_resolve`]): an identical fingerprint does
|
||||
/// not prove the live toolset is current, only that the *config* is.
|
||||
stale_resolve: bool,
|
||||
}
|
||||
|
||||
impl SessionSnapshot {
|
||||
/// Capture against a candidate bind-config fingerprint (`None` = default
|
||||
/// resolution) — the owner-rebind and update-RPC triggers.
|
||||
pub(crate) async fn capture(
|
||||
session: &WorkspaceSession,
|
||||
tracker: &ActivityTracker,
|
||||
candidate_fingerprint: Option<&serde_json::Value>,
|
||||
) -> Self {
|
||||
let transition = classify(&session.bind_tool_config_fingerprint, candidate_fingerprint);
|
||||
Self::with_transition(session, tracker, Some(transition)).await
|
||||
}
|
||||
|
||||
/// Capture for an in-place rebuild of the session's current baseline (the
|
||||
/// snapshot-driven triggers): no candidate config, so no fingerprint
|
||||
/// transition to exempt on.
|
||||
pub(crate) async fn capture_for_rebuild(
|
||||
session: &WorkspaceSession,
|
||||
tracker: &ActivityTracker,
|
||||
) -> Self {
|
||||
Self::with_transition(session, tracker, None).await
|
||||
}
|
||||
|
||||
async fn with_transition(
|
||||
session: &WorkspaceSession,
|
||||
tracker: &ActivityTracker,
|
||||
transition: Option<BindFingerprintTransition>,
|
||||
) -> Self {
|
||||
let session_id = session.session_id();
|
||||
Self {
|
||||
transition,
|
||||
turn_active: tracker.is_turn_active(session_id),
|
||||
in_flight_calls: tracker.session_active_tool_calls(session_id),
|
||||
toolset_terminal_session_owned: session.toolset_terminal_is_session_owned().await,
|
||||
stale_resolve: session.stale_resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tool calls in flight at capture time.
|
||||
pub(crate) fn in_flight_calls(&self) -> u32 {
|
||||
self.in_flight_calls
|
||||
}
|
||||
}
|
||||
|
||||
/// The toolset-swap guard policy. Stateless: the whole table lives in
|
||||
/// [`Self::evaluate`].
|
||||
pub(crate) struct SwapPolicy;
|
||||
|
||||
impl SwapPolicy {
|
||||
/// Decide what `trigger` should do with its candidate, per the module
|
||||
/// table. Pure function of the snapshot — callers act on the decision
|
||||
/// under the same `update_lock` hold the snapshot was captured under.
|
||||
pub(crate) fn evaluate(snap: &SessionSnapshot, trigger: SwapTrigger) -> SwapDecision {
|
||||
use BindFingerprintTransition::{FromExplicit, Unchanged};
|
||||
match (trigger, snap.transition) {
|
||||
(SwapTrigger::UpdateRpc | SwapTrigger::OwnerRebind, Some(Unchanged))
|
||||
if !snap.stale_resolve =>
|
||||
{
|
||||
SwapDecision::Reuse
|
||||
}
|
||||
(
|
||||
SwapTrigger::McpSnapshot | SwapTrigger::HubTools | SwapTrigger::Other,
|
||||
Some(Unchanged),
|
||||
) => SwapDecision::Reuse,
|
||||
|
||||
(SwapTrigger::OwnerRebind, Some(FromExplicit | Unchanged))
|
||||
if snap.in_flight_calls > 0 =>
|
||||
{
|
||||
SwapDecision::Defer(DeferReason::InFlightCalls)
|
||||
}
|
||||
(SwapTrigger::OwnerRebind, _) if !snap.toolset_terminal_session_owned => {
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned)
|
||||
}
|
||||
(SwapTrigger::OwnerRebind, _) => SwapDecision::Apply,
|
||||
|
||||
(SwapTrigger::UpdateRpc, _) if snap.turn_active => {
|
||||
SwapDecision::Defer(DeferReason::TurnActive)
|
||||
}
|
||||
(SwapTrigger::UpdateRpc, _) if !snap.toolset_terminal_session_owned => {
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned)
|
||||
}
|
||||
(SwapTrigger::UpdateRpc, _) => SwapDecision::Apply,
|
||||
|
||||
(SwapTrigger::McpSnapshot | SwapTrigger::HubTools | SwapTrigger::Other, _)
|
||||
if !snap.toolset_terminal_session_owned =>
|
||||
{
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned)
|
||||
}
|
||||
(SwapTrigger::McpSnapshot | SwapTrigger::HubTools | SwapTrigger::Other, _) => {
|
||||
SwapDecision::Apply
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What acting on a [`SwapDecision`] ultimately did — the key of
|
||||
/// [`record_swap_decision`]. No `Reused` action: a reuse changes nothing
|
||||
/// and no metric family counts it.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum SwapAction {
|
||||
/// [`SwapDecision::Skip`] honored.
|
||||
Skipped(SkipReason),
|
||||
/// [`SwapDecision::Defer`] honored (existing toolset kept).
|
||||
Deferred(DeferReason),
|
||||
/// [`SwapDecision::Apply`] succeeded: toolset resolved and installed.
|
||||
Applied,
|
||||
/// [`SwapDecision::Apply`] failed: the re-resolve errored, existing kept.
|
||||
ApplyFailed,
|
||||
}
|
||||
|
||||
/// The single chokepoint all three swap metric families emit from (swap
|
||||
/// total on `Applied`, rejected total on `Deferred`, rebind-reresolve on
|
||||
/// owner-rebind results), so label values cannot drift per call site.
|
||||
pub(crate) fn record_swap_decision(
|
||||
tracker: &ActivityTracker,
|
||||
trigger: SwapTrigger,
|
||||
session_id: &str,
|
||||
action: SwapAction,
|
||||
) {
|
||||
match action {
|
||||
SwapAction::Deferred(reason) => {
|
||||
WORKSPACE_TOOLSET_SWAP_REJECTED_TOTAL
|
||||
.with_label_values(&[reason.metric_reason(), trigger.metric_label()])
|
||||
.inc();
|
||||
}
|
||||
SwapAction::Skipped(SkipReason::ExternallyOwned) => {
|
||||
if trigger == SwapTrigger::OwnerRebind {
|
||||
WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL
|
||||
.with_label_values(&["skipped_externally_owned"])
|
||||
.inc();
|
||||
}
|
||||
}
|
||||
SwapAction::Applied => {
|
||||
record_toolset_swap(tracker, trigger.metric_label(), session_id);
|
||||
if trigger == SwapTrigger::OwnerRebind {
|
||||
WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL
|
||||
.with_label_values(&["ok"])
|
||||
.inc();
|
||||
}
|
||||
}
|
||||
SwapAction::ApplyFailed => {
|
||||
if trigger == SwapTrigger::OwnerRebind {
|
||||
WORKSPACE_BIND_REBIND_RERESOLVE_TOTAL
|
||||
.with_label_values(&["error"])
|
||||
.inc();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn snap(
|
||||
transition: Option<BindFingerprintTransition>,
|
||||
turn_active: bool,
|
||||
in_flight_calls: u32,
|
||||
owned: bool,
|
||||
stale_resolve: bool,
|
||||
) -> SessionSnapshot {
|
||||
SessionSnapshot {
|
||||
transition,
|
||||
turn_active,
|
||||
in_flight_calls,
|
||||
toolset_terminal_session_owned: owned,
|
||||
stale_resolve,
|
||||
}
|
||||
}
|
||||
|
||||
const ALL_TRIGGERS: [SwapTrigger; 5] = [
|
||||
SwapTrigger::OwnerRebind,
|
||||
SwapTrigger::UpdateRpc,
|
||||
SwapTrigger::McpSnapshot,
|
||||
SwapTrigger::HubTools,
|
||||
SwapTrigger::Other,
|
||||
];
|
||||
|
||||
const ALL_TRANSITIONS: [Option<BindFingerprintTransition>; 4] = [
|
||||
Some(BindFingerprintTransition::Unchanged),
|
||||
Some(BindFingerprintTransition::FromDefault),
|
||||
Some(BindFingerprintTransition::FromExplicit),
|
||||
None,
|
||||
];
|
||||
|
||||
/// Spec mirror of the module decision table, maintained independently of
|
||||
/// [`SwapPolicy::evaluate`].
|
||||
fn expected_decision(
|
||||
trigger: SwapTrigger,
|
||||
transition: Option<BindFingerprintTransition>,
|
||||
turn_active: bool,
|
||||
in_flight_calls: u32,
|
||||
owned: bool,
|
||||
stale_resolve: bool,
|
||||
) -> SwapDecision {
|
||||
if transition == Some(BindFingerprintTransition::Unchanged)
|
||||
&& !(matches!(trigger, SwapTrigger::UpdateRpc | SwapTrigger::OwnerRebind)
|
||||
&& stale_resolve)
|
||||
{
|
||||
return SwapDecision::Reuse;
|
||||
}
|
||||
match trigger {
|
||||
SwapTrigger::OwnerRebind => {
|
||||
if matches!(
|
||||
transition,
|
||||
Some(
|
||||
BindFingerprintTransition::FromExplicit
|
||||
| BindFingerprintTransition::Unchanged
|
||||
)
|
||||
) && in_flight_calls > 0
|
||||
{
|
||||
SwapDecision::Defer(DeferReason::InFlightCalls)
|
||||
} else if !owned {
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned)
|
||||
} else {
|
||||
SwapDecision::Apply
|
||||
}
|
||||
}
|
||||
SwapTrigger::UpdateRpc => {
|
||||
if turn_active {
|
||||
SwapDecision::Defer(DeferReason::TurnActive)
|
||||
} else if !owned {
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned)
|
||||
} else {
|
||||
SwapDecision::Apply
|
||||
}
|
||||
}
|
||||
SwapTrigger::McpSnapshot | SwapTrigger::HubTools | SwapTrigger::Other => {
|
||||
if !owned {
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned)
|
||||
} else {
|
||||
SwapDecision::Apply
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn evaluate_matches_decision_table_over_full_matrix() {
|
||||
for trigger in ALL_TRIGGERS {
|
||||
for transition in ALL_TRANSITIONS {
|
||||
for turn_active in [false, true] {
|
||||
for in_flight_calls in [0u32, 2] {
|
||||
for owned in [true, false] {
|
||||
for stale_resolve in [false, true] {
|
||||
let got = SwapPolicy::evaluate(
|
||||
&snap(
|
||||
transition,
|
||||
turn_active,
|
||||
in_flight_calls,
|
||||
owned,
|
||||
stale_resolve,
|
||||
),
|
||||
trigger,
|
||||
);
|
||||
let expected = expected_decision(
|
||||
trigger,
|
||||
transition,
|
||||
turn_active,
|
||||
in_flight_calls,
|
||||
owned,
|
||||
stale_resolve,
|
||||
);
|
||||
assert_eq!(
|
||||
got, expected,
|
||||
"trigger={trigger:?} transition={transition:?} \
|
||||
turn_active={turn_active} in_flight={in_flight_calls} \
|
||||
owned={owned} stale_resolve={stale_resolve}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identical_fingerprint_reuses_regardless_of_gates() {
|
||||
for trigger in ALL_TRIGGERS {
|
||||
let decision = SwapPolicy::evaluate(
|
||||
&snap(
|
||||
Some(BindFingerprintTransition::Unchanged),
|
||||
true,
|
||||
3,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
trigger,
|
||||
);
|
||||
assert_eq!(decision, SwapDecision::Reuse, "trigger={trigger:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rpc_identical_reapply_recovers_after_failed_rebuild() {
|
||||
let identical = Some(BindFingerprintTransition::Unchanged);
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(
|
||||
&snap(identical, false, 0, true, true),
|
||||
SwapTrigger::UpdateRpc
|
||||
),
|
||||
SwapDecision::Apply,
|
||||
"idle + session-owned: the identical re-apply repairs the stale toolset"
|
||||
);
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(
|
||||
&snap(identical, true, 0, true, true),
|
||||
SwapTrigger::UpdateRpc
|
||||
),
|
||||
SwapDecision::Defer(DeferReason::TurnActive),
|
||||
"the recovery apply is turn-gated like any mutation"
|
||||
);
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(
|
||||
&snap(identical, false, 0, false, true),
|
||||
SwapTrigger::UpdateRpc
|
||||
),
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned),
|
||||
"an externally-owned toolset cannot be rebuilt, stale or not"
|
||||
);
|
||||
for trigger in [
|
||||
SwapTrigger::McpSnapshot,
|
||||
SwapTrigger::HubTools,
|
||||
SwapTrigger::Other,
|
||||
] {
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(&snap(identical, false, 0, true, true), trigger),
|
||||
SwapDecision::Reuse,
|
||||
"trigger={trigger:?}: snapshot triggers carry no recovery lever"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_rebind_identical_reapply_recovers_after_failed_rebuild() {
|
||||
let identical = Some(BindFingerprintTransition::Unchanged);
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(
|
||||
&snap(identical, false, 0, true, true),
|
||||
SwapTrigger::OwnerRebind
|
||||
),
|
||||
SwapDecision::Apply,
|
||||
"a reconnect's identical rebind heals the stale toolset"
|
||||
);
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(
|
||||
&snap(identical, false, 2, true, true),
|
||||
SwapTrigger::OwnerRebind
|
||||
),
|
||||
SwapDecision::Defer(DeferReason::InFlightCalls),
|
||||
"the heal defers while tool calls are in flight"
|
||||
);
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(
|
||||
&snap(identical, false, 0, false, true),
|
||||
SwapTrigger::OwnerRebind
|
||||
),
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned),
|
||||
"an externally-owned toolset cannot be rebuilt, stale or not"
|
||||
);
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(
|
||||
&snap(identical, false, 0, true, false),
|
||||
SwapTrigger::OwnerRebind
|
||||
),
|
||||
SwapDecision::Reuse,
|
||||
"without the stale marker the identical rebind stays a no-op reuse"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_rebind_from_default_applies_mid_turn_with_calls_in_flight() {
|
||||
let decision = SwapPolicy::evaluate(
|
||||
&snap(
|
||||
Some(BindFingerprintTransition::FromDefault),
|
||||
true,
|
||||
2,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
SwapTrigger::OwnerRebind,
|
||||
);
|
||||
assert_eq!(decision, SwapDecision::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_rebind_in_flight_defer_wins_over_external_ownership() {
|
||||
let decision = SwapPolicy::evaluate(
|
||||
&snap(
|
||||
Some(BindFingerprintTransition::FromExplicit),
|
||||
false,
|
||||
1,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
SwapTrigger::OwnerRebind,
|
||||
);
|
||||
assert_eq!(decision, SwapDecision::Defer(DeferReason::InFlightCalls));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_rpc_turn_gate_wins_over_external_ownership() {
|
||||
let decision = SwapPolicy::evaluate(
|
||||
&snap(
|
||||
Some(BindFingerprintTransition::FromDefault),
|
||||
true,
|
||||
0,
|
||||
false,
|
||||
false,
|
||||
),
|
||||
SwapTrigger::UpdateRpc,
|
||||
);
|
||||
assert_eq!(decision, SwapDecision::Defer(DeferReason::TurnActive));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn owner_rebind_ignores_turn_active() {
|
||||
let decision = SwapPolicy::evaluate(
|
||||
&snap(
|
||||
Some(BindFingerprintTransition::FromExplicit),
|
||||
true,
|
||||
0,
|
||||
true,
|
||||
false,
|
||||
),
|
||||
SwapTrigger::OwnerRebind,
|
||||
);
|
||||
assert_eq!(decision, SwapDecision::Apply);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_rebuilds_ignore_turn_and_in_flight_gates() {
|
||||
for trigger in [
|
||||
SwapTrigger::McpSnapshot,
|
||||
SwapTrigger::HubTools,
|
||||
SwapTrigger::Other,
|
||||
] {
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(&snap(None, true, 4, true, false), trigger),
|
||||
SwapDecision::Apply,
|
||||
"trigger={trigger:?}"
|
||||
);
|
||||
assert_eq!(
|
||||
SwapPolicy::evaluate(&snap(None, true, 4, false, false), trigger),
|
||||
SwapDecision::Skip(SkipReason::ExternallyOwned),
|
||||
"trigger={trigger:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn metric_labels_are_locked() {
|
||||
assert_eq!(SwapTrigger::OwnerRebind.metric_label(), "owner_rebind");
|
||||
assert_eq!(SwapTrigger::UpdateRpc.metric_label(), "update_tool_config");
|
||||
assert_eq!(SwapTrigger::McpSnapshot.metric_label(), "mcp_snapshot");
|
||||
assert_eq!(SwapTrigger::HubTools.metric_label(), "hub_tools");
|
||||
assert_eq!(SwapTrigger::Other.metric_label(), "other");
|
||||
assert_eq!(DeferReason::InFlightCalls.metric_reason(), "in_flight");
|
||||
assert_eq!(DeferReason::TurnActive.metric_reason(), "turn_active");
|
||||
assert_eq!(
|
||||
DeferReason::TurnActiveLate.metric_reason(),
|
||||
"turn_active_late"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_update_rpc_rechecks_after_resolve() {
|
||||
for trigger in ALL_TRIGGERS {
|
||||
assert_eq!(
|
||||
trigger.rechecks_after_resolve(),
|
||||
trigger == SwapTrigger::UpdateRpc,
|
||||
"trigger={trigger:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rebuild_source_mapping_matches_legacy_labels() {
|
||||
assert_eq!(
|
||||
SwapTrigger::from_rebuild_source("mcp_snapshot_changed"),
|
||||
SwapTrigger::McpSnapshot
|
||||
);
|
||||
assert_eq!(
|
||||
SwapTrigger::from_rebuild_source("hub_tools_changed"),
|
||||
SwapTrigger::HubTools
|
||||
);
|
||||
assert_eq!(
|
||||
SwapTrigger::from_rebuild_source("hub_notification"),
|
||||
SwapTrigger::HubTools
|
||||
);
|
||||
assert_eq!(
|
||||
SwapTrigger::from_rebuild_source("test_preserves_feed"),
|
||||
SwapTrigger::Other
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_is_poison_safe() {
|
||||
let stored: std::sync::Arc<std::sync::Mutex<Option<serde_json::Value>>> =
|
||||
std::sync::Arc::new(std::sync::Mutex::new(Some(serde_json::json!({"a": 1}))));
|
||||
let poisoner = stored.clone();
|
||||
let _ = std::thread::spawn(move || {
|
||||
let _guard = poisoner.lock().unwrap();
|
||||
panic!("poison the fingerprint lock");
|
||||
})
|
||||
.join();
|
||||
assert!(stored.is_poisoned(), "precondition: the lock is poisoned");
|
||||
|
||||
let same = serde_json::json!({"a": 1});
|
||||
let other = serde_json::json!({"b": 2});
|
||||
assert_eq!(
|
||||
classify(&stored, Some(&same)),
|
||||
BindFingerprintTransition::Unchanged
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&stored, Some(&other)),
|
||||
BindFingerprintTransition::FromExplicit
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&stored, None),
|
||||
BindFingerprintTransition::FromExplicit
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_from_default_transitions() {
|
||||
let stored = std::sync::Mutex::new(None);
|
||||
let candidate = serde_json::json!({"a": 1});
|
||||
assert_eq!(
|
||||
classify(&stored, Some(&candidate)),
|
||||
BindFingerprintTransition::FromDefault
|
||||
);
|
||||
assert_eq!(
|
||||
classify(&stored, None),
|
||||
BindFingerprintTransition::Unchanged
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user