diff --git a/docs/architecture/detached-task-dispatch.md b/docs/architecture/detached-task-dispatch.md index 99ca167411..4574166414 100644 --- a/docs/architecture/detached-task-dispatch.md +++ b/docs/architecture/detached-task-dispatch.md @@ -109,10 +109,26 @@ path and capture mode. Before packaging a later job, it recomputes a lightweight fingerprint from the selected paths and their filesystem identity, size, executable state, and write/change timestamps. An unchanged fingerprint hard-links the cached immutable archive into the new job instead of rereading -and recompressing every file. Source mode ignores changes below ignored paths; -exact mode observes them. A selected entry change invalidates and atomically -replaces the cache. The per-job link remains immutable during submission, so a -later cache replacement cannot change an in-flight job's bytes. +and recompressing every file. + +That fingerprint is metadata-only, so operations that leave every byte intact +still change it: `chmod`, an editor's write-then-rename, a `git checkout` round +trip. Because the target's own cache is keyed by the archive digest, a +controller miss forces a full retransfer as well, so a changed fingerprint alone +is not allowed to condemn the cache. Packaging therefore publishes the archive's +per-file manifest as a sidecar next to the cached archive, and a fingerprint +mismatch falls through to comparing the source against it: first structurally, +by path, kind, size, and executable bit, which needs no more I/O than the +fingerprint itself and rejects nearly every real change; then, only when the +structure is identical, by per-file SHA-256. An identical tree reuses the cached +archive and writes the new fingerprint back, so the content comparison is paid +once rather than on every later job. A cache entry with no manifest sidecar — +one written by an older build — silently repackages as before. + +Source mode ignores changes below ignored paths; exact mode observes them. A +selected entry change invalidates and atomically replaces the cache. The per-job +link remains immutable during submission, so a later cache replacement cannot +change an in-flight job's bytes. SSH transports the archive with SFTP after `workspace-begin`. Account-device RPC uses bounded base64 chunks inside the existing end-to-end encrypted @@ -209,7 +225,22 @@ per observer and the target has no controller lease. Explicitly listing jobs for a selected target adopts observer-only routing records on that controller; it does not copy sessions or acquire workspace/runtime ownership. -Target and outbound records are retained for 30 days after terminal state. +A cursor records how far into the event log an observer has read, not what it +drew, so a cursor on its own cannot restore a projection. The controller +therefore caches each observer's rendered transcript beside its outbound index, +one file per job, holding the projected turns together with the cursor that +produced them and the completeness facts that applied at that point. Storing +them in one document is what keeps them consistent: a restart resumes from the +cached cursor rather than any other stored one, because only that pair was +written together, and a truncated history stays marked as truncated. The cache +is versioned by the projection rules that wrote it, and anything missing, +corrupt, mismatched, or above the size ceiling replays the job from byte zero. +The controller stores the projection verbatim and never interprets it; caching +it creates no durable session and acquires no runtime ownership. + +Target and outbound records are retained for 30 days after terminal state, as +are the cached transcripts, which are also dropped as soon as a projection is +deleted or archived. Garbage collection never removes queued or running jobs. Removing a terminal snapshot also removes only the managed directory bound to that job; an arbitrary user-supplied target directory is never a cleanup target. diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 07968dd955..998ed174fc 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -84,6 +84,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_load_transcript", + "dispatch_save_transcript", ]; /// Desktop IDE surfaces that CLI Peer Host does not implement. @@ -141,6 +143,8 @@ mod tests { "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_load_transcript", + "dispatch_save_transcript", ] { assert!(is_local_only_command(command), "{command}"); } diff --git a/src/apps/desktop/src/api/dispatch_api.rs b/src/apps/desktop/src/api/dispatch_api.rs index 4efd9242ee..2043ae9b2c 100644 --- a/src/apps/desktop/src/api/dispatch_api.rs +++ b/src/apps/desktop/src/api/dispatch_api.rs @@ -22,8 +22,9 @@ use bitfun_core::service::dispatch::{ DispatchAppendRequest, DispatchApplyResultRequest, DispatchConnectionRequest, DispatchInstallPollRequest, DispatchInstallStartRequest, DispatchJobRequest, DispatchListJobsRequest, DispatchListTargetsRequest, DispatchProbeTargetRequest, - DispatchStatusRequest, DispatchSubmitRequest, DispatchTarget, DispatchTargetOption, - DispatchTargetRequest, OutboundDispatchStore, WorkspaceResultApplyOutcome, + DispatchSaveTranscriptRequest, DispatchStatusRequest, DispatchSubmitRequest, DispatchTarget, + DispatchTargetOption, DispatchTargetRequest, DispatchTranscriptRequest, OutboundDispatchStore, + WorkspaceResultApplyOutcome, }; use bitfun_core::service::remote_ssh::dispatch_ssh::{ DispatchInstallPoll, DispatchInstallStart, DispatchSshProbe, @@ -525,6 +526,48 @@ pub async fn dispatch_append( .map_err(|error| error.to_string()) } +/// Read this controller's cached observer transcript for one dispatch job. +/// +/// Purely local: it touches neither the target nor any local session runtime. +/// A missing or unreadable cache returns `null` and the observer replays the +/// job from the beginning. +#[tauri::command] +pub async fn dispatch_load_transcript( + path_manager: State<'_, Arc>, + request: DispatchTranscriptRequest, +) -> Result, String> { + OutboundDispatchStore::new(path_manager.as_ref()) + .read_transcript(&request.job_id) + .await + .map_err(|error| error.to_string()) +} + +/// Persist this controller's observer transcript for one dispatch job. +/// +/// A `null` transcript erases the cache instead, which is how deleting a +/// projection drops its cached content right away. +/// +/// Returns `false` when the transcript exceeds the cache ceiling, in which case +/// the previous entry is kept and the renderer keeps polling as before. +#[tauri::command] +pub async fn dispatch_save_transcript( + path_manager: State<'_, Arc>, + request: DispatchSaveTranscriptRequest, +) -> Result { + let store = OutboundDispatchStore::new(path_manager.as_ref()); + let Some(transcript) = request.transcript else { + return store + .remove_transcript(&request.job_id) + .await + .map(|()| true) + .map_err(|error| error.to_string()); + }; + store + .write_transcript(&request.job_id, &transcript) + .await + .map_err(|error| error.to_string()) +} + #[cfg(test)] mod tests { use super::decode_device_dispatch_rpc; diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 25e3bf6b51..e771f2fc99 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -110,6 +110,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "dispatch_list_jobs", "dispatch_answer", "dispatch_append", + "dispatch_load_transcript", + "dispatch_save_transcript", // One-click relay deploy SSHes from the controller to a user host "relay_deploy_preflight", "relay_deploy_install_docker", diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 73ed8e5c2c..23dae7d282 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -383,6 +383,14 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("dispatch_answer", RemoteWorkspacePolicy::WorkspaceAgnostic), ("dispatch_append", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "dispatch_load_transcript", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "dispatch_save_transcript", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("dispatch_status", RemoteWorkspacePolicy::WorkspaceAgnostic), ("dispatch_submit", RemoteWorkspacePolicy::WorkspaceAgnostic), ( diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 0f086330ac..181c4878f8 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1758,6 +1758,8 @@ pub async fn run() { api::dispatch_api::dispatch_list_jobs, api::dispatch_api::dispatch_answer, api::dispatch_api::dispatch_append, + api::dispatch_api::dispatch_load_transcript, + api::dispatch_api::dispatch_save_transcript, // Relay self-deploy API api::relay_deploy_api::relay_deploy_preflight, api::relay_deploy_api::relay_deploy_install_docker, diff --git a/src/crates/assembly/core/src/service/dispatch/mod.rs b/src/crates/assembly/core/src/service/dispatch/mod.rs index 7d018ba458..b8faf80404 100644 --- a/src/crates/assembly/core/src/service/dispatch/mod.rs +++ b/src/crates/assembly/core/src/service/dispatch/mod.rs @@ -8,13 +8,15 @@ use std::path::{Path, PathBuf}; use anyhow::Context as _; use bitfun_services_core::dispatch_workspace::{ - exact_workspace_snapshot_source_fingerprint, prepare_exact_workspace_snapshot, - prepare_source_workspace_snapshot, sha256_file, source_workspace_snapshot_source_fingerprint, - WorkspaceSnapshotMetadata, WorkspaceSnapshotSourceFingerprint, + exact_workspace_matches_manifest, exact_workspace_snapshot_source_fingerprint, + prepare_exact_workspace_snapshot, prepare_source_workspace_snapshot, sha256_file, + source_workspace_matches_manifest, source_workspace_snapshot_source_fingerprint, + WorkspaceSnapshotManifest, WorkspaceSnapshotMetadata, WorkspaceSnapshotSourceFingerprint, }; use bitfun_services_core::json_store::{JsonFileStore, JsonFileStoreError}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; +use serde_json::Value; use sha2::{Digest, Sha256}; use thiserror::Error; use tokio::fs; @@ -57,7 +59,15 @@ const OUTBOUND_WORKSPACE_UPLOADS_DIR: &str = ".workspace-uploads"; const OUTBOUND_WORKSPACE_CACHE_DIR: &str = ".workspace-cache"; /// Where pulled result bundles are staged before the user applies them. pub(super) const OUTBOUND_RESULTS_DIR: &str = ".results"; +/// Where the renderer's observer transcript cache lives. +const OUTBOUND_TRANSCRIPTS_DIR: &str = ".transcripts"; const TERMINAL_OUTBOUND_RETENTION_DAYS: i64 = 30; +/// Ceiling for one cached observer transcript. +/// +/// A transcript that outgrows this stops being cached rather than growing +/// without bound; the observer then replays that job from the beginning, which +/// is exactly the behavior that existed before the cache. +const MAX_OUTBOUND_TRANSCRIPT_BYTES: usize = 8 * 1024 * 1024; #[derive(Debug, Clone)] pub struct PreparedOutboundWorkspaceSnapshot { @@ -204,6 +214,25 @@ impl OutboundDispatchRecord { } } +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchTranscriptRequest { + pub job_id: String, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DispatchSaveTranscriptRequest { + pub job_id: String, + /// Stored verbatim. The controller never interprets the renderer's + /// projection, so its shape is versioned by the renderer alone. + /// + /// `null` erases the cached transcript. That is how deleting a projection + /// drops its cached content immediately instead of waiting for retention. + #[serde(default)] + pub transcript: Option, +} + #[derive(Debug, Error)] pub enum DispatchStoreError { #[error("Invalid dispatch job id")] @@ -349,6 +378,13 @@ impl OutboundDispatchStore { error ); } + if let Err(error) = self.remove_transcript(&record.job_id).await { + log::warn!( + "Failed to remove expired dispatch observer transcript: job_id={} error={}", + record.job_id, + error + ); + } if let Err(error) = self.remove_workspace_snapshot(&record.job_id).await { log::warn!( "Failed to remove expired outbound dispatch snapshot: job_id={} error={}", @@ -504,14 +540,41 @@ impl OutboundDispatchStore { ); cached = None; } + let cache_manifest_path = cache.join(format!("{cache_key}.manifest.json")); + let decision_started_at = std::time::Instant::now(); if let Some(mut cached) = cached { let current_fingerprint = workspace_source_fingerprint(source.clone(), capture_mode).await?; - if current_fingerprint == cached.source_fingerprint + // The fingerprint is metadata-only, so it also reports a change for + // content-neutral operations such as chmod, a git checkout round + // trip, or an editor's write-then-rename. Ask the per-file manifest + // for a second opinion before paying for a full repack and a full + // retransfer to the target. + let fingerprint_matched = current_fingerprint == cached.source_fingerprint; + let reusable = if fingerprint_matched { + true + } else { + match self + .cached_workspace_manifest(&cache_manifest_path, &cached.metadata) + .await + { + // A cache written before this sidecar existed, or one whose + // sidecar no longer belongs to the cached archive, simply + // degrades to the previous full-repack behavior. + None => false, + Some(manifest) => { + workspace_matches_manifest(source.clone(), capture_mode, manifest).await? + } + } + }; + if reusable && snapshot_archive_is_valid(cache_archive_path.clone(), cached.metadata.clone()) .await? { replace_snapshot_archive(&cache_archive_path, &archive_path).await?; + // Adopt the current fingerprint so the next dispatch takes the + // cheap path instead of rereading the tree every time. + cached.source_fingerprint = current_fingerprint; cached.last_used_at = Utc::now(); self.json_store .write_atomic_strict(&cache_record_path, &cached) @@ -527,6 +590,20 @@ impl OutboundDispatchStore { .write_atomic_strict(&record_path, &record) .await?; harden_file_permissions(&record_path).await?; + // Reported so the cost of each decision layer is observable + // before deciding whether per-file delta transfer is worth its + // protocol change: a "content" reuse is the layer this cache + // gained, and its elapsed time is what that layer costs. + log::info!( + "Reused cached dispatch workspace snapshot: job_id={job_id} mode={capture_mode:?} matched_by={} bytes={} elapsed_ms={}", + if fingerprint_matched { + "metadata" + } else { + "content" + }, + cached.metadata.archive_size, + decision_started_at.elapsed().as_millis() + ); return Ok(PreparedOutboundWorkspaceSnapshot { archive_path, metadata: cached.metadata, @@ -536,6 +613,7 @@ impl OutboundDispatchStore { remove_file_if_present(&cache_record_path).await?; remove_file_if_present(&cache_archive_path).await?; + remove_file_if_present(&cache_manifest_path).await?; let package_source = source.clone(); let package_archive = archive_path.clone(); let prepared = tokio::task::spawn_blocking(move || match capture_mode { @@ -548,6 +626,15 @@ impl OutboundDispatchStore { }) .await .map_err(|error| anyhow::anyhow!("snapshot packaging task failed: {error}"))??; + // The counterpart of the reuse log. These two lines together are what + // says how often a repack is genuinely earned and how many bytes a + // delta would have saved. + log::info!( + "Repacked dispatch workspace snapshot: job_id={job_id} mode={capture_mode:?} files={} bytes={} elapsed_ms={}", + prepared.metadata.file_count, + prepared.metadata.archive_size, + decision_started_at.elapsed().as_millis() + ); harden_file_permissions(&archive_path).await?; publish_snapshot_cache_archive( &archive_path, @@ -557,6 +644,12 @@ impl OutboundDispatchStore { job_id, ) .await?; + // Written before the record so a reader can never observe a cache + // record that claims a manifest sidecar which is not there yet. + self.json_store + .write_atomic(&cache_manifest_path, &prepared.manifest) + .await?; + harden_file_permissions(&cache_manifest_path).await?; let now = Utc::now(); let cache_record = OutboundWorkspaceCacheRecord { source_workspace_path: source_wire.clone(), @@ -607,6 +700,65 @@ impl OutboundDispatchStore { Ok(()) } + /// Read the renderer's cached observer transcript for one job. + /// + /// This is the "UI cache" half of the outbound store: it exists so a + /// restarted renderer can resume from its persisted cursor instead of + /// replaying the target's whole event log. The controller stores it + /// verbatim and never interprets it — the target CLI remains the only owner + /// of the durable session, and reading this must not create a local one. + pub async fn read_transcript(&self, job_id: &str) -> anyhow::Result> { + let path = self.transcript_path(job_id)?; + match self.json_store.read_optional::(&path).await { + Ok(value) => Ok(value), + Err(error) => { + // A damaged cache is not a failure; the observer simply replays + // the job from the beginning. + log::warn!( + "Ignoring unreadable dispatch observer transcript: job_id={job_id} error={error}" + ); + Ok(None) + } + } + } + + /// Persist the renderer's observer transcript for one job. + /// + /// Returns `false` when the transcript is too large to cache. The previous + /// entry is then left in place: it pairs an older cursor with the turns for + /// exactly that cursor, so it stays internally consistent and still saves + /// the observer part of the replay. + pub async fn write_transcript(&self, job_id: &str, transcript: &Value) -> anyhow::Result { + let path = self.transcript_path(job_id)?; + let encoded = serde_json::to_vec(transcript).context("encode observer transcript")?; + if encoded.len() > MAX_OUTBOUND_TRANSCRIPT_BYTES { + log::debug!( + "Skipping dispatch observer transcript above the cache limit: job_id={job_id} bytes={}", + encoded.len() + ); + return Ok(false); + } + let transcripts = self.root.join(OUTBOUND_TRANSCRIPTS_DIR); + fs::create_dir_all(&transcripts).await?; + harden_directory_permissions(&transcripts).await?; + let _lock = self.json_store.acquire_cross_process_lock(&path).await?; + self.json_store.write_atomic(&path, transcript).await?; + harden_file_permissions(&path).await?; + Ok(true) + } + + pub async fn remove_transcript(&self, job_id: &str) -> anyhow::Result<()> { + remove_file_if_present(&self.transcript_path(job_id)?).await + } + + fn transcript_path(&self, job_id: &str) -> Result { + validate_id(job_id)?; + Ok(self + .root + .join(OUTBOUND_TRANSCRIPTS_DIR) + .join(format!("{job_id}.json"))) + } + pub async fn remove_workspace_snapshot(&self, job_id: &str) -> anyhow::Result<()> { validate_id(job_id)?; let uploads = self.root.join(OUTBOUND_WORKSPACE_UPLOADS_DIR); @@ -626,6 +778,49 @@ impl OutboundDispatchStore { Ok(()) } + /// Load the per-file manifest that belongs to a cached snapshot archive. + /// + /// The sidecar is only trusted when it re-encodes to the manifest digest the + /// cached archive was sealed with. That binding is what makes it safe to + /// hand the archive to a target after comparing against this manifest + /// instead of repacking. Anything unreadable, unparseable, or mismatched + /// yields `None`, which degrades to a full repack. + async fn cached_workspace_manifest( + &self, + manifest_path: &Path, + expected: &WorkspaceSnapshotMetadata, + ) -> Option { + let manifest = match self + .json_store + .read_optional::(manifest_path) + .await + { + Ok(Some(manifest)) => manifest, + Ok(None) => return None, + Err(error) => { + log::warn!( + "Ignoring unreadable outbound workspace manifest: path={} error={}", + manifest_path.display(), + error + ); + return None; + } + }; + let encoded = serde_json::to_vec(&manifest).ok()?; + let mut digest = Sha256::new(); + digest.update(&encoded); + let digest = format!("{:x}", digest.finalize()); + if !digest.eq_ignore_ascii_case(&expected.manifest_sha256) { + log::warn!( + "Ignoring outbound workspace manifest that does not match its cached archive: \ + path={}", + manifest_path.display() + ); + return None; + } + Some(manifest) + } + fn record_path(&self, job_id: &str) -> Result { validate_id(job_id)?; Ok(self.root.join(format!("{job_id}.json"))) @@ -666,6 +861,23 @@ async fn workspace_source_fingerprint( .map_err(|error| anyhow::anyhow!("snapshot fingerprint task failed: {error}"))? } +async fn workspace_matches_manifest( + source: PathBuf, + capture_mode: DispatchWorkspaceSnapshotCaptureMode, + manifest: WorkspaceSnapshotManifest, +) -> anyhow::Result { + tokio::task::spawn_blocking(move || match capture_mode { + DispatchWorkspaceSnapshotCaptureMode::Source => { + source_workspace_matches_manifest(&source, &manifest) + } + DispatchWorkspaceSnapshotCaptureMode::Exact => { + exact_workspace_matches_manifest(&source, &manifest) + } + }) + .await + .map_err(|error| anyhow::anyhow!("snapshot manifest comparison task failed: {error}"))? +} + async fn snapshot_archive_is_valid( archive: PathBuf, expected: WorkspaceSnapshotMetadata, @@ -1037,6 +1249,188 @@ mod tests { ); } + #[tokio::test] + async fn observer_transcript_round_trips_and_deletion_is_idempotent() { + let temp = tempfile::tempdir().expect("temp dir"); + let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); + let transcript = serde_json::json!({ + "schemaVersion": 1, + "jobId": "job-1", + "cursor": 4096, + "dialogTurns": [{ "id": "turn-1" }], + }); + + assert!( + store + .write_transcript("job-1", &transcript) + .await + .expect("write transcript"), + "a small transcript must be cached" + ); + assert_eq!( + store + .read_transcript("job-1") + .await + .expect("read transcript"), + Some(transcript), + "the controller stores the renderer projection verbatim" + ); + assert_eq!( + store + .read_transcript("job-2") + .await + .expect("read absent transcript"), + None, + "an uncached job replays from the beginning" + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = temp + .path() + .join(OUTBOUND_TRANSCRIPTS_DIR) + .join("job-1.json"); + assert_eq!( + std::fs::metadata(&path) + .expect("transcript file") + .permissions() + .mode() + & 0o777, + 0o600 + ); + } + + store.remove_transcript("job-1").await.expect("remove"); + assert_eq!( + store + .read_transcript("job-1") + .await + .expect("read removed transcript"), + None + ); + store + .remove_transcript("job-1") + .await + .expect("removing an absent transcript is not an error"); + assert!( + store.remove_transcript("../escape").await.is_err(), + "job ids must stay validated on this path too" + ); + } + + #[tokio::test] + async fn oversized_observer_transcript_keeps_the_previous_cache() { + let temp = tempfile::tempdir().expect("temp dir"); + let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); + let cached = serde_json::json!({ "schemaVersion": 1, "cursor": 128 }); + store + .write_transcript("job-1", &cached) + .await + .expect("cache the first transcript"); + + let oversized = serde_json::json!({ + "schemaVersion": 1, + "cursor": 4096, + "dialogTurns": "x".repeat(MAX_OUTBOUND_TRANSCRIPT_BYTES + 1), + }); + assert!( + !store + .write_transcript("job-1", &oversized) + .await + .expect("an oversized transcript is not an error"), + "a transcript above the ceiling must not be cached" + ); + assert_eq!( + store + .read_transcript("job-1") + .await + .expect("read transcript"), + Some(cached), + "the older entry stays because its cursor and turns still agree" + ); + } + + #[tokio::test] + async fn unreadable_observer_transcript_falls_back_to_full_replay() { + let temp = tempfile::tempdir().expect("temp dir"); + let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); + let transcripts = temp.path().join(OUTBOUND_TRANSCRIPTS_DIR); + fs::create_dir_all(&transcripts) + .await + .expect("transcripts dir"); + fs::write(transcripts.join("job-1.json"), b"{\"dialogTurns\": [") + .await + .expect("truncated transcript"); + + assert_eq!( + store + .read_transcript("job-1") + .await + .expect("a damaged cache must not fail the observer"), + None + ); + } + + #[tokio::test] + async fn expired_jobs_do_not_strand_their_observer_transcripts() { + let temp = tempfile::tempdir().expect("temp dir"); + let store = OutboundDispatchStore::new_in_root_for_tests(temp.path().to_path_buf()); + let mut expired = OutboundDispatchRecord::new( + "job-1".to_string(), + target(), + "session-1".to_string(), + "/srv/app".to_string(), + "Summarize the repository", + "succeeded", + ) + .expect("record"); + expired.updated_at = Utc::now() - chrono::Duration::days(TERMINAL_OUTBOUND_RETENTION_DAYS); + store.bind_if_absent(&expired).await.expect("persist"); + let live = OutboundDispatchRecord::new( + "job-2".to_string(), + target(), + "session-2".to_string(), + "/srv/app".to_string(), + "Still running", + "running", + ) + .expect("record"); + store.bind_if_absent(&live).await.expect("persist"); + for job_id in ["job-1", "job-2"] { + store + .write_transcript(job_id, &serde_json::json!({ "schemaVersion": 1 })) + .await + .expect("cache transcript"); + } + + let records = store.list().await.expect("list"); + + assert_eq!( + records + .iter() + .map(|record| &record.job_id) + .collect::>(), + vec!["job-2"], + "retention must drop the expired record" + ); + assert_eq!( + store + .read_transcript("job-1") + .await + .expect("read expired transcript"), + None, + "the expired job's transcript must go with its record" + ); + assert!( + store + .read_transcript("job-2") + .await + .expect("read live transcript") + .is_some(), + "a live job must keep its cached transcript" + ); + } + #[tokio::test] async fn workspace_cache_reuses_unchanged_source_across_dispatch_jobs() { let temp = tempfile::tempdir().expect("temp dir"); @@ -1128,6 +1522,206 @@ mod tests { ); } + /// Fixture shared by the metadata-churn cache tests. + /// + /// Returns the canonical source path, the cache key directory entries, and + /// a store rooted inside the same temp dir. + fn snapshot_cache_fixture( + temp: &tempfile::TempDir, + ) -> (OutboundDispatchStore, PathBuf, String, PathBuf, PathBuf) { + let root = temp.path().join("outbound"); + let store = OutboundDispatchStore::new_in_root_for_tests(root.clone()); + let source = temp.path().join("workspace"); + std::fs::create_dir_all(source.join(".git")).expect("repository marker"); + std::fs::create_dir_all(source.join("src")).expect("source directory"); + std::fs::write(source.join(".gitignore"), b"target/\n").expect("gitignore"); + std::fs::write(source.join("src/main.rs"), b"fn main() {}").expect("source"); + let canonical = source.canonicalize().expect("canonical source"); + let source_wire = canonical.to_string_lossy().to_string(); + let cache_key = outbound_workspace_cache_key( + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ); + let cache_dir = root.join(OUTBOUND_WORKSPACE_CACHE_DIR); + ( + store, + canonical, + source_wire, + cache_dir.join(format!("{cache_key}.tar.gz")), + cache_dir.join(format!("{cache_key}.manifest.json")), + ) + } + + /// The source fingerprint is metadata-only, so operations that leave every + /// byte intact still change it: `chmod`, an editor's write-then-rename, a + /// `git checkout` round trip. Those must not force a full repack and a full + /// retransfer to the target. + #[cfg(unix)] + #[tokio::test] + async fn workspace_cache_survives_content_neutral_metadata_changes() { + use std::os::unix::fs::{MetadataExt, PermissionsExt}; + + let temp = tempfile::tempdir().expect("temp dir"); + let (store, source, source_wire, cache_archive, cache_manifest) = + snapshot_cache_fixture(&temp); + + let first = store + .prepare_workspace_snapshot( + "job-1", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("first snapshot"); + let first_cache_ino = std::fs::metadata(&cache_archive) + .expect("first cached archive") + .ino(); + assert!( + cache_manifest.exists(), + "packaging must publish the manifest sidecar the comparison relies on" + ); + + // chmod, without touching the executable bit the manifest records. + std::fs::set_permissions( + source.join(".gitignore"), + std::fs::Permissions::from_mode(0o640), + ) + .expect("chmod"); + // Write-then-rename: identical bytes, brand new inode. + let staging = temp.path().join("main.rs.tmp"); + std::fs::write(&staging, b"fn main() {}").expect("staging write"); + std::fs::rename(&staging, source.join("src/main.rs")).expect("rename over source"); + + let second = store + .prepare_workspace_snapshot( + "job-2", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("second snapshot"); + + assert_eq!( + first.metadata, second.metadata, + "content-neutral churn must reuse the cached snapshot" + ); + assert_eq!( + first_cache_ino, + std::fs::metadata(&cache_archive) + .expect("reused cached archive") + .ino(), + "a content match must not repack the cached archive" + ); + + // The adopted fingerprint is what keeps the next dispatch on the cheap + // path instead of rehashing the tree every single time. + let cache_key = outbound_workspace_cache_key( + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ); + let cache_record: OutboundWorkspaceCacheRecord = JsonFileStore + .read_optional( + &temp + .path() + .join("outbound") + .join(OUTBOUND_WORKSPACE_CACHE_DIR) + .join(format!("{cache_key}.json")), + ) + .await + .expect("read cache record") + .expect("cache record present"); + assert_eq!( + cache_record.source_fingerprint, + source_workspace_snapshot_source_fingerprint(&source).expect("current fingerprint"), + "a content match must adopt the current fingerprint" + ); + } + + /// The structural pass only compares stat data, so an edit that preserves + /// file size has to be caught by the content pass. + #[tokio::test] + async fn workspace_cache_invalidates_on_same_size_content_change() { + let temp = tempfile::tempdir().expect("temp dir"); + let (store, source, source_wire, _cache_archive, _cache_manifest) = + snapshot_cache_fixture(&temp); + + std::fs::write(source.join("src/config.rs"), b"const N: u8 = 1;").expect("config"); + let first = store + .prepare_workspace_snapshot( + "job-1", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("first snapshot"); + + // Same byte count, different content: only the content pass can see it. + std::fs::write(source.join("src/config.rs"), b"const N: u8 = 2;").expect("same-size edit"); + + let second = store + .prepare_workspace_snapshot( + "job-2", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("second snapshot"); + assert_ne!( + first.metadata.archive_sha256, second.metadata.archive_sha256, + "a same-size content change must still invalidate the cache" + ); + } + + /// A cache written before the manifest sidecar existed, or one whose + /// sidecar no longer matches its archive, must fall back to the previous + /// full-repack behavior rather than reusing an unverified archive. + #[cfg(unix)] + #[tokio::test] + async fn workspace_cache_without_manifest_falls_back_to_repacking() { + use std::os::unix::fs::MetadataExt; + + let temp = tempfile::tempdir().expect("temp dir"); + let (store, source, source_wire, cache_archive, cache_manifest) = + snapshot_cache_fixture(&temp); + + store + .prepare_workspace_snapshot( + "job-1", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("first snapshot"); + let first_cache_ino = std::fs::metadata(&cache_archive) + .expect("first cached archive") + .ino(); + std::fs::remove_file(&cache_manifest).expect("simulate a pre-sidecar cache"); + + let staging = temp.path().join("main.rs.tmp"); + std::fs::write(&staging, b"fn main() {}").expect("staging write"); + std::fs::rename(&staging, source.join("src/main.rs")).expect("rename over source"); + + store + .prepare_workspace_snapshot( + "job-2", + &source_wire, + DispatchWorkspaceSnapshotCaptureMode::Source, + ) + .await + .expect("second snapshot"); + assert_ne!( + first_cache_ino, + std::fs::metadata(&cache_archive) + .expect("republished cached archive") + .ino(), + "a cache with no manifest must repack instead of reusing the archive" + ); + assert!( + cache_manifest.exists(), + "the repack must publish a sidecar so the next dispatch can compare" + ); + } + #[tokio::test] async fn rejects_path_traversal_job_ids() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/crates/services/services-core/src/dispatch_workspace.rs b/src/crates/services/services-core/src/dispatch_workspace.rs index ae2a2aaf63..964bfcc205 100644 --- a/src/crates/services/services-core/src/dispatch_workspace.rs +++ b/src/crates/services/services-core/src/dispatch_workspace.rs @@ -6,7 +6,7 @@ use std::collections::{BTreeMap, BTreeSet, HashSet}; use std::fs::{self, File, OpenOptions}; use std::io::{self, Read, Write}; -use std::path::{Component, Path}; +use std::path::{Component, Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; use flate2::read::GzDecoder; @@ -88,6 +88,12 @@ pub struct WorkspaceSnapshotSourceFingerprint { pub struct PreparedWorkspaceSnapshot { pub metadata: WorkspaceSnapshotMetadata, pub source_fingerprint: WorkspaceSnapshotSourceFingerprint, + /// The per-file manifest that was sealed into the archive. + /// + /// Packaging already hashes every file while writing the tar stream, so + /// handing this back lets a controller cache answer "did the content + /// actually change?" without repacking. + pub manifest: WorkspaceSnapshotManifest, } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -495,6 +501,20 @@ pub fn source_workspace_snapshot_source_fingerprint( workspace_snapshot_source_fingerprint(source, WorkspaceSnapshotCaptureMode::Source) } +pub fn exact_workspace_matches_manifest( + source: &Path, + manifest: &WorkspaceSnapshotManifest, +) -> Result { + workspace_matches_manifest(source, WorkspaceSnapshotCaptureMode::Exact, manifest) +} + +pub fn source_workspace_matches_manifest( + source: &Path, + manifest: &WorkspaceSnapshotManifest, +) -> Result { + workspace_matches_manifest(source, WorkspaceSnapshotCaptureMode::Source, manifest) +} + fn create_workspace_snapshot( source: &Path, archive_path: &Path, @@ -676,6 +696,7 @@ fn create_workspace_snapshot_inner( uncompressed_bytes, }, source_fingerprint: finish_source_fingerprint(source_fingerprint), + manifest, }) } @@ -764,6 +785,97 @@ fn workspace_snapshot_source_fingerprint( Ok(finish_source_fingerprint(fingerprint)) } +/// Decide whether a source tree still produces the contents of a known manifest. +/// +/// The source fingerprint is deliberately cheap, so it also reports a change for +/// content-neutral operations: `chmod`, a `git checkout` round trip, an editor's +/// write-then-rename (new inode), or a backup tool touching timestamps. This is +/// the more expensive second opinion, and it is only worth asking after the +/// fingerprint already disagreed. +/// +/// Two passes, cheapest first: +/// 1. Structure, using stat data only. Any added, removed, resized, or +/// re-typed entry, or a flipped executable bit, rejects at today's cost. +/// 2. Content, only once the structure matched exactly. Each file is hashed and +/// compared against the digest packaging recorded for it. +/// +/// Anything this function cannot verify — a symlink, a special file, a manifest +/// entry with no recorded digest — is reported as "does not match" so the caller +/// repacks. Repacking surfaces the real diagnostic for those cases. +fn workspace_matches_manifest( + source: &Path, + capture_mode: WorkspaceSnapshotCaptureMode, + manifest: &WorkspaceSnapshotManifest, +) -> Result { + let source_metadata = fs::symlink_metadata(source) + .with_context(|| format!("inspect workspace {}", source.display()))?; + if source_metadata.file_type().is_symlink() || !source_metadata.is_dir() { + bail!( + "workspace snapshot source is not a real directory: {}", + source.display() + ); + } + let source = source + .canonicalize() + .with_context(|| format!("resolve workspace {}", source.display()))?; + + let mut expected: BTreeMap<&str, &WorkspaceSnapshotEntry> = manifest + .entries + .iter() + .map(|entry| (entry.path.as_str(), entry)) + .collect(); + let mut pending_content: Vec<(PathBuf, &str)> = Vec::new(); + + for walked in workspace_walk(&source, capture_mode) { + let walked = walked.context("walk workspace for dispatch snapshot comparison")?; + let path = walked.path(); + if path == source { + continue; + } + let relative = path + .strip_prefix(&source) + .with_context(|| format!("resolve snapshot path {}", path.display()))?; + let relative_wire = portable_relative_path(relative)?; + let metadata = fs::symlink_metadata(path) + .with_context(|| format!("inspect snapshot entry {}", path.display()))?; + let Some(entry) = expected.remove(relative_wire.as_str()) else { + // Added since the snapshot was packaged. + return Ok(false); + }; + if metadata.file_type().is_symlink() || !(metadata.is_dir() || metadata.is_file()) { + return Ok(false); + } + if metadata.is_dir() { + if entry.kind != WorkspaceSnapshotEntryKind::Directory { + return Ok(false); + } + continue; + } + if entry.kind != WorkspaceSnapshotEntryKind::File + || entry.size != metadata.len() + || entry.executable != is_executable(&metadata) + { + return Ok(false); + } + let Some(digest) = entry.sha256.as_deref() else { + return Ok(false); + }; + pending_content.push((path.to_path_buf(), digest)); + } + + if !expected.is_empty() { + // Removed since the snapshot was packaged. + return Ok(false); + } + + for (path, expected_digest) in pending_content { + if !expected_digest.eq_ignore_ascii_case(&sha256_file(&path)?) { + return Ok(false); + } + } + Ok(true) +} + fn workspace_walk(source: &Path, capture_mode: WorkspaceSnapshotCaptureMode) -> ignore::Walk { let mut walk = WalkBuilder::new(source); walk.hidden(false).follow_links(false); @@ -1540,6 +1652,97 @@ mod tests { ); } + /// The manifest comparison is the second opinion the source fingerprint + /// cannot give: it must forgive metadata churn while still catching every + /// real difference in the captured set. + #[test] + fn manifest_comparison_separates_metadata_churn_from_content_changes() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(source.join(".git")).expect("repository marker"); + fs::create_dir_all(source.join("nested")).expect("nested"); + fs::write(source.join(".gitignore"), b"target/\n").expect("gitignore"); + fs::write(source.join("keep.txt"), b"unchanged").expect("keep"); + fs::write(source.join("nested/deep.txt"), b"deep").expect("deep"); + let archive = temp.path().join("snapshot.tar.gz"); + let prepared = + prepare_source_workspace_snapshot(&source, &archive).expect("prepare snapshot"); + let manifest = &prepared.manifest; + + assert!( + source_workspace_matches_manifest(&source, manifest).expect("compare untouched"), + "an untouched tree must match its own manifest" + ); + + // Rewriting identical bytes changes mtime (and ctime) but nothing the + // archive would contain. + fs::write(source.join("keep.txt"), b"unchanged").expect("rewrite identical bytes"); + assert_ne!( + prepared.source_fingerprint, + source_workspace_snapshot_source_fingerprint(&source).expect("fingerprint"), + "the cheap fingerprint is expected to report this as a change" + ); + assert!( + source_workspace_matches_manifest(&source, manifest).expect("compare after rewrite"), + "identical bytes must still match the manifest" + ); + + // An ignored path is outside the captured set entirely. + fs::create_dir_all(source.join("target")).expect("ignored directory"); + fs::write(source.join("target/app"), b"build output").expect("ignored output"); + assert!( + source_workspace_matches_manifest(&source, manifest).expect("compare ignored addition"), + "an ignored addition is not part of the captured set" + ); + + // Same length, different bytes: only the content pass can see this. + fs::write(source.join("keep.txt"), b"unchangeD").expect("same-size edit"); + assert!( + !source_workspace_matches_manifest(&source, manifest).expect("compare same-size edit"), + "a same-size content change must not match" + ); + fs::write(source.join("keep.txt"), b"unchanged").expect("restore"); + + fs::write(source.join("added.txt"), b"new").expect("added"); + assert!( + !source_workspace_matches_manifest(&source, manifest).expect("compare addition"), + "an added file must not match" + ); + fs::remove_file(source.join("added.txt")).expect("undo addition"); + + fs::remove_file(source.join("nested/deep.txt")).expect("deleted"); + assert!( + !source_workspace_matches_manifest(&source, manifest).expect("compare deletion"), + "a deleted file must not match" + ); + } + + /// Packaging refuses symlinks. The comparison must not quietly approve a + /// tree that packaging would reject; it reports "no match" and lets the + /// repack produce the real diagnostic. + #[cfg(unix)] + #[test] + fn manifest_comparison_rejects_a_path_that_became_a_symlink() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + fs::create_dir_all(&source).expect("source"); + fs::write(source.join("real.txt"), b"payload").expect("real"); + let archive = temp.path().join("snapshot.tar.gz"); + let prepared = + prepare_exact_workspace_snapshot(&source, &archive).expect("prepare snapshot"); + + fs::write(temp.path().join("outside.txt"), b"payload").expect("outside"); + fs::remove_file(source.join("real.txt")).expect("remove real file"); + std::os::unix::fs::symlink(temp.path().join("outside.txt"), source.join("real.txt")) + .expect("symlink"); + + assert!( + !exact_workspace_matches_manifest(&source, &prepared.manifest) + .expect("compare symlinked path"), + "a path that became a symlink must not be reported as a match" + ); + } + #[test] fn result_bundle_reports_adds_edits_and_deletes_without_git() { let temp = tempfile::tempdir().expect("tempdir"); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index 97ef528488..21abb82144 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -16,6 +16,7 @@ const mocks = vi.hoisted(() => ({ installCliCancel: vi.fn(), syncModelConfig: vi.fn(), confirmWarning: vi.fn(), + getConfig: vi.fn(), modalOnClose: null as (() => void) | null, modalLifecycleProps: null as { closeOnOverlayClick?: boolean; @@ -40,6 +41,15 @@ vi.mock('@/infrastructure/i18n', () => ({ }), })); +vi.mock('@/infrastructure/config', () => ({ + configManager: { getConfig: mocks.getConfig }, +})); + +vi.mock('@/infrastructure/config/services/modelConfigs', () => ({ + getModelDisplayName: (config: { name?: string; model_name?: string }) => + `${config.name ?? ''}/${config.model_name ?? ''}`, +})); + vi.mock('@/component-library', () => ({ Alert: ({ message }: { message: string }) =>
{message}
, Button: ({ @@ -129,6 +139,7 @@ describe('DispatchInstallDialog installation lifecycle', () => { }); mocks.confirmWarning.mockResolvedValue(true); mocks.installCliCancel.mockResolvedValue(undefined); + mocks.getConfig.mockResolvedValue([]); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -508,6 +519,7 @@ describe('DispatchInstallDialog model configuration sync', () => { mocks.modalOnClose = null; mocks.probeTarget.mockImplementation(async () => probeResult()); mocks.confirmWarning.mockResolvedValue(true); + mocks.getConfig.mockResolvedValue([]); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -583,3 +595,125 @@ describe('DispatchInstallDialog model configuration sync', () => { expect(mocks.probeTarget.mock.calls.length).toBe(probesBeforeClose); }); }); + +describe('DispatchInstallDialog target model readout', () => { + let container: HTMLDivElement; + let root: Root; + + const target = { + kind: 'ssh' as const, + connectionId: 'ssh-1', + displayName: 'build-host', + }; + + function localModel(id: string, modelName: string) { + return { + id, + name: 'Anthropic', + model_name: modelName, + provider: 'anthropic', + base_url: 'https://example.test', + api_key: 'secret', + enabled: true, + category: 'chat', + capabilities: [], + }; + } + + function probeWith(availableModels: string[], defaultModel: string) { + return { + cliInstalled: true, + os: 'linux', + arch: 'x86_64', + installSupported: true, + protocol: { + protocolVersion: 2, + cliVersion: '1.2.3', + os: 'linux', + arch: 'x86_64', + capabilities: [ + 'persistent_jobs', + 'cursor_events', + 'detached_worker', + 'frontend_event_projection', + 'workspace_serialization', + 'dispatch_worker_cli_profile', + ], + modelConfigured: true, + availableModels, + defaultModel, + }, + }; + } + + async function mount() { + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); + } + + beforeEach(() => { + vi.clearAllMocks(); + mocks.modalOnClose = null; + mocks.confirmWarning.mockResolvedValue(true); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + it('reports parity with this device instead of an opaque config id', async () => { + mocks.probeTarget.mockResolvedValue( + probeWith(['model_1', 'model_2'], 'model_2'), + ); + mocks.getConfig.mockResolvedValue([ + localModel('model_1', 'claude-haiku'), + localModel('model_2', 'claude-opus'), + ]); + + await mount(); + + expect(container.textContent).toContain('dispatch.modelMatchesLocal'); + expect(container.textContent).not.toContain('dispatch.modelDiffersFromLocal'); + // The id itself must never be what the user is asked to read. + expect(container.textContent).not.toContain('model_2'); + }); + + it('reports the target model count when the catalogs differ', async () => { + mocks.probeTarget.mockResolvedValue(probeWith(['model_1'], 'model_1')); + mocks.getConfig.mockResolvedValue([ + localModel('model_1', 'claude-haiku'), + localModel('model_2', 'claude-opus'), + ]); + + await mount(); + + expect(container.textContent).toContain('dispatch.modelDiffersFromLocal'); + expect(container.textContent).not.toContain('dispatch.modelMatchesLocal'); + }); + + it('claims no parity when the local catalog cannot be read', async () => { + mocks.probeTarget.mockResolvedValue(probeWith(['model_1'], 'model_1')); + mocks.getConfig.mockRejectedValue(new Error('config unavailable')); + + await mount(); + + expect(container.textContent).toContain('dispatch.modelReadyCount'); + expect(container.textContent).not.toContain('dispatch.modelMatchesLocal'); + expect(container.textContent).not.toContain('dispatch.modelDiffersFromLocal'); + }); +}); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx index f44018e293..e51e62b378 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.tsx @@ -30,6 +30,13 @@ import { DISPATCH_PROTOCOL_VERSION, isDispatchWorkspaceReady, } from './dispatchPreflight'; +import { + compareDispatchModels, + syncableLocalModelIds, +} from './dispatchModelParity'; +import { configManager } from '@/infrastructure/config'; +import { getModelDisplayName } from '@/infrastructure/config/services/modelConfigs'; +import type { AIModelConfig } from '@/infrastructure/config/types'; import './DispatchInstallDialog.scss'; const log = createLogger('DispatchInstallDialog'); @@ -83,6 +90,7 @@ export const DispatchInstallDialog: React.FC = ({ const [installStart, setInstallStart] = useState(null); const [installOutput, setInstallOutput] = useState(''); const [error, setError] = useState(null); + const [localModels, setLocalModels] = useState(null); const generationRef = useRef(0); const activeInstallRef = useRef(null); const workspacePathRef = useRef(workspacePath); @@ -147,6 +155,28 @@ export const DispatchInstallDialog: React.FC = ({ void runProbe(initialPath); }, [open, runProbe, sourceWorkspacePath, target?.defaultWorkspace, targetId]); + // Reload on every open: the model catalog can change in settings while this + // dialog is closed, and a stale local list would report a false divergence. + useEffect(() => { + if (!open) return; + let cancelled = false; + void configManager.getConfig('ai.models') + .then(models => { + if (!cancelled) setLocalModels(Array.isArray(models) ? models : []); + }) + .catch(nextError => { + // Parity is advisory. Losing it degrades the readout to the target's + // own facts rather than blocking the dialog. + log.warn('Failed to read local model configuration for dispatch parity', { + error: nextError, + }); + if (!cancelled) setLocalModels(null); + }); + return () => { + cancelled = true; + }; + }, [open]); + const clearActiveInstall = useCallback((generation: number) => { if (activeInstallRef.current?.generation === generation) { activeInstallRef.current = null; @@ -379,6 +409,22 @@ export const DispatchInstallDialog: React.FC = ({ const modelReady = protocol?.modelConfigured === true; const ready = cliReady && workspaceReady && modelReady && approvalPolicy !== null; + const targetModelCount = protocol?.availableModels?.length ?? 0; + const modelParity = compareDispatchModels( + syncableLocalModelIds(localModels), + protocol?.availableModels, + ); + // The probe carries ids, which name nothing a user recognizes. Resolve the + // target's default through the local catalog when the two agree; when they + // do not, the id would be misleading anyway and the count is the actionable + // fact. + const targetDefaultModelLabel = (() => { + const id = protocol?.defaultModel?.trim(); + if (!id) return t('dispatch.modelAutomatic'); + const local = localModels?.find(model => model.id?.trim() === id); + return local ? getModelDisplayName(local) : id; + })(); + const confirmTarget = () => { if ( !target @@ -649,9 +695,13 @@ export const DispatchInstallDialog: React.FC = ({
{t('dispatch.modelStatus')} - {modelReady - ? t('dispatch.modelReady', { model: protocol?.defaultModel || t('dispatch.modelAutomatic') }) - : protocol?.modelDiagnostic || t('dispatch.modelMissing')} + {!modelReady + ? protocol?.modelDiagnostic || t('dispatch.modelMissing') + : modelParity === 'match' + ? t('dispatch.modelMatchesLocal', { model: targetDefaultModelLabel }) + : modelParity === 'diverged' + ? t('dispatch.modelDiffersFromLocal', { count: targetModelCount }) + : t('dispatch.modelReadyCount', { count: targetModelCount })}
diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts index 9b1e5a56dd..2f255338eb 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.test.ts @@ -28,6 +28,8 @@ import { const mocks = vi.hoisted(() => ({ listJobs: vi.fn(), status: vi.fn(), + loadTranscript: vi.fn(), + saveTranscript: vi.fn(), dispatchExternal: vi.fn(), })); @@ -35,6 +37,8 @@ vi.mock('./dispatchApi', () => ({ dispatchApi: { listJobs: mocks.listJobs, status: mocks.status, + loadTranscript: mocks.loadTranscript, + saveTranscript: mocks.saveTranscript, }, })); @@ -71,7 +75,9 @@ function runningOutboundRecord() { }; } -function registerRunningJob(): void { +function registerRunningJob( + overrides: { cursor?: number; appliedEventIds?: string[] } = {}, +): void { mocks.listJobs.mockResolvedValue([runningOutboundRecord()]); dispatchJobStore.getState().registerJob({ jobId: 'job-1', @@ -101,6 +107,7 @@ function registerRunningJob(): void { omittedEventCount: 0, createdAt: 1, updatedAt: 1, + ...overrides, }); } @@ -271,6 +278,34 @@ function status( }; } +function cachedTranscript( + overrides: Record = {}, +): Record { + return { + schemaVersion: 1, + jobId: 'job-1', + sessionId: 'session-1', + cursor: 120, + dialogTurns: [{ + id: 'turn-cached', + sessionId: 'session-1', + userMessage: { + id: 'user-cached', + content: 'run task', + timestamp: 1, + }, + modelRounds: [], + status: 'completed', + startTime: 1, + }], + appliedEventIds: ['event-cached'], + eventLogComplete: true, + historyTruncated: false, + omittedEventCount: 0, + ...overrides, + }; +} + function createDeferred() { let resolve!: (value: T) => void; const promise = new Promise(resolvePromise => { @@ -292,6 +327,8 @@ describe('DispatchJobObserver', () => { stateMachineManager.clear(); mocks.listJobs.mockReset().mockResolvedValue([]); mocks.status.mockReset(); + mocks.loadTranscript.mockReset().mockResolvedValue(null); + mocks.saveTranscript.mockReset().mockResolvedValue(true); mocks.dispatchExternal.mockReset().mockReturnValue(true); }); @@ -665,7 +702,7 @@ describe('DispatchJobObserver', () => { cleanup(); }); - it('settles cancellation after the terminal log drains without a dialog-turn-cancelled event', async () => { + it('drains the terminal log within one poll and settles cancellation without a dialog-turn-cancelled event', async () => { registerRunningJob(); installProcessingProjection(); const context = createTerminalContext(); @@ -692,13 +729,12 @@ describe('DispatchJobObserver', () => { const cleanup = installDispatchJobObserver(context); await vi.advanceTimersByTimeAsync(0); - expect(flowChatStore.getState().sessions.get('session-1')?.dialogTurns[0].status) - .toBe('processing'); - expect(stateMachineManager.getCurrentState('session-1')) - .toBe(SessionExecutionState.PROCESSING); - - requestDispatchJobRefresh('job-1'); - await vi.advanceTimersByTimeAsync(0); + // The terminal page carrying events does not settle anything by itself; + // the empty page at the same cursor does. Both are pulled in this one + // cycle, so a long log no longer costs one poll interval per page. + expect(mocks.status).toHaveBeenCalledTimes(2); + expect(mocks.status).toHaveBeenNthCalledWith(1, 'job-1', 0); + expect(mocks.status).toHaveBeenNthCalledWith(2, 'job-1', 12); const turn = flowChatStore.getState().sessions.get('session-1')?.dialogTurns[0]; expect(turn).toMatchObject({ status: 'cancelled', @@ -720,6 +756,108 @@ describe('DispatchJobObserver', () => { cleanup(); }); + it('resumes a restarted projection from the cached transcript instead of replaying', async () => { + // The renderer's own cursor survived in localStorage but its transcript did + // not. The cache is what makes resuming possible at all, so it also decides + // where to resume — even though it trails the persisted cursor here. + registerRunningJob({ cursor: 900, appliedEventIds: ['event-stale'] }); + mocks.loadTranscript.mockResolvedValue(cachedTranscript()); + mocks.status.mockResolvedValue(status({ state: 'running', cursor: 120 })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.status).toHaveBeenNthCalledWith(1, 'job-1', 120); + const session = flowChatStore.getState().sessions.get('session-1'); + expect(session?.dialogTurns.map(turn => turn.id)).toEqual(['turn-cached']); + expect(session?.config.dispatchCursor).toBe(120); + const job = dispatchJobStore.getState().jobs['job-1']; + expect(job.cursor).toBe(120); + // Replaced, not merged: an id remembered past the cached cursor would make + // the observer skip an event whose projection is not in the restored turns. + expect(job.appliedEventIds).toEqual(['event-cached']); + cleanup(); + }); + + it('replays from byte zero when no transcript is cached', async () => { + registerRunningJob({ cursor: 900 }); + mocks.loadTranscript.mockResolvedValue(null); + mocks.status.mockResolvedValue(status({ state: 'running', cursor: 0 })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.status).toHaveBeenNthCalledWith(1, 'job-1', 0); + expect(dispatchJobStore.getState().jobs['job-1'].cursor).toBe(0); + expect(flowChatStore.getState().sessions.get('session-1')?.dialogTurns) + .toEqual([]); + cleanup(); + }); + + it('replays from byte zero when the cached transcript predates the current projection rules', async () => { + registerRunningJob({ cursor: 900 }); + mocks.loadTranscript.mockResolvedValue(cachedTranscript({ schemaVersion: 0 })); + mocks.status.mockResolvedValue(status({ state: 'running', cursor: 0 })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + expect(mocks.status).toHaveBeenNthCalledWith(1, 'job-1', 0); + expect(flowChatStore.getState().sessions.get('session-1')?.dialogTurns) + .toEqual([]); + cleanup(); + }); + + it('restores truncation facts with the transcript so an incomplete history is not shown as whole', async () => { + registerRunningJob(); + mocks.loadTranscript.mockResolvedValue(cachedTranscript({ + eventLogComplete: false, + historyTruncated: true, + omittedEventCount: 42, + })); + mocks.status.mockResolvedValue(status({ + state: 'running', + cursor: 120, + eventLogComplete: false, + historyTruncated: true, + omittedEventCount: 42, + })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + + expect(dispatchJobStore.getState().jobs['job-1']).toMatchObject({ + eventLogComplete: false, + historyTruncated: true, + omittedEventCount: 42, + }); + cleanup(); + }); + + it('caches the transcript together with the cursor that produced it', async () => { + registerRunningJob(); + installProcessingProjection(); + mocks.status.mockResolvedValue(status({ state: 'running', cursor: 12 })); + const cleanup = installDispatchJobObserver(createTerminalContext()); + + await vi.advanceTimersByTimeAsync(0); + expect(mocks.saveTranscript).not.toHaveBeenCalled(); + + await vi.advanceTimersByTimeAsync(3000); + expect(mocks.saveTranscript).toHaveBeenCalledTimes(1); + const [jobId, payload] = mocks.saveTranscript.mock.calls[0]; + expect(jobId).toBe('job-1'); + expect(payload).toMatchObject({ + schemaVersion: 1, + jobId: 'job-1', + sessionId: 'session-1', + cursor: 12, + }); + expect(payload.dialogTurns.map((turn: { id: string }) => turn.id)) + .toEqual(['turn-1']); + cleanup(); + }); + it('cancels delayed runtime status rendering when a terminal snapshot drains', async () => { registerRunningJob(); installProcessingProjection(); diff --git a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts index 3622a9f9d5..3cf2eda20f 100644 --- a/src/web-ui/src/features/dispatch/DispatchJobObserver.ts +++ b/src/web-ui/src/features/dispatch/DispatchJobObserver.ts @@ -5,6 +5,7 @@ import { i18nService } from '@/infrastructure/i18n'; import { notificationService } from '@/shared/notification-system'; import { agenticEventListener } from '@/flow_chat/services/AgenticEventListener'; import type { FlowChatContext } from '@/flow_chat/services/flow-chat-manager/types'; +import type { DialogTurn } from '@/flow_chat/types/flow-chat'; import { clearRuntimeStatus } from '@/flow_chat/services/flow-chat-manager/RuntimeStatusModule'; import { clearRuntimeStatusState } from '@/flow_chat/store/runtimeStatusStore'; import { stateMachineManager } from '@/flow_chat/state-machine'; @@ -14,6 +15,12 @@ import { } from '@/flow_chat/state-machine/types'; import { dispatchApi } from './dispatchApi'; import { dispatchJobStore, type DispatchObserverJob } from './dispatchJobStore'; +import { + cancelDispatchTranscriptSaves, + flushDispatchTranscriptSave, + loadDispatchTranscript, + scheduleDispatchTranscriptSave, +} from './dispatchTranscriptCache'; import type { DispatchAgentEventEnvelope, DispatchEvent, @@ -182,7 +189,10 @@ export function dispatchEventId(event: DispatchEvent): string { return `${event.type}:${event.timestamp}:${hashText(JSON.stringify(event))}`; } -function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): boolean { +async function ensureProjection( + context: FlowChatContext, + job: DispatchObserverJob, +): Promise { const sourceWorkspacePath = job.sourceWorkspacePath?.trim() || undefined; const existing = context.flowChatStore.getState().sessions.get(job.sessionId); if (existing) { @@ -221,16 +231,12 @@ function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): b return false; } - // The persisted cursor represents a transcript that lived only in the old - // renderer process. Rebuild a fresh in-memory projection by replaying from - // byte zero; never skip straight to that cursor. - dispatchJobStore.getState().resetReplay(job.jobId); - log.info('Dispatch diagnostic: observer created a flow chat projection', { - jobId: job.jobId, - sessionId: job.sessionId, - sourceWorkspaceId: job.sourceWorkspaceId, - state: job.state, - }); + // A cursor alone cannot rebuild a projection, so it may only be resumed + // together with the transcript it produced. Read that pairing before + // touching any store: if it is missing or unusable, this falls back to the + // original behavior of replaying the whole event log from byte zero. + const cached = await loadDispatchTranscript(job); + context.flowChatStore.addExternalSession( job.sessionId, job.title, @@ -241,18 +247,58 @@ function ensureProjection(context: FlowChatContext, job: DispatchObserverJob): b workspaceId: job.sourceWorkspaceId, }, ); - context.flowChatStore.updateSessionDispatchTarget(job.sessionId, { - targetRequest: job.targetRequest, - target: job.target, + const bindTarget = (cursor: number) => { + context.flowChatStore.updateSessionDispatchTarget(job.sessionId, { + targetRequest: job.targetRequest, + target: job.target, + jobId: job.jobId, + approvalPolicy: job.approvalPolicy, + model: job.model, + availableModels: job.availableModels, + defaultModel: job.defaultModel, + state: job.state, + cursor, + sourceWorkspacePath, + sourceWorkspaceId: job.sourceWorkspaceId, + }); + }; + // Bind the target before hydrating: restoring a transcript is only allowed + // on a session already known to be an observer projection. + bindTarget(0); + const hydrated = + !!cached && + context.flowChatStore.hydrateDispatchTranscript( + job.sessionId, + // Cache content is disk state, not a validated projection. It is + // rendered as-is, exactly like the turns the event replay would build. + cached.dialogTurns as DialogTurn[], + ); + if (hydrated && cached) { + bindTarget(cached.cursor); + // The cache, not the persisted renderer state, decides where to resume. + // The two are written separately, so the renderer's own cursor can be + // ahead of the last transcript that was actually stored; resuming from + // the ahead one would silently skip events the restored turns never saw. + dispatchJobStore.getState().adoptCachedReplay(job.jobId, { + cursor: cached.cursor, + appliedEventIds: cached.appliedEventIds, + eventLogComplete: cached.eventLogComplete, + historyTruncated: cached.historyTruncated, + omittedEventCount: cached.omittedEventCount, + }); + } else { + dispatchJobStore.getState().resetReplay(job.jobId); + } + log.info('Dispatch diagnostic: observer created a flow chat projection', { jobId: job.jobId, - approvalPolicy: job.approvalPolicy, - model: job.model, - availableModels: job.availableModels, - defaultModel: job.defaultModel, - state: job.state, - cursor: 0, - sourceWorkspacePath, + sessionId: job.sessionId, sourceWorkspaceId: job.sourceWorkspaceId, + state: job.state, + // Which of the two restore paths ran, and from where. A projection that + // reports `restoredFromCache: false` on every restart is the symptom to + // chase if long histories still reload page by page. + restoredFromCache: hydrated, + resumeCursor: hydrated && cached ? cached.cursor : 0, }); return context.flowChatStore.getState().sessions.has(job.sessionId); } @@ -360,32 +406,24 @@ function reconcileDispatchTerminalRuntime( }); } -async function refreshJob( +/** + * One status page: pull from the job's current cursor, apply every event, then + * commit. Returns whether the cursor moved, which is the only reason to ask for + * another page in the same poll. + */ +async function refreshJobPage( context: FlowChatContext, requestedJobId: string, isObserverCurrent: () => boolean, -): Promise { +): Promise<'progressed' | 'settled'> { if (!isObserverCurrent()) { - return; + return 'settled'; } - let job = dispatchJobStore.getState().jobs[requestedJobId]; + // Re-read every page: draining spans several awaits, and the store is where + // the cursor this page must request has just been committed. + const job = dispatchJobStore.getState().jobs[requestedJobId]; if (!job) { - return; - } - // `submitting` is a local pre-ack state. There is no durable target job to - // query yet, and a failed submit intentionally remains retryable. - if (job.state === 'submitting') { - return; - } - const projectionExisted = context.flowChatStore.getState().sessions.has(job.sessionId); - if (!ensureProjection(context, job)) { - return; - } - if (projectionExisted && isDispatchJobTerminal(job.state) && job.terminalDrained) { - return; - } - if (!projectionExisted) { - job = dispatchJobStore.getState().jobs[requestedJobId] ?? job; + return 'settled'; } const requestCursor = job.cursor; @@ -394,7 +432,7 @@ async function refreshJob( response = await dispatchApi.status(job.jobId, requestCursor); } catch (error) { if (!isObserverCurrent() || !isJobStillObserved(job)) { - return; + return 'settled'; } dispatchJobStore.getState().setTransportState( job.jobId, @@ -407,7 +445,7 @@ async function refreshJob( // already-issued target poll may still be in flight. Never let that stale // response project SessionCreated/DialogTurnStarted and recreate the row. if (!isObserverCurrent() || !isJobStillObserved(job)) { - return; + return 'settled'; } // A successful target status request is the only authoritative signal that // clears a transient transport failure. It does not alter the durable job @@ -420,14 +458,14 @@ async function refreshJob( context.userCancelledSessionIds?.has(job.sessionId) ?? false; for (const event of response.events) { if (!isObserverCurrent() || !isJobStillObserved(job)) { - return; + return 'settled'; } const eventId = dispatchEventId(event); if (dispatchJobStore.getState().hasAppliedEvent(job.jobId, eventId)) { continue; } if (!applyEvent(context, event)) { - return; + return 'settled'; } // Persist each applied id immediately. If a later event in this response // fails, the cursor stays put but already-applied chunks are not duplicated. @@ -436,7 +474,7 @@ async function refreshJob( }); } if (!isObserverCurrent() || !isJobStillObserved(job)) { - return; + return 'settled'; } const terminalDrained = @@ -468,7 +506,7 @@ async function refreshJob( terminalDrained: needsTerminalFallback, }); if (!applied.applied) { - return; + return 'settled'; } const background = typeof document !== 'undefined' @@ -493,6 +531,19 @@ async function refreshJob( historyTruncated: response.historyTruncated, omittedEventCount: response.omittedEventCount, }); + // Both halves are committed here: the projection holds every event of this + // page and the cursor is the one that produced it. Capturing the pair now, + // rather than when the throttled write runs, is what keeps a cached cursor + // from ever being paired with turns from a different point in the stream. + const projectedSession = context.flowChatStore.getState().sessions.get(job.sessionId); + const committedJob = dispatchJobStore.getState().jobs[job.jobId]; + if (projectedSession && committedJob) { + scheduleDispatchTranscriptSave( + committedJob, + projectedSession.dialogTurns, + applied.cursor, + ); + } if ( job.eventLogComplete !== false && response.eventLogComplete === false @@ -550,6 +601,12 @@ async function refreshJob( }); } } + if (terminalDrained) { + // Nothing more will ever change this transcript. Write it now instead of + // leaving the last few turns to a throttle window that a shutdown could + // cut short. + void flushDispatchTranscriptSave(job.jobId); + } if (needsTerminalFallback) { const effectiveSession = context.flowChatStore .getState() @@ -557,7 +614,7 @@ async function refreshJob( .get(job.sessionId); const effectiveState = effectiveSession?.config.dispatchJobState; if (!effectiveState || !isDispatchJobTerminal(effectiveState)) { - return; + return 'settled'; } reconcileDispatchTerminalRuntime( context, @@ -568,6 +625,51 @@ async function refreshJob( response.lastError, ); } + return response.cursor > requestCursor ? 'progressed' : 'settled'; +} + +/** + * Upper bound on pages pulled in one poll cycle. + * + * Draining is what makes a long history load in one pass instead of one page + * per 1.8s tick. The bound keeps a target that keeps producing events from + * starving every other job in the same cycle. + */ +const MAX_DRAIN_PAGES = 12; + +async function refreshJob( + context: FlowChatContext, + requestedJobId: string, + isObserverCurrent: () => boolean, +): Promise { + if (!isObserverCurrent()) { + return; + } + const job = dispatchJobStore.getState().jobs[requestedJobId]; + if (!job) { + return; + } + // `submitting` is a local pre-ack state. There is no durable target job to + // query yet, and a failed submit intentionally remains retryable. + if (job.state === 'submitting') { + return; + } + const projectionExisted = context.flowChatStore.getState().sessions.has(job.sessionId); + if (!await ensureProjection(context, job)) { + return; + } + if (projectionExisted && isDispatchJobTerminal(job.state) && job.terminalDrained) { + return; + } + + for (let page = 0; page < MAX_DRAIN_PAGES; page += 1) { + if (await refreshJobPage(context, requestedJobId, isObserverCurrent) === 'settled') { + return; + } + } + log.debug('Stopped draining dispatch pages at the per-poll bound', { + jobId: requestedJobId, + }); } export function installDispatchJobObserver(context: FlowChatContext): () => void { @@ -679,6 +781,10 @@ export function installDispatchJobObserver(context: FlowChatContext): () => void if (typeof document !== 'undefined' && handleVisibilityChanged) { document.removeEventListener('visibilitychange', handleVisibilityChanged); } + // Drop rather than flush: a pending payload only ever trails what is + // already cached, so losing it costs a few replayed events, while writing + // during teardown could race whatever tears the store down next. + cancelDispatchTranscriptSaves(); }; lease = { requestRefresh: schedule, diff --git a/src/web-ui/src/features/dispatch/README.md b/src/web-ui/src/features/dispatch/README.md index b2a6d4aab1..57f026edb0 100644 --- a/src/web-ui/src/features/dispatch/README.md +++ b/src/web-ui/src/features/dispatch/README.md @@ -14,9 +14,17 @@ dispatch. local session persistence for it. 4. The target CLI owns the ordinary durable session and the append-only event log. The controller owns only the outbound observer index and a UI cache. + That UI cache is the observer transcript stored under + `~/.bitfun/dispatch/outbound/.transcripts/.json`. It holds the + rendered projection, never a durable session, and the controller stores it + verbatim without interpreting it. 5. Status cursors advance only after every returned event has been applied. Agent envelope ids are deduplicated before replay. Terminal jobs keep - polling until an empty page confirms that the event log is fully drained. + polling until an empty page confirms that the event log is fully drained. A + persisted cursor is only reusable while its transcript cache is present and + valid; the cursor recorded in that cache wins over any other stored cursor, + because only those two were written together. Missing, corrupt, or + version-mismatched cache means replay from byte zero. 6. SSH CLI installation is always a separate, explicit confirmation. The UI displays the resolved version, URL, and SHA256 before starting it. 7. Account devices use encrypted request/response RPC and distinct diff --git a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts index 1d27bb3830..c9f98dd015 100644 --- a/src/web-ui/src/features/dispatch/dispatch.contract.test.ts +++ b/src/web-ui/src/features/dispatch/dispatch.contract.test.ts @@ -19,6 +19,8 @@ const OUTBOUND_DISPATCH_COMMANDS = [ 'dispatch_list_jobs', 'dispatch_answer', 'dispatch_append', + 'dispatch_load_transcript', + 'dispatch_save_transcript', ] as const; function read(relativePath: string): string { diff --git a/src/web-ui/src/features/dispatch/dispatchApi.ts b/src/web-ui/src/features/dispatch/dispatchApi.ts index e6497b6b77..d7a90c8e3b 100644 --- a/src/web-ui/src/features/dispatch/dispatchApi.ts +++ b/src/web-ui/src/features/dispatch/dispatchApi.ts @@ -13,6 +13,7 @@ import type { DispatchSubmitResponse, DispatchTargetOption, DispatchTargetRequest, + DispatchTranscriptCache, DispatchWorkspaceDeliveryRequest, OutboundDispatchRecord, } from './types'; @@ -167,4 +168,32 @@ export const dispatchApi = { request: { target }, }); }, + + /** + * Read this controller's cached observer transcript for a job. + * + * Controller-local only. Returns `null` when nothing is cached, which sends + * the observer back to replaying the target's event log from byte zero. + */ + async loadTranscript(jobId: string): Promise { + return api.invoke('dispatch_load_transcript', { + request: { jobId }, + }); + }, + + /** + * Persist this controller's observer transcript for a job, or pass `null` to + * erase it. + * + * Resolves `false` when the transcript is above the controller's cache + * ceiling; the previous entry is kept and observing continues unchanged. + */ + async saveTranscript( + jobId: string, + transcript: DispatchTranscriptCache | null, + ): Promise { + return api.invoke('dispatch_save_transcript', { + request: { jobId, transcript }, + }); + }, }; diff --git a/src/web-ui/src/features/dispatch/dispatchJobStore.ts b/src/web-ui/src/features/dispatch/dispatchJobStore.ts index fa82a1d5f2..4bcfcca310 100644 --- a/src/web-ui/src/features/dispatch/dispatchJobStore.ts +++ b/src/web-ui/src/features/dispatch/dispatchJobStore.ts @@ -196,6 +196,16 @@ interface DispatchJobStoreState { lastTransportError?: string, ) => void; resetReplay: (jobId: string) => void; + adoptCachedReplay: ( + jobId: string, + cached: { + cursor: number; + appliedEventIds: string[]; + eventLogComplete: boolean; + historyTruncated: boolean; + omittedEventCount: number; + }, + ) => void; updateTitle: (jobId: string, title: string) => void; updateModel: (jobId: string, model: string) => void; updateApprovalPolicy: (jobId: string, policy: DispatchApprovalPolicy) => void; @@ -515,6 +525,32 @@ export const useDispatchJobStore = create()( }); }, + adoptCachedReplay: (jobId, cached) => { + set(state => { + const current = state.jobs[jobId]; + if (!current) return state; + return { + jobs: { + ...state.jobs, + [jobId]: { + ...current, + // Replaces rather than merges, and may move the cursor + // backwards. The restored transcript defines exactly which + // events are already on screen; anything this store remembers + // beyond that was projected into a renderer that is gone. + cursor: Math.max(0, cached.cursor), + terminalDrained: false, + appliedEventIds: cached.appliedEventIds.slice(-MAX_APPLIED_EVENT_IDS), + eventLogComplete: cached.eventLogComplete, + historyTruncated: cached.historyTruncated, + omittedEventCount: cached.omittedEventCount, + updatedAt: Date.now(), + }, + }, + }; + }); + }, + updateTitle: (jobId, title) => { set(state => { const current = state.jobs[jobId]; diff --git a/src/web-ui/src/features/dispatch/dispatchModelParity.test.ts b/src/web-ui/src/features/dispatch/dispatchModelParity.test.ts new file mode 100644 index 0000000000..f8e426fa41 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchModelParity.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import { + compareDispatchModels, + syncableLocalModelIds, +} from './dispatchModelParity'; +import type { AIModelConfig } from '@/infrastructure/config/types'; + +function model(overrides: Partial & { id: string }): AIModelConfig { + return { + name: 'Anthropic', + provider: 'anthropic', + base_url: 'https://example.test', + model_name: 'claude', + api_key: 'secret', + enabled: true, + category: 'chat', + capabilities: [], + ...overrides, + } as AIModelConfig; +} + +describe('syncableLocalModelIds', () => { + it('keeps only what a target could construct a client for', () => { + expect( + syncableLocalModelIds([ + model({ id: 'ready' }), + model({ id: 'disabled', enabled: false }), + model({ id: 'no-key', api_key: ' ' }), + model({ id: ' ' }), + ]), + ).toEqual(['ready']); + }); + + it('keeps a subscription model without an inline key', () => { + expect( + syncableLocalModelIds([ + model({ id: 'oauth', api_key: '', auth: { type: 'subscription', provider: 'codex' } }), + ]), + ).toEqual(['oauth']); + }); + + it('reports an unreadable catalog as unknown rather than empty', () => { + expect(syncableLocalModelIds(null)).toBeNull(); + expect(syncableLocalModelIds(undefined)).toBeNull(); + expect(syncableLocalModelIds([])).toEqual([]); + }); +}); + +describe('compareDispatchModels', () => { + it('matches on the same id set regardless of order', () => { + expect(compareDispatchModels(['a', 'b'], ['b', 'a'])).toBe('match'); + }); + + it('diverges when the target is missing or carries an extra model', () => { + expect(compareDispatchModels(['a', 'b'], ['a'])).toBe('diverged'); + expect(compareDispatchModels(['a'], ['a', 'b'])).toBe('diverged'); + expect(compareDispatchModels(['a'], ['b'])).toBe('diverged'); + }); + + it('never claims parity without both sides', () => { + expect(compareDispatchModels(null, ['a'])).toBe('unknown'); + expect(compareDispatchModels(['a'], undefined)).toBe('unknown'); + }); + + it('treats two empty catalogs as matching', () => { + expect(compareDispatchModels([], [])).toBe('match'); + }); +}); diff --git a/src/web-ui/src/features/dispatch/dispatchModelParity.ts b/src/web-ui/src/features/dispatch/dispatchModelParity.ts new file mode 100644 index 0000000000..4e7bdd72d7 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchModelParity.ts @@ -0,0 +1,52 @@ +import type { AIModelConfig } from '@/infrastructure/config/types'; + +/** + * How the target's model catalog relates to this controller's. + * + * `unknown` is a real outcome, not an error: the local catalog may not have + * loaded yet, and claiming parity we have not established would be worse than + * reporting only what the target advertised. + */ +export type DispatchModelParity = 'match' | 'diverged' | 'unknown'; + +/** + * The local model ids a target should end up advertising after a model-config + * sync. + * + * A target's probe lists only the ids it could actually construct a client + * for, so the comparable local set applies the same two filters the target + * applies to the configuration it receives: the model must be enabled, and an + * api-key model must carry a key. A local model that fails either filter can + * never appear on the target, so counting it as a difference would report a + * divergence the user cannot resolve by syncing. + */ +export function syncableLocalModelIds( + models: AIModelConfig[] | null | undefined, +): string[] | null { + if (!Array.isArray(models)) return null; + const ids = new Set(); + for (const model of models) { + const id = model?.id?.trim(); + if (!id || !model.enabled) continue; + const usesApiKey = !model.auth || model.auth.type === 'api_key'; + if (usesApiKey && !model.api_key?.trim()) continue; + ids.add(id); + } + return Array.from(ids).sort(); +} + +/** + * Compare the target's ready model ids against the local ones. + * + * Ids are stable across a sync because the sync copies the local catalog + * verbatim, so set equality is what "same configuration" means here. + */ +export function compareDispatchModels( + localIds: string[] | null, + targetIds: string[] | null | undefined, +): DispatchModelParity { + if (!localIds || !Array.isArray(targetIds)) return 'unknown'; + const target = new Set(targetIds.map(id => id.trim()).filter(Boolean)); + if (target.size !== localIds.length) return 'diverged'; + return localIds.every(id => target.has(id)) ? 'match' : 'diverged'; +} diff --git a/src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts b/src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts new file mode 100644 index 0000000000..5736b364f6 --- /dev/null +++ b/src/web-ui/src/features/dispatch/dispatchTranscriptCache.ts @@ -0,0 +1,163 @@ +import type { DialogTurn } from '@/flow_chat/types/flow-chat'; +import { createLogger } from '@/shared/utils/logger'; +import { dispatchApi } from './dispatchApi'; +import type { DispatchObserverJob } from './dispatchJobStore'; +import { + DISPATCH_TRANSCRIPT_SCHEMA_VERSION, + type DispatchTranscriptCache, +} from './types'; + +const log = createLogger('DispatchTranscriptCache'); + +/** + * Coalescing window for transcript writes. + * + * A streaming job produces many small projection updates per second. Writing + * the whole transcript for each one would trade the replay cost this cache + * removes for a file-write cost of the same order. + */ +const SAVE_THROTTLE_MS = 3000; + +/** + * Payloads are captured at a quiescent point and written later, so what is + * pending here is always internally consistent even if the observer has moved + * on since. Only the newest payload per job survives the throttle window. + */ +const pendingPayloads = new Map(); +const pendingTimers = new Map>(); + +/** + * Read and validate this controller's cached transcript for a job. + * + * Every rejection path returns `null`, which puts the observer back on the + * full-replay-from-byte-zero behavior it had before this cache existed. That + * fallback is the reason none of these checks need to be recoverable. + */ +export async function loadDispatchTranscript( + job: Pick, +): Promise { + let cached: DispatchTranscriptCache | null; + try { + cached = await dispatchApi.loadTranscript(job.jobId); + } catch (error) { + log.warn('Failed to read cached dispatch transcript', { + jobId: job.jobId, + error, + }); + return null; + } + if (!cached || typeof cached !== 'object') { + return null; + } + if (cached.schemaVersion !== DISPATCH_TRANSCRIPT_SCHEMA_VERSION) { + // The projection rules changed since this was written. Replaying is the + // only way to reproject under the current ones. + return null; + } + if (cached.jobId !== job.jobId || cached.sessionId !== job.sessionId) { + return null; + } + if (!Number.isFinite(cached.cursor) || cached.cursor <= 0) { + return null; + } + if (!Array.isArray(cached.dialogTurns) || cached.dialogTurns.length === 0) { + return null; + } + if (!Array.isArray(cached.appliedEventIds)) { + return null; + } + return cached; +} + +/** + * Capture the current projection for a job so it can be written soon. + * + * Call this only where the transcript and the cursor agree — that is, after a + * whole status page has been applied and committed. Capturing eagerly and + * writing lazily is what keeps a throttled write from ever pairing a cursor + * with turns from a different point in the stream. + */ +export function scheduleDispatchTranscriptSave( + job: DispatchObserverJob, + dialogTurns: DialogTurn[], + cursor: number, +): void { + if (cursor <= 0 || dialogTurns.length === 0) { + return; + } + pendingPayloads.set(job.jobId, { + schemaVersion: DISPATCH_TRANSCRIPT_SCHEMA_VERSION, + jobId: job.jobId, + sessionId: job.sessionId, + cursor, + dialogTurns, + appliedEventIds: job.appliedEventIds, + // Completeness travels with the transcript. Restoring turns without these + // would render a truncated history as a whole one. + eventLogComplete: job.eventLogComplete, + historyTruncated: job.historyTruncated, + omittedEventCount: job.omittedEventCount, + }); + if (pendingTimers.has(job.jobId)) { + return; + } + pendingTimers.set( + job.jobId, + setTimeout(() => { + pendingTimers.delete(job.jobId); + void flushDispatchTranscriptSave(job.jobId); + }, SAVE_THROTTLE_MS), + ); +} + +/** Write whatever was last captured for a job, if anything. */ +export async function flushDispatchTranscriptSave(jobId: string): Promise { + const timer = pendingTimers.get(jobId); + if (timer) { + clearTimeout(timer); + pendingTimers.delete(jobId); + } + const payload = pendingPayloads.get(jobId); + if (!payload) { + return; + } + pendingPayloads.delete(jobId); + try { + const saved = await dispatchApi.saveTranscript(jobId, payload); + if (!saved) { + log.debug('Dispatch transcript above the controller cache limit', { jobId }); + } + } catch (error) { + // Losing the cache costs a full replay on the next restart, nothing more. + log.warn('Failed to cache dispatch transcript', { jobId, error }); + } +} + +/** + * Drop a job's cached transcript, on disk and in flight. + * + * Deleting a projection must not leave its content readable in the controller's + * cache until retention gets around to it. + */ +export async function forgetDispatchTranscript(jobId: string): Promise { + const timer = pendingTimers.get(jobId); + if (timer) { + clearTimeout(timer); + pendingTimers.delete(jobId); + } + pendingPayloads.delete(jobId); + try { + await dispatchApi.saveTranscript(jobId, null); + } catch (error) { + log.warn('Failed to drop cached dispatch transcript', { jobId, error }); + } +} + +/** Cancel every pending write without flushing. Used when the observer stops. */ +export function cancelDispatchTranscriptSaves(): void { + for (const timer of pendingTimers.values()) { + clearTimeout(timer); + } + pendingTimers.clear(); + pendingPayloads.clear(); +} diff --git a/src/web-ui/src/features/dispatch/types.ts b/src/web-ui/src/features/dispatch/types.ts index 77f710dcf0..5669e96817 100644 --- a/src/web-ui/src/features/dispatch/types.ts +++ b/src/web-ui/src/features/dispatch/types.ts @@ -242,6 +242,34 @@ export interface OutboundDispatchRecord { updatedAt: string; } +/** + * Bumped whenever the projection this cache stores changes shape or meaning. + * + * A mismatch discards the cache and replays the job from byte zero, so this is + * the only thing standing between a projection change and a transcript rendered + * by rules that no longer exist. + */ +export const DISPATCH_TRANSCRIPT_SCHEMA_VERSION = 1; + +/** + * The controller's UI cache for one observer projection. + * + * Cursor, turns, and completeness facts are one document on purpose: the cursor + * is only meaningful next to the turns it produced, and rendering a truncated + * transcript as a complete one is exactly what dispatch invariant 14 forbids. + */ +export interface DispatchTranscriptCache { + schemaVersion: number; + jobId: string; + sessionId: string; + cursor: number; + dialogTurns: unknown[]; + appliedEventIds: string[]; + eventLogComplete: boolean; + historyTruncated: boolean; + omittedEventCount: number; +} + export interface DispatchSelection { request: Exclude; target: Exclude; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index 2f47b7a954..80b0fe60d6 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -55,6 +55,7 @@ import { type DispatchTarget, } from '@/features/dispatch/types'; import { dispatchJobStore } from '@/features/dispatch/dispatchJobStore'; +import { forgetDispatchTranscript } from '@/features/dispatch/dispatchTranscriptCache'; const log = createLogger('SessionModule'); const pendingSessionCreations = new Map>(); @@ -79,10 +80,28 @@ function dismissDispatchObserverProjection( sessionId: string, session: Session | undefined, ): void { + // Collect before dismissing: dismissSession removes the store entries that + // are the only remaining link from this session to its cached transcripts. + const jobIds = new Set( + Object.values(dispatchJobStore.getState().jobs) + .filter(job => job.sessionId === sessionId) + .map(job => job.jobId), + ); + const configuredJobId = session?.config.dispatchJobId?.trim(); + if (configuredJobId) { + jobIds.add(configuredJobId); + } + dispatchJobStore.getState().dismissSession( sessionId, session?.config.dispatchJobId, ); + + // The projection is gone for good, so its cached transcript must not stay + // readable on disk until retention gets around to it. + jobIds.forEach(jobId => { + void forgetDispatchTranscript(jobId); + }); } const getHydrationLocationKey = ( diff --git a/src/web-ui/src/flow_chat/store/FlowChatStore.ts b/src/web-ui/src/flow_chat/store/FlowChatStore.ts index 7211142d96..8c1f23e1dd 100644 --- a/src/web-ui/src/flow_chat/store/FlowChatStore.ts +++ b/src/web-ui/src/flow_chat/store/FlowChatStore.ts @@ -2340,6 +2340,51 @@ export class FlowChatStore { }); } + /** + * Restore an observer projection's transcript from the controller's UI cache. + * + * Frontend state only, exactly like {@link updateSessionDispatchTarget}: the + * target CLI still owns the durable session, so this must not create a local + * runtime session or write the normal session store. + * + * The turns and the cursor are cached together, so the cursor may only be + * adopted when this call reports success. Refuses to hydrate a session that + * already has turns — replacing live content with a stale cache would drop + * whatever the observer projected in the meantime. + */ + public hydrateDispatchTranscript( + sessionId: string, + turns: DialogTurn[], + ): boolean { + if (turns.length === 0) return false; + let hydrated = false; + + this.setState(prev => { + const session = prev.sessions.get(sessionId); + if ( + !session || + session.dialogTurns.length > 0 || + !session.config.dispatchTarget || + session.config.dispatchTarget.kind === 'local' + ) { + return prev; + } + + const newSessions = new Map(prev.sessions); + newSessions.set(sessionId, { + ...session, + // `historyState` deliberately stays as `addExternalSession` left it. + // An observer projection has no local history to lazily hydrate, and + // the full-replay path does not move it either. + dialogTurns: [...turns].sort(compareDialogTurnOrder), + }); + hydrated = true; + return { ...prev, sessions: newSessions }; + }); + + return hydrated; + } + /** * Commit a target-side status snapshot only when it still follows the cursor * that was polled. The observer applies all events first, then calls this diff --git a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts index 57f220b936..f52e214c0f 100644 --- a/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts +++ b/src/web-ui/src/infrastructure/api/adapters/peer-device-adapter.ts @@ -71,6 +71,8 @@ const LOCAL_ONLY_COMMANDS = new Set([ 'dispatch_list_jobs', 'dispatch_answer', 'dispatch_append', + 'dispatch_load_transcript', + 'dispatch_save_transcript', 'remote_connect_get_device_info', 'remote_connect_get_lan_ip', 'remote_connect_get_lan_network_info', diff --git a/src/web-ui/src/locales/en-US/common.json b/src/web-ui/src/locales/en-US/common.json index 6b6dac69bb..18e6c35720 100644 --- a/src/web-ui/src/locales/en-US/common.json +++ b/src/web-ui/src/locales/en-US/common.json @@ -1489,7 +1489,9 @@ "upstreamStatus": "Upstream", "upstreamCounts": "{{ahead}} ahead · {{behind}} behind", "modelStatus": "Target model", - "modelReady": "Ready ({{model}})", + "modelMatchesLocal": "Ready (same as this device · {{model}})", + "modelDiffersFromLocal": "Ready (differs from this device · {{count}} models on target)", + "modelReadyCount": "Ready ({{count}} models on target)", "modelAutomatic": "target default", "modelMissing": "No usable model is configured on the target", "syncModelRequired": "Sync model configuration", diff --git a/src/web-ui/src/locales/zh-CN/common.json b/src/web-ui/src/locales/zh-CN/common.json index 714f5548c6..0d5cb73709 100644 --- a/src/web-ui/src/locales/zh-CN/common.json +++ b/src/web-ui/src/locales/zh-CN/common.json @@ -1489,7 +1489,9 @@ "upstreamStatus": "上游", "upstreamCounts": "领先 {{ahead}} · 落后 {{behind}}", "modelStatus": "目标模型", - "modelReady": "就绪({{model}})", + "modelMatchesLocal": "就绪(与本机一致 · {{model}})", + "modelDiffersFromLocal": "就绪(与本机不同 · 目标有 {{count}} 个模型)", + "modelReadyCount": "就绪(目标有 {{count}} 个模型)", "modelAutomatic": "目标默认模型", "modelMissing": "目标上没有可用的模型配置", "syncModelRequired": "同步模型配置", diff --git a/src/web-ui/src/locales/zh-TW/common.json b/src/web-ui/src/locales/zh-TW/common.json index 3179eee6d8..73fd4d53ff 100644 --- a/src/web-ui/src/locales/zh-TW/common.json +++ b/src/web-ui/src/locales/zh-TW/common.json @@ -1489,7 +1489,9 @@ "upstreamStatus": "上游", "upstreamCounts": "領先 {{ahead}} · 落後 {{behind}}", "modelStatus": "目標模型", - "modelReady": "就緒({{model}})", + "modelMatchesLocal": "就緒(與本機一致 · {{model}})", + "modelDiffersFromLocal": "就緒(與本機不同 · 目標有 {{count}} 個模型)", + "modelReadyCount": "就緒(目標有 {{count}} 個模型)", "modelAutomatic": "目標預設模型", "modelMissing": "目標上沒有可用的模型設定", "syncModelRequired": "同步模型設定",