diff --git a/Cargo.lock b/Cargo.lock index 8053c9987..41fb46abc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6383,6 +6383,7 @@ dependencies = [ name = "yoi-workspace-server" version = "0.1.0" dependencies = [ + "agen", "async-trait", "axum", "chrono", @@ -6410,6 +6411,7 @@ dependencies = [ "tokio", "tokio-tungstenite 0.29.0", "toml", + "tools", "tower", "tracing", "tracing-subscriber", @@ -6421,6 +6423,7 @@ dependencies = [ "workdir", "worker", "worker-runtime", + "yoi", "zeroize", ] diff --git a/crates/fs-operation/src/lib.rs b/crates/fs-operation/src/lib.rs index 465e3b6b9..7fa897df8 100644 --- a/crates/fs-operation/src/lib.rs +++ b/crates/fs-operation/src/lib.rs @@ -15,7 +15,8 @@ use thiserror::Error; pub use glob::run_glob; pub use local::{ - resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_stat, run_write, + resolve_access_path, run_edit, run_list, run_read, run_read_bounded, run_read_bytes, + run_read_bytes_bounded, run_stat, run_write, }; pub use operation::*; pub use search::run_grep; @@ -569,6 +570,103 @@ mod tests { } } + #[test] + fn bounded_byte_reads_preserve_binary_ranges_and_fence_versions() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + let original = b"\x89PNG\r\n\x1a\n\0line\n\xfftail"; + std::fs::write(root.join("image.bin"), original).unwrap(); + let access = RootAccess(root.clone()); + let limits = BoundedReadLimits::new(1024, 8).unwrap(); + + let first = run_read_bytes_bounded( + &root, + ReadBytesRequest { + path: FsPath::new("image.bin").unwrap(), + offset: 0, + max_bytes: 8, + expected_hash: None, + }, + &access, + limits, + ) + .unwrap(); + assert_eq!(first.bytes, original[..8]); + assert_eq!(first.total_bytes, original.len() as u64); + assert!(!first.eof); + + let second = run_read_bytes_bounded( + &root, + ReadBytesRequest { + path: FsPath::new("image.bin").unwrap(), + offset: 8, + max_bytes: 8, + expected_hash: Some(first.content_hash), + }, + &access, + limits, + ) + .unwrap(); + assert_eq!(second.bytes, original[8..16]); + assert_eq!(second.content_hash, first.content_hash); + + std::fs::write(root.join("image.bin"), b"different bytes long!").unwrap(); + let error = run_read_bytes_bounded( + &root, + ReadBytesRequest { + path: FsPath::new("image.bin").unwrap(), + offset: 16, + max_bytes: 8, + expected_hash: Some(first.content_hash), + }, + &access, + limits, + ) + .expect_err("a later chunk must not cross file versions"); + assert!(matches!(error, FsError::Conflict(_))); + } + + #[test] + fn bounded_byte_reads_reject_request_and_source_limit_overflow() { + let directory = tempfile::tempdir().unwrap(); + let root = directory.path().canonicalize().unwrap(); + std::fs::write(root.join("image.bin"), [0_u8; 17]).unwrap(); + let access = RootAccess(root.clone()); + let limits = BoundedReadLimits::new(16, 8).unwrap(); + + let response_error = run_read_bytes_bounded( + &root, + ReadBytesRequest { + path: FsPath::new("image.bin").unwrap(), + offset: 0, + max_bytes: 9, + expected_hash: None, + }, + &access, + limits, + ) + .expect_err("requests above the response ceiling must be rejected"); + assert!( + matches!(response_error, FsError::InvalidArgument(message) if message.contains("8 bytes")) + ); + + let source_error = run_read_bytes_bounded( + &root, + ReadBytesRequest { + path: FsPath::new("image.bin").unwrap(), + offset: 0, + max_bytes: 8, + expected_hash: None, + }, + &access, + limits, + ) + .expect_err("sources above the provider ceiling must be rejected"); + assert!( + matches!(source_error, FsError::InvalidArgument(message) if message.contains("provider limit 16")) + ); + } + #[test] fn cancellation_stops_a_read_already_in_progress() { use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; diff --git a/crates/fs-operation/src/local.rs b/crates/fs-operation/src/local.rs index aaff61910..34f60b92d 100644 --- a/crates/fs-operation/src/local.rs +++ b/crates/fs-operation/src/local.rs @@ -7,8 +7,9 @@ use sha2::{Digest, Sha256}; use crate::{ AtomicWriteMode, BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, - FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadRequest, ReadResult, - StatRequest, StatResult, WriteRequest, WriteResult, direct_symlink, + FsAccessPolicy, FsError, FsPath, ListEntry, ListRequest, ListResult, ReadBytesRequest, + ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, + direct_symlink, }; const READ_BUFFER_BYTES: usize = 16 * 1024; @@ -195,6 +196,137 @@ fn run_read_with_limits( }) } +pub fn run_read_bytes( + root: &Path, + request: ReadBytesRequest, + access: &dyn FsAccessPolicy, +) -> Result { + run_read_bytes_with_limits(root, request, access, None) +} + +pub fn run_read_bytes_bounded( + root: &Path, + request: ReadBytesRequest, + access: &dyn FsAccessPolicy, + limits: BoundedReadLimits, +) -> Result { + run_read_bytes_with_limits(root, request, access, Some(limits)) +} + +fn run_read_bytes_with_limits( + root: &Path, + request: ReadBytesRequest, + access: &dyn FsAccessPolicy, + limits: Option, +) -> Result { + let logical = request.path; + if request.max_bytes == 0 { + return Err(FsError::InvalidArgument( + "byte read max_bytes must be greater than zero".to_string(), + )); + } + if let Some(limits) = limits + && request.max_bytes > limits.max_response_bytes + { + return Err(FsError::InvalidArgument(format!( + "byte read response limit is {} bytes", + limits.max_response_bytes + ))); + } + + access + .check_cancelled() + .map_err(|error| map_io(&logical, error))?; + let path = resolve(root, &logical)?; + let target = require_access(&path, &logical, access, false, false)?; + let file = access + .open_read_file(&path, &target) + .map_err(|error| map_io(&logical, error))?; + let metadata = file.metadata().map_err(|error| map_io(&logical, error))?; + if metadata.is_dir() { + return Err(FsError::IsDirectory(PathBuf::from(logical.as_str()))); + } + if !metadata.is_file() { + return Err(FsError::InvalidArgument( + "byte read source must be a regular file".to_string(), + )); + } + if let Some(limits) = limits + && metadata.len() > limits.max_source_bytes + { + return Err(FsError::InvalidArgument(format!( + "byte read source exceeds provider limit {}", + limits.max_source_bytes + ))); + } + + let mut bytes = Vec::with_capacity(request.max_bytes.min(READ_BUFFER_BYTES)); + let mut source_bytes = 0_u64; + let mut hasher = Sha256::new(); + let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file); + let mut buffer = [0_u8; READ_BUFFER_BYTES]; + loop { + access + .check_cancelled() + .map_err(|error| map_io(&logical, error))?; + let read_limit = limits + .map(|limits| { + limits + .max_source_bytes + .saturating_sub(source_bytes) + .saturating_add(1) + .min(READ_BUFFER_BYTES as u64) as usize + }) + .unwrap_or(READ_BUFFER_BYTES); + let read = reader + .read(&mut buffer[..read_limit]) + .map_err(|error| map_io(&logical, error))?; + if read == 0 { + break; + } + let chunk_start = source_bytes; + source_bytes = source_bytes.saturating_add(read as u64); + if let Some(limits) = limits + && source_bytes > limits.max_source_bytes + { + return Err(FsError::InvalidArgument(format!( + "byte read source exceeds provider limit {}", + limits.max_source_bytes + ))); + } + hasher.update(&buffer[..read]); + + if bytes.len() < request.max_bytes && source_bytes > request.offset { + let start = request.offset.saturating_sub(chunk_start).min(read as u64) as usize; + let retained = (request.max_bytes - bytes.len()).min(read - start); + bytes.extend_from_slice(&buffer[start..start + retained]); + } + } + + let content_hash: ContentHash = hasher.finalize().into(); + if request + .expected_hash + .is_some_and(|expected| expected != content_hash) + { + return Err(FsError::Conflict(logical.as_str().to_string())); + } + if request.offset > source_bytes { + return Err(FsError::InvalidArgument(format!( + "byte offset {} exceeds file size {source_bytes}", + request.offset + ))); + } + let eof = request.offset.saturating_add(bytes.len() as u64) == source_bytes; + Ok(ReadBytesResult { + path: logical, + bytes, + offset: request.offset, + total_bytes: source_bytes, + content_hash, + eof, + }) +} + pub fn run_write( root: &Path, request: WriteRequest, diff --git a/crates/fs-operation/src/operation.rs b/crates/fs-operation/src/operation.rs index f5765b6f6..ddb309a8d 100644 --- a/crates/fs-operation/src/operation.rs +++ b/crates/fs-operation/src/operation.rs @@ -207,6 +207,36 @@ pub struct ReadResult { pub truncated: bool, } +/// Binary-safe byte range read with an optional whole-file version fence. +/// +/// Unlike [`ReadRequest`], offsets and limits are bytes rather than lines. The +/// provider still hashes the complete bounded source so callers can reject +/// chunks from different file versions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReadBytesRequest { + pub path: FsPath, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub offset: u64, + #[schemars(range(min = 1, max = 9_007_199_254_740_991_usize))] + pub max_bytes: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReadBytesResult { + pub path: FsPath, + pub bytes: Vec, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub offset: u64, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub total_bytes: u64, + pub content_hash: ContentHash, + pub eof: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WriteRequest { diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 5478443c2..05c9f37ae 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -838,6 +838,56 @@ pub struct SessionSnapshot { pub entries: Vec, } +/// One user-visible conversation turn. A turn starts at a canonical user entry +/// and contains every public committed entry until the next user entry. +/// `turn_id` is the stable `entry_id` of that first user entry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +pub struct SessionConversationTurn { + pub turn_id: String, + pub entries: Vec, +} + +/// The immediate parent lineage and the stable real-user boundary adopted from it. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +pub struct SessionHistoryLineageBoundary { + pub lineage_id: String, + /// Canonical public form of the final user-visible turn at the exact + /// provider boundary. Entries after that boundary are excluded. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub adopted_through_turn: Option, +} + +/// Bounded backward page over the adopted conversation lineage. +/// +/// Turns remain chronological within a page. `next_cursor` is an opaque, +/// identity-bound boundary for the page immediately preceding `turns`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] +pub struct SessionHistoryPage { + pub session_id: String, + /// Stable identity of the adopted active lineage used to fence stale pages. + pub lineage_id: String, + /// Older lineage identities that the active lineage fully adopted through + /// one or more consecutive Compact rotations. Clients may retain already + /// loaded turns when their prior lineage appears here, while replacing the + /// cursor with this page's boundary. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub compact_ancestor_lineage_ids: Vec, + /// Immediate parent relationship used to retain only the adopted prefix + /// across fork/rewind refreshes that have no newest-page overlap. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_lineage: Option, + pub turns: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "json-schema", derive(schemars::JsonSchema))] @@ -963,8 +1013,17 @@ pub enum Event { /// activated for a turn. Broadcast to every subscribed client so TUI / GUI /// instances show the same user line that reconnect snapshots replay. UserMessage { + /// Stable durable history identity. Older mixed-version producers omit it. + #[serde(default, skip_serializing_if = "Option::is_none")] + entry_id: Option, segments: Vec, }, + /// A canonical assistant/tool entry has committed to durable history. + /// Streaming events remain the in-flight authority; this event supplies the + /// stable identity used to reconcile that live block with paged history. + SessionEntryCommitted { + entry: SessionSnapshotEntry, + }, /// One agent-injected system item committed to history. /// /// Carries the JSON form of `session_store::SystemItem`. Covers @@ -2765,6 +2824,7 @@ mod tests { #[test] fn event_user_message_roundtrip() { let event = Event::UserMessage { + entry_id: Some("entry-1".to_string()), segments: vec![Segment::text("hello 世界")], }; let json = serde_json::to_string(&event).unwrap(); @@ -2775,7 +2835,7 @@ mod tests { let decoded: Event = serde_json::from_str(&json).unwrap(); match decoded { - Event::UserMessage { segments } => { + Event::UserMessage { segments, .. } => { assert_eq!(segments.len(), 1); match &segments[0] { Segment::Text { content } => assert_eq!(content, "hello 世界"), diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index d55474566..d0a051dae 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -11,12 +11,13 @@ use crate::{ InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, PasteArtifactAvailability, PasteArtifactMediaType, PasteArtifactRef, PendingSubmissionSummary, PendingSubmissionsSnapshot, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, - ScopeRule, Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, - SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, - SubmissionDisposition, SymlinkPolicy, ToolResultDisposition, TurnResult, - UploadedFileAvailability, UploadedFileRef, WorkerBusyState, WorkerCommandAcknowledgement, - WorkerCommandDisposition, WorkerCommandEnvelope, WorkerCommandKind, WorkerEvent, - WorkerMaintenanceState, WorkerRunState, WorkerState, WorkerStateSnapshot, WorkerStatus, + ScopeRule, Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance, + SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot, + SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, SubmissionDisposition, + SymlinkPolicy, ToolResultDisposition, TurnResult, UploadedFileAvailability, UploadedFileRef, + WorkerBusyState, WorkerCommandAcknowledgement, WorkerCommandDisposition, WorkerCommandEnvelope, + WorkerCommandKind, WorkerEvent, WorkerMaintenanceState, WorkerRunState, WorkerState, + WorkerStateSnapshot, WorkerStatus, subscription::{ EventSubscriptionSelector, MAX_CORRELATION_ID_BYTES, MAX_REJECTION_MESSAGE_BYTES, MAX_RESOURCE_ID_BYTES, MAX_SUBSCRIPTION_COLLECTION_ITEMS, @@ -104,6 +105,9 @@ pub fn generated_protocol_types() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index f8364c2ce..ab2a611af 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -655,6 +655,41 @@ pub struct WorkerSessionRequest { pub workspace_id: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerSessionHistoryRequest { + pub workspace_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkerSessionHistoryUnavailableReason { + RetentionMissing, + ActivePointerMissing, + CorruptLog, + MigrationRequired, + RetentionExpired, + StorageUnavailable, + InvalidCursor, + ResourceLimit, + Unsupported, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(tag = "availability", rename_all = "snake_case")] +pub enum WorkerSessionHistoryAvailability { + Page { + page: protocol::SessionHistoryPage, + }, + Unavailable { + reason: WorkerSessionHistoryUnavailableReason, + message: String, + }, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RetainedSessionIdentity { pub session_id: String, @@ -718,6 +753,17 @@ pub trait RuntimeApi { #[query] request: WorkerSessionRequest, ) -> Result; + #[get( + "/v1/workers/{worker_id}/session/history", + status = 200, + error_status = 400 + )] + async fn worker_session_history( + &self, + #[path] worker_id: String, + #[query] request: WorkerSessionHistoryRequest, + ) -> Result; + #[post("/v1/workers", status = 200, error_status = 400)] async fn create_worker( &self, @@ -1053,6 +1099,17 @@ mod tests { Ok(WorkerSessionAvailability::LiveProtocol) } + async fn worker_session_history( + &self, + _worker_id: String, + _request: WorkerSessionHistoryRequest, + ) -> Result { + Ok(WorkerSessionHistoryAvailability::Unavailable { + reason: WorkerSessionHistoryUnavailableReason::Unsupported, + message: "unsupported".to_string(), + }) + } + async fn create_worker( &self, _request: CreateWorkerRequest, @@ -1174,10 +1231,22 @@ mod tests { assert_eq!(value["message"], "snapshot exceeds limit"); } + #[test] + fn worker_session_history_availability_has_closed_tagged_wire_shape() { + let value = serde_json::to_value(WorkerSessionHistoryAvailability::Unavailable { + reason: WorkerSessionHistoryUnavailableReason::InvalidCursor, + message: "cursor is stale".to_string(), + }) + .unwrap(); + assert_eq!(value["availability"], "unavailable"); + assert_eq!(value["reason"], "invalid_cursor"); + assert_eq!(value["message"], "cursor is stale"); + } + #[test] fn contract_inventory_is_complete_and_unique() { let operations = RuntimeApiMetadata::OPERATIONS; - assert_eq!(operations.len(), 16); + assert_eq!(operations.len(), 17); let mut routes = operations .iter() .map(|operation| (format!("{:?}", operation.method), operation.path)) diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index e68a33cf2..5b14e83c9 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -389,6 +389,20 @@ pub struct WorkspaceWorkerSessionResponse { pub observation: runtime_api::WorkerSessionAvailability, } +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceWorkerSessionHistoryQuery { + pub cursor: Option, + pub limit: Option, +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct WorkspaceWorkerSessionHistoryResponse { + pub subject: WorkspaceWorkerSubject, + #[serde(flatten)] + pub observation: runtime_api::WorkerSessionHistoryAvailability, +} + #[api(reqwest, axum, openapi)] pub trait ServerApi { #[get("/health", status = 200, error_status = 400)] @@ -1141,6 +1155,20 @@ pub trait ServerApi { #[path] worker_id: String, ) -> Result; + #[get( + "/api/w/{workspace_id}/runtimes/{runtime_id}/workers/{worker_id}/session/history", + status = 200, + error_status = 400, + openapi = false + )] + async fn worker_session_history( + &self, + #[path] workspace_id: String, + #[path] runtime_id: String, + #[path] worker_id: String, + #[query] query: WorkspaceWorkerSessionHistoryQuery, + ) -> Result; + #[get( "/api/workers", status = 200, @@ -5262,27 +5290,71 @@ impl From for WorkingDirectorySumma } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ExternalWorkdirPermissionLevel { + Read, + Write, + Command, +} + +impl ExternalWorkdirPermissionLevel { + pub const fn permissions(self) -> ExternalWorkdirPermissions { + match self { + Self::Read => ExternalWorkdirPermissions::READ, + Self::Write => ExternalWorkdirPermissions::WRITE, + Self::Command => ExternalWorkdirPermissions::COMMAND, + } + } +} + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct ExternalWorkdirPermissions { /// Read files and use read-only discovery operations such as Glob and Grep. + /// Every supported External Workdir permission level enables this field. pub read: bool, - /// Create or update files with Write and Edit. Existing-file mutation also - /// requires `read` so callers can satisfy the read-before-write contract. + /// Create or update files with Write and Edit. WRITE includes READ so callers + /// can satisfy the read-before-write contract. pub write: bool, - /// Execute commands as the sharing CLI user on the provider host. This is - /// explicit authority and does not imply either filesystem category. + /// Execute commands as the sharing CLI user on the provider host. COMMAND + /// includes WRITE and READ, remains explicit, and is not sandboxed. pub command: bool, } +impl ExternalWorkdirPermissions { + pub const READ: Self = Self { + read: true, + write: false, + command: false, + }; + pub const WRITE: Self = Self { + read: true, + write: true, + command: false, + }; + pub const COMMAND: Self = Self { + read: true, + write: true, + command: true, + }; + + /// Returns the single hierarchical level represented by a grant permission + /// object. Partial combinations remain valid for attachment/session + /// projections, but are not valid authority for creating an External grant. + pub const fn grant_level(self) -> Option { + match (self.read, self.write, self.command) { + (true, false, false) => Some(ExternalWorkdirPermissionLevel::Read), + (true, true, false) => Some(ExternalWorkdirPermissionLevel::Write), + (true, true, true) => Some(ExternalWorkdirPermissionLevel::Command), + _ => None, + } + } +} + impl Default for ExternalWorkdirPermissions { fn default() -> Self { - Self { - read: true, - write: false, - command: false, - } + Self::READ } } @@ -11462,7 +11534,7 @@ mod openapi_artifact_tests { ]; // Session observation retains its existing explicit non-OpenAPI boundary independently of // the Runtime/Worker-source signed operation inventory above. - const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session"]; + const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"]; const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"]; const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"]; diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index 1fb147da2..1cae06d04 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -37,23 +37,31 @@ use std::path::Path; /// Read an existing retained file without updating its access timestamp. /// Observation fails closed when the platform cannot provide that guarantee. pub(crate) fn read_without_atime(path: &Path) -> io::Result> { + read_without_atime_bounded(path, u64::MAX) +} + +/// Read at most `max_bytes + 1` retained bytes without updating access time. +/// The extra byte lets callers prove that the authoritative file exceeded a +/// limit without first racing a separate metadata length observation. +pub(crate) fn read_without_atime_bounded(path: &Path, max_bytes: u64) -> io::Result> { #[cfg(any(target_os = "linux", target_os = "android"))] { use std::fs::OpenOptions; use std::io::Read; use std::os::unix::fs::OpenOptionsExt; - let mut file = OpenOptions::new() + let file = OpenOptions::new() .read(true) .custom_flags(libc::O_NOATIME) .open(path)?; let mut bytes = Vec::new(); - file.read_to_end(&mut bytes)?; + file.take(max_bytes.saturating_add(1)) + .read_to_end(&mut bytes)?; Ok(bytes) } #[cfg(not(any(target_os = "linux", target_os = "android")))] { - let _ = path; + let _ = (path, max_bytes); Err(io::Error::new( io::ErrorKind::Unsupported, "read-only retained observation requires no-atime file reads", @@ -88,8 +96,12 @@ pub use history::{ pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use paste_artifact::PasteArtifactLimits; pub use public_snapshot::{ - DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, RetainedSessionIdentity, RetainedSessionSnapshot, - RetainedSnapshotReadError, project_session_snapshot, read_retained_session_snapshot, + DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES, + DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS, + DEFAULT_RETAINED_HISTORY_PAGE_TURNS, DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, + MAX_RETAINED_HISTORY_PAGE_TURNS, RetainedHistoryReadError, RetainedHistoryReadLimits, + RetainedSessionIdentity, RetainedSessionSnapshot, RetainedSnapshotReadError, + project_session_snapshot, read_retained_session_history_page, read_retained_session_snapshot, }; pub use segment::{ SegmentStartState, append_entry, append_system_item, classify_logged_history_entry, diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 9fae33a15..a3f75fb15 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -3,18 +3,22 @@ use base64::{ engine::general_purpose::{STANDARD as BASE64, URL_SAFE_NO_PAD}, }; use protocol::{ - Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshot, + Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance, + SessionHistoryLineageBoundary, SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, }; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; use std::path::Path; use thiserror::Error; use crate::{ LogEntry, LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, - LoggedSessionHistoryOrigin, SessionId, StoreError, SystemItem, WorkerAggregateStore, - WorkerSessionStore, WorkerStoreError, + LoggedSessionHistoryOrigin, SegmentId, SegmentOrigin, SessionId, StoreError, SystemItem, + WorkerAggregateStore, WorkerSessionStore, WorkerStoreError, }; /// Projection limit leaves one MiB for the typed API envelope so a public @@ -50,6 +54,106 @@ pub enum RetainedSnapshotReadError { SnapshotTooLarge, } +pub const DEFAULT_RETAINED_HISTORY_PAGE_TURNS: usize = 5; +pub const MAX_RETAINED_HISTORY_PAGE_TURNS: usize = 5; +pub const DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024; +pub const DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS: usize = 64; +pub const DEFAULT_RETAINED_HISTORY_MAX_ENTRIES: usize = 100_000; +pub const DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES: u64 = 8 * 1024 * 1024; + +/// Explicit resource bounds for one retained-history page read. +/// +/// The operation never truncates a turn to fit these limits. Bounded lineage +/// metadata or one selected page that exceeds a bound fails with +/// [`RetainedHistoryReadError::ResourceLimit`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RetainedHistoryReadLimits { + pub max_scan_bytes: u64, + pub max_segments: usize, + pub max_entries: usize, + pub max_response_bytes: u64, +} + +impl Default for RetainedHistoryReadLimits { + fn default() -> Self { + Self { + max_scan_bytes: DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, + max_segments: DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS, + max_entries: DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, + max_response_bytes: DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES, + } + } +} + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum RetainedHistoryReadError { + #[error("retained worker state is unavailable")] + RetentionMissing, + #[error("retained worker state has no active session pointer")] + ActivePointerMissing, + #[error("retained session requires migration")] + MigrationRequired, + #[error("retained session log is corrupt")] + CorruptLog, + #[error("retained session storage is unavailable")] + StorageUnavailable, + #[error("retained session history cursor is invalid or stale")] + InvalidCursor, + #[error("retained session history exceeds a resource limit")] + ResourceLimit, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct RetainedHistoryCursor { + version: u8, + worker_name: String, + session_id: String, + active_segment_id: String, + lineage_id: String, + before_turn_id: String, + segment_id: String, + before_offset: u64, + record_end_offset: Option, + seed_entry_index: Option, +} + +#[derive(Debug)] +struct LoadedLineageSegment { + segment_id: SegmentId, + adopted_through_turn: Option, + file_len: u64, + seed_end_offset: u64, + origin: Option, + seed_entries: Vec, +} + +#[derive(Debug, Clone)] +struct HistoryTurnPosition { + segment_id: SegmentId, + before_offset: u64, + record_end_offset: Option, + seed_entry_index: Option, +} + +#[derive(Debug)] +struct PositionedHistoryTurn { + turn: SessionConversationTurn, + position: HistoryTurnPosition, +} + +#[derive(Debug, Clone, Copy)] +enum LineageOriginKind { + Fork, + Compact, +} + +#[derive(Debug, Clone)] +struct LineageOrigin { + kind: LineageOriginKind, + origin: SegmentOrigin, +} + /// Read the active retained Session without creating, migrating, restoring, or /// rewriting any persisted state. pub fn read_retained_session_snapshot( @@ -95,6 +199,742 @@ pub fn read_retained_session_snapshot( }) } +/// Read one bounded backward page from the retained active Segment's adopted +/// lineage without creating, migrating, restoring, or rewriting persisted state. +/// +/// `cursor` is an opaque boundary returned by the previous page. It is bound to +/// the Worker name, Session, active lineage, and stable user-entry identity. +pub fn read_retained_session_history_page( + aggregate_root: &Path, + worker_name: &str, + cursor: Option<&str>, + limit: Option, + limits: RetainedHistoryReadLimits, +) -> Result { + let page_limit = limit.unwrap_or(DEFAULT_RETAINED_HISTORY_PAGE_TURNS); + if page_limit == 0 || page_limit > MAX_RETAINED_HISTORY_PAGE_TURNS { + return Err(RetainedHistoryReadError::ResourceLimit); + } + + let aggregate = WorkerAggregateStore::open_read_only(aggregate_root, worker_name) + .map_err(map_history_worker_store_error)?; + let metadata = aggregate + .read_read_only() + .map_err(map_history_worker_store_error)? + .ok_or(RetainedHistoryReadError::RetentionMissing)?; + let active = metadata + .active + .ok_or(RetainedHistoryReadError::ActivePointerMissing)?; + let active_segment_id = active + .segment_id + .ok_or(RetainedHistoryReadError::ActivePointerMissing)?; + let store = WorkerSessionStore::open_read_only(aggregate_root.join("session")) + .map_err(map_history_store_error)?; + let (lineage, lineage_id, metadata_bytes, metadata_entries) = + load_adopted_lineage(&store, active.session_id, active_segment_id, limits)?; + let cursor = cursor.map(decode_history_cursor).transpose()?; + let start = if let Some(cursor) = cursor.as_ref() { + if cursor.version != 2 + || cursor.worker_name != worker_name + || cursor.session_id != active.session_id.to_string() + || cursor.active_segment_id != active_segment_id.to_string() + || cursor.lineage_id != lineage_id + || !lineage + .iter() + .any(|segment| segment.segment_id.to_string() == cursor.segment_id) + { + return Err(RetainedHistoryReadError::InvalidCursor); + } + let segment_id = cursor + .segment_id + .parse() + .map_err(|_| RetainedHistoryReadError::InvalidCursor)?; + Some(( + HistoryTurnPosition { + segment_id, + before_offset: cursor.before_offset, + record_end_offset: cursor.record_end_offset, + seed_entry_index: cursor.seed_entry_index, + }, + cursor.before_turn_id.as_str(), + )) + } else { + None + }; + let mut scanned_bytes = metadata_bytes; + let mut scanned_entries = metadata_entries; + let parent_lineage = if cursor.is_none() && lineage.len() > 1 { + let parent_turns = read_history_turns_backward( + &store, + active.session_id, + &lineage[1..], + None, + 1, + limits, + &mut scanned_bytes, + &mut scanned_entries, + )?; + Some(SessionHistoryLineageBoundary { + lineage_id: lineage_identity(active.session_id, &lineage[1..]), + adopted_through_turn: parent_turns + .into_iter() + .next() + .map(|positioned| positioned.turn), + }) + } else { + None + }; + let mut turns = read_history_turns_backward( + &store, + active.session_id, + &lineage, + start, + page_limit.saturating_add(1), + limits, + &mut scanned_bytes, + &mut scanned_entries, + )?; + let has_more = turns.len() > page_limit; + if has_more { + turns.truncate(page_limit); + } + let next_cursor = if has_more { + let oldest = turns + .last() + .expect("a page with earlier history cannot be empty"); + Some(encode_history_cursor(&RetainedHistoryCursor { + version: 2, + worker_name: worker_name.to_owned(), + session_id: active.session_id.to_string(), + active_segment_id: active_segment_id.to_string(), + lineage_id: lineage_id.clone(), + before_turn_id: oldest.turn.turn_id.clone(), + segment_id: oldest.position.segment_id.to_string(), + before_offset: oldest.position.before_offset, + record_end_offset: oldest.position.record_end_offset, + seed_entry_index: oldest.position.seed_entry_index, + })?) + } else { + None + }; + turns.reverse(); + let compact_ancestor_lineage_ids = compact_ancestor_lineage_ids(active.session_id, &lineage); + let page = SessionHistoryPage { + session_id: active.session_id.to_string(), + lineage_id, + compact_ancestor_lineage_ids, + parent_lineage, + turns: turns + .into_iter() + .map(|positioned| positioned.turn) + .collect(), + next_cursor, + has_more, + }; + let response_bytes = serde_json::to_vec(&page) + .map_err(|_| RetainedHistoryReadError::CorruptLog)? + .len() as u64; + if response_bytes > limits.max_response_bytes { + return Err(RetainedHistoryReadError::ResourceLimit); + } + Ok(page) +} + +fn load_adopted_lineage( + store: &WorkerSessionStore, + session_id: SessionId, + active_segment_id: SegmentId, + limits: RetainedHistoryReadLimits, +) -> Result<(Vec, String, u64, usize), RetainedHistoryReadError> { + let mut lineage = Vec::new(); + let mut visited = HashSet::new(); + let mut segment_id = active_segment_id; + let mut adopted_through_turn = None; + let mut scanned_bytes = 0_u64; + let mut scanned_entries = 0_usize; + + loop { + if lineage.len() >= limits.max_segments { + return Err(RetainedHistoryReadError::ResourceLimit); + } + if !visited.insert(segment_id) { + return Err(RetainedHistoryReadError::CorruptLog); + } + let remaining_scan_bytes = limits + .max_scan_bytes + .checked_sub(scanned_bytes) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + let (first, segment_bytes, file_len) = store + .read_first_log_record_read_only_bounded(session_id, segment_id, remaining_scan_bytes) + .map_err(map_history_store_error)?; + scanned_bytes = scanned_bytes + .checked_add(segment_bytes) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if let Some(first) = first.as_ref() { + scanned_entries = scanned_entries + .checked_add(persisted_entry_units(std::slice::from_ref(&first.entry))) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if scanned_entries > limits.max_entries { + return Err(RetainedHistoryReadError::ResourceLimit); + } + } + let origin = + validate_segment_start_entry(session_id, first.as_ref().map(|entry| &entry.entry))?; + + let seed_entries = if origin.is_none() { + let mut seed_records = first + .as_ref() + .map(|first| vec![first.entry.clone()]) + .unwrap_or_default(); + if let Some(first) = first.as_ref() { + let remaining_scan_bytes = limits.max_scan_bytes.saturating_sub(scanned_bytes); + let (next, next_bytes) = store + .read_next_log_record_read_only_bounded( + session_id, + segment_id, + first.end_offset, + remaining_scan_bytes, + ) + .map_err(map_history_store_error)?; + scanned_bytes = scanned_bytes + .checked_add(next_bytes) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if let Some(next) = next { + scanned_entries = scanned_entries + .checked_add(persisted_entry_units(std::slice::from_ref(&next.entry))) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if scanned_entries > limits.max_entries { + return Err(RetainedHistoryReadError::ResourceLimit); + } + if matches!(&next.entry, LogEntry::InputSegmentsCheckpoint { .. }) { + seed_records.push(next.entry); + } + } + } + project_session_snapshot_for_segment(session_id, Some(segment_id), &seed_records) + .entries + .into_iter() + .filter(|entry| entry.provenance != SessionEntryProvenance::DerivedSummary) + .collect() + } else { + Vec::new() + }; + lineage.push(LoadedLineageSegment { + segment_id, + adopted_through_turn, + file_len, + seed_end_offset: first.as_ref().map(|record| record.end_offset).unwrap_or(0), + origin: origin.clone(), + seed_entries, + }); + let Some(origin) = origin else { + break; + }; + segment_id = origin.origin.segment_id; + adopted_through_turn = Some(origin.origin.at_turn_index); + } + + let lineage_id = lineage_identity(session_id, &lineage); + Ok((lineage, lineage_id, scanned_bytes, scanned_entries)) +} + +fn lineage_identity(session_id: SessionId, lineage: &[LoadedLineageSegment]) -> String { + let active_segment_id = lineage + .first() + .expect("an adopted lineage always contains its active Segment") + .segment_id; + let mut hash = Sha256::new(); + hash.update(b"yoi-retained-history-lineage-v2\0"); + hash.update(session_id.as_bytes()); + hash.update(active_segment_id.as_bytes()); + for (index, segment) in lineage.iter().enumerate() { + hash.update(segment.segment_id.as_bytes()); + let boundary = (index != 0) + .then_some(segment.adopted_through_turn) + .flatten(); + match boundary { + Some(turn) => { + hash.update([1]); + hash.update((turn as u64).to_be_bytes()); + } + None => hash.update([0]), + } + if let Some(origin) = &segment.origin { + hash.update(match origin.kind { + LineageOriginKind::Fork => [1], + LineageOriginKind::Compact => [2], + }); + hash.update(origin.origin.segment_id.as_bytes()); + hash.update((origin.origin.at_turn_index as u64).to_be_bytes()); + } else { + hash.update([0]); + } + } + URL_SAFE_NO_PAD.encode(hash.finalize()) +} + +fn compact_ancestor_lineage_ids( + session_id: SessionId, + lineage: &[LoadedLineageSegment], +) -> Vec { + let mut ancestors = Vec::new(); + for index in 0..lineage.len().saturating_sub(1) { + if !matches!( + lineage[index].origin.as_ref().map(|origin| origin.kind), + Some(LineageOriginKind::Compact) + ) { + break; + } + ancestors.push(lineage_identity(session_id, &lineage[index + 1..])); + } + ancestors +} + +fn validate_segment_start_entry( + expected_session_id: SessionId, + entry: Option<&LogEntry>, +) -> Result, RetainedHistoryReadError> { + let Some(entry) = entry else { + return Ok(None); + }; + let LogEntry::AnnotatedSegmentStart { + session_id, + forked_from, + compacted_from, + .. + } = entry + else { + return Err(RetainedHistoryReadError::CorruptLog); + }; + if *session_id != expected_session_id { + return Err(RetainedHistoryReadError::CorruptLog); + } + match (forked_from, compacted_from) { + (Some(_), Some(_)) => Err(RetainedHistoryReadError::CorruptLog), + (Some(origin), None) => Ok(Some(LineageOrigin { + kind: LineageOriginKind::Fork, + origin: origin.clone(), + })), + (None, Some(origin)) => Ok(Some(LineageOrigin { + kind: LineageOriginKind::Compact, + origin: origin.clone(), + })), + (None, None) => Ok(None), + } +} + +fn persisted_entry_units(entries: &[LogEntry]) -> usize { + entries.iter().fold(0_usize, |total, entry| { + let embedded = match entry { + LogEntry::AnnotatedSegmentStart { history, .. } + | LogEntry::AnnotatedUserInput { history, .. } => history.len(), + LogEntry::AnnotatedAssistantItem { .. } + | LogEntry::AnnotatedToolResult { .. } + | LogEntry::AnnotatedSystemItem { .. } => 1, + _ => 0, + }; + total.saturating_add(1).saturating_add(embedded) + }) +} + +fn locate_adopted_segment_end( + store: &WorkerSessionStore, + session_id: SessionId, + segment: &LoadedLineageSegment, + limits: RetainedHistoryReadLimits, + scanned_bytes: &mut u64, + scanned_entries: &mut usize, +) -> Result { + const FORWARD_BOUNDARY_TURN_THRESHOLD: usize = 64; + + let Some(boundary) = segment.adopted_through_turn else { + return Ok(segment.file_len); + }; + let inherited_turns = segment + .origin + .as_ref() + .map(|origin| origin.origin.at_turn_index) + .unwrap_or(0); + if boundary <= inherited_turns { + // This edge adopts no locally completed turns. The Segment's own + // origin remains an independent edge with its original boundary. + return Ok(segment.seed_end_offset); + } + + // Small/early boundaries are cheapest to resolve from the Segment seed and + // must not pay for an arbitrarily large unadopted suffix. Later boundaries + // are resolved backward so a large adopted prefix does not have to be read + // merely to find the nearby split point. + if boundary <= inherited_turns.saturating_add(FORWARD_BOUNDARY_TURN_THRESHOLD) { + let mut offset = segment.seed_end_offset; + loop { + let remaining = limits.max_scan_bytes.saturating_sub(*scanned_bytes); + let (record, bytes) = store + .read_next_log_record_read_only_bounded( + session_id, + segment.segment_id, + offset, + remaining, + ) + .map_err(map_history_store_error)?; + *scanned_bytes = scanned_bytes + .checked_add(bytes) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + let record = record.ok_or(RetainedHistoryReadError::CorruptLog)?; + *scanned_entries = scanned_entries + .checked_add(persisted_entry_units(std::slice::from_ref(&record.entry))) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if *scanned_entries > limits.max_entries { + return Err(RetainedHistoryReadError::ResourceLimit); + } + offset = record.end_offset; + match record.entry { + LogEntry::TurnEnd { turn_count, .. } if turn_count == boundary => { + return Ok(offset); + } + LogEntry::TurnEnd { turn_count, .. } if turn_count > boundary => { + return Err(RetainedHistoryReadError::CorruptLog); + } + _ => {} + } + } + } + + let mut reader = store + .open_retained_segment_reader(session_id, segment.segment_id, segment.file_len) + .map_err(map_history_store_error)?; + loop { + let remaining = limits.max_scan_bytes.saturating_sub(*scanned_bytes); + let (record, bytes) = reader + .previous_record(remaining) + .map_err(map_history_store_error)?; + *scanned_bytes = scanned_bytes + .checked_add(bytes) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + let record = record.ok_or(RetainedHistoryReadError::CorruptLog)?; + *scanned_entries = scanned_entries + .checked_add(persisted_entry_units(std::slice::from_ref(&record.entry))) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if *scanned_entries > limits.max_entries { + return Err(RetainedHistoryReadError::ResourceLimit); + } + match record.entry { + LogEntry::TurnEnd { turn_count, .. } if turn_count == boundary => { + return Ok(record.end_offset); + } + LogEntry::TurnEnd { turn_count, .. } if turn_count < boundary => { + return Err(RetainedHistoryReadError::CorruptLog); + } + LogEntry::AnnotatedSegmentStart { .. } => { + return Err(RetainedHistoryReadError::CorruptLog); + } + _ => {} + } + } +} + +fn read_history_turns_backward( + store: &WorkerSessionStore, + session_id: SessionId, + lineage: &[LoadedLineageSegment], + start: Option<(HistoryTurnPosition, &str)>, + turn_limit: usize, + limits: RetainedHistoryReadLimits, + scanned_bytes: &mut u64, + scanned_entries: &mut usize, +) -> Result, RetainedHistoryReadError> { + let mut turns = Vec::new(); + let mut pending_entries = Vec::new(); + let mut seen_entries = HashSet::new(); + + let (start_segment_index, start_position, cursor_turn_id) = match start { + Some((position, turn_id)) => { + let index = lineage + .iter() + .position(|segment| segment.segment_id == position.segment_id) + .ok_or(RetainedHistoryReadError::InvalidCursor)?; + (index, Some(position), Some(turn_id)) + } + None => (0, None, None), + }; + + if let (Some(position), Some(turn_id)) = (start_position.as_ref(), cursor_turn_id) { + let segment = &lineage[start_segment_index]; + if let Some(seed_index) = position.seed_entry_index { + if start_segment_index + 1 != lineage.len() + || position.before_offset != 0 + || position.record_end_offset.is_some() + || segment + .seed_entries + .get(seed_index) + .is_none_or(|entry| entry.entry_id != turn_id || !is_user_entry(entry)) + { + return Err(RetainedHistoryReadError::InvalidCursor); + } + } else { + let record_end = position + .record_end_offset + .ok_or(RetainedHistoryReadError::InvalidCursor)?; + let remaining = limits.max_scan_bytes.saturating_sub(*scanned_bytes); + let record = store + .read_log_record_range_read_only_bounded( + session_id, + segment.segment_id, + position.before_offset, + record_end, + remaining, + ) + .map_err(map_history_cursor_store_error)?; + *scanned_bytes = scanned_bytes + .checked_add(record.end_offset - record.start_offset) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if record.start_offset != position.before_offset + || !project_history_record(session_id, segment.segment_id, &record.entry) + .iter() + .any(|entry| entry.entry_id == turn_id && is_user_entry(entry)) + { + return Err(RetainedHistoryReadError::InvalidCursor); + } + *scanned_entries = scanned_entries + .checked_add(persisted_entry_units(std::slice::from_ref(&record.entry))) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if *scanned_entries > limits.max_entries { + return Err(RetainedHistoryReadError::ResourceLimit); + } + } + } + + for (segment_index, segment) in lineage.iter().enumerate().skip(start_segment_index) { + let starting_here = segment_index == start_segment_index; + if starting_here + && start_position + .as_ref() + .and_then(|position| position.seed_entry_index) + .is_some() + { + let seed_index = start_position + .as_ref() + .and_then(|position| position.seed_entry_index) + .expect("checked seed cursor"); + if collect_history_entries_backward( + &segment.seed_entries[..seed_index], + segment.segment_id, + 0, + None, + true, + turn_limit, + &mut seen_entries, + &mut pending_entries, + &mut turns, + ) { + return Ok(turns); + } + continue; + } + + let adopted_end = locate_adopted_segment_end( + store, + session_id, + segment, + limits, + scanned_bytes, + scanned_entries, + )?; + let before_offset = if starting_here { + start_position + .as_ref() + .map(|position| position.before_offset) + .unwrap_or(adopted_end) + } else { + adopted_end + }; + if before_offset > adopted_end { + return Err(RetainedHistoryReadError::InvalidCursor); + } + let mut reader = store + .open_retained_segment_reader(session_id, segment.segment_id, before_offset) + .map_err(map_history_store_error)?; + loop { + let remaining = limits.max_scan_bytes.saturating_sub(*scanned_bytes); + let (record, bytes) = reader + .previous_record(remaining) + .map_err(map_history_store_error)?; + *scanned_bytes = scanned_bytes + .checked_add(bytes) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + let Some(record) = record else { + break; + }; + *scanned_entries = scanned_entries + .checked_add(persisted_entry_units(std::slice::from_ref(&record.entry))) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if *scanned_entries > limits.max_entries { + return Err(RetainedHistoryReadError::ResourceLimit); + } + if matches!(record.entry, LogEntry::AnnotatedSegmentStart { .. }) { + if record.start_offset != 0 { + return Err(RetainedHistoryReadError::CorruptLog); + } + continue; + } + let entries = project_history_record(session_id, segment.segment_id, &record.entry); + if collect_history_entries_backward( + &entries, + segment.segment_id, + record.start_offset, + Some(record.end_offset), + false, + turn_limit, + &mut seen_entries, + &mut pending_entries, + &mut turns, + ) { + return Ok(turns); + } + } + + if segment_index + 1 == lineage.len() + && collect_history_entries_backward( + &segment.seed_entries, + segment.segment_id, + 0, + None, + true, + turn_limit, + &mut seen_entries, + &mut pending_entries, + &mut turns, + ) + { + return Ok(turns); + } + } + Ok(turns) +} + +#[allow(clippy::too_many_arguments)] +fn collect_history_entries_backward( + entries: &[SessionSnapshotEntry], + segment_id: SegmentId, + before_offset: u64, + record_end_offset: Option, + seed: bool, + turn_limit: usize, + seen_entries: &mut HashSet, + pending_entries: &mut Vec, + turns: &mut Vec, +) -> bool { + for (index, entry) in entries.iter().enumerate().rev() { + if entry.provenance == SessionEntryProvenance::DerivedSummary + || !seen_entries.insert(entry.entry_id.clone()) + { + continue; + } + pending_entries.push(entry.clone()); + if is_user_entry(entry) { + let mut turn_entries = std::mem::take(pending_entries); + turn_entries.reverse(); + turns.push(PositionedHistoryTurn { + turn: SessionConversationTurn { + turn_id: entry.entry_id.clone(), + entries: turn_entries, + }, + position: HistoryTurnPosition { + segment_id, + before_offset, + record_end_offset, + seed_entry_index: seed.then_some(index), + }, + }); + if turns.len() >= turn_limit { + return true; + } + } + } + false +} + +fn project_history_record( + session_id: SessionId, + segment_id: SegmentId, + record: &LogEntry, +) -> Vec { + project_session_snapshot_for_segment(session_id, Some(segment_id), std::slice::from_ref(record)) + .entries + .into_iter() + .filter(|entry| entry.provenance != SessionEntryProvenance::DerivedSummary) + .collect() +} + +fn is_user_entry(entry: &SessionSnapshotEntry) -> bool { + matches!( + &entry.data, + SessionSnapshotEntryData::UserInput { .. } + | SessionSnapshotEntryData::Message { + role: SessionMessageRole::User, + .. + } + ) +} + +fn encode_history_cursor( + cursor: &RetainedHistoryCursor, +) -> Result { + serde_json::to_vec(cursor) + .map(|bytes| URL_SAFE_NO_PAD.encode(bytes)) + .map_err(|_| RetainedHistoryReadError::CorruptLog) +} + +fn decode_history_cursor(cursor: &str) -> Result { + const MAX_CURSOR_BYTES: usize = 4096; + if cursor.is_empty() || cursor.len() > MAX_CURSOR_BYTES { + return Err(RetainedHistoryReadError::InvalidCursor); + } + let bytes = URL_SAFE_NO_PAD + .decode(cursor) + .map_err(|_| RetainedHistoryReadError::InvalidCursor)?; + if bytes.len() > MAX_CURSOR_BYTES { + return Err(RetainedHistoryReadError::InvalidCursor); + } + serde_json::from_slice(&bytes).map_err(|_| RetainedHistoryReadError::InvalidCursor) +} + +fn map_history_worker_store_error(error: WorkerStoreError) -> RetainedHistoryReadError { + match error { + WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { + RetainedHistoryReadError::RetentionMissing + } + WorkerStoreError::Serde(_) | WorkerStoreError::InvalidWorkerName(_) => { + RetainedHistoryReadError::CorruptLog + } + WorkerStoreError::Io(_) => RetainedHistoryReadError::StorageUnavailable, + } +} + +fn map_history_cursor_store_error(error: StoreError) -> RetainedHistoryReadError { + match error { + StoreError::ReadLimitExceeded => RetainedHistoryReadError::ResourceLimit, + _ => RetainedHistoryReadError::InvalidCursor, + } +} + +fn map_history_store_error(error: StoreError) -> RetainedHistoryReadError { + match error { + StoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { + RetainedHistoryReadError::RetentionMissing + } + StoreError::Corrupt { message, .. } if message.contains("requires migration") => { + RetainedHistoryReadError::MigrationRequired + } + StoreError::ReadLimitExceeded => RetainedHistoryReadError::ResourceLimit, + StoreError::Io(_) => RetainedHistoryReadError::StorageUnavailable, + StoreError::Serde(_) | StoreError::Corrupt { .. } | StoreError::NotFound(_) => { + RetainedHistoryReadError::CorruptLog + } + _ => RetainedHistoryReadError::CorruptLog, + } +} + fn map_worker_store_error(error: WorkerStoreError) -> RetainedSnapshotReadError { match error { WorkerStoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { @@ -135,6 +975,14 @@ pub fn project_current_session_snapshot(log: &[LogEntry]) -> SessionSnapshot { /// Project the current durable segment into the only public session-history /// representation. Append-log records remain an internal persistence format. pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> SessionSnapshot { + project_session_snapshot_for_segment(session_id, None, log) +} + +fn project_session_snapshot_for_segment( + session_id: SessionId, + segment_id: Option, + log: &[LogEntry], +) -> SessionSnapshot { let mut session_key = session_id; let mut entries = Vec::new(); @@ -189,6 +1037,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess )), LogEntry::RunErrored { ts, message, .. } => entries.push(legacy_entry( &session_key, + segment_id.as_ref(), log_index, 0, *ts, @@ -288,13 +1137,14 @@ fn derivation_ids(entry: &crate::LoggedSystemHistoryEntry) -> Vec { fn legacy_entry( session_key: &SessionId, + segment_id: Option<&SegmentId>, log_index: usize, item_index: usize, timestamp: u64, data: SessionSnapshotEntryData, ) -> SessionSnapshotEntry { SessionSnapshotEntry { - entry_id: legacy_entry_id(session_key, log_index, item_index), + entry_id: legacy_entry_id(session_key, segment_id, log_index, item_index), timestamp, provenance: SessionEntryProvenance::LegacyUnknown, derived_from: Vec::new(), @@ -302,9 +1152,17 @@ fn legacy_entry( } } -fn legacy_entry_id(session_key: &SessionId, log_index: usize, item_index: usize) -> String { - let mut identity = Vec::with_capacity(32); +fn legacy_entry_id( + session_key: &SessionId, + segment_id: Option<&SegmentId>, + log_index: usize, + item_index: usize, +) -> String { + let mut identity = Vec::with_capacity(48); identity.extend_from_slice(session_key.as_bytes()); + if let Some(segment_id) = segment_id { + identity.extend_from_slice(segment_id.as_bytes()); + } identity.extend_from_slice(&(log_index as u64).to_be_bytes()); identity.extend_from_slice(&(item_index as u64).to_be_bytes()); format!("l-{}", URL_SAFE_NO_PAD.encode(identity)) @@ -424,6 +1282,7 @@ fn system_entry( #[cfg(test)] mod tests { use agen::llm_client::RequestConfig; + use protocol::InvokeKind; use super::*; use crate::{ @@ -431,6 +1290,1155 @@ mod tests { LoggedWorkerSubject, Store, WorkerMetadataStore, }; + fn history_message( + entry_id: impl Into, + role: LoggedRole, + text: impl Into, + origin: LoggedSessionHistoryOrigin, + ) -> LoggedHistoryEntry { + LoggedHistoryEntry { + item: LoggedItem::Message { + role, + content: vec![LoggedContentPart::Text { text: text.into() }], + }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId(entry_id.into()), + origin, + derivation: None, + }, + } + } + + fn user_message(turn: usize) -> LoggedHistoryEntry { + history_message( + format!("user-{turn}"), + LoggedRole::User, + format!("request {turn}"), + LoggedSessionHistoryOrigin::HumanInput { + account_id: "account-1".into(), + }, + ) + } + + fn assistant_message(turn: usize, suffix: &str) -> LoggedHistoryEntry { + history_message( + format!("assistant-{turn}-{suffix}"), + LoggedRole::Assistant, + format!("response {turn} {suffix}"), + LoggedSessionHistoryOrigin::LegacyUnknown, + ) + } + + fn append_turn(log: &mut Vec, turn: usize) { + log.push(LogEntry::Invoke { + ts: turn as u64 * 10, + trigger: InvokeKind::UserSend, + }); + log.push(LogEntry::AnnotatedUserInput { + ts: turn as u64 * 10 + 1, + segments: vec![Segment::Text { + content: format!("request {turn}"), + }], + history: vec![user_message(turn)], + extensions: Vec::new(), + }); + log.push(LogEntry::AnnotatedAssistantItem { + ts: turn as u64 * 10 + 2, + entry: assistant_message(turn, "final"), + }); + log.push(LogEntry::TurnEnd { + ts: turn as u64 * 10 + 3, + turn_count: turn, + }); + } + + fn segment_start( + session_id: SessionId, + history: Vec, + forked_from: Option, + compacted_from: Option, + ) -> LogEntry { + LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: None, + config: RequestConfig::default(), + history, + forked_from, + compacted_from, + } + } + + fn persist_history_fixture( + worker_name: &str, + session_id: SessionId, + active_segment_id: SegmentId, + segments: Vec<(SegmentId, Vec)>, + ) -> (tempfile::TempDir, std::path::PathBuf) { + let root = tempfile::tempdir().unwrap(); + let aggregate_root = root.path().join(worker_name); + let session = WorkerSessionStore::new(aggregate_root.join("session")).unwrap(); + for (segment_id, entries) in segments { + session + .create_segment(session_id, segment_id, &entries) + .unwrap(); + } + let aggregate = WorkerAggregateStore::new(&aggregate_root, worker_name).unwrap(); + aggregate + .write(&crate::WorkerMetadata::new( + worker_name, + Some(crate::WorkerActiveSegmentRef::active_segment( + session_id, + active_segment_id, + )), + )) + .unwrap(); + (root, aggregate_root) + } + + fn linear_fixture( + worker_name: &str, + turn_count: usize, + ) -> (tempfile::TempDir, std::path::PathBuf) { + let session_id = crate::new_session_id(); + let segment_id = crate::new_segment_id(); + let mut log = vec![segment_start(session_id, Vec::new(), None, None)]; + for turn in 1..=turn_count { + append_turn(&mut log, turn); + } + persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]) + } + + fn turn_ids(page: &SessionHistoryPage) -> Vec<&str> { + page.turns + .iter() + .map(|turn| turn.turn_id.as_str()) + .collect() + } + + #[test] + fn retained_history_pages_twelve_turns_as_five_five_two_across_compaction() { + let worker_name = "worker-history-12"; + let session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let middle_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut source_log = vec![segment_start(session_id, Vec::new(), None, None)]; + for turn in 1..=5 { + append_turn(&mut source_log, turn); + } + let mut middle_log = vec![segment_start( + session_id, + vec![user_message(5), assistant_message(5, "final")], + None, + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 5, + }), + )]; + for turn in 6..=10 { + append_turn(&mut middle_log, turn); + } + let synthetic_compaction_input = history_message( + "derived-user", + LoggedRole::User, + "synthetic compaction input", + LoggedSessionHistoryOrigin::DerivedSummary, + ); + let mut active_log = vec![segment_start( + session_id, + vec![ + user_message(10), + assistant_message(10, "final"), + synthetic_compaction_input, + ], + None, + Some(SegmentOrigin { + segment_id: middle_segment, + at_turn_index: 10, + }), + )]; + append_turn(&mut active_log, 11); + append_turn(&mut active_log, 12); + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![ + (source_segment, source_log), + (middle_segment, middle_log), + (active_segment, active_log), + ], + ); + + let newest = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!( + turn_ids(&newest), + vec!["user-8", "user-9", "user-10", "user-11", "user-12"] + ); + assert!(newest.has_more); + assert!(newest.next_cursor.is_some()); + assert_eq!(newest.compact_ancestor_lineage_ids.len(), 2); + assert_eq!( + newest.turns[2] + .entries + .iter() + .filter(|entry| entry.entry_id == "user-10") + .count(), + 1, + "the active Segment seed must not duplicate the source turn" + ); + assert!(newest.turns.iter().all(|turn| { + turn.entries + .iter() + .all(|entry| entry.provenance != SessionEntryProvenance::DerivedSummary) + })); + + let middle = read_retained_session_history_page( + &aggregate_root, + worker_name, + newest.next_cursor.as_deref(), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!( + turn_ids(&middle), + vec!["user-3", "user-4", "user-5", "user-6", "user-7"] + ); + assert!(middle.has_more); + + let oldest = read_retained_session_history_page( + &aggregate_root, + worker_name, + middle.next_cursor.as_deref(), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(turn_ids(&oldest), vec!["user-1", "user-2"]); + assert!(!oldest.has_more); + assert!(oldest.next_cursor.is_none()); + } + + #[test] + fn retained_history_page_boundaries_cover_zero_one_four_five_and_six_turns() { + for count in [0, 1, 4, 5, 6] { + let worker_name = format!("worker-history-{count}"); + let (_root, aggregate_root) = linear_fixture(&worker_name, count); + let page = read_retained_session_history_page( + &aggregate_root, + &worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(page.turns.len(), count.min(5), "turn count {count}"); + assert_eq!(page.has_more, count > 5, "turn count {count}"); + assert_eq!(page.next_cursor.is_some(), count > 5, "turn count {count}"); + if count == 6 { + let prior = read_retained_session_history_page( + &aggregate_root, + &worker_name, + page.next_cursor.as_deref(), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(turn_ids(&prior), vec!["user-1"]); + assert!(!prior.has_more); + } + } + } + + #[test] + fn retained_history_keeps_cross_compaction_response_in_one_real_user_turn() { + let worker_name = "worker-cross-compact"; + let session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut source_log = vec![segment_start(session_id, Vec::new(), None, None)]; + source_log.push(LogEntry::Invoke { + ts: 10, + trigger: InvokeKind::UserSend, + }); + source_log.push(LogEntry::AnnotatedUserInput { + ts: 11, + segments: vec![Segment::Text { + content: "request 1".into(), + }], + history: vec![user_message(1)], + extensions: Vec::new(), + }); + source_log.push(LogEntry::AnnotatedAssistantItem { + ts: 12, + entry: assistant_message(1, "accepted"), + }); + source_log.push(LogEntry::AnnotatedAssistantItem { + ts: 13, + entry: assistant_message(1, "progress"), + }); + source_log.push(LogEntry::TurnEnd { + ts: 14, + turn_count: 1, + }); + let mut active_log = vec![segment_start( + session_id, + vec![history_message( + "derived-user", + LoggedRole::User, + "summarize prior context", + LoggedSessionHistoryOrigin::DerivedSummary, + )], + None, + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 1, + }), + )]; + active_log.push(LogEntry::AnnotatedAssistantItem { + ts: 20, + entry: assistant_message(1, "completed"), + }); + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![(source_segment, source_log), (active_segment, active_log)], + ); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + + assert_eq!(page.turns.len(), 1); + assert_eq!(page.turns[0].turn_id, "user-1"); + assert_eq!( + page.turns[0] + .entries + .iter() + .map(|entry| entry.entry_id.as_str()) + .collect::>(), + vec![ + "user-1", + "assistant-1-accepted", + "assistant-1-progress", + "assistant-1-completed" + ] + ); + } + + #[test] + fn retained_history_deduplicates_seed_history_by_stable_entry_id() { + let worker_name = "worker-duplicate-seed"; + let session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut source_log = vec![segment_start(session_id, Vec::new(), None, None)]; + append_turn(&mut source_log, 1); + let active_log = vec![segment_start( + session_id, + vec![user_message(1), assistant_message(1, "final")], + None, + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 1, + }), + )]; + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![(source_segment, source_log), (active_segment, active_log)], + ); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(page.turns.len(), 1); + assert_eq!(page.turns[0].entries.len(), 2); + assert_eq!(turn_ids(&page), vec!["user-1"]); + } + + #[test] + fn retained_history_fork_uses_persisted_turn_boundary_not_ui_turn_count() { + let worker_name = "worker-fork-boundary"; + let session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut source_log = vec![segment_start(session_id, Vec::new(), None, None)]; + source_log.push(LogEntry::Invoke { + ts: 10, + trigger: InvokeKind::UserSend, + }); + source_log.push(LogEntry::AnnotatedUserInput { + ts: 11, + segments: vec![Segment::Text { + content: "request 1".into(), + }], + history: vec![user_message(1)], + extensions: Vec::new(), + }); + source_log.push(LogEntry::AnnotatedAssistantItem { + ts: 12, + entry: assistant_message(1, "boundary"), + }); + source_log.push(LogEntry::TurnEnd { + ts: 13, + turn_count: 1, + }); + source_log.push(LogEntry::Invoke { + ts: 14, + trigger: InvokeKind::Notify, + }); + source_log.push(LogEntry::AnnotatedAssistantItem { + ts: 15, + entry: assistant_message(1, "unadopted"), + }); + source_log.push(LogEntry::TurnEnd { + ts: 16, + turn_count: 2, + }); + append_turn(&mut source_log, 2); + + let mut active_log = vec![segment_start( + session_id, + vec![ + user_message(1), + assistant_message(1, "boundary"), + user_message(2), + assistant_message(2, "final"), + ], + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 1, + }), + None, + )]; + append_turn(&mut active_log, 3); + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![(source_segment, source_log), (active_segment, active_log)], + ); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + + assert_eq!(turn_ids(&page), vec!["user-1", "user-3"]); + assert!(page.turns.iter().all(|turn| { + turn.entries + .iter() + .all(|entry| entry.entry_id != "assistant-1-unadopted") + })); + assert!(page.turns.iter().all(|turn| turn.turn_id != "user-2")); + let store = WorkerSessionStore::open_read_only(aggregate_root.join("session")).unwrap(); + let (_, source_lineage_id, _, _) = load_adopted_lineage( + &store, + session_id, + source_segment, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + let parent = page.parent_lineage.as_ref().unwrap(); + assert_eq!(parent.lineage_id, source_lineage_id); + let adopted_turn = parent.adopted_through_turn.as_ref().unwrap(); + assert_eq!(adopted_turn.turn_id, "user-1"); + assert_eq!( + adopted_turn + .entries + .iter() + .map(|entry| entry.entry_id.as_str()) + .collect::>(), + vec!["user-1", "assistant-1-boundary"] + ); + } + + #[test] + fn retained_history_rejects_a_lineage_segment_from_another_session() { + let worker_name = "worker-foreign-session"; + let session_id = crate::new_session_id(); + let foreign_session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let source_log = vec![segment_start(foreign_session_id, Vec::new(), None, None)]; + let active_log = vec![segment_start( + session_id, + Vec::new(), + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 0, + }), + None, + )]; + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![(source_segment, source_log), (active_segment, active_log)], + ); + + let error = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap_err(); + + assert_eq!(error, RetainedHistoryReadError::CorruptLog); + } + + #[test] + fn retained_history_cursor_rejects_invalid_cross_worker_and_stale_lineage() { + let (_root_a, aggregate_a) = linear_fixture("worker-a", 6); + let page = read_retained_session_history_page( + &aggregate_a, + "worker-a", + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + let cursor = page.next_cursor.unwrap(); + + let (_root_b, aggregate_b) = linear_fixture("worker-b", 6); + assert_eq!( + read_retained_session_history_page( + &aggregate_b, + "worker-b", + Some(&cursor), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap_err(), + RetainedHistoryReadError::InvalidCursor + ); + assert_eq!( + read_retained_session_history_page( + &aggregate_a, + "worker-a", + Some("not-base64!"), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap_err(), + RetainedHistoryReadError::InvalidCursor + ); + let mut tampered = decode_history_cursor(&cursor).unwrap(); + tampered.before_turn_id = "user-1".to_string(); + let tampered = encode_history_cursor(&tampered).unwrap(); + assert_eq!( + read_retained_session_history_page( + &aggregate_a, + "worker-a", + Some(&tampered), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap_err(), + RetainedHistoryReadError::InvalidCursor + ); + + let aggregate = WorkerAggregateStore::new(&aggregate_a, "worker-a").unwrap(); + let metadata = aggregate.read_by_name("worker-a").unwrap().unwrap(); + let active = metadata.active.unwrap(); + let old_segment = active.segment_id.unwrap(); + let mut appended = Vec::new(); + append_turn(&mut appended, 7); + let session = WorkerSessionStore::new(aggregate_a.join("session")).unwrap(); + for entry in appended { + session + .append(active.session_id, old_segment, &entry) + .unwrap(); + } + let older = read_retained_session_history_page( + &aggregate_a, + "worker-a", + Some(&cursor), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(turn_ids(&older), vec!["user-1"]); + + let new_segment = crate::new_segment_id(); + WorkerSessionStore::new(aggregate_a.join("session")) + .unwrap() + .create_segment( + active.session_id, + new_segment, + &[segment_start( + active.session_id, + Vec::new(), + None, + Some(SegmentOrigin { + segment_id: old_segment, + at_turn_index: 6, + }), + )], + ) + .unwrap(); + aggregate + .write(&crate::WorkerMetadata::new( + "worker-a", + Some(crate::WorkerActiveSegmentRef::active_segment( + active.session_id, + new_segment, + )), + )) + .unwrap(); + assert_eq!( + read_retained_session_history_page( + &aggregate_a, + "worker-a", + Some(&cursor), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap_err(), + RetainedHistoryReadError::InvalidCursor + ); + } + + #[test] + fn retained_history_reads_only_a_bounded_suffix_for_each_page() { + let worker_name = "worker-bounded-backward-pages"; + let session_id = crate::new_session_id(); + let segment_id = crate::new_segment_id(); + let mut log = vec![segment_start(session_id, Vec::new(), None, None)]; + for turn in 1..=200 { + append_turn(&mut log, turn); + } + let (_root, aggregate_root) = + persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]); + let limits = RetainedHistoryReadLimits { + max_scan_bytes: 64 * 1024, + ..RetainedHistoryReadLimits::default() + }; + assert!( + std::fs::metadata( + aggregate_root + .join("session/segments") + .join(format!("{segment_id}.jsonl")), + ) + .unwrap() + .len() + > limits.max_scan_bytes, + "the fixture must be larger than one page's complete scan budget", + ); + + let newest = + read_retained_session_history_page(&aggregate_root, worker_name, None, None, limits) + .unwrap(); + assert_eq!( + turn_ids(&newest), + vec!["user-196", "user-197", "user-198", "user-199", "user-200"] + ); + + let earlier = read_retained_session_history_page( + &aggregate_root, + worker_name, + newest.next_cursor.as_deref(), + None, + limits, + ) + .unwrap(); + assert_eq!( + turn_ids(&earlier), + vec!["user-191", "user-192", "user-193", "user-194", "user-195"] + ); + } + + #[test] + fn retained_history_does_not_scan_a_fork_sources_unadopted_suffix() { + let worker_name = "worker-bounded-fork-prefix"; + let session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut source_log = vec![segment_start(session_id, Vec::new(), None, None)]; + for turn in 1..=200 { + append_turn(&mut source_log, turn); + } + let mut active_log = vec![segment_start( + session_id, + Vec::new(), + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 5, + }), + None, + )]; + append_turn(&mut active_log, 201); + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![(source_segment, source_log), (active_segment, active_log)], + ); + let limits = RetainedHistoryReadLimits { + max_scan_bytes: 64 * 1024, + ..RetainedHistoryReadLimits::default() + }; + assert!( + std::fs::metadata( + aggregate_root + .join("session/segments") + .join(format!("{source_segment}.jsonl")), + ) + .unwrap() + .len() + > limits.max_scan_bytes, + ); + + let page = + read_retained_session_history_page(&aggregate_root, worker_name, None, None, limits) + .unwrap(); + assert_eq!( + turn_ids(&page), + vec!["user-2", "user-3", "user-4", "user-5", "user-201"] + ); + assert!(page.has_more); + } + + #[test] + fn retained_history_pages_within_one_root_seed_record() { + let worker_name = "worker-root-seed-pages"; + let session_id = crate::new_session_id(); + let segment_id = crate::new_segment_id(); + let history = (1..=12) + .flat_map(|turn| [user_message(turn), assistant_message(turn, "final")]) + .collect(); + let log = vec![ + segment_start(session_id, history, None, None), + LogEntry::InputSegmentsCheckpoint { + ts: 2, + user_segments: (1..=12) + .map(|turn| { + vec![Segment::Text { + content: format!("typed request {turn}"), + }] + }) + .collect(), + }, + ]; + let (_root, aggregate_root) = + persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]); + + let newest = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!( + turn_ids(&newest), + vec!["user-8", "user-9", "user-10", "user-11", "user-12"] + ); + assert_eq!( + newest.turns[0].entries[0].data, + SessionSnapshotEntryData::UserInput { + segments: vec![Segment::Text { + content: "typed request 8".to_string(), + }], + } + ); + let middle = read_retained_session_history_page( + &aggregate_root, + worker_name, + newest.next_cursor.as_deref(), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!( + turn_ids(&middle), + vec!["user-3", "user-4", "user-5", "user-6", "user-7"] + ); + let oldest = read_retained_session_history_page( + &aggregate_root, + worker_name, + middle.next_cursor.as_deref(), + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!(turn_ids(&oldest), vec!["user-1", "user-2"]); + assert!(!oldest.has_more); + } + + #[test] + fn retained_history_keeps_each_nested_lineage_edge_boundary_local() { + let worker_name = "worker-nested-boundaries"; + let session_id = crate::new_session_id(); + let root_segment = crate::new_segment_id(); + let middle_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut root_log = vec![segment_start(session_id, Vec::new(), None, None)]; + for turn in 1..=3 { + append_turn(&mut root_log, turn); + } + let middle_log = vec![segment_start( + session_id, + vec![user_message(3), assistant_message(3, "final")], + Some(SegmentOrigin { + segment_id: root_segment, + at_turn_index: 3, + }), + None, + )]; + let mut active_log = vec![segment_start( + session_id, + Vec::new(), + Some(SegmentOrigin { + segment_id: middle_segment, + at_turn_index: 0, + }), + None, + )]; + append_turn(&mut active_log, 4); + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![ + (root_segment, root_log), + (middle_segment, middle_log), + (active_segment, active_log), + ], + ); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!( + turn_ids(&page), + vec!["user-1", "user-2", "user-3", "user-4"] + ); + } + + #[test] + fn retained_history_resolves_an_immediate_consecutive_compact_at_the_seed() { + let worker_name = "worker-consecutive-compact"; + let session_id = crate::new_session_id(); + let root_segment = crate::new_segment_id(); + let middle_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut root_log = vec![segment_start(session_id, Vec::new(), None, None)]; + for turn in 1..=5 { + append_turn(&mut root_log, turn); + } + let middle_log = vec![segment_start( + session_id, + Vec::new(), + None, + Some(SegmentOrigin { + segment_id: root_segment, + at_turn_index: 5, + }), + )]; + let active_log = vec![segment_start( + session_id, + Vec::new(), + None, + Some(SegmentOrigin { + segment_id: middle_segment, + at_turn_index: 5, + }), + )]; + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![ + (root_segment, root_log), + (middle_segment, middle_log), + (active_segment, active_log), + ], + ); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); + assert_eq!( + turn_ids(&page), + vec!["user-1", "user-2", "user-3", "user-4", "user-5"] + ); + assert_eq!(page.compact_ancestor_lineage_ids.len(), 2); + } + + #[test] + fn retained_history_finds_a_late_adopted_boundary_from_the_tail() { + let worker_name = "worker-late-adopted-boundary"; + let session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut source_log = vec![segment_start(session_id, Vec::new(), None, None)]; + append_turn(&mut source_log, 1); + source_log.insert( + 3, + LogEntry::AnnotatedAssistantItem { + ts: 12, + entry: history_message( + "assistant-1-large", + LoggedRole::Assistant, + "x".repeat(300 * 1024), + LoggedSessionHistoryOrigin::LegacyUnknown, + ), + }, + ); + for turn in 2..=101 { + append_turn(&mut source_log, turn); + } + let active_log = vec![segment_start( + session_id, + Vec::new(), + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 100, + }), + None, + )]; + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![(source_segment, source_log), (active_segment, active_log)], + ); + let limits = RetainedHistoryReadLimits { + max_scan_bytes: 256 * 1024, + ..RetainedHistoryReadLimits::default() + }; + assert!( + std::fs::metadata( + aggregate_root + .join("session/segments") + .join(format!("{source_segment}.jsonl")), + ) + .unwrap() + .len() + > limits.max_scan_bytes, + ); + + let page = + read_retained_session_history_page(&aggregate_root, worker_name, None, None, limits) + .unwrap(); + assert_eq!( + turn_ids(&page), + vec!["user-96", "user-97", "user-98", "user-99", "user-100"] + ); + assert!(page.has_more); + } + + #[test] + fn retained_history_shares_one_entry_budget_with_parent_boundary_lookup() { + let worker_name = "worker-shared-parent-budget"; + let session_id = crate::new_session_id(); + let source_segment = crate::new_segment_id(); + let active_segment = crate::new_segment_id(); + let mut source_log = vec![segment_start(session_id, Vec::new(), None, None)]; + for turn in 1..=5 { + append_turn(&mut source_log, turn); + } + let mut active_log = vec![segment_start( + session_id, + Vec::new(), + Some(SegmentOrigin { + segment_id: source_segment, + at_turn_index: 5, + }), + None, + )]; + for turn in 6..=10 { + append_turn(&mut active_log, turn); + } + let (_root, aggregate_root) = persist_history_fixture( + worker_name, + session_id, + active_segment, + vec![(source_segment, source_log), (active_segment, active_log)], + ); + + let error = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + Some(1), + RetainedHistoryReadLimits { + max_entries: 40, + ..RetainedHistoryReadLimits::default() + }, + ) + .unwrap_err(); + assert_eq!(error, RetainedHistoryReadError::ResourceLimit); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + Some(1), + RetainedHistoryReadLimits { + max_entries: 100, + ..RetainedHistoryReadLimits::default() + }, + ) + .unwrap(); + assert_eq!(turn_ids(&page), vec!["user-10"]); + } + + #[test] + fn retained_history_enforces_page_scan_and_response_limits_without_splitting_turns() { + let worker_name = "worker-history-limits"; + let (_root, aggregate_root) = linear_fixture(worker_name, 1); + let defaults = RetainedHistoryReadLimits::default(); + + for limits in [ + RetainedHistoryReadLimits { + max_scan_bytes: 0, + ..defaults + }, + RetainedHistoryReadLimits { + max_segments: 0, + ..defaults + }, + RetainedHistoryReadLimits { + max_entries: 1, + ..defaults + }, + RetainedHistoryReadLimits { + max_response_bytes: 1, + ..defaults + }, + ] { + assert_eq!( + read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + None, + limits, + ) + .unwrap_err(), + RetainedHistoryReadError::ResourceLimit + ); + } + assert_eq!( + read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + Some(MAX_RETAINED_HISTORY_PAGE_TURNS + 1), + defaults, + ) + .unwrap_err(), + RetainedHistoryReadError::ResourceLimit + ); + + let page = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + Some(1), + defaults, + ) + .unwrap(); + assert_eq!(page.turns.len(), 1); + assert_eq!(page.turns[0].entries.len(), 2); + } + + #[test] + fn retained_history_rejects_an_oversized_single_turn_instead_of_splitting_it() { + let worker_name = "worker-oversized-turn"; + let session_id = crate::new_session_id(); + let segment_id = crate::new_segment_id(); + let body = "x".repeat(128 * 1024); + let log = vec![ + segment_start(session_id, Vec::new(), None, None), + LogEntry::Invoke { + ts: 2, + trigger: InvokeKind::UserSend, + }, + LogEntry::AnnotatedUserInput { + ts: 3, + segments: vec![Segment::Text { + content: body.clone(), + }], + history: vec![history_message( + "large-user", + LoggedRole::User, + body, + LoggedSessionHistoryOrigin::HumanInput { + account_id: "account-1".into(), + }, + )], + extensions: Vec::new(), + }, + LogEntry::AnnotatedAssistantItem { + ts: 4, + entry: history_message( + "large-assistant", + LoggedRole::Assistant, + "complete", + LoggedSessionHistoryOrigin::LegacyUnknown, + ), + }, + ]; + let (_root, aggregate_root) = + persist_history_fixture(worker_name, session_id, segment_id, vec![(segment_id, log)]); + + let error = read_retained_session_history_page( + &aggregate_root, + worker_name, + None, + Some(1), + RetainedHistoryReadLimits { + max_response_bytes: 1024, + ..RetainedHistoryReadLimits::default() + }, + ) + .unwrap_err(); + + assert_eq!(error, RetainedHistoryReadError::ResourceLimit); + } + #[test] fn current_projection_is_stable_and_hides_reasoning_and_system_prompts() { let session_id = crate::new_session_id(); @@ -708,11 +2716,21 @@ mod tests { DEFAULT_RETAINED_SNAPSHOT_MAX_BYTES, ) .unwrap(); + let history = read_retained_session_history_page( + &aggregate_root, + "worker-a", + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap(); assert_eq!(retained.identity.session_id, session_id.to_string()); assert_eq!(retained.identity.segment_id, segment_id.to_string()); assert_eq!(retained.identity.entry_count, 0); assert!(retained.snapshot.entries.is_empty()); + assert!(history.turns.is_empty()); + assert!(!history.has_more); for (index, path) in [&metadata_path, &manifest_path, &segment_path] .into_iter() .enumerate() @@ -740,7 +2758,7 @@ mod tests { } #[test] - fn retained_snapshot_read_does_not_create_missing_aggregate() { + fn retained_reads_do_not_create_missing_aggregate() { let root = tempfile::tempdir().unwrap(); let aggregate = root.path().join("missing-worker"); @@ -752,6 +2770,17 @@ mod tests { .unwrap_err(); assert!(matches!(error, RetainedSnapshotReadError::RetentionMissing)); + assert_eq!( + read_retained_session_history_page( + &aggregate, + "missing-worker", + None, + None, + RetainedHistoryReadLimits::default(), + ) + .unwrap_err(), + RetainedHistoryReadError::RetentionMissing + ); assert!(!aggregate.exists()); } } diff --git a/crates/session-store/src/store.rs b/crates/session-store/src/store.rs index 33daae39e..a32f9038e 100644 --- a/crates/session-store/src/store.rs +++ b/crates/session-store/src/store.rs @@ -33,6 +33,9 @@ pub enum StoreError { #[error("log corrupted at line {line}: {message}")] Corrupt { line: usize, message: String }, + #[error("retained observation read limit exceeded")] + ReadLimitExceeded, + #[error("paste artifact storage is unavailable")] PasteArtifactUnsupported, diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index b8a5bdf29..35218068c 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -25,7 +25,7 @@ use crate::{ use protocol::{PasteArtifactRef, UploadedFileRef}; use serde::{Deserialize, Serialize}; use std::fs::{self, File, OpenOptions}; -use std::io::{Read, Seek, SeekFrom, Write}; +use std::io::{BufRead, BufReader, Read, Seek, SeekFrom, Write}; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; use std::time::SystemTime; @@ -50,6 +50,106 @@ struct SessionManifest { session_id: SessionId, } +const RETAINED_BACKWARD_READ_CHUNK_BYTES: usize = 64 * 1024; + +#[derive(Debug)] +pub(crate) struct RetainedLogRecord { + pub start_offset: u64, + pub end_offset: u64, + pub entry: LogEntry, +} + +pub(crate) struct RetainedSegmentReader { + file: File, + read_end: u64, + buffer_start: u64, + buffer: Vec, +} + +impl RetainedSegmentReader { + pub fn previous_record( + &mut self, + max_additional_bytes: u64, + ) -> Result<(Option, u64), StoreError> { + let mut read_bytes = 0_u64; + loop { + if !self.buffer.is_empty() { + if self.buffer.last() != Some(&b'\n') { + return Err(StoreError::Corrupt { + line: 0, + message: "retained Segment cursor is not at a record boundary".to_string(), + }); + } + let prior_newline = self.buffer[..self.buffer.len() - 1] + .iter() + .rposition(|byte| *byte == b'\n'); + if let Some(newline) = + prior_newline.or((self.buffer_start == 0).then_some(usize::MAX)) + { + let record_start_in_buffer = if newline == usize::MAX { + 0 + } else { + newline + 1 + }; + let record_start = self.buffer_start + record_start_in_buffer as u64; + let record_end = self.buffer_start + self.buffer.len() as u64; + let record = + self.buffer[record_start_in_buffer..self.buffer.len() - 1].to_vec(); + self.buffer.truncate(record_start_in_buffer); + self.read_end = record_start; + if record.is_empty() { + return Err(StoreError::Corrupt { + line: 0, + message: "empty retained Segment log record".to_string(), + }); + } + let entry = serde_json::from_slice(&record)?; + return Ok(( + Some(RetainedLogRecord { + start_offset: record_start, + end_offset: record_end, + entry, + }), + read_bytes, + )); + } + } else if self.read_end == 0 { + return Ok((None, read_bytes)); + } + + if self.buffer_start == 0 && !self.buffer.is_empty() { + return Err(StoreError::Corrupt { + line: 0, + message: "retained Segment log does not end at a record boundary".to_string(), + }); + } + if read_bytes >= max_additional_bytes { + return Err(StoreError::ReadLimitExceeded); + } + let end = if self.buffer.is_empty() { + self.read_end + } else { + self.buffer_start + }; + let available = max_additional_bytes - read_bytes; + let chunk_len = end + .min(RETAINED_BACKWARD_READ_CHUNK_BYTES as u64) + .min(available); + if chunk_len == 0 { + return Err(StoreError::ReadLimitExceeded); + } + let start = end - chunk_len; + self.file.seek(SeekFrom::Start(start))?; + let mut chunk = vec![0_u8; chunk_len as usize]; + self.file.read_exact(&mut chunk)?; + read_bytes += chunk_len; + chunk.extend_from_slice(&self.buffer); + self.buffer = chunk; + self.buffer_start = start; + } + } +} + impl WorkerSessionStore { /// Open the Session store rooted at `/session`. pub fn new(root: impl Into) -> Result { @@ -119,6 +219,19 @@ impl WorkerSessionStore { session_id: SessionId, segment_id: SegmentId, ) -> Result, StoreError> { + self.read_all_read_only_bounded(session_id, segment_id, u64::MAX) + .map(|(entries, _)| entries) + } + + /// Read one retained Segment under an actual byte bound without touching + /// access time. The file descriptor read, rather than a preceding stat, is + /// the limit authority so concurrent appends cannot evade accounting. + pub fn read_all_read_only_bounded( + &self, + session_id: SessionId, + segment_id: SegmentId, + max_bytes: u64, + ) -> Result<(Vec, u64), StoreError> { let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt { line: 0, message: "Worker Session identity lock poisoned".to_string(), @@ -131,14 +244,210 @@ impl WorkerSessionStore { }); } let path = self.log_path(segment_id); - let bytes = crate::read_without_atime(&path).map_err(|error| { + let bytes = crate::read_without_atime_bounded(&path, max_bytes).map_err(|error| { if error.kind() == std::io::ErrorKind::NotFound { StoreError::NotFound(segment_id) } else { StoreError::Io(error) } })?; - parse_jsonl(&bytes) + let byte_len = bytes.len() as u64; + if byte_len > max_bytes { + return Err(StoreError::ReadLimitExceeded); + } + Ok((parse_jsonl(&bytes)?, byte_len)) + } + + pub(crate) fn read_first_log_record_read_only_bounded( + &self, + session_id: SessionId, + segment_id: SegmentId, + max_bytes: u64, + ) -> Result<(Option, u64, u64), StoreError> { + self.validate_retained_session(session_id)?; + let file = open_retained_file(&self.log_path(segment_id)).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + StoreError::NotFound(segment_id) + } else { + StoreError::Io(error) + } + })?; + let file_len = file.metadata()?.len(); + if file_len == 0 { + return Ok((None, 0, 0)); + } + let mut reader = BufReader::new(file.take(max_bytes.saturating_add(1))); + let mut record = Vec::new(); + let read_bytes = reader.read_until(b'\n', &mut record)? as u64; + if read_bytes > max_bytes || record.last() != Some(&b'\n') { + return Err(StoreError::ReadLimitExceeded); + } + record.pop(); + if record.is_empty() { + return Err(StoreError::Corrupt { + line: 0, + message: "empty retained Segment log record".to_string(), + }); + } + let entry = serde_json::from_slice(&record)?; + Ok(( + Some(RetainedLogRecord { + start_offset: 0, + end_offset: read_bytes, + entry, + }), + read_bytes, + file_len, + )) + } + + pub(crate) fn read_next_log_record_read_only_bounded( + &self, + session_id: SessionId, + segment_id: SegmentId, + start_offset: u64, + max_bytes: u64, + ) -> Result<(Option, u64), StoreError> { + self.validate_retained_session(session_id)?; + let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + StoreError::NotFound(segment_id) + } else { + StoreError::Io(error) + } + })?; + let file_len = file.metadata()?.len(); + if start_offset >= file_len { + return Ok((None, 0)); + } + file.seek(SeekFrom::Start(start_offset))?; + let mut reader = BufReader::new(file.take(max_bytes.saturating_add(1))); + let mut record = Vec::new(); + let read_bytes = reader.read_until(b'\n', &mut record)? as u64; + if read_bytes > max_bytes || record.last() != Some(&b'\n') { + return Err(StoreError::ReadLimitExceeded); + } + record.pop(); + if record.is_empty() { + return Err(StoreError::Corrupt { + line: 0, + message: "empty retained Segment log record".to_string(), + }); + } + let entry = serde_json::from_slice(&record)?; + Ok(( + Some(RetainedLogRecord { + start_offset, + end_offset: start_offset + read_bytes, + entry, + }), + read_bytes, + )) + } + + pub(crate) fn read_log_record_range_read_only_bounded( + &self, + session_id: SessionId, + segment_id: SegmentId, + start_offset: u64, + end_offset: u64, + max_bytes: u64, + ) -> Result { + self.validate_retained_session(session_id)?; + let record_len = + end_offset + .checked_sub(start_offset) + .ok_or_else(|| StoreError::Corrupt { + line: 0, + message: "retained Segment record range is reversed".to_string(), + })?; + if record_len == 0 || record_len > max_bytes { + return Err(StoreError::ReadLimitExceeded); + } + let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + StoreError::NotFound(segment_id) + } else { + StoreError::Io(error) + } + })?; + if end_offset > file.metadata()?.len() { + return Err(StoreError::Corrupt { + line: 0, + message: "retained Segment record range exceeds the committed log".to_string(), + }); + } + file.seek(SeekFrom::Start(start_offset))?; + let mut record = vec![0_u8; record_len as usize]; + file.read_exact(&mut record)?; + if record.last() != Some(&b'\n') || record[..record.len() - 1].contains(&b'\n') { + return Err(StoreError::Corrupt { + line: 0, + message: "retained Segment cursor does not identify one record".to_string(), + }); + } + record.pop(); + let entry = serde_json::from_slice(&record)?; + Ok(RetainedLogRecord { + start_offset, + end_offset, + entry, + }) + } + + pub(crate) fn open_retained_segment_reader( + &self, + session_id: SessionId, + segment_id: SegmentId, + before_offset: u64, + ) -> Result { + self.validate_retained_session(session_id)?; + let mut file = open_retained_file(&self.log_path(segment_id)).map_err(|error| { + if error.kind() == std::io::ErrorKind::NotFound { + StoreError::NotFound(segment_id) + } else { + StoreError::Io(error) + } + })?; + let file_len = file.metadata()?.len(); + if before_offset > file_len { + return Err(StoreError::Corrupt { + line: 0, + message: "retained Segment cursor is beyond the committed log".to_string(), + }); + } + if before_offset > 0 { + file.seek(SeekFrom::Start(before_offset - 1))?; + let mut boundary = [0_u8; 1]; + file.read_exact(&mut boundary)?; + if boundary[0] != b'\n' { + return Err(StoreError::Corrupt { + line: 0, + message: "retained Segment cursor is not at a record boundary".to_string(), + }); + } + } + Ok(RetainedSegmentReader { + file, + read_end: before_offset, + buffer_start: before_offset, + buffer: Vec::new(), + }) + } + + fn validate_retained_session(&self, requested: SessionId) -> Result<(), StoreError> { + let retained_session_id = self.session_id.lock().map_err(|_| StoreError::Corrupt { + line: 0, + message: "Worker Session identity lock poisoned".to_string(), + })?; + if *retained_session_id != Some(requested) { + return Err(StoreError::Corrupt { + line: 0, + message: "active Worker Session identity does not match retained manifest" + .to_string(), + }); + } + Ok(()) } pub fn segment_log_len(&self, segment_id: SegmentId) -> Result { @@ -649,6 +958,25 @@ impl Store for WorkerSessionStore { } } +fn open_retained_file(path: &Path) -> std::io::Result { + #[cfg(any(target_os = "linux", target_os = "android"))] + { + use std::os::unix::fs::OpenOptionsExt; + OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOATIME) + .open(path) + } + #[cfg(not(any(target_os = "linux", target_os = "android")))] + { + let _ = path; + Err(std::io::Error::new( + std::io::ErrorKind::Unsupported, + "read-only retained observation requires no-atime file reads", + )) + } +} + fn segment_log_paths(root: &Path) -> Result, StoreError> { let segments = root.join(SEGMENTS_DIR); if !segments.exists() { diff --git a/crates/standalone/tests/host.rs b/crates/standalone/tests/host.rs index dfcaa9483..e962bcd57 100644 --- a/crates/standalone/tests/host.rs +++ b/crates/standalone/tests/host.rs @@ -117,7 +117,7 @@ async fn in_process_host_runs_text_and_read_tool_then_shuts_down() { .expect("protocol event") .expect("worker event") { - Event::UserMessage { segments } + Event::UserMessage { segments, .. } if format!("{segments:?}").contains("read the probe") => { saw_user_message = true; diff --git a/crates/tools/src/view_image.rs b/crates/tools/src/view_image.rs index 1fc86ca44..255695b9a 100644 --- a/crates/tools/src/view_image.rs +++ b/crates/tools/src/view_image.rs @@ -7,12 +7,14 @@ use agen::tool::{ }; use async_trait::async_trait; use serde::Deserialize; -use workdir::{ReadRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; +use workdir::{ReadBytesRequest, WorkdirPath, WorkdirSessionHandle, WorkdirSessionRouter}; use crate::error::ToolsError; /// Maximum image body accepted for one model request. pub const MAX_IMAGE_BYTES: usize = 10 * 1024 * 1024; +/// One binary response remains within the External Workdir transport bound. +const IMAGE_READ_CHUNK_BYTES: usize = 1024 * 1024; const DESCRIPTION: &str = "Attach an image from the selected Workdir attachment to the next model request. \ The path must be logical and Workdir-relative. Supported formats: PNG, JPEG, GIF, and WebP. \ @@ -47,42 +49,111 @@ impl Tool for ViewImageTool { workdir::WorkdirSessionCapability::Read, )?; let path = WorkdirPath::new(&input.path).map_err(ToolsError::from)?; - let result = selected - .session - .read(ReadRequest { - path: path.clone(), - offset: 0, - limit: usize::MAX, - // The scoped provider enforces this cap while reading, rather - // than allocating an unbounded binary body first. - max_bytes: MAX_IMAGE_BYTES + 1, - }) + let bytes = read_image_bytes(selected.session.as_ref(), path.clone()) .await .map_err(ToolsError::from)?; - - if result.truncated || result.bytes.len() > MAX_IMAGE_BYTES { - return Err(ToolError::InvalidArgument(format!( - "image exceeds the {MAX_IMAGE_BYTES}-byte limit" - ))); - } - let mime_type = detect_image_mime(&result.bytes).ok_or_else(|| { + let mime_type = detect_image_mime(&bytes).ok_or_else(|| { ToolError::InvalidArgument( "unsupported image; expected PNG, JPEG, GIF, or WebP bytes".to_string(), ) })?; - let bytes = result.bytes.len(); + let byte_count = bytes.len(); Ok(ToolOutput { - summary: format!("Attached image {path} ({mime_type}, {bytes} bytes)"), + summary: format!("Attached image {path} ({mime_type}, {byte_count} bytes)"), content: None, attachments: vec![Attachment::Image(ImageAttachment::new( mime_type, - Arc::<[u8]>::from(result.bytes), + Arc::<[u8]>::from(bytes), ))], }) } } +async fn read_image_bytes( + session: &dyn workdir::WorkdirSession, + path: WorkdirPath, +) -> Result, workdir::WorkdirError> { + let mut bytes = Vec::new(); + let mut offset = 0_u64; + let mut expected_hash = None; + let mut expected_total = None; + + loop { + let remaining = expected_total + .map(|total: u64| total.saturating_sub(offset) as usize) + .unwrap_or(IMAGE_READ_CHUNK_BYTES); + let request_bytes = remaining.min(IMAGE_READ_CHUNK_BYTES).max(1); + let result = session + .read_bytes(ReadBytesRequest { + path: path.clone(), + offset, + max_bytes: request_bytes, + expected_hash, + }) + .await?; + + if result.path != path || result.offset != offset { + return Err(workdir::WorkdirError::Transport( + "bounded binary read returned a mismatched path or offset".to_string(), + )); + } + if result.bytes.len() > request_bytes { + return Err(workdir::WorkdirError::Transport( + "bounded binary read exceeded the requested response size".to_string(), + )); + } + let end = result + .offset + .checked_add(result.bytes.len() as u64) + .ok_or_else(|| { + workdir::WorkdirError::Transport( + "bounded binary read returned an invalid byte range".to_string(), + ) + })?; + if end > result.total_bytes || result.eof != (end == result.total_bytes) { + return Err(workdir::WorkdirError::Transport( + "bounded binary read returned a truncated or invalid byte range".to_string(), + )); + } + if result.total_bytes > MAX_IMAGE_BYTES as u64 { + return Err(workdir::WorkdirError::InvalidArgument(format!( + "image exceeds the {MAX_IMAGE_BYTES}-byte limit" + ))); + } + if expected_total.is_some_and(|total| total != result.total_bytes) + || expected_hash.is_some_and(|hash| hash != result.content_hash) + { + return Err(workdir::WorkdirError::Conflict( + "image changed while it was being read; retry ViewImage".to_string(), + )); + } + if expected_total.is_none() { + bytes + .try_reserve_exact(result.total_bytes as usize) + .map_err(|_| workdir::WorkdirError::OperationFailed)?; + expected_total = Some(result.total_bytes); + expected_hash = Some(result.content_hash); + } + + if result.bytes.is_empty() && !result.eof { + return Err(workdir::WorkdirError::Transport( + "bounded binary read made no progress".to_string(), + )); + } + bytes.extend_from_slice(&result.bytes); + offset = end; + if result.eof { + if bytes.len() as u64 != result.total_bytes { + return Err(workdir::WorkdirError::Transport( + "bounded binary read returned a partial image".to_string(), + )); + } + return Ok(bytes); + } + } +} + pub fn view_image_tool(session: WorkdirSessionHandle) -> ToolDefinition { routed_view_image_tool(crate::routing::singleton_router(session)) } diff --git a/crates/tools/tests/fixtures/view-image/valid.gif b/crates/tools/tests/fixtures/view-image/valid.gif new file mode 100644 index 000000000..f1eda11f8 Binary files /dev/null and b/crates/tools/tests/fixtures/view-image/valid.gif differ diff --git a/crates/tools/tests/fixtures/view-image/valid.jpg b/crates/tools/tests/fixtures/view-image/valid.jpg new file mode 100644 index 000000000..60191034e Binary files /dev/null and b/crates/tools/tests/fixtures/view-image/valid.jpg differ diff --git a/crates/tools/tests/fixtures/view-image/valid.png b/crates/tools/tests/fixtures/view-image/valid.png new file mode 100644 index 000000000..5d5869660 Binary files /dev/null and b/crates/tools/tests/fixtures/view-image/valid.png differ diff --git a/crates/tools/tests/fixtures/view-image/valid.webp b/crates/tools/tests/fixtures/view-image/valid.webp new file mode 100644 index 000000000..0c9cd5469 Binary files /dev/null and b/crates/tools/tests/fixtures/view-image/valid.webp differ diff --git a/crates/tools/tests/integration.rs b/crates/tools/tests/integration.rs index f1d346e60..6cf261ccb 100644 --- a/crates/tools/tests/integration.rs +++ b/crates/tools/tests/integration.rs @@ -18,7 +18,9 @@ use tools::{ Tracker, core_builtin_tools, routed_builtin_tools, routed_view_image_tool, view_image_tool, }; use workdir::{ - LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirSessionHandle, WorkdirSessionRouter, + LocalWorkdirSession, WorkdirAttachmentAlias, WorkdirPath, WorkdirSessionHandle, + WorkdirSessionRouter, WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission, + WorkdirToolScopeRule, }; fn scope_with_spill(workspace: &Path, spill: &Path) -> Scope { @@ -94,6 +96,33 @@ fn setup_routed() -> ( (left, right, spill, router, Registry::new(definitions)) } +fn valid_gif_with_len(target_len: usize) -> Vec { + let fixture = include_bytes!("fixtures/view-image/valid.gif"); + assert_eq!( + fixture.last(), + Some(&0x3b), + "GIF fixture must end in trailer" + ); + let mut bytes = fixture[..fixture.len() - 1].to_vec(); + bytes.extend_from_slice(&[0x21, 0xfe]); + let mut remaining = target_len + .checked_sub(bytes.len() + 2) + .expect("target must leave room for comment terminator and GIF trailer"); + while remaining > 0 { + let mut block = remaining.min(256); + if remaining - block == 1 { + block -= 1; + } + assert!(block >= 2); + bytes.push((block - 1) as u8); + bytes.extend(std::iter::repeat_n(b'x', block - 1)); + remaining -= block; + } + bytes.extend_from_slice(&[0, 0x3b]); + assert_eq!(bytes.len(), target_len); + bytes +} + async fn call(tool: &Arc, input: serde_json::Value) -> agen::tool::ToolOutput { tool.execute(&input.to_string(), Default::default()) .await @@ -399,6 +428,301 @@ async fn detach_and_reattach_same_alias_requires_a_fresh_read() { router.close_all().await.unwrap(); } +#[derive(Debug)] +struct ExternalValidatingSession { + inner: LocalWorkdirSession, + truncate_first_binary_response: bool, + mutate_after_first_binary_response: Option, +} + +#[async_trait::async_trait] +impl workdir::WorkdirSession for ExternalValidatingSession { + fn workdir(&self) -> &workdir::Workdir { + self.inner.workdir() + } + + fn capabilities(&self) -> workdir::WorkdirSessionCapabilities { + self.inner.capabilities() + } + + async fn stat( + &self, + request: workdir::StatRequest, + ) -> Result { + self.inner.stat(request).await + } + + async fn read( + &self, + request: workdir::ReadRequest, + ) -> Result { + let operation = workdir::http::WorkdirSessionOperation::Read(request); + let operation = workdir::external::ExternalWorkdirOperation::try_from(operation) + .map_err(workdir::WorkdirError::UnsupportedOperation)?; + let workdir::http::WorkdirSessionOperation::Read(request) = operation.into_inner() else { + unreachable!("validated Read changed operation kind") + }; + self.inner.read(request).await + } + + async fn read_bytes( + &self, + request: workdir::ReadBytesRequest, + ) -> Result { + let operation = workdir::http::WorkdirSessionOperation::ReadBytes(request); + let operation = workdir::external::ExternalWorkdirOperation::try_from(operation) + .map_err(workdir::WorkdirError::UnsupportedOperation)?; + let workdir::http::WorkdirSessionOperation::ReadBytes(request) = operation.into_inner() + else { + unreachable!("validated ReadBytes changed operation kind") + }; + let result = self.inner.read_bytes(request).await?; + let result = workdir::external::ExternalWorkdirOperationResult::try_from( + workdir::http::WorkdirSessionOperationResult::ReadBytes(result), + ) + .map_err(workdir::WorkdirError::UnsupportedOperation)?; + let workdir::http::WorkdirSessionOperationResult::ReadBytes(mut result) = + result.into_inner() + else { + unreachable!("validated ReadBytes result changed operation kind") + }; + if result.offset == 0 { + if let Some(path) = &self.mutate_after_first_binary_response { + std::fs::write(path, vec![b'z'; result.total_bytes as usize]).unwrap(); + } + if self.truncate_first_binary_response { + result.bytes.pop(); + } + } + Ok(result) + } + + async fn write( + &self, + request: workdir::WriteRequest, + ) -> Result { + self.inner.write(request).await + } + + async fn edit( + &self, + request: workdir::EditRequest, + ) -> Result { + self.inner.edit(request).await + } + + async fn list( + &self, + request: workdir::ListRequest, + ) -> Result { + self.inner.list(request).await + } + + async fn glob( + &self, + request: workdir::GlobRequest, + ) -> Result { + self.inner.glob(request).await + } + + async fn grep( + &self, + request: workdir::GrepRequest, + ) -> Result { + self.inner.grep(request).await + } + + async fn start_command( + &self, + request: workdir::CommandRequest, + ) -> Result { + self.inner.start_command(request).await + } + + async fn command_status( + &self, + handle: workdir::CommandHandle, + ) -> Result { + self.inner.command_status(handle).await + } + + async fn command_output( + &self, + request: workdir::CommandOutputRequest, + ) -> Result { + self.inner.command_output(request).await + } + + async fn cancel_command( + &self, + handle: workdir::CommandHandle, + ) -> Result<(), workdir::WorkdirError> { + self.inner.cancel_command(handle).await + } + + async fn close(&self) -> Result<(), workdir::WorkdirError> { + self.inner.close().await + } +} + +#[tokio::test] +async fn external_view_image_uses_a_protocol_bounded_request() { + let dir = TempDir::new().unwrap(); + let spill = TempDir::new().unwrap(); + let scope = scope_with_spill(dir.path(), spill.path()); + let session: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { + inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), + truncate_first_binary_response: false, + mutate_after_first_binary_response: None, + }); + let png = include_bytes!("fixtures/view-image/valid.png"); + assert!(png.contains(&0)); + assert!(png.iter().any(|byte| *byte >= 0x80)); + std::fs::write(dir.path().join("image.png"), png).unwrap(); + let (_meta, tool) = view_image_tool(session)(); + + let output = call(&tool, json!({ "path": "image.png" })).await; + let agen::tool::Attachment::Image(image) = &output.attachments[0]; + assert_eq!(image.mime_type(), "image/png"); + assert_eq!(image.data(), png); +} + +#[tokio::test] +async fn external_view_image_rejects_truncated_and_cross_version_chunks() { + let dir = TempDir::new().unwrap(); + let spill = TempDir::new().unwrap(); + let image_path = dir.path().join("image.png"); + let scope = scope_with_spill(dir.path(), spill.path()); + std::fs::write(&image_path, include_bytes!("fixtures/view-image/valid.png")).unwrap(); + let truncated: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { + inner: LocalWorkdirSession::new(scope.clone(), dir.path().to_path_buf()), + truncate_first_binary_response: true, + mutate_after_first_binary_response: None, + }); + let (_meta, tool) = view_image_tool(truncated)(); + let error = call_err(&tool, json!({ "path": "image.png" })) + .await + .to_string(); + assert!( + error.contains("truncated") || error.contains("partial"), + "{error}" + ); + + let large = valid_gif_with_len(1024 * 1024 + 32); + std::fs::write(&image_path, large).unwrap(); + let changing: WorkdirSessionHandle = Arc::new(ExternalValidatingSession { + inner: LocalWorkdirSession::new(scope, dir.path().to_path_buf()), + truncate_first_binary_response: false, + mutate_after_first_binary_response: Some(image_path), + }); + let (_meta, tool) = view_image_tool(changing)(); + let error = call_err(&tool, json!({ "path": "image.png" })) + .await + .to_string(); + assert!(error.contains("changed"), "{error}"); +} + +#[tokio::test] +async fn view_image_enforces_the_ten_mib_boundary_without_partial_attachments() { + const LIMIT: usize = 10 * 1024 * 1024; + let dir = TempDir::new().unwrap(); + let spill = TempDir::new().unwrap(); + let scope = scope_with_spill(dir.path(), spill.path()); + let session: WorkdirSessionHandle = + Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); + let (_meta, tool) = view_image_tool(session)(); + let mut image = valid_gif_with_len(LIMIT); + std::fs::write(dir.path().join("boundary.gif"), &image).unwrap(); + + let output = call(&tool, json!({ "path": "boundary.gif" })).await; + let agen::tool::Attachment::Image(attachment) = &output.attachments[0]; + assert_eq!(attachment.mime_type(), "image/gif"); + assert_eq!(attachment.data(), image); + + image.push(0); + std::fs::write(dir.path().join("boundary.gif"), image).unwrap(); + let error = call_err(&tool, json!({ "path": "boundary.gif" })) + .await + .to_string(); + assert!(error.contains("10485760-byte limit"), "{error}"); +} + +#[tokio::test] +async fn view_image_attaches_valid_supported_image_fixtures() { + let dir = TempDir::new().unwrap(); + let spill = TempDir::new().unwrap(); + let scope = scope_with_spill(dir.path(), spill.path()); + let session: WorkdirSessionHandle = + Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); + let (_meta, tool) = view_image_tool(session)(); + let fixtures: [(&str, &[u8], &str); 4] = [ + ( + "valid.png", + include_bytes!("fixtures/view-image/valid.png"), + "image/png", + ), + ( + "valid.jpg", + include_bytes!("fixtures/view-image/valid.jpg"), + "image/jpeg", + ), + ( + "valid.gif", + include_bytes!("fixtures/view-image/valid.gif"), + "image/gif", + ), + ( + "valid.webp", + include_bytes!("fixtures/view-image/valid.webp"), + "image/webp", + ), + ]; + + for (name, fixture, mime) in fixtures { + std::fs::write(dir.path().join(name), fixture).unwrap(); + let output = call(&tool, json!({ "path": name })).await; + let agen::tool::Attachment::Image(image) = &output.attachments[0]; + assert_eq!(image.mime_type(), mime); + assert_eq!(image.data(), fixture); + } +} + +#[tokio::test] +async fn delegated_view_image_preserves_caller_relative_path_identity() { + let dir = TempDir::new().unwrap(); + std::fs::create_dir(dir.path().join("docs")).unwrap(); + let fixture = include_bytes!("fixtures/view-image/valid.png"); + std::fs::write(dir.path().join("docs/image.png"), fixture).unwrap(); + let source: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new( + Scope::writable(dir.path()).unwrap(), + dir.path().to_path_buf(), + )); + let broker = WorkdirToolBroker::new(source); + let lease = broker + .scope(WorkdirToolScope { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::new("docs").unwrap(), + permission: WorkdirToolScopePermission::Read, + recursive: true, + symlink_policy: Default::default(), + }], + cwd: WorkdirPath::new("docs").unwrap(), + command: false, + }) + .await + .unwrap(); + let (_meta, tool) = view_image_tool(lease.tool_session())(); + + let output = call(&tool, json!({ "path": "image.png" })).await; + assert!(output.summary.contains("image.png")); + assert!(!output.summary.contains("docs/image.png")); + let agen::tool::Attachment::Image(image) = &output.attachments[0]; + assert_eq!(image.data(), fixture); + + lease.close().await.unwrap(); + broker.close().await.unwrap(); +} + #[tokio::test] async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { let dir = TempDir::new().unwrap(); @@ -406,7 +730,7 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { let scope = scope_with_spill(dir.path(), spill.path()); let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::new(scope, dir.path().to_path_buf())); - let png = b"\x89PNG\r\n\x1a\nprivate-image-body"; + let png = include_bytes!("fixtures/view-image/valid.png"); std::fs::write(dir.path().join("image.png"), png).unwrap(); let definition = view_image_tool(session); let (_meta, tool) = definition(); @@ -417,7 +741,6 @@ async fn view_image_reads_scoped_bytes_into_durable_tool_detail() { assert_eq!(image.mime_type(), "image/png"); assert_eq!(image.data(), png); let serialized = serde_json::to_string(&output).unwrap(); - assert!(!serialized.contains("private-image-body")); assert!(serialized.contains("attachments")); let restored: agen::tool::ToolOutput = serde_json::from_str(&serialized).unwrap(); let agen::tool::Attachment::Image(restored_image) = &restored.attachments[0]; diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 620382fc9..3ab3e5123 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1148,7 +1148,7 @@ impl App { Event::PendingSubmissionsChanged { pending } => { self.pending_submissions = pending; } - Event::UserMessage { segments } => { + Event::UserMessage { segments, .. } => { self.turn_index += 1; self.blocks.push(Block::TurnHeader { turn: self.turn_index, @@ -1168,6 +1168,9 @@ impl App { } self.assistant_streaming = false; } + // The TUI already renders assistant/tool streaming events. This commit + // marker exists so identity-aware clients can reconcile durable history. + Event::SessionEntryCommitted { .. } => {} Event::SystemItem { item } => { self.apply_system_item(&item); self.assistant_streaming = false; @@ -3391,6 +3394,7 @@ mod completion_flow_tests { ]), }); app.handle_worker_event(Event::UserMessage { + entry_id: None, segments: vec![Segment::text("first persisted message")], }); @@ -3409,6 +3413,7 @@ mod completion_flow_tests { assert_eq!(input_text(&app), ""); app.handle_worker_event(Event::UserMessage { + entry_id: None, segments: submitted, }); // Simulate run-derived attachment display after the submitted user line. @@ -3449,6 +3454,7 @@ mod completion_flow_tests { let mut app = App::new("test".into()); let submitted = submit_text(&mut app, "original submit"); app.handle_worker_event(Event::UserMessage { + entry_id: None, segments: submitted, }); for c in "draft while running".chars() { @@ -3477,6 +3483,7 @@ mod completion_flow_tests { let mut app = App::new("test".into()); let submitted = submit_text(&mut app, "normal run"); app.handle_worker_event(Event::UserMessage { + entry_id: None, segments: submitted, }); app.handle_worker_event(Event::RunEnd { result }); diff --git a/crates/tui/src/console/mod.rs b/crates/tui/src/console/mod.rs index c61b0213e..bcac99c32 100644 --- a/crates/tui/src/console/mod.rs +++ b/crates/tui/src/console/mod.rs @@ -1688,6 +1688,7 @@ mod tests { ); connection.observe_worker_event(&Event::UserMessage { + entry_id: None, segments: vec![Segment::text("inspect"), Segment::UploadedFile { file }], }); assert!(connection.pending_attachments.is_empty()); @@ -1779,7 +1780,10 @@ mod tests { assert!(!reconcile_attachment_submission( &mut pending, &mut awaiting, - &Event::UserMessage { segments }, + &Event::UserMessage { + entry_id: None, + segments, + }, )); assert_eq!(awaiting, expected); assert!(pending.is_empty()); diff --git a/crates/workdir/src/external.rs b/crates/workdir/src/external.rs index 5557231ff..c94582867 100644 --- a/crates/workdir/src/external.rs +++ b/crates/workdir/src/external.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::http::{WorkdirSessionOperation, WorkdirSessionOperationResult, WorkdirTransportError}; use crate::{BoundedReadLimits, WorkdirId, WorkdirPath, WorkdirSessionCapabilities}; -pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 1; +pub const EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION: u16 = 2; /// Maximum serialized Backend-to-provider command frame size. pub const MAX_EXTERNAL_SERVER_FRAME_BYTES: usize = 1024 * 1024; /// Maximum serialized provider-to-Backend result frame size. Read bytes are @@ -155,6 +155,12 @@ fn validate_external_operation(operation: &WorkdirSessionOperation) -> Result<() && request.limit <= 1_000_000 && request.max_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes } + WorkdirSessionOperation::ReadBytes(request) => { + is_root_relative(&request.path) + && request.offset <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes + && (1..=BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes) + .contains(&request.max_bytes) + } WorkdirSessionOperation::List(request) => { is_root_relative(&request.path) && request.limit <= MAX_EXTERNAL_RESULT_ITEMS } @@ -275,6 +281,15 @@ fn validate_external_result(result: &WorkdirSessionOperationResult) -> Result<() is_root_relative(&result.path) && result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes } + WorkdirSessionOperationResult::ReadBytes(result) => { + let returned_end = result.offset.checked_add(result.bytes.len() as u64); + is_root_relative(&result.path) + && result.bytes.len() <= BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + && result.total_bytes <= BoundedReadLimits::EXTERNAL_DEFAULT.max_source_bytes + && returned_end.is_some_and(|end| { + end <= result.total_bytes && result.eof == (end == result.total_bytes) + }) + } WorkdirSessionOperationResult::List(result) => { result.entries.len() <= MAX_EXTERNAL_RESULT_ITEMS && result @@ -482,8 +497,8 @@ mod tests { use super::*; use crate::{ - CommandRequest, EditRequest, GrepResult, ListRequest, ReadRequest, Workdir, WorkdirPath, - WriteRequest, + CommandRequest, EditRequest, GrepResult, ListRequest, ReadBytesRequest, ReadBytesResult, + ReadRequest, Workdir, WorkdirPath, WriteRequest, }; fn assert_no_provider_authority(value: &Value) { @@ -614,7 +629,7 @@ mod tests { #[test] fn unsupported_protocol_versions_and_invalid_read_limits_fail_closed() { let future = r#"{ - "version": 2, + "version": 3, "message": {"kind": "heartbeat", "generation": 1, "sequence": 1} }"#; assert!(serde_json::from_str::(future).is_err()); @@ -726,10 +741,51 @@ mod tests { ); } + #[test] + fn protocol_bounds_binary_read_requests_and_rejects_partial_results() { + let path = WorkdirPath::new("image.png").unwrap(); + let bounded = WorkdirSessionOperation::ReadBytes(ReadBytesRequest { + path: path.clone(), + offset: 0, + max_bytes: BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes, + expected_hash: None, + }); + assert!(ExternalWorkdirOperation::try_from(bounded).is_ok()); + + let oversized = WorkdirSessionOperation::ReadBytes(ReadBytesRequest { + path: path.clone(), + offset: 0, + max_bytes: BoundedReadLimits::EXTERNAL_DEFAULT.max_response_bytes + 1, + expected_hash: None, + }); + assert!(ExternalWorkdirOperation::try_from(oversized).is_err()); + + let hash = [7; 32]; + let complete = WorkdirSessionOperationResult::ReadBytes(ReadBytesResult { + path: path.clone(), + bytes: vec![1, 2, 3], + offset: 0, + total_bytes: 3, + content_hash: hash, + eof: true, + }); + assert!(ExternalWorkdirOperationResult::try_from(complete).is_ok()); + + let partial_marked_complete = WorkdirSessionOperationResult::ReadBytes(ReadBytesResult { + path, + bytes: vec![1, 2], + offset: 0, + total_bytes: 3, + content_hash: hash, + eof: true, + }); + assert!(ExternalWorkdirOperationResult::try_from(partial_marked_complete).is_err()); + } + #[test] fn protocol_rejects_unknown_nested_fields_and_unbounded_requests() { let unknown = r#"{ - "version": 1, + "version": 2, "message": { "kind": "heartbeat", "generation": 1, diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 0a3f7e8aa..4017056a0 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -11,9 +11,9 @@ use serde::{Deserialize, Serialize}; use crate::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, - ReadRequest, ReadResult, StatRequest, StatResult, WorkdirError, WorkdirId, - WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, - WorkdirSessionCapabilities, WriteRequest, WriteResult, + ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, + WorkdirError, WorkdirId, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, + WorkdirSession, WorkdirSessionCapabilities, WriteRequest, WriteResult, }; /// Opaque Runtime-owned identifier for one ephemeral Workdir session. @@ -66,6 +66,7 @@ pub enum WorkdirSessionOperation { ScopeRulesOverlap(WorkdirScopeOverlapRequest), Stat(StatRequest), Read(ReadRequest), + ReadBytes(ReadBytesRequest), Write(WriteRequest), Edit(EditRequest), List(ListRequest), @@ -97,6 +98,7 @@ pub enum WorkdirSessionOperationResult { ScopeRulesOverlap { overlaps: bool }, Stat(StatResult), Read(ReadResult), + ReadBytes(ReadBytesResult), Write(WriteResult), Edit(EditResult), List(ListResult), @@ -135,6 +137,10 @@ pub async fn dispatch_workdir_session_operation( .read(request) .await .map(WorkdirSessionOperationResult::Read), + WorkdirSessionOperation::ReadBytes(request) => session + .read_bytes(request) + .await + .map(WorkdirSessionOperationResult::ReadBytes), WorkdirSessionOperation::Write(request) => session .write(request) .await @@ -572,6 +578,19 @@ mod client { } } + async fn read_bytes( + &self, + request: ReadBytesRequest, + ) -> Result { + match self + .operate(WorkdirSessionOperation::ReadBytes(request)) + .await? + { + WorkdirSessionOperationResult::ReadBytes(result) => Ok(result), + _ => Err(Self::mismatch("read_bytes")), + } + } + async fn write(&self, request: WriteRequest) -> Result { match self .operate(WorkdirSessionOperation::Write(request)) diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index 806a1431a..a390b1084 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -27,7 +27,8 @@ use tokio::sync::broadcast; pub use fs_operation::{ BoundedReadLimits, ContentHash, EditRequest, EditResult, EntryKind, FsPath as WorkdirPath, GlobRequest, GlobResult, GrepOutputMode, GrepRequest, GrepResult, ListEntry, ListRequest, - ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, + ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, + StatResult, WriteRequest, WriteResult, }; pub use http::dispatch_workdir_session_operation; pub use local::{ @@ -220,6 +221,14 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync { async fn stat(&self, request: StatRequest) -> Result; async fn read(&self, request: ReadRequest) -> Result; + async fn read_bytes( + &self, + _request: ReadBytesRequest, + ) -> Result { + Err(WorkdirError::UnsupportedOperation( + "bounded binary reads are not supported by this provider".to_string(), + )) + } async fn write(&self, request: WriteRequest) -> Result; async fn edit(&self, request: EditRequest) -> Result; async fn list(&self, request: ListRequest) -> Result; diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 4a4a3b61e..33850729b 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -30,10 +30,10 @@ use crate::{ BoundedReadLimits, CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, - ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, - WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, - WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirToolScopePermission, WriteRequest, - WriteResult, + ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, + WorkdirError, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, + WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability, + WorkdirToolScopePermission, WriteRequest, WriteResult, }; #[cfg(test)] use crate::{EntryKind, WriteOutcome}; @@ -1261,6 +1261,21 @@ impl WorkdirSession for LocalWorkdirSession { .map_err(|error| sanitize_error(error, &logical)) } + async fn read_bytes(&self, request: ReadBytesRequest) -> Result { + self.ensure_capability(WorkdirSessionCapability::Read)?; + let logical = request.path.clone(); + self.validate_operation_path(&request.path)?; + let access = self.scope_access(); + match self.inner.read_limits { + Some(limits) => { + fs_operation::run_read_bytes_bounded(&self.inner.root, request, &access, limits) + } + None => fs_operation::run_read_bytes(&self.inner.root, request, &access), + } + .map_err(WorkdirError::from) + .map_err(|error| sanitize_error(error, &logical)) + } + async fn write(&self, request: WriteRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Write)?; let logical = request.path.clone(); diff --git a/crates/workdir/src/router.rs b/crates/workdir/src/router.rs index eed9a9e50..270f0ec90 100644 --- a/crates/workdir/src/router.rs +++ b/crates/workdir/src/router.rs @@ -16,9 +16,10 @@ use tokio::sync::broadcast; use crate::{ CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandSnapshot, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, - GrepResult, ListRequest, ListResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, - WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, - WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, + GrepResult, ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, + ReadResult, StatRequest, StatResult, Workdir, WorkdirError, WorkdirScopeAuthorizationRequest, + WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle, + WriteRequest, WriteResult, }; /// Stable Worker-local routing key for one attached Workdir. @@ -257,6 +258,11 @@ impl WorkdirSession for RoutedWorkdirSession { self.state.session.read(request).await } + async fn read_bytes(&self, request: ReadBytesRequest) -> Result { + let _active = self.state.enter()?; + self.state.session.read_bytes(request).await + } + async fn write(&self, request: WriteRequest) -> Result { let _active = self.state.enter()?; self.state.session.write(request).await diff --git a/crates/workdir/src/scope.rs b/crates/workdir/src/scope.rs index e76f59125..a58e0b01d 100644 --- a/crates/workdir/src/scope.rs +++ b/crates/workdir/src/scope.rs @@ -6,7 +6,8 @@ use std::sync::{Arc, Mutex, Weak}; use async_trait::async_trait; use fs_operation::{ EditRequest, EditResult, FsPath, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, - ListResult, ReadRequest, ReadResult, StatRequest, StatResult, WriteRequest, WriteResult, + ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, + StatResult, WriteRequest, WriteResult, }; use manifest::SymlinkPolicy; use schemars::JsonSchema; @@ -1011,6 +1012,21 @@ impl WorkdirSession for ScopedWorkdirSession { self.source.read(request).await } + async fn read_bytes( + &self, + mut request: ReadBytesRequest, + ) -> Result { + let caller_path = request.path.clone(); + let path = self + .resolve_operation_path(&request.path, WorkdirToolScopePermission::Read) + .await?; + self.ensure_read(&path, WorkdirSessionCapability::Read)?; + request.path = path; + let mut result = self.source.read_bytes(request).await?; + result.path = caller_path; + Ok(result) + } + async fn write(&self, mut request: WriteRequest) -> Result { let _scope_guard = self.scope_lock.lock().await; let path = self @@ -1276,6 +1292,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession { self.inner.read(request).await } + async fn read_bytes(&self, request: ReadBytesRequest) -> Result { + self.inner.read_bytes(request).await + } + async fn write(&self, _request: WriteRequest) -> Result { Err(WorkdirError::Denied("read-only workdir session".into())) } @@ -1656,6 +1676,15 @@ mod tests { } } + fn read_bytes(path: &str) -> ReadBytesRequest { + ReadBytesRequest { + path: fs_path(path), + offset: 0, + max_bytes: 1024, + expected_hash: None, + } + } + fn write(path: &str, content: &str) -> WriteRequest { WriteRequest { path: fs_path(path), @@ -1887,6 +1916,9 @@ mod tests { child.read(read("readme.md")).await.unwrap().bytes, b"visible" ); + let binary = child.read_bytes(read_bytes("readme.md")).await.unwrap(); + assert_eq!(binary.path, fs_path("readme.md")); + assert_eq!(binary.bytes, b"visible"); assert!(matches!( child.write(write("new.md", "no")).await, Err(WorkdirError::Denied(_)) diff --git a/crates/worker-runtime/src/execution.rs b/crates/worker-runtime/src/execution.rs index 148309e32..01b95521b 100644 --- a/crates/worker-runtime/src/execution.rs +++ b/crates/worker-runtime/src/execution.rs @@ -477,6 +477,13 @@ pub struct WorkerSessionObservationRequest { pub worker_ref: WorkerRef, } +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct WorkerSessionHistoryRequest { + pub worker_ref: WorkerRef, + pub cursor: Option, + pub limit: Option, +} + pub trait WorkerExecutionBackend: Send + Sync + 'static { fn backend_id(&self) -> &str; @@ -497,6 +504,16 @@ pub trait WorkerExecutionBackend: Send + Sync + 'static { } } + fn worker_session_history( + &self, + _request: WorkerSessionHistoryRequest, + ) -> runtime_api::WorkerSessionHistoryAvailability { + runtime_api::WorkerSessionHistoryAvailability::Unavailable { + reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported, + message: "session history paging is not supported by this Runtime".to_string(), + } + } + fn spawn_worker(&self, request: WorkerExecutionSpawnRequest) -> WorkerExecutionSpawnResult; /// Read-only restore validation performed before any live controller, @@ -726,6 +743,13 @@ impl WorkerExecutionBackendRef { self.backend.worker_session(request) } + pub(crate) fn worker_session_history( + &self, + request: WorkerSessionHistoryRequest, + ) -> runtime_api::WorkerSessionHistoryAvailability { + self.backend.worker_session_history(request) + } + pub(crate) fn spawn_worker( &self, request: WorkerExecutionSpawnRequest, diff --git a/crates/worker-runtime/src/http_server/runtime_management_api.rs b/crates/worker-runtime/src/http_server/runtime_management_api.rs index 6ede160d6..ec0045d06 100644 --- a/crates/worker-runtime/src/http_server/runtime_management_api.rs +++ b/crates/worker-runtime/src/http_server/runtime_management_api.rs @@ -260,6 +260,35 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi { .map_err(api_error) } + async fn worker_session_history( + &self, + worker_id: String, + request: runtime_api::WorkerSessionHistoryRequest, + ) -> Result { + let worker_ref = worker_ref_for(&self.state.runtime, worker_id).map_err(api_error)?; + let scope = auth_workspace_scope(&self.state, auth_extension().as_ref()) + .map_err(api_error)? + .ok_or_else(|| { + runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_worker_session_scope_required", + "Worker Session history requires a Workspace-scoped capability", + ) + })?; + if scope.workspace_id != request.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_worker_session_workspace_scope_mismatch", + "Worker Session Workspace scope does not match the authenticated capability", + )); + } + self.state + .runtime + .worker_session_history_scoped(&scope, &worker_ref, request.cursor, request.limit) + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error) + } + async fn create_worker( &self, value: runtime_api::CreateWorkerRequest, diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index 27d0754a3..48b43dbc9 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -16,7 +16,8 @@ use crate::execution::{ WorkerExecutionBackend, WorkerExecutionBackendRef, WorkerExecutionHandle, WorkerExecutionOperation, WorkerExecutionResult, WorkerExecutionSpawnRequest, WorkerExecutionSpawnResult, WorkerExecutionStopRequest, WorkerLifecycleOperationId, - WorkerSessionObservationRequest, WorkspaceConfigFetchRequest, WorkspaceConfigFetchResult, + WorkerSessionHistoryRequest, WorkerSessionObservationRequest, WorkspaceConfigFetchRequest, + WorkspaceConfigFetchResult, }; #[cfg(feature = "fs-store")] use crate::fs_store::{ @@ -1613,6 +1614,38 @@ impl Runtime { }) } + /// Read one bounded page from the adopted durable Session lineage without + /// restoring the Worker or modifying model context. + pub fn worker_session_history_scoped( + &self, + scope: &RuntimeWorkspaceScope, + worker_ref: &WorkerRef, + cursor: Option, + limit: Option, + ) -> Result { + let operation_lock = self.worker_operation_lock(worker_ref.worker_id)?; + let _operation_guard = operation_lock + .lock() + .map_err(|_| RuntimeError::StatePoisoned)?; + self.ensure_worker_in_workspace(scope, worker_ref)?; + let backend = { + let state = self.lock()?; + state.worker(worker_ref)?; + state.execution_backend.clone() + }; + Ok(match backend { + Some(backend) => backend.worker_session_history(WorkerSessionHistoryRequest { + worker_ref: worker_ref.clone(), + cursor, + limit, + }), + None => runtime_api::WorkerSessionHistoryAvailability::Unavailable { + reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported, + message: "session history paging is not supported by this Runtime".to_string(), + }, + }) + } + /// Attach a live execution through a workspace-scoped Runtime authorization context. pub fn restore_worker_scoped( &self, diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index d46ee5502..bac17e018 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -172,6 +172,15 @@ pub trait RuntimeWorkerFactory: Send + Sync + 'static { Err(session_store::RetainedSnapshotReadError::RetentionMissing) } + fn retained_session_history_page( + &self, + _worker_ref: &WorkerRef, + _cursor: Option<&str>, + _limit: Option, + ) -> Result { + Err(session_store::RetainedHistoryReadError::RetentionMissing) + } + async fn spawn_controller( &self, request: WorkerExecutionSpawnRequest, @@ -1027,6 +1036,25 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { ) } + fn retained_session_history_page( + &self, + worker_ref: &WorkerRef, + cursor: Option<&str>, + limit: Option, + ) -> Result { + let aggregate_dir = self + .worker_aggregate_dir(worker_ref) + .map_err(|_| session_store::RetainedHistoryReadError::RetentionMissing)?; + let worker_name = Self::runtime_worker_name_for_ref(worker_ref); + session_store::read_retained_session_history_page( + &aggregate_dir, + &worker_name, + cursor, + limit, + session_store::RetainedHistoryReadLimits::default(), + ) + } + fn observe_workspace_prompt_projection( &self, projection: worker::WorkspacePromptProjection, @@ -2216,6 +2244,48 @@ where } } + fn worker_session_history( + &self, + request: crate::execution::WorkerSessionHistoryRequest, + ) -> runtime_api::WorkerSessionHistoryAvailability { + match self.factory.retained_session_history_page( + &request.worker_ref, + request.cursor.as_deref(), + request.limit, + ) { + Ok(page) => runtime_api::WorkerSessionHistoryAvailability::Page { page }, + Err(error) => { + let reason = match error { + session_store::RetainedHistoryReadError::RetentionMissing => { + runtime_api::WorkerSessionHistoryUnavailableReason::RetentionMissing + } + session_store::RetainedHistoryReadError::ActivePointerMissing => { + runtime_api::WorkerSessionHistoryUnavailableReason::ActivePointerMissing + } + session_store::RetainedHistoryReadError::MigrationRequired => { + runtime_api::WorkerSessionHistoryUnavailableReason::MigrationRequired + } + session_store::RetainedHistoryReadError::CorruptLog => { + runtime_api::WorkerSessionHistoryUnavailableReason::CorruptLog + } + session_store::RetainedHistoryReadError::StorageUnavailable => { + runtime_api::WorkerSessionHistoryUnavailableReason::StorageUnavailable + } + session_store::RetainedHistoryReadError::InvalidCursor => { + runtime_api::WorkerSessionHistoryUnavailableReason::InvalidCursor + } + session_store::RetainedHistoryReadError::ResourceLimit => { + runtime_api::WorkerSessionHistoryUnavailableReason::ResourceLimit + } + }; + runtime_api::WorkerSessionHistoryAvailability::Unavailable { + reason, + message: error.to_string(), + } + } + } + } + fn observe_workspace_prompt_projection( &self, projection: worker::WorkspacePromptProjection, diff --git a/crates/worker/src/feature/builtin/manage_workdir.rs b/crates/worker/src/feature/builtin/manage_workdir.rs index 517f7c236..54514dd75 100644 --- a/crates/worker/src/feature/builtin/manage_workdir.rs +++ b/crates/worker/src/feature/builtin/manage_workdir.rs @@ -18,8 +18,8 @@ use workdir::workspace::WorkspaceWorkdirSessionOperationRequest; use workdir::{ CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, - ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirError, - WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, + ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, StatRequest, StatResult, Workdir, + WorkdirError, WorkdirScopeAuthorizationRequest, WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult, }; @@ -357,6 +357,13 @@ impl WorkdirSession for WorkspaceAttachedWorkdirSession { } } + async fn read_bytes(&self, request: ReadBytesRequest) -> Result { + match self.operate(WorkdirSessionOperation::ReadBytes(request))? { + WorkdirSessionOperationResult::ReadBytes(result) => Ok(result), + _ => Err(Self::mismatch("read_bytes")), + } + } + async fn write(&self, request: WriteRequest) -> Result { match self.operate(WorkdirSessionOperation::Write(request))? { WorkdirSessionOperationResult::Write(result) => Ok(result), diff --git a/crates/worker/src/in_flight.rs b/crates/worker/src/in_flight.rs index f76c81fbd..07568d198 100644 --- a/crates/worker/src/in_flight.rs +++ b/crates/worker/src/in_flight.rs @@ -619,8 +619,8 @@ mod tests { let (committed_tx, committed_rx) = mpsc::channel(); let commit_thread = thread::spawn(move || { // This mirrors Worker::append_entry ordering: clear in-flight first, - // then publish the finalized AssistantItem. AssistantItem entries - // are mirror-only and are not delivered as live entry events. + // then publish the finalized AssistantItem. Identity-aware clients + // reconcile this durable commit with the preceding streamed block. in_flight_for_commit.clear_for_committed_item_then(&assistant_item, || { sink_for_commit.publish(assistant_entry); }); @@ -639,7 +639,10 @@ mod tests { in_flight_snapshot.blocks.as_slice(), [InFlightBlock::Text { text, finished: true }] if text == "done" )); - assert!(entry_rx.try_recv().is_err()); + assert!(matches!( + entry_rx.try_recv(), + Ok(LogEntry::AnnotatedAssistantItem { .. }) + )); let post_commit_guard = in_flight.snapshot_guard(); assert!(snapshot_from_guard(&post_commit_guard).is_empty()); } diff --git a/crates/worker/src/ipc/protocol_session.rs b/crates/worker/src/ipc/protocol_session.rs index 4368fa2ba..602c08d73 100644 --- a/crates/worker/src/ipc/protocol_session.rs +++ b/crates/worker/src/ipc/protocol_session.rs @@ -34,7 +34,41 @@ pub fn live_log_entry_event(entry: LogEntry) -> Option { session_store::public_snapshot::project_current_session_snapshot(&[entry]); Some(Event::SegmentRotated { session }) } - LogEntry::AnnotatedUserInput { segments, .. } => Some(Event::UserMessage { segments }), + LogEntry::AnnotatedUserInput { + ts, + segments, + history, + extensions, + } => { + let projected = session_store::public_snapshot::project_current_session_snapshot(&[ + LogEntry::AnnotatedUserInput { + ts, + segments: segments.clone(), + history, + extensions, + }, + ]); + let entry_id = projected + .entries + .iter() + .find(|entry| { + matches!( + entry.data, + protocol::SessionSnapshotEntryData::UserInput { .. } + ) + }) + .map(|entry| entry.entry_id.clone()); + Some(Event::UserMessage { entry_id, segments }) + } + entry + @ (LogEntry::AnnotatedAssistantItem { .. } | LogEntry::AnnotatedToolResult { .. }) => { + let mut projected = + session_store::public_snapshot::project_current_session_snapshot(&[entry]); + projected + .entries + .pop() + .map(|entry| Event::SessionEntryCommitted { entry }) + } LogEntry::AnnotatedSystemItem { entry, .. } => { let value = serde_json::to_value(&entry.item).expect("SystemItem is Serialize"); Some(Event::SystemItem { item: value }) @@ -94,7 +128,9 @@ mod tests { .expect("UserInput must be live-relevant"); match event { - Event::UserMessage { segments: echoed } => assert_eq!(echoed, segments), + Event::UserMessage { + segments: echoed, .. + } => assert_eq!(echoed, segments), other => panic!("expected UserMessage, got {other:?}"), } } diff --git a/crates/worker/src/segment_log_sink.rs b/crates/worker/src/segment_log_sink.rs index 202339835..3d26b7ec4 100644 --- a/crates/worker/src/segment_log_sink.rs +++ b/crates/worker/src/segment_log_sink.rs @@ -92,14 +92,11 @@ impl SegmentLogSink { /// - `LogEntry::AnnotatedSegmentStart` → `Event::SegmentRotated` on the wire. /// - `LogEntry::AnnotatedUserInput` → `Event::UserMessage`. /// - `LogEntry::AnnotatedSystemItem` → `Event::SystemItem`. + /// - assistant/tool commits → `Event::SessionEntryCommitted`. /// - `LogEntry::Invoke` → `Event::InvokeStart`. - /// Everything else (AssistantItem, ToolResult, TurnEnd, - /// RunCompleted, RunErrored, PausedTurnAbandoned, LlmUsage, Extension, - /// ConfigChanged) is reflected in the mirror so reconnect snapshots stay accurate, - /// but is not sent live — the streaming events (TextDelta / - /// ToolCallStart / ToolResult / TurnEnd / etc.) already provide - /// that data, and re-broadcasting it as a typed entry would just - /// double-render every block on the client side. + /// Operational checkpoints remain mirror-only. Assistant/tool streaming + /// events are still the in-flight authority; the committed-entry event adds + /// the durable stable identity needed to reconcile retained history. pub fn publish(&self, entry: LogEntry) { let mut mirror = self .inner @@ -122,6 +119,8 @@ impl SegmentLogSink { entry, LogEntry::AnnotatedSegmentStart { .. } | LogEntry::AnnotatedUserInput { .. } + | LogEntry::AnnotatedAssistantItem { .. } + | LogEntry::AnnotatedToolResult { .. } | LogEntry::AnnotatedSystemItem { .. } | LogEntry::Invoke { .. } ) diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index 2438e67c8..5c78de89c 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -50,7 +50,10 @@ zeroize.workspace = true webauthn-rs = { workspace = true } [dev-dependencies] +agen.workspace = true tempfile.workspace = true +tools.workspace = true +yoi = { path = "../yoi" } tower = { workspace = true, features = ["util"] } tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } webauthn-authenticator-rs = { version = "0.5.5", default-features = false, features = ["softpasskey"] } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index a9a769d0e..2e8fd65b1 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1188,6 +1188,19 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { }) } + fn worker_session_history( + &self, + _workspace_id: &str, + _worker_ref: &EmbeddedWorkerRef, + _cursor: Option, + _limit: Option, + ) -> Result { + Ok(runtime_api::WorkerSessionHistoryAvailability::Unavailable { + reason: runtime_api::WorkerSessionHistoryUnavailableReason::Unsupported, + message: "session history paging is not supported by this Runtime".to_string(), + }) + } + fn observation_source( &self, _worker_id: &str, @@ -2017,6 +2030,31 @@ impl RuntimeRegistry { }) } + pub fn worker_session_history( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + cursor: Option, + limit: Option, + ) -> Result { + validate_backend_identifier("runtime_id", &worker.runtime_id)?; + validate_backend_identifier("worker_id", &worker.worker_id)?; + let runtime = self.runtime(&worker.runtime_id)?; + let worker_ref = + EmbeddedWorkerRef::new(EmbeddedWorkerId::parse(&worker.worker_id).ok_or_else( + || RuntimeRegistryError::UnknownWorker { + worker: worker.clone(), + }, + )?); + runtime + .worker_session_history(workspace_id, &worker_ref, cursor, limit) + .map_err(|message| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: worker.runtime_id.clone(), + code: "worker_session_history_failed".to_string(), + message, + }) + } + pub fn observation_source( &self, worker: &RuntimeWorkerRef, @@ -2909,6 +2947,19 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .map_err(|error| error.to_string()) } + fn worker_session_history( + &self, + workspace_id: &str, + worker_ref: &EmbeddedWorkerRef, + cursor: Option, + limit: Option, + ) -> Result { + let scope = RuntimeWorkspaceScope::new(workspace_id, "embedded-backend"); + self.runtime + .worker_session_history_scoped(&scope, worker_ref, cursor, limit) + .map_err(|error| error.to_string()) + } + fn observation_source( &self, worker_id: &str, @@ -4924,6 +4975,27 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { .map_err(|diagnostic| diagnostic.message) } + fn worker_session_history( + &self, + workspace_id: &str, + worker_ref: &EmbeddedWorkerRef, + cursor: Option, + limit: Option, + ) -> Result { + let worker_id = worker_ref.worker_id.to_string(); + let request = runtime_api::WorkerSessionHistoryRequest { + workspace_id: workspace_id.to_string(), + cursor, + limit, + }; + self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.worker_session_history(worker_id, request).await }, + ) + .map_err(|diagnostic| diagnostic.message) + } + fn observation_source( &self, worker_id: &str, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index abd2b5ec4..8e38123e9 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5,7 +5,7 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::{Arc, Mutex, RwLock, Weak}; use async_trait::async_trait; -use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; +use axum::extract::ws::{CloseFrame, Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::{DefaultBodyLimit, Extension, Path as AxumPath, Query, Request, State}; #[cfg(test)] use axum::http::header::{CACHE_CONTROL, ETAG, IF_NONE_MATCH}; @@ -1074,6 +1074,7 @@ fn external_workdir_operation_kind(operation: &WorkdirSessionOperation) -> &'sta WorkdirSessionOperation::ScopeRulesOverlap(_) => "scope_rules_overlap", WorkdirSessionOperation::Stat(_) => "stat", WorkdirSessionOperation::Read(_) => "read", + WorkdirSessionOperation::ReadBytes(_) => "read_bytes", WorkdirSessionOperation::Write(_) => "write", WorkdirSessionOperation::Edit(_) => "edit", WorkdirSessionOperation::List(_) => "list", @@ -1141,6 +1142,19 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { _ => Err(Self::mismatch("read")), } } + async fn read_bytes( + &self, + request: workdir::ReadBytesRequest, + ) -> std::result::Result { + self.ensure_capability(workdir::WorkdirSessionCapability::Read)?; + match self + .operate(WorkdirSessionOperation::ReadBytes(request)) + .await? + { + WorkdirSessionOperationResult::ReadBytes(value) => Ok(value), + _ => Err(Self::mismatch("read_bytes")), + } + } async fn write( &self, request: workdir::WriteRequest, @@ -3905,72 +3919,47 @@ struct ValidatedTicketImplementationTarget { } fn external_workdir_permissions(persisted: &str) -> Result { - let permissions = match persisted { - "read_only" => server_api::ExternalWorkdirPermissions { - read: true, - write: false, - command: false, - }, - "read_write" => server_api::ExternalWorkdirPermissions { - read: true, - write: true, - command: false, - }, - "command_only" => server_api::ExternalWorkdirPermissions { - read: false, - write: false, - command: true, - }, - "read_command" => server_api::ExternalWorkdirPermissions { - read: true, - write: false, - command: true, - }, - "read_write_command" => server_api::ExternalWorkdirPermissions { - read: true, - write: true, - command: true, - }, - _ => { - return Err(Error::Store( - "External Workdir grant has invalid persisted permissions".to_string(), - )); - } - }; - Ok(permissions) + match persisted { + "read_only" => Ok(server_api::ExternalWorkdirPermissions::READ), + "read_write" => Ok(server_api::ExternalWorkdirPermissions::WRITE), + "read_write_command" => Ok(server_api::ExternalWorkdirPermissions::COMMAND), + "command_only" | "read_command" => Err(Error::InvalidInput( + "External Workdir grant uses a legacy non-hierarchical permission combination; revoke it and share the directory again with READ, WRITE, or COMMAND" + .to_string(), + )), + _ => Err(Error::Store( + "External Workdir grant has invalid persisted permissions".to_string(), + )), + } } fn persist_external_workdir_permissions( permissions: server_api::ExternalWorkdirPermissions, ) -> Result<&'static str> { - match (permissions.read, permissions.write, permissions.command) { - (true, false, false) => Ok("read_only"), - (true, true, false) => Ok("read_write"), - (false, false, true) => Ok("command_only"), - (true, false, true) => Ok("read_command"), - (true, true, true) => Ok("read_write_command"), - (false, true, _) => Err(Error::InvalidInput( - "External Workdir WRITE permission requires READ for read-before-write".to_string(), - )), - (false, false, false) => Err(Error::InvalidInput( - "External Workdir sharing requires at least one of READ or COMMAND".to_string(), + match permissions.grant_level() { + Some(server_api::ExternalWorkdirPermissionLevel::Read) => Ok("read_only"), + Some(server_api::ExternalWorkdirPermissionLevel::Write) => Ok("read_write"), + Some(server_api::ExternalWorkdirPermissionLevel::Command) => Ok("read_write_command"), + None => Err(Error::InvalidInput( + "External Workdir permissions must select exactly one level: READ=(true,false,false), WRITE=(true,true,false), or COMMAND=(true,true,true)" + .to_string(), )), } } fn external_workdir_capabilities(permissions: &str) -> Result { - let permissions = external_workdir_permissions(permissions)?; - let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY; - if permissions.read { - capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY); + match external_workdir_permissions(permissions)?.grant_level() { + Some(server_api::ExternalWorkdirPermissionLevel::Read) => { + Ok(workdir::WorkdirSessionCapabilities::READ_ONLY) + } + Some(server_api::ExternalWorkdirPermissionLevel::Write) => { + Ok(workdir::WorkdirSessionCapabilities::READ_WRITE) + } + Some(server_api::ExternalWorkdirPermissionLevel::Command) => { + Ok(workdir::WorkdirSessionCapabilities::ALL) + } + None => unreachable!("persisted External grant permissions were already validated"), } - if permissions.write { - capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY); - } - if permissions.command { - capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY); - } - Ok(capabilities) } fn workdir_permission_summary( @@ -4609,6 +4598,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::skill_get(service.clone())) .merge(server_api::server_api_axum::skill_activate(service.clone())) .merge(server_api::server_api_axum::worker_session(service.clone())) + .merge(server_api::server_api_axum::worker_session_history( + service.clone(), + )) .merge(server_api::server_api_axum::repository_list( service.clone(), )) @@ -6273,6 +6265,50 @@ impl server_api::ServerApi for ServerApiContractService { }) } + async fn worker_session_history( + &self, + workspace_id: String, + runtime_id: String, + worker_id: String, + query: server_api::WorkspaceWorkerSessionHistoryQuery, + ) -> std::result::Result< + server_api::WorkspaceWorkerSessionHistoryResponse, + server_api::ServerApiError, + > { + let api = self.workspace_api().map_err(|_| { + runtime_api::RuntimeApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "workspace_not_found", + "Workspace was not found", + ) + })?; + if workspace_id != api.config.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "workspace_not_found", + "Workspace was not found", + )); + } + let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); + let observation = api + .runtime + .worker_session_history(&workspace_id, &worker, query.cursor, query.limit) + .map_err(|_error| { + runtime_api::RuntimeApiError::new( + StatusCode::BAD_GATEWAY.as_u16(), + "worker_session_history_failed", + "Worker Session history request failed", + ) + })?; + Ok(server_api::WorkspaceWorkerSessionHistoryResponse { + subject: server_api::WorkspaceWorkerSubject::RuntimeWorker { + runtime_id, + worker_id, + }, + observation, + }) + } + async fn workspace_worker_list_alias( &self, ) -> std::result::Result { @@ -14849,6 +14885,7 @@ async fn scoped_execute_current_worker_workdir_operation( | WorkdirSessionOperation::ScopeRulesOverlap(_) | WorkdirSessionOperation::Stat(_) | WorkdirSessionOperation::Read(_) + | WorkdirSessionOperation::ReadBytes(_) | WorkdirSessionOperation::Write(_) | WorkdirSessionOperation::Edit(_) | WorkdirSessionOperation::List(_) @@ -14915,6 +14952,10 @@ async fn execute_workdir_session_operation( .read(request) .await .map(WorkdirSessionOperationResult::Read), + WorkdirSessionOperation::ReadBytes(request) => session + .read_bytes(request) + .await + .map(WorkdirSessionOperationResult::ReadBytes), WorkdirSessionOperation::Write(request) => session .write(request) .await @@ -17478,41 +17519,116 @@ async fn send_external_workdir_server_frame( ) } +async fn reject_external_workdir_provider(socket: &mut WebSocket, code: u16, mut reason: String) { + const MAX_CLOSE_REASON_BYTES: usize = 123; + if reason.len() > MAX_CLOSE_REASON_BYTES { + reason.truncate(MAX_CLOSE_REASON_BYTES); + } + let _ = socket + .send(WsMessage::Close(Some(CloseFrame { + code, + reason: reason.into(), + }))) + .await; +} + +fn external_workdir_registration_parse_error(text: &str) -> String { + let actual_version = serde_json::from_str::(text) + .ok() + .and_then(|value| value.get("version")?.as_u64()); + match actual_version { + Some(actual) + if actual != workdir::external::EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION as u64 => + { + format!( + "unsupported External Workdir protocol version {actual}; Backend requires {}", + workdir::external::EXTERNAL_WORKDIR_PROVIDER_PROTOCOL_VERSION + ) + } + _ => "invalid External Workdir provider registration".to_string(), + } +} + async fn serve_external_workdir_provider( api: WorkspaceApi, initial_grant: ExternalWorkdirGrantRecord, mut socket: WebSocket, ) { - let registration = + let registration_text = match tokio::time::timeout(std::time::Duration::from_secs(10), socket.next()).await { - Ok(Some(Ok(WsMessage::Text(text)))) if text.len() <= 64 * 1024 => { - serde_json::from_str::(&text).ok() + Ok(Some(Ok(WsMessage::Text(text)))) if text.len() <= 64 * 1024 => text, + _ => { + reject_external_workdir_provider( + &mut socket, + 1008, + "External Workdir provider registration was missing or oversized".to_string(), + ) + .await; + return; } - _ => None, }; - let Some(ExternalWorkdirProviderFrame { + let registration_frame = + match serde_json::from_str::(®istration_text) { + Ok(frame) => frame, + Err(_) => { + let reason = external_workdir_registration_parse_error(®istration_text); + reject_external_workdir_provider(&mut socket, 1002, reason).await; + return; + } + }; + let ExternalWorkdirProviderFrame { message: ExternalWorkdirProviderMessage::Register { registration }, .. - }) = registration + } = registration_frame else { - let _ = socket.close().await; + reject_external_workdir_provider( + &mut socket, + 1008, + "expected External Workdir provider registration".to_string(), + ) + .await; return; }; let expected_capabilities = match external_workdir_capabilities(&initial_grant.permissions) { Ok(capabilities) => capabilities, Err(_) => { - let _ = socket.close().await; + reject_external_workdir_provider( + &mut socket, + 1011, + "Backend could not validate External Workdir capabilities".to_string(), + ) + .await; return; } }; + if registration.read_limits != workdir::BoundedReadLimits::EXTERNAL_DEFAULT { + let required = workdir::BoundedReadLimits::EXTERNAL_DEFAULT; + reject_external_workdir_provider( + &mut socket, + 1008, + format!( + "provider read limits source={} response={}; Backend requires source={} response={}", + registration.read_limits.max_source_bytes, + registration.read_limits.max_response_bytes, + required.max_source_bytes, + required.max_response_bytes, + ), + ) + .await; + return; + } if registration.grant_id.as_str() != initial_grant.grant_id || registration.workdir_id.as_str() != initial_grant.workdir_id || registration.provider_instance_id.as_str() != initial_grant.provider_instance_id || registration.capabilities != expected_capabilities - || registration.read_limits != workdir::BoundedReadLimits::EXTERNAL_DEFAULT || registration.generation == 0 { - let _ = socket.close().await; + reject_external_workdir_provider( + &mut socket, + 1008, + "External Workdir provider registration does not match the grant".to_string(), + ) + .await; return; } let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); @@ -17667,7 +17783,7 @@ async fn serve_external_workdir_provider( Ok(operation) => operation, Err(message) => { let _ = response.send(Err(WorkdirTransportError { - code: workdir::http::WorkdirTransportErrorCode::Unsupported, + code: workdir::http::WorkdirTransportErrorCode::InvalidRequest, message, })); continue; @@ -26479,37 +26595,70 @@ mod tests { WorkspaceRuntimeBinding, }; + fn valid_gif_with_len(target_len: usize) -> Vec { + let fixture = include_bytes!("../../tools/tests/fixtures/view-image/valid.gif"); + assert_eq!(fixture.last(), Some(&0x3b)); + let mut bytes = fixture[..fixture.len() - 1].to_vec(); + bytes.extend_from_slice(&[0x21, 0xfe]); + let mut remaining = target_len.checked_sub(bytes.len() + 2).unwrap(); + while remaining > 0 { + let mut block = remaining.min(256); + if remaining - block == 1 { + block -= 1; + } + bytes.push((block - 1) as u8); + bytes.extend(std::iter::repeat_n(b'x', block - 1)); + remaining -= block; + } + bytes.extend_from_slice(&[0, 0x3b]); + assert_eq!(bytes.len(), target_len); + bytes + } + #[test] - fn external_permission_categories_map_to_existing_capability_bits_without_escalation() { + fn external_permission_levels_map_to_existing_bits_and_reject_legacy_partial_grants() { + let bits = |capabilities| { + serde_json::to_value(capabilities).unwrap()["bits"] + .as_u64() + .unwrap() + }; assert_eq!( - external_workdir_capabilities("read_only").unwrap(), - workdir::WorkdirSessionCapabilities::READ_ONLY + bits(external_workdir_capabilities("read_only").unwrap()), + 25 ); assert_eq!( - external_workdir_capabilities("read_write").unwrap(), - workdir::WorkdirSessionCapabilities::READ_WRITE + bits(external_workdir_capabilities("read_write").unwrap()), + 31 ); assert_eq!( - external_workdir_capabilities("command_only").unwrap(), - workdir::WorkdirSessionCapabilities::COMMAND_ONLY + bits(external_workdir_capabilities("read_write_command").unwrap()), + 63 ); - assert_eq!( - external_workdir_capabilities("read_command").unwrap(), - workdir::WorkdirSessionCapabilities::READ_ONLY - .union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY) - ); - assert_eq!( - external_workdir_capabilities("read_write_command").unwrap(), - workdir::WorkdirSessionCapabilities::ALL - ); - assert!( - persist_external_workdir_permissions(server_api::ExternalWorkdirPermissions { + + for legacy in ["command_only", "read_command"] { + let error = external_workdir_capabilities(legacy).unwrap_err(); + assert!(error.to_string().contains("revoke it and share")); + } + for invalid in [ + server_api::ExternalWorkdirPermissions { + read: false, + write: false, + command: true, + }, + server_api::ExternalWorkdirPermissions { read: false, write: true, command: false, - }) - .is_err() - ); + }, + server_api::ExternalWorkdirPermissions { + read: true, + write: false, + command: true, + }, + ] { + let error = persist_external_workdir_permissions(invalid).unwrap_err(); + assert!(error.to_string().contains("exactly one level")); + } } #[test] @@ -26736,7 +26885,41 @@ mod tests { } #[tokio::test] - async fn external_provider_command_only_session_denies_read_and_routes_bounded_lifecycle() { + async fn external_provider_write_level_denies_command_without_provider_fallback() { + let (sender, mut commands) = tokio::sync::mpsc::channel(1); + let connection = Arc::new(ExternalProviderConnection { + grant_id: "grant-write".to_string(), + workdir_id: "external-write".to_string(), + provider_instance_id: "provider-write".to_string(), + generation: 1, + expires_at: None, + capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE, + admission: Arc::new(tokio::sync::Semaphore::new(16)), + shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sender, + }); + let session = ExternalProviderWorkdirSession::new(connection, None); + let denied = session + .start_command(workdir::CommandRequest { + command: "printf forbidden".to_string(), + timeout_secs: 30, + output_limit: 1024, + cwd: workdir::WorkdirPath::root(), + spill_dir: None, + tool_call_id: None, + }) + .await; + assert!(matches!( + denied, + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Command + )) + )); + assert!(commands.try_recv().is_err()); + } + + #[tokio::test] + async fn external_provider_partial_session_capabilities_remain_supported() { let (sender, mut commands) = tokio::sync::mpsc::channel(4); let capabilities = workdir::WorkdirSessionCapabilities::COMMAND_ONLY; let connection = Arc::new(ExternalProviderConnection { @@ -27445,6 +27628,105 @@ mod tests { assert!(audit_actions.contains(&"external_workdir_cleanup_completed".to_string())); } + #[tokio::test] + async fn external_grant_api_persists_only_hierarchical_levels_with_exact_bits() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api(dir.path()).await; + let levels = [ + ( + server_api::ExternalWorkdirPermissions::READ, + "read_only", + 25, + ), + ( + server_api::ExternalWorkdirPermissions::WRITE, + "read_write", + 31, + ), + ( + server_api::ExternalWorkdirPermissions::COMMAND, + "read_write_command", + 63, + ), + ]; + for (index, (permissions, persisted, expected_bits)) in levels.into_iter().enumerate() { + let (_, Json(grant)) = scoped_create_external_workdir_grant( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_browser_request_actor()), + Json(ExternalWorkdirGrantCreateRequest { + provider_instance_id: format!("provider-{index}"), + display_name: format!("Level {index}"), + ttl_seconds: None, + permissions, + }), + ) + .await + .unwrap(); + assert_eq!(grant.permissions, permissions); + let stored = api + .store + .get_external_workdir_grant(TEST_WORKSPACE_ID, &grant.grant_id) + .unwrap() + .unwrap(); + assert_eq!(stored.permissions, persisted); + let bits = + serde_json::to_value(external_workdir_capabilities(&stored.permissions).unwrap()) + .unwrap()["bits"] + .as_u64() + .unwrap(); + assert_eq!(bits, expected_bits); + } + + for (index, permissions) in [ + server_api::ExternalWorkdirPermissions { + read: false, + write: false, + command: true, + }, + server_api::ExternalWorkdirPermissions { + read: false, + write: true, + command: false, + }, + server_api::ExternalWorkdirPermissions { + read: true, + write: false, + command: true, + }, + ] + .into_iter() + .enumerate() + { + let error = scoped_create_external_workdir_grant( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + Extension(test_browser_request_actor()), + Json(ExternalWorkdirGrantCreateRequest { + provider_instance_id: format!("invalid-provider-{index}"), + display_name: format!("Invalid {index}"), + ttl_seconds: None, + permissions, + }), + ) + .await + .unwrap_err(); + assert!(error.error.to_string().contains("exactly one level")); + } + assert_eq!( + api.store + .list_external_workdir_grants(TEST_WORKSPACE_ID) + .unwrap() + .len(), + 3 + ); + } + #[tokio::test] async fn external_workdirs_are_launchable_with_real_multiple_attachments() { let dir = tempfile::tempdir().unwrap(); @@ -27460,15 +27742,11 @@ mod tests { for index in 1..=2 { let (permissions, capabilities) = if index == 1 { - ( - "command_only", - workdir::WorkdirSessionCapabilities::COMMAND_ONLY, - ) + ("read_only", workdir::WorkdirSessionCapabilities::READ_ONLY) } else { ( - "read_command", - workdir::WorkdirSessionCapabilities::READ_ONLY - .union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY), + "read_write_command", + workdir::WorkdirSessionCapabilities::ALL, ) }; let grant = ExternalWorkdirGrantRecord { @@ -27557,17 +27835,9 @@ mod tests { .find(|summary| summary.working_directory_id == *workdir_id) .unwrap(); let expected_permissions = if index == 0 { - server_api::ExternalWorkdirPermissions { - read: false, - write: false, - command: true, - } + server_api::ExternalWorkdirPermissions::READ } else { - server_api::ExternalWorkdirPermissions { - read: true, - write: false, - command: true, - } + server_api::ExternalWorkdirPermissions::COMMAND }; assert!(matches!( &summary.source, @@ -27628,10 +27898,9 @@ mod tests { .unwrap(); for (index, receiver) in receivers.iter_mut().enumerate() { let expected_capabilities = if index == 0 { - workdir::WorkdirSessionCapabilities::COMMAND_ONLY - } else { workdir::WorkdirSessionCapabilities::READ_ONLY - .union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY) + } else { + workdir::WorkdirSessionCapabilities::ALL }; let link = links .iter() @@ -27658,20 +27927,37 @@ mod tests { drop(session_guard); assert_eq!(session.capabilities(), expected_capabilities); if index == 0 { - let denied = session - .read(workdir::ReadRequest { + let denied_write = session + .write(workdir::WriteRequest { path: workdir::WorkdirPath::new("forbidden.txt").unwrap(), - offset: 0, - limit: 1, - max_bytes: 1, + content: b"no fallback".to_vec(), + expected_hash: None, }) .await; assert!(matches!( - denied, + denied_write, Err(workdir::WorkdirError::Unsupported( - workdir::WorkdirSessionCapability::Read + workdir::WorkdirSessionCapability::Write )) | Err(workdir::WorkdirError::Denied(_)) )); + let denied_command = session + .start_command(workdir::CommandRequest { + command: "printf forbidden".to_string(), + timeout_secs: 30, + output_limit: 1024, + cwd: workdir::WorkdirPath::root(), + spill_dir: None, + tool_call_id: None, + }) + .await; + assert!(matches!( + denied_command, + Err(workdir::WorkdirError::Unsupported( + workdir::WorkdirSessionCapability::Command + )) | Err(workdir::WorkdirError::Denied(_)) + )); + assert!(receiver.try_recv().is_err()); + continue; } let mut starter = { @@ -27805,7 +28091,7 @@ mod tests { } #[tokio::test] - async fn external_provider_websocket_routes_files_and_commands_then_reconnects_and_revokes() { + async fn external_provider_websocket_runs_production_cli_pump_for_images_and_revoke() { let dir = tempfile::tempdir().unwrap(); init_clean_git_workspace(dir.path()); let api = test_api(dir.path()).await; @@ -27874,6 +28160,50 @@ mod tests { }) }; + let (mut old_provider, _) = connect_async(&url).await.unwrap(); + let old_registration = serde_json::to_string(®ister(1)).unwrap().replacen( + r#""version":2"#, + r#""version":1"#, + 1, + ); + old_provider + .send(Message::Text(old_registration.into())) + .await + .unwrap(); + let rejected = old_provider.next().await.unwrap().unwrap(); + let Message::Close(Some(frame)) = rejected else { + panic!("old provider must receive a bounded compatibility close reason"); + }; + assert!( + frame + .reason + .contains("unsupported External Workdir protocol version 1") + ); + assert!(frame.reason.contains("requires 2")); + + let (mut smaller_provider, _) = connect_async(&url).await.unwrap(); + let mut smaller_registration = serde_json::to_value(register(1)).unwrap(); + smaller_registration["message"]["registration"]["read_limits"] = serde_json::json!({ + "max_source_bytes": 32 * 1024 * 1024, + "max_response_bytes": 512 * 1024, + }); + smaller_provider + .send(Message::Text( + serde_json::to_string(&smaller_registration).unwrap().into(), + )) + .await + .unwrap(); + let rejected = smaller_provider.next().await.unwrap().unwrap(); + let Message::Close(Some(frame)) = rejected else { + panic!("smaller provider limits must receive the required effective limits"); + }; + assert!(frame.reason.contains("response=524288"), "{}", frame.reason); + assert!( + frame.reason.contains("response=1048576"), + "{}", + frame.reason + ); + let (mut first, _) = connect_async(&url).await.unwrap(); first .send(Message::Text( @@ -27901,20 +28231,49 @@ mod tests { tokio::time::sleep(std::time::Duration::from_millis(10)).await; } - let (mut second, _) = connect_async(&url).await.unwrap(); - second - .send(Message::Text( - serde_json::to_string(®ister(2)).unwrap().into(), - )) + let provider_root = tempfile::tempdir().unwrap(); + fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap(); + let image_bytes = valid_gif_with_len(1024 * 1024 + 257); + fs::write(provider_root.path().join("image.gif"), &image_bytes).unwrap(); + let pinned_provider_root = workdir::ExternalWorkdirRoot::pin(provider_root.path()).unwrap(); + let local_session = workdir::LocalWorkdirSession::external_with_capabilities_pinned( + workdir::Workdir::new(&grant.working_directory_id), + pinned_provider_root, + workdir::BoundedReadLimits::EXTERNAL_DEFAULT, + workdir::WorkdirSessionCapabilities::ALL, + ) + .unwrap(); + let (second, _) = connect_async(&url).await.unwrap(); + let registration = register(2); + let provider = tokio::spawn(async move { + yoi::workdir_share::serve_external_workdir_provider_socket( + second, + registration, + &local_session, + 2, + std::future::pending::>(), + ) .await - .unwrap(); - let registered = second.next().await.unwrap().unwrap().into_text().unwrap(); - assert!(matches!( - serde_json::from_str::(®istered) + }); + for _ in 0..50 { + if api + .store + .get_external_workdir_grant(TEST_WORKSPACE_ID, &grant.grant_id) .unwrap() - .message, - ExternalWorkdirServerMessage::Registered { generation: 2, .. } - )); + .is_some_and(|stored| stored.status == "online") + { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + assert_eq!( + api.store + .get_external_workdir_grant(TEST_WORKSPACE_ID, &grant.grant_id) + .unwrap() + .unwrap() + .status, + "online" + ); api.store .upsert_workdir_registry(&WorkdirRegistryRecord { @@ -27982,16 +28341,6 @@ mod tests { external_capabilities, workdir::WorkdirSessionCapabilities::ALL ); - let provider_root = tempfile::tempdir().unwrap(); - fs::write(provider_root.path().join("events.jsonl"), b"one\ntwo\n").unwrap(); - let pinned_provider_root = workdir::ExternalWorkdirRoot::pin(provider_root.path()).unwrap(); - let local_session = workdir::LocalWorkdirSession::external_with_capabilities_pinned( - workdir::Workdir::new(&grant.working_directory_id), - pinned_provider_root, - workdir::BoundedReadLimits::EXTERNAL_DEFAULT, - workdir::WorkdirSessionCapabilities::ALL, - ) - .unwrap(); let session_lock = current_worker_session_lock(&api, &worker); let session_guard = session_lock.lock().await; let broker_session = @@ -28003,162 +28352,60 @@ mod tests { workdir::WorkdirSessionCapabilities::ALL ); drop(session_guard); - let read = { - let broker_session = broker_session.clone(); - tokio::spawn(async move { - broker_session - .read(workdir::ReadRequest { - path: workdir::WorkdirPath::new("events.jsonl").unwrap(), - offset: 0, - limit: 10, - max_bytes: 1024, - }) - .await + let read_result = broker_session + .read(workdir::ReadRequest { + path: workdir::WorkdirPath::new("events.jsonl").unwrap(), + offset: 0, + limit: 10, + max_bytes: 1024, }) - }; - let (operation_id, operation) = loop { - let text = second.next().await.unwrap().unwrap().into_text().unwrap(); - let frame = serde_json::from_str::(&text).unwrap(); - if let ExternalWorkdirServerMessage::Operation { - generation: 2, - operation_id, - operation, - } = frame.message - { - break (operation_id, operation); - } - }; - let result = workdir::http::dispatch_workdir_session_operation( - &local_session, - operation.into_inner(), - ) - .await - .unwrap() - .try_into() - .unwrap(); - second - .send(Message::Text( - serde_json::to_string(&ExternalWorkdirProviderFrame::current( - ExternalWorkdirProviderMessage::OperationResult { - generation: 2, - operation_id, - outcome: ExternalWorkdirOperationOutcome::Completed { result }, - }, - )) - .unwrap() - .into(), - )) .await .unwrap(); - let read_result = read.await.unwrap().unwrap(); assert_eq!(read_result.bytes, b"one\ntwo\n"); - let edit = { - let broker_session = broker_session.clone(); - let expected_hash = read_result.content_hash; - tokio::spawn(async move { - broker_session - .edit(workdir::EditRequest { - path: workdir::WorkdirPath::new("events.jsonl").unwrap(), - old_string: "two".to_string(), - new_string: "changed".to_string(), - replace_all: false, - expected_hash, - }) - .await - }) - }; - let (operation_id, operation) = loop { - let text = second.next().await.unwrap().unwrap().into_text().unwrap(); - let frame = serde_json::from_str::(&text).unwrap(); - if let ExternalWorkdirServerMessage::Operation { - generation: 2, - operation_id, - operation, - } = frame.message - { - break (operation_id, operation); - } - }; - assert!(matches!( - operation.as_inner(), - WorkdirSessionOperation::Edit(_) - )); - let result = workdir::http::dispatch_workdir_session_operation( - &local_session, - operation.into_inner(), - ) - .await - .unwrap() - .try_into() - .unwrap(); - second - .send(Message::Text( - serde_json::to_string(&ExternalWorkdirProviderFrame::current( - ExternalWorkdirProviderMessage::OperationResult { - generation: 2, - operation_id, - outcome: ExternalWorkdirOperationOutcome::Completed { result }, - }, - )) - .unwrap() - .into(), - )) + let (_meta, view_image) = tools::view_image_tool(broker_session.clone())(); + let output = view_image + .execute( + r#"{"path":"image.gif"}"#, + agen::tool::ToolExecutionContext::default(), + ) .await .unwrap(); - assert_eq!(edit.await.unwrap().unwrap().replacements, 1); + let agen::tool::Attachment::Image(image) = &output.attachments[0]; + assert_eq!(image.mime_type(), "image/gif"); + assert_eq!(image.data(), image_bytes); - let write = { - let broker_session = broker_session.clone(); - tokio::spawn(async move { - broker_session - .write(workdir::WriteRequest { - path: workdir::WorkdirPath::new("created.txt").unwrap(), - content: b"created".to_vec(), - expected_hash: None, - }) - .await + let error = view_image + .execute( + r#"{"path":"missing.gif"}"#, + agen::tool::ToolExecutionContext::default(), + ) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("not found"), "{error}"); + + let edit = broker_session + .edit(workdir::EditRequest { + path: workdir::WorkdirPath::new("events.jsonl").unwrap(), + old_string: "two".to_string(), + new_string: "changed".to_string(), + replace_all: false, + expected_hash: read_result.content_hash, }) - }; - let (operation_id, operation) = loop { - let text = second.next().await.unwrap().unwrap().into_text().unwrap(); - let frame = serde_json::from_str::(&text).unwrap(); - if let ExternalWorkdirServerMessage::Operation { - generation: 2, - operation_id, - operation, - } = frame.message - { - break (operation_id, operation); - } - }; - assert!(matches!( - operation.as_inner(), - WorkdirSessionOperation::Write(_) - )); - let result = workdir::http::dispatch_workdir_session_operation( - &local_session, - operation.into_inner(), - ) - .await - .unwrap() - .try_into() - .unwrap(); - second - .send(Message::Text( - serde_json::to_string(&ExternalWorkdirProviderFrame::current( - ExternalWorkdirProviderMessage::OperationResult { - generation: 2, - operation_id, - outcome: ExternalWorkdirOperationOutcome::Completed { result }, - }, - )) - .unwrap() - .into(), - )) .await .unwrap(); - assert!(write.await.unwrap().unwrap().created); + assert_eq!(edit.replacements, 1); + + let write = broker_session + .write(workdir::WriteRequest { + path: workdir::WorkdirPath::new("created.txt").unwrap(), + content: b"created".to_vec(), + expected_hash: None, + }) + .await + .unwrap(); + assert!(write.created); assert_eq!( fs::read_to_string(provider_root.path().join("events.jsonl")).unwrap(), "one\nchanged\n" @@ -28167,64 +28414,17 @@ mod tests { fs::read_to_string(provider_root.path().join("created.txt")).unwrap(), "created" ); - let command_start = { - let broker_session = broker_session.clone(); - tokio::spawn(async move { - broker_session - .start_command(workdir::CommandRequest { - command: "sleep 30 & child=$!; echo $child > child.pid; wait $child" - .to_string(), - timeout_secs: 30, - output_limit: 1024, - cwd: workdir::WorkdirPath::root(), - spill_dir: Some("/worker/private/spill".into()), - tool_call_id: Some("call-e2e".to_string()), - }) - .await + let command_handle = broker_session + .start_command(workdir::CommandRequest { + command: "sleep 30 & child=$!; echo $child > child.pid; wait $child".to_string(), + timeout_secs: 30, + output_limit: 1024, + cwd: workdir::WorkdirPath::root(), + spill_dir: Some("/worker/private/spill".into()), + tool_call_id: Some("call-e2e".to_string()), }) - }; - let (operation_id, operation) = loop { - let text = second.next().await.unwrap().unwrap().into_text().unwrap(); - let frame = serde_json::from_str::(&text).unwrap(); - if let ExternalWorkdirServerMessage::Operation { - generation: 2, - operation_id, - operation, - } = frame.message - { - break (operation_id, operation); - } - }; - assert!(matches!( - operation.as_inner(), - WorkdirSessionOperation::CommandStart(workdir::CommandRequest { - spill_dir: None, - .. - }) - )); - let result = workdir::http::dispatch_workdir_session_operation( - &local_session, - operation.into_inner(), - ) - .await - .unwrap() - .try_into() - .unwrap(); - second - .send(Message::Text( - serde_json::to_string(&ExternalWorkdirProviderFrame::current( - ExternalWorkdirProviderMessage::OperationResult { - generation: 2, - operation_id, - outcome: ExternalWorkdirOperationOutcome::Completed { result }, - }, - )) - .unwrap() - .into(), - )) .await .unwrap(); - let command_handle = command_start.await.unwrap().unwrap(); for _ in 0..100 { if provider_root.path().join("child.pid").is_file() { @@ -28234,162 +28434,25 @@ mod tests { } assert!(provider_root.path().join("child.pid").is_file()); - let command_output = { - let broker_session = broker_session.clone(); - let handle = command_handle.clone(); - tokio::spawn(async move { - broker_session - .command_output(workdir::CommandOutputRequest { - handle, - cursor: 0, - limit: 1024, - wait: false, - }) - .await - }) - }; - let (operation_id, operation) = loop { - let text = second.next().await.unwrap().unwrap().into_text().unwrap(); - let frame = serde_json::from_str::(&text).unwrap(); - if let ExternalWorkdirServerMessage::Operation { - generation: 2, - operation_id, - operation, - } = frame.message - { - break (operation_id, operation); - } - }; - assert!(matches!( - operation.as_inner(), - WorkdirSessionOperation::CommandOutput(workdir::CommandOutputRequest { + let command_output = broker_session + .command_output(workdir::CommandOutputRequest { + handle: command_handle, + cursor: 0, + limit: 1024, wait: false, - .. }) - )); - let result = workdir::http::dispatch_workdir_session_operation( - &local_session, - operation.into_inner(), - ) - .await - .unwrap() - .try_into() - .unwrap(); - second - .send(Message::Text( - serde_json::to_string(&ExternalWorkdirProviderFrame::current( - ExternalWorkdirProviderMessage::OperationResult { - generation: 2, - operation_id, - outcome: ExternalWorkdirOperationOutcome::Completed { result }, - }, - )) - .unwrap() - .into(), - )) .await .unwrap(); - let command_output = command_output.await.unwrap().unwrap(); assert_eq!(command_output.status, workdir::CommandStatus::Running); assert_eq!(command_output.output_path, None); - let revoker = { - let api = api.clone(); - let grant_id = grant.grant_id.clone(); - tokio::spawn(async move { - scoped_revoke_external_workdir_grant( - State(api), - Extension(test_browser_request_actor()), - AxumPath((TEST_WORKSPACE_ID.to_string(), grant_id)), - ) - .await - }) - }; - let (operation_id, operation) = loop { - let text = second.next().await.unwrap().unwrap().into_text().unwrap(); - let frame = serde_json::from_str::(&text).unwrap(); - if let ExternalWorkdirServerMessage::Operation { - generation: 2, - operation_id, - operation, - } = frame.message - { - break (operation_id, operation); - } - }; - assert!(matches!( - operation.as_inner(), - WorkdirSessionOperation::CommandCancel(_) - )); - let result = workdir::http::dispatch_workdir_session_operation( - &local_session, - operation.into_inner(), - ) - .await - .unwrap() - .try_into() - .unwrap(); - second - .send(Message::Text( - serde_json::to_string(&ExternalWorkdirProviderFrame::current( - ExternalWorkdirProviderMessage::OperationResult { - generation: 2, - operation_id, - outcome: ExternalWorkdirOperationOutcome::Completed { result }, - }, - )) - .unwrap() - .into(), - )) - .await - .unwrap(); - - let (operation_id, operation) = loop { - let text = second.next().await.unwrap().unwrap().into_text().unwrap(); - let frame = serde_json::from_str::(&text).unwrap(); - if let ExternalWorkdirServerMessage::Operation { - generation: 2, - operation_id, - operation, - } = frame.message - { - break (operation_id, operation); - } - }; - assert!(matches!( - operation.as_inner(), - WorkdirSessionOperation::CommandOutput(workdir::CommandOutputRequest { - wait: true, - .. - }) - )); - let provider_result = workdir::http::dispatch_workdir_session_operation( - &local_session, - operation.into_inner(), + let Json(revoked) = scoped_revoke_external_workdir_grant( + State(api.clone()), + Extension(test_browser_request_actor()), + AxumPath((TEST_WORKSPACE_ID.to_string(), grant.grant_id.clone())), ) .await .unwrap(); - assert!(matches!( - provider_result, - WorkdirSessionOperationResult::CommandOutput(ref output) - if output.status != workdir::CommandStatus::Running - )); - let result = provider_result.try_into().unwrap(); - second - .send(Message::Text( - serde_json::to_string(&ExternalWorkdirProviderFrame::current( - ExternalWorkdirProviderMessage::OperationResult { - generation: 2, - operation_id, - outcome: ExternalWorkdirOperationOutcome::Completed { result }, - }, - )) - .unwrap() - .into(), - )) - .await - .unwrap(); - let Json(revoked) = revoker.await.unwrap().unwrap(); assert_eq!(revoked.status, "revoked"); let active_links = api .store @@ -28401,27 +28464,12 @@ mod tests { assert_eq!(active_links.len(), 1); assert_eq!(active_links[0].alias, "repository"); assert_eq!(active_links[0].workdir_id, "analysis-repository"); - let revoke = loop { - let text = second.next().await.unwrap().unwrap().into_text().unwrap(); - let frame = serde_json::from_str::(&text).unwrap(); - if matches!(frame.message, ExternalWorkdirServerMessage::Revoke { .. }) { - break frame; - } - }; - assert!(matches!( - revoke.message, - ExternalWorkdirServerMessage::Revoke { generation: 2, .. } - )); - second - .send(Message::Text( - serde_json::to_string(&ExternalWorkdirProviderFrame::current( - ExternalWorkdirProviderMessage::RevokeAcknowledged { generation: 2 }, - )) - .unwrap() - .into(), - )) + let provider_end = tokio::time::timeout(std::time::Duration::from_secs(2), provider) .await + .expect("production CLI provider pump did not acknowledge revocation") + .unwrap() .unwrap(); + assert_eq!(provider_end, yoi::workdir_share::ProviderEnd::Revoked); server.abort(); } diff --git a/crates/yoi/src/lib.rs b/crates/yoi/src/lib.rs new file mode 100644 index 000000000..32e2a7501 --- /dev/null +++ b/crates/yoi/src/lib.rs @@ -0,0 +1 @@ +pub mod workdir_share; diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 7a3577091..6c36ba56e 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -4,10 +4,11 @@ mod objective_cli; mod plugin_cli; mod session_cli; mod ticket_cli; -mod workdir_share; mod worker_cleanup_cli; mod workspace_bootstrap; +use yoi::workdir_share; + use std::collections::BTreeMap; use std::fmt; use std::fs; @@ -623,7 +624,7 @@ fn parse_workdir_args( let mut ttl = None; let mut read_only = false; let mut read_write = false; - let mut explicit_permissions: Option = None; + let mut explicit_permission_level = None; let mut non_interactive = false; let mut index = 0; while index < args.len() { @@ -668,29 +669,22 @@ fn parse_workdir_args( index += 2; } "--permission" => { - let category = required_option_value(args, index, "--permission")?; - let permissions = - explicit_permissions.get_or_insert(server_api::ExternalWorkdirPermissions { - read: false, - write: false, - command: false, - }); - let enabled = match category.to_ascii_lowercase().as_str() { - "read" => &mut permissions.read, - "write" => &mut permissions.write, - "command" => &mut permissions.command, + let level = required_option_value(args, index, "--permission")?; + if explicit_permission_level.is_some() { + return Err(ParseError( + "--permission selects one level and may only be provided once".to_string(), + )); + } + explicit_permission_level = Some(match level.to_ascii_lowercase().as_str() { + "read" => server_api::ExternalWorkdirPermissionLevel::Read, + "write" => server_api::ExternalWorkdirPermissionLevel::Write, + "command" => server_api::ExternalWorkdirPermissionLevel::Command, _ => { return Err(ParseError(format!( - "unknown --permission category `{category}`; expected read, write, or command" + "unknown --permission level `{level}`; expected read, write, or command" ))); } - }; - if *enabled { - return Err(ParseError(format!( - "--permission {category} may only be provided once" - ))); - } - *enabled = true; + }); index += 2; } "--read-only" => { @@ -742,21 +736,18 @@ fn parse_workdir_args( "--read-only conflicts with --read-write".to_string(), )); } - if explicit_permissions.is_some() && (read_only || read_write) { + if explicit_permission_level.is_some() && (read_only || read_write) { return Err(ParseError( "--permission conflicts with legacy --read-only/--read-write".to_string(), )); } - let permissions = explicit_permissions.unwrap_or(server_api::ExternalWorkdirPermissions { - read: true, - write: read_write, - command: false, - }); - if permissions.write && !permissions.read { - return Err(ParseError( - "--permission write requires --permission read for read-before-write".to_string(), - )); - } + let permissions = explicit_permission_level + .map(server_api::ExternalWorkdirPermissionLevel::permissions) + .unwrap_or(if read_write { + server_api::ExternalWorkdirPermissions::WRITE + } else { + server_api::ExternalWorkdirPermissions::READ + }); if non_interactive && workspace_id.is_none() { return Err(ParseError( "--non-interactive requires --workspace-id".to_string(), @@ -1900,7 +1891,7 @@ Usage: yoi --backend [--workspace-id ] panel yoi [--backend ] init --display-name --repository-key [--repository ] [--default-ref ] yoi [--backend ] login [--no-wait] - yoi [--backend ] workdir share [--workspace-id ] [--permission ]... [--read-only|--read-write] [--ttl ] [--display-name ] [--non-interactive] + yoi [--backend ] workdir share [--workspace-id ] [--permission ] [--read-only|--read-write] [--ttl ] [--display-name ] [--non-interactive] yoi [OPTIONS] Target selection: @@ -1946,7 +1937,7 @@ Options: fn print_workdir_help() { println!( - "yoi workdir share\n\nUsage:\n yoi [--backend ] workdir share [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, READ/WRITE/COMMAND categories, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE enables Write/Edit and requires READ for read-before-write. COMMAND is independent, disabled by default, and runs with the sharing CLI user's host authority; it is not sandboxed.\nRepeat --permission to choose explicit categories in automation. Legacy --read-only and --read-write remain file-only aliases and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id Preselect a Workspace; required with --non-interactive\n --permission Enable read, write, or command; repeat per category\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for read + write (commands remain disabled)\n --ttl Automatic expiry; omission is unlimited\n --display-name Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n" + "yoi workdir share\n\nUsage:\n yoi [--backend ] workdir share [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, one READ/WRITE/COMMAND permission level, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE includes READ and adds Write/Edit. COMMAND includes WRITE and READ, adds command start/status/output/cancel, and runs with the sharing CLI user's host authority; it is not sandboxed.\nUse --permission once to choose the level in automation. Legacy --read-only and --read-write retain the READ and WRITE meanings and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id Preselect a Workspace; required with --non-interactive\n --permission Grant read, write (includes read), or command (includes write + read)\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for --permission write\n --ttl Automatic expiry; omission is unlimited\n --display-name Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n" ); } @@ -2038,7 +2029,7 @@ mod tests { } #[test] - fn workdir_share_parses_independent_command_permission() { + fn workdir_share_parses_command_as_the_full_hierarchical_level() { let args = vec![ "share".to_string(), ".".to_string(), @@ -2056,11 +2047,7 @@ mod tests { }; assert_eq!( options.permissions, - server_api::ExternalWorkdirPermissions { - read: false, - write: false, - command: true, - } + server_api::ExternalWorkdirPermissions::COMMAND ); } @@ -2131,17 +2118,19 @@ mod tests { .contains("conflicts") ); - let write_without_read = vec![ + let multiple_levels = vec![ "share".to_string(), ".".to_string(), "--permission".to_string(), "write".to_string(), + "--permission".to_string(), + "command".to_string(), ]; assert!( - parse_workdir_args(&write_without_read, &target) + parse_workdir_args(&multiple_levels, &target) .unwrap_err() .0 - .contains("requires --permission read") + .contains("selects one level") ); let missing_workspace = TargetSelection { diff --git a/crates/yoi/src/workdir_share.rs b/crates/yoi/src/workdir_share.rs index 196863b3f..b97bf96ab 100644 --- a/crates/yoi/src/workdir_share.rs +++ b/crates/yoi/src/workdir_share.rs @@ -25,7 +25,7 @@ use workdir::{ }; #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct WorkdirShareOptions { +pub struct WorkdirShareOptions { pub path: PathBuf, pub workspace_id: Option, pub backend_url: String, @@ -37,6 +37,39 @@ pub(crate) struct WorkdirShareOptions { const PROVIDER_OPERATION_TIMEOUT: Duration = Duration::from_secs(30); +fn validate_provider_registration(message: Message, generation: u64) -> Result<(), String> { + let registered = match message { + Message::Text(text) => text, + Message::Close(Some(frame)) => { + let reason = frame.reason.trim(); + return Err(if reason.is_empty() { + "Backend rejected the External Workdir provider registration".to_string() + } else { + format!("Backend rejected the External Workdir provider registration: {reason}") + }); + } + Message::Close(None) => { + return Err("Backend rejected the External Workdir provider registration".to_string()); + } + _ => { + return Err( + "Backend returned an invalid External Workdir registration frame".to_string(), + ); + } + }; + let frame = serde_json::from_str::(®istered) + .map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?; + if matches!( + frame.message, + ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. } + if registered_generation == generation + ) { + Ok(()) + } else { + Err("Backend rejected the External Workdir provider registration".to_string()) + } +} + fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation) -> Duration { if matches!( operation, @@ -49,7 +82,7 @@ fn provider_operation_timeout(operation: &workdir::http::WorkdirSessionOperation } #[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ProviderEnd { +pub enum ProviderEnd { Disconnected, Revoked, Interrupted, @@ -140,47 +173,42 @@ fn authenticated_server_api_client( Ok(ServerApiClient::with_client(base_url, http_client)) } -fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> String { - let mut enabled = Vec::new(); - if permissions.read { - enabled.push("READ"); +fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> &'static str { + match permissions.grant_level() { + Some(server_api::ExternalWorkdirPermissionLevel::Read) => "READ", + Some(server_api::ExternalWorkdirPermissionLevel::Write) => "WRITE (includes READ)", + Some(server_api::ExternalWorkdirPermissionLevel::Command) => { + "COMMAND (includes WRITE + READ)" + } + None => "INVALID", } - if permissions.write { - enabled.push("WRITE"); - } - if permissions.command { - enabled.push("COMMAND"); - } - enabled.join(", ") } fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> { - if permissions.write && !permissions.read { + if permissions.grant_level().is_none() { return Err( - "WRITE requires READ so existing files can satisfy read-before-write; select both categories" + "select exactly one permission level: READ, WRITE (includes READ), or COMMAND (includes WRITE + READ)" .to_string(), ); } - if !permissions.read && !permissions.command { - return Err("select at least one of READ or COMMAND".to_string()); - } Ok(()) } fn workdir_capabilities( permissions: server_api::ExternalWorkdirPermissions, ) -> workdir::WorkdirSessionCapabilities { - let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY; - if permissions.read { - capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY); + match permissions.grant_level() { + Some(server_api::ExternalWorkdirPermissionLevel::Read) => { + workdir::WorkdirSessionCapabilities::READ_ONLY + } + Some(server_api::ExternalWorkdirPermissionLevel::Write) => { + workdir::WorkdirSessionCapabilities::READ_WRITE + } + Some(server_api::ExternalWorkdirPermissionLevel::Command) => { + workdir::WorkdirSessionCapabilities::ALL + } + None => workdir::WorkdirSessionCapabilities::EMPTY, } - if permissions.write { - capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY); - } - if permissions.command { - capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY); - } - capabilities } #[derive(Debug, Clone, PartialEq, Eq)] @@ -193,7 +221,7 @@ struct ResolvedWorkdirShareOptions { permissions: server_api::ExternalWorkdirPermissions, } -pub(crate) fn parse_ttl(value: &str) -> Result { +pub fn parse_ttl(value: &str) -> Result { let (number, multiplier) = if let Some(value) = value.strip_suffix('h') { (value, 60 * 60) } else if let Some(value) = value.strip_suffix('m') { @@ -302,14 +330,14 @@ fn configure_interactively( writeln!( output, - "Permissions are independent categories: READ (Read/Glob/Grep), WRITE (Write/Edit), and COMMAND (host command lifecycle). WRITE requires READ for read-before-write. COMMAND runs with this CLI user's host authority and is not sandboxed." + "Permission levels: READ (Read/Glob/Grep), WRITE (includes READ + Write/Edit), or COMMAND (includes WRITE + READ + host command lifecycle). COMMAND runs with this CLI user's host authority and is not sandboxed." ) .map_err(|error| format!("failed to write prompt: {error}"))?; let Some(permission_input) = read_prompt_line( input, output, &format!( - "Permissions [comma-separated READ/WRITE/COMMAND; current {}; q to cancel]: ", + "Permission level [READ/WRITE/COMMAND; current {}; q to cancel]: ", permissions_label(options.permissions) ), )? @@ -320,28 +348,20 @@ fn configure_interactively( return Ok(None); } if !permission_input.is_empty() { - let mut permissions = server_api::ExternalWorkdirPermissions { - read: false, - write: false, - command: false, - }; - for category in permission_input - .split([',', ' ']) - .filter(|category| !category.is_empty()) - { - match category.to_ascii_lowercase().as_str() { - "r" | "read" | "read-only" | "read_only" => permissions.read = true, - "w" | "write" | "read-write" | "read_write" => permissions.write = true, - "c" | "command" => permissions.command = true, - _ => { - return Err(format!( - "unknown permission category `{category}`; use READ, WRITE, or COMMAND" - )); - } + options.permissions = match permission_input.to_ascii_lowercase().as_str() { + "r" | "read" | "read-only" | "read_only" => { + server_api::ExternalWorkdirPermissions::READ } - } - validate_permissions(permissions)?; - options.permissions = permissions; + "w" | "write" | "read-write" | "read_write" => { + server_api::ExternalWorkdirPermissions::WRITE + } + "c" | "command" => server_api::ExternalWorkdirPermissions::COMMAND, + _ => { + return Err(format!( + "unknown permission level `{permission_input}`; use READ, WRITE, or COMMAND" + )); + } + }; } writeln!(output, "TTL: {}", ttl_label(options.ttl)) @@ -437,7 +457,7 @@ async fn resolve_share_options( configure_interactively(options, &workspaces, &mut input, &mut output) } -pub(crate) async fn run(options: WorkdirShareOptions) -> Result<(), String> { +pub async fn run(options: WorkdirShareOptions) -> Result<(), String> { let Some(options) = resolve_share_options(options).await? else { eprintln!("External Workdir share cancelled; no grant was created."); return Ok(()); @@ -649,7 +669,7 @@ async fn serve_provider_connection( .max_message_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES)) .max_frame_size(Some(workdir::external::MAX_EXTERNAL_SERVER_FRAME_BYTES)); let connection = connect_async_with_config(request, Some(websocket_config), false); - let (mut socket, _) = tokio::select! { + let (socket, _) = tokio::select! { signal = tokio::signal::ctrl_c() => { signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?; @@ -671,11 +691,40 @@ async fn serve_provider_connection( read_limits: BoundedReadLimits::EXTERNAL_DEFAULT, }, }); + let end = + serve_external_workdir_provider_socket(socket, registration, session, generation, async { + tokio::signal::ctrl_c() + .await + .map_err(|error| format!("failed to wait for Ctrl-C: {error}")) + }) + .await?; + if end == ProviderEnd::Interrupted { + revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?; + } + Ok(end) +} + +/// Run the production External Workdir provider protocol over an established WebSocket. +/// +/// The CLI uses this pump after opening its authenticated Backend connection. Keeping the +/// protocol pump separate lets composed tests exercise the same registration, operation, +/// cancellation, and result-forwarding implementation without spawning a second CLI process. +pub async fn serve_external_workdir_provider_socket( + mut socket: tokio_tungstenite::WebSocketStream, + registration: ExternalWorkdirProviderFrame, + session: &LocalWorkdirSession, + generation: u64, + interrupt: I, +) -> Result +where + S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + I: std::future::Future>, +{ + tokio::pin!(interrupt); send_provider_frame(&mut socket, ®istration).await?; let registered = tokio::select! { - signal = tokio::signal::ctrl_c() => { - signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; - revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?; + signal = &mut interrupt => { + signal?; return Ok(ProviderEnd::Interrupted); } registered = tokio::time::timeout(Duration::from_secs(10), socket.next()) => { @@ -685,18 +734,7 @@ async fn serve_provider_connection( .map_err(|error| format!("External Workdir provider registration failed: {error}"))? } }; - let Message::Text(registered) = registered else { - return Err("Backend returned an invalid External Workdir registration frame".to_string()); - }; - let frame = serde_json::from_str::(®istered) - .map_err(|error| format!("Backend returned an invalid External Workdir frame: {error}"))?; - if !matches!( - frame.message, - ExternalWorkdirServerMessage::Registered { generation: registered_generation, .. } - if registered_generation == generation - ) { - return Err("Backend rejected the External Workdir provider registration".to_string()); - } + validate_provider_registration(registered, generation)?; println!("Connection: online (generation {generation})"); let (completion_sender, mut completions) = @@ -704,10 +742,9 @@ async fn serve_provider_connection( let mut operations = HashMap::::new(); loop { tokio::select! { - signal = tokio::signal::ctrl_c() => { - signal.map_err(|error| format!("failed to wait for Ctrl-C: {error}"))?; + signal = &mut interrupt => { + signal?; stop_provider_operations(session, &mut operations, &mut completions).await?; - revoke(server_api_client, &options.workspace_id, &grant.grant_id).await?; return Ok(ProviderEnd::Interrupted); } completion = completions.recv() => { @@ -916,7 +953,7 @@ async fn execute_operation( Ok(result) => ExternalWorkdirOperationOutcome::Completed { result }, Err(message) => ExternalWorkdirOperationOutcome::Failed { error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError { - code: workdir::http::WorkdirTransportErrorCode::Unsupported, + code: workdir::http::WorkdirTransportErrorCode::InvalidRequest, message, }), }, @@ -1060,6 +1097,23 @@ mod tests { } } + #[test] + fn provider_registration_surfaces_backend_close_reason() { + use tokio_tungstenite::tungstenite::protocol::CloseFrame; + use tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode; + + let error = validate_provider_registration( + Message::Close(Some(CloseFrame { + code: CloseCode::Policy, + reason: "provider read limits source=1024 response=512; Backend requires source=2048 response=1024".into(), + })), + 1, + ) + .unwrap_err(); + assert!(error.contains("provider read limits"), "{error}"); + assert!(error.contains("Backend requires"), "{error}"); + } + #[test] fn interactive_share_defaults_to_first_workspace_read_only_and_unlimited() { let workspaces = [workspace("workspace-a", "Alpha")]; @@ -1085,12 +1139,12 @@ mod tests { } #[test] - fn interactive_share_requires_explicit_write_and_command_selection_and_confirmation() { + fn interactive_share_selects_one_hierarchical_level_and_requires_confirmation() { let workspaces = [ workspace("workspace-a", "Alpha"), workspace("workspace-b", "Beta"), ]; - let mut input = std::io::Cursor::new(b"2\nREAD,WRITE,COMMAND\n15m\ny\n".to_vec()); + let mut input = std::io::Cursor::new(b"2\nCOMMAND\n15m\ny\n".to_vec()); let mut output = Vec::new(); let selected = @@ -1110,7 +1164,39 @@ mod tests { assert_eq!(selected.ttl, Some(Duration::from_secs(900))); let output = String::from_utf8(output).unwrap(); assert!(output.contains("not sandboxed")); - assert!(output.contains("Permissions: READ, WRITE, COMMAND")); + assert!(output.contains("Permissions: COMMAND (includes WRITE + READ)")); + } + + #[test] + fn share_permission_levels_map_to_exact_existing_capability_bits() { + let bits = |permissions| { + serde_json::to_value(workdir_capabilities(permissions)).unwrap()["bits"] + .as_u64() + .unwrap() + }; + assert_eq!(bits(server_api::ExternalWorkdirPermissions::READ), 25); + assert_eq!(bits(server_api::ExternalWorkdirPermissions::WRITE), 31); + assert_eq!(bits(server_api::ExternalWorkdirPermissions::COMMAND), 63); + + for invalid in [ + server_api::ExternalWorkdirPermissions { + read: false, + write: false, + command: true, + }, + server_api::ExternalWorkdirPermissions { + read: false, + write: true, + command: false, + }, + server_api::ExternalWorkdirPermissions { + read: true, + write: false, + command: true, + }, + ] { + assert!(validate_permissions(invalid).is_err()); + } } #[test] diff --git a/docs/development/external-workdir-sharing.md b/docs/development/external-workdir-sharing.md index dbe980168..1030bc6ef 100644 --- a/docs/development/external-workdir-sharing.md +++ b/docs/development/external-workdir-sharing.md @@ -1,34 +1,49 @@ # External Workdir sharing permissions `yoi workdir share` exposes a client-hosted directory through an authenticated, -outbound Backend connection. The operator grants three independent categories: +outbound Backend connection. The operator selects exactly one permission level: -- **READ**: file reads and read-only discovery operations such as Stat, List, - ViewImage, Glob, and Grep. -- **WRITE**: Write and Edit. WRITE requires READ because updating an existing - file uses the read-before-write content-hash contract. Selecting WRITE never - enables COMMAND. -- **COMMAND**: command start, status, bounded output, and cancellation. COMMAND - does not enable READ or WRITE. It executes on the sharing host with the OS +- **READ** (capability bits `25`): file reads and read-only discovery operations + such as Stat, List, ViewImage, Glob, and Grep. +- **WRITE** (capability bits `31`): READ plus Write and Edit. Including READ + preserves the read-before-write content-hash contract. WRITE never enables + COMMAND. +- **COMMAND** (capability bits `63`): WRITE plus READ plus command start, status, + bounded output, and cancellation. It executes on the sharing host with the OS authority of the user running the CLI; External Workdir command execution is **not a sandbox** and does not confine the process to the shared directory. -READ is enabled by default and COMMAND is disabled by default. Interactive mode -asks for a comma-separated category list and shows the exact grant before it is -created. For automation, repeat `--permission`: +In short, `COMMAND ⇒ WRITE ⇒ READ`. READ is the default. Interactive mode asks +for one level and shows its included permissions before creating the grant. For +automation, pass `--permission` once: ```sh yoi --backend https://example.invalid workdir share ./project \ --workspace-id workspace-a \ - --permission read \ - --permission write \ --permission command \ --non-interactive ``` -`--read-only` remains an alias for READ. `--read-write` remains an alias for -READ+WRITE and never enables COMMAND. These legacy flags conflict with explicit -`--permission` arguments so ambiguous automation fails closed. +`--read-only` remains an alias for the READ level. `--read-write` remains an +alias for the WRITE level and never enables COMMAND. These legacy flags conflict +with `--permission`, and multiple `--permission` arguments are rejected so +ambiguous automation fails closed. + +The grant API retains the boolean projection for compatibility, but accepts only +these combinations when creating new grants: + +| Level | `read` | `write` | `command` | +|---|---:|---:|---:| +| READ | `true` | `false` | `false` | +| WRITE | `true` | `true` | `false` | +| COMMAND | `true` | `true` | `true` | + +Older non-hierarchical grants such as command-only or READ+COMMAND are not +silently widened. The Backend refuses their projection, provider registration, +and subsequent restore/reconnect with guidance to revoke and re-share the +directory at one of the supported levels. General Workdir attachment/session +capability intersections may still produce partial capability sets; that +restriction applies only to External grant authority. Omitting `--ttl` creates no automatic expiry. An explicit TTL must be between 60 seconds and 24 hours. Ctrl-C, revoke, expiry, provider disconnect, attachment diff --git a/openapi/server-api.json b/openapi/server-api.json index 85a0e6c25..bc652123f 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -2092,15 +2092,15 @@ "additionalProperties": false, "properties": { "command": { - "description": "Execute commands as the sharing CLI user on the provider host. This is\nexplicit authority and does not imply either filesystem category.", + "description": "Execute commands as the sharing CLI user on the provider host. COMMAND\nincludes WRITE and READ, remains explicit, and is not sandboxed.", "type": "boolean" }, "read": { - "description": "Read files and use read-only discovery operations such as Glob and Grep.", + "description": "Read files and use read-only discovery operations such as Glob and Grep.\nEvery supported External Workdir permission level enables this field.", "type": "boolean" }, "write": { - "description": "Create or update files with Write and Edit. Existing-file mutation also\nrequires `read` so callers can satisfy the read-before-write contract.", + "description": "Create or update files with Write and Edit. WRITE includes READ so callers\ncan satisfy the read-before-write contract.", "type": "boolean" } }, @@ -14020,7 +14020,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c" + "x-yoi-source-digest": "sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0" }, "openapi": "3.1.0", "paths": { diff --git a/tools/web-ux/browser-tests/console_history_fixture_server.ts b/tools/web-ux/browser-tests/console_history_fixture_server.ts new file mode 100644 index 000000000..53035f71b --- /dev/null +++ b/tools/web-ux/browser-tests/console_history_fixture_server.ts @@ -0,0 +1,224 @@ +import { extname, join, normalize } from "jsr:@std/path@1.1.4"; + +const port = Number(Deno.args[0]); +const buildRoot = Deno.args[1]; +if (!Number.isInteger(port) || !buildRoot) { + throw new Error("usage: console_history_fixture_server.ts "); +} + +const workspaceId = "console-history-review"; +const runtimeId = "runtime-a"; +const workerId = "worker-a"; +const workerResourceKey = "W-900"; +let historyRequests = 0; + +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); + +const worker = { + worker_id: workerId, + runtime_id: runtimeId, + resource_key: workerResourceKey, + display_name: "Console Fixture", + label: "Console Fixture", + availability: "observed", + state: "stopped", + retention_state: "retained", + host_id: "fixture-host", + profile: "default", + tags: [], + diagnostics: [], + capabilities: { can_stop: false, can_spawn_followup: false }, + workspace: { + workspace_id: workspaceId, + visibility: "workspace", + identity: workspaceId, + }, + implementation: { kind: "builtin", display_hint: "fixture" }, + workdir_attachments: [], +}; + +function historyEntry(index: number, role: "user" | "assistant") { + const user = role === "user"; + return { + entry_id: `${role}-${index}`, + timestamp: index, + provenance: user ? "human_input" : "model_output", + kind: "message", + role, + content: [{ + kind: "text", + text: user + ? `question ${index}` + : `done ${index}\ndetail ${index}.1\ndetail ${index}.2\ndetail ${index}.3`, + }], + }; +} + +function historyPage(indices: number[], cursor: string | null) { + return { + availability: "page", + page: { + session_id: "session-a", + lineage_id: "lineage-a", + compact_ancestor_lineage_ids: [], + turns: indices.map((index) => ({ + turn_id: `user-${index}`, + entries: [historyEntry(index, "user"), historyEntry(index, "assistant")], + })), + next_cursor: cursor, + has_more: cursor !== null, + }, + }; +} + +const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", +}; + +async function staticResponse(pathname: string): Promise { + const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); + const normalized = normalize(relative); + if (normalized.startsWith("..")) return new Response("not found", { status: 404 }); + let filePath = join(buildRoot, normalized); + try { + const stat = await Deno.stat(filePath); + if (stat.isDirectory) filePath = join(filePath, "index.html"); + return new Response(await Deno.readFile(filePath), { + headers: { "content-type": mime[extname(filePath)] ?? "application/octet-stream" }, + }); + } catch { + return new Response(await Deno.readFile(join(buildRoot, "index.html")), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } +} + +Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + if (url.pathname === "/fixture-state") return json({ history_requests: historyRequests }); + if (url.pathname === "/api/workspaces") { + return json([{ + workspace_id: workspaceId, + owner_account_id: "owner", + display_name: "Console History Review", + state: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }]); + } + if (url.pathname === `/api/w/${workspaceId}/workspace`) { + return json({ + workspace_id: workspaceId, + display_name: "Console History Review", + record_authority: "fixture", + schema_version: 1, + auth: { + Passkey: { + rp_id: "127.0.0.1", + origin: `http://127.0.0.1:${port}`, + public_base_url: `http://127.0.0.1:${port}`, + cookie_name: "fixture", + }, + }, + permissions: { + manage_repositories: false, + manage_secrets: false, + manage_runtimes: false, + delete_workspace: false, + }, + extension_points: { + store: "fixture", + event_stream: { status: "ready", note: "fixture", diagnostics: [] }, + host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] }, + companion_console: { status: "ready", note: "fixture", diagnostics: [] }, + }, + }); + } + if (url.pathname === `/api/w/${workspaceId}/repositories`) { + return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] }); + } + if (url.pathname === `/api/w/${workspaceId}/working-directories`) { + return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); + } + if ( + url.pathname === `/api/w/${workspaceId}/workers/${workerResourceKey}` || + url.pathname === `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}` + ) { + return json(worker); + } + if ( + url.pathname === + `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session` + ) { + return json({ + availability: "retained_snapshot", + identity: { session_id: "session-a", segment_id: "segment-a", entry_count: 12 }, + snapshot: { + pending_submissions: { + revision: 0, + notification_count: 0, + head_id: null, + submissions: [], + }, + entries: [7, 8, 9, 10, 11, 12].flatMap((index) => [ + historyEntry(index, "user"), + historyEntry(index, "assistant"), + ]), + }, + }); + } + if ( + url.pathname === + `/api/w/${workspaceId}/runtimes/${runtimeId}/workers/${workerId}/session/history` + ) { + historyRequests += 1; + switch (url.searchParams.get("cursor")) { + case null: + return json(historyPage([8, 9, 10, 11, 12], "older-8")); + case "older-8": + return json(historyPage([3, 4, 5, 6, 7], "older-3")); + case "older-3": + return json(historyPage([1, 2], null)); + default: + return json({ availability: "unavailable", reason: "invalid_cursor", message: "invalid" }); + } + } + if ( + url.pathname === `/api/w/${workspaceId}/protocol/ws` && + request.headers.get("upgrade") === "websocket" + ) { + const { socket, response } = Deno.upgradeWebSocket(request); + socket.onmessage = (event) => { + const frame = JSON.parse(String(event.data)); + if (frame?.message?.method !== "subscribe_events") return; + const requestId = frame.message.params.request_id; + socket.send(JSON.stringify({ + protocol_version: 1, + frame: "response", + message: { + result: "subscribed", + payload: { + request_id: requestId, + subscription_id: "fixture-workers", + selector: frame.message.params.selector, + snapshot_revision: 1, + snapshot: { topic: "workers", data: { workers: [] } }, + }, + }, + })); + }; + return response; + } + return await staticResponse(url.pathname); +}); diff --git a/tools/web-ux/browser-tests/console_history_shell_test.ts b/tools/web-ux/browser-tests/console_history_shell_test.ts new file mode 100644 index 000000000..d2bcb45a2 --- /dev/null +++ b/tools/web-ux/browser-tests/console_history_shell_test.ts @@ -0,0 +1,217 @@ +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium } from "playwright"; + +const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const workspaceRoot = join(repositoryRoot, "web/workspace"); +const fixtureServer = join( + repositoryRoot, + "tools/web-ux/browser-tests/console_history_fixture_server.ts", +); +const workspaceId = "console-history-review"; + +async function freePort(): Promise { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + return port; +} + +async function waitForServer(url: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + if ((await fetch(url)).ok) return; + } catch { + // Retry until the bounded deadline while the owned server starts. + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`fixture server did not become ready: ${url}`); +} + +async function historyRequestCount(baseUrl: string): Promise { + const response = await fetch(`${baseUrl}/fixture-state`); + const state = await response.json(); + return state.history_requests; +} + +Deno.test("production Console pages backward by real turns while preserving both scroll anchors", async () => { + const build = await new Deno.Command(Deno.execPath(), { + args: ["task", "build"], + cwd: workspaceRoot, + stdout: "piped", + stderr: "piped", + }).output(); + if (!build.success) { + throw new Error( + `Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`, + ); + } + + const port = await freePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + fixtureServer, + String(port), + join(workspaceRoot, "build"), + ], + stdout: "null", + stderr: "null", + }).spawn(); + + try { + await waitForServer(`${baseUrl}/health`); + const browser = await chromium.launch({ headless: true }); + try { + const context = await browser.newContext({ + viewport: { width: 1440, height: 600 }, + colorScheme: "dark", + }); + const page = await context.newPage(); + const errors: string[] = []; + const responses: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(String(error))); + page.on("response", (response) => { + if (response.status() >= 400 || response.url().includes("/api/")) { + responses.push(`${response.status()} ${response.url()}`); + } + }); + + await page.goto( + `${baseUrl}/w/${workspaceId}/workers/W-900-console-fixture/console`, + ); + const transcript = page.getByRole("article", { name: "main transcript" }); + try { + await transcript.getByText("question 8", { exact: true }).waitFor({ timeout: 5_000 }); + } catch (error) { + throw new Error( + `Console did not render retained history: ${error}\nresponses=${ + JSON.stringify(responses) + }\nerrors=${JSON.stringify(errors)}\nbody=${await page.locator("body").innerText()}`, + ); + } + const navigation = page.getByRole("complementary", { name: "Conversation turns" }); + await navigation.getByText("question 7", { exact: true }).waitFor(); + assertEquals(await historyRequestCount(baseUrl), 1); + assertEquals(await transcript.getByText(/^question (7|8|9|10|11|12)$/).count(), 6); + assertEquals(await navigation.getByText(/^question (7|8|9|10|11|12)$/).count(), 6); + const currentQuestionTop = await transcript + .getByText("question 7", { exact: true }) + .evaluate((element) => element.getBoundingClientRect().top); + const retainedQuestionTop = await transcript + .getByText("question 8", { exact: true }) + .evaluate((element) => element.getBoundingClientRect().top); + assert( + currentQuestionTop < retainedQuestionTop, + "the unmatched current-snapshot turn must precede overlapping retained history", + ); + + const question12 = navigation.getByRole("button", { + name: "Jump to conversation: question 12", + }); + assertEquals(await question12.getByText("detail 12.1", { exact: true }).count(), 1); + assertEquals(await question12.getByText("detail 12.2", { exact: true }).count(), 1); + assertEquals(await question12.getByText("detail 12.3", { exact: true }).count(), 0); + assert(!(await question12.textContent())?.includes("User")); + assert(!(await question12.textContent())?.includes("AI")); + + const consoleScroll = transcript.locator("xpath=.."); + const topAlignment = await page.evaluate(() => { + const document = (globalThis as any).document; + const body = document.querySelector(".console-scroll")?.getBoundingClientRect(); + const turns = document.querySelector(".turn-navigation")?.getBoundingClientRect(); + return body && turns ? Math.abs(body.top - turns.top) : Number.POSITIVE_INFINITY; + }); + assert(topAlignment <= 1, `transcript and turn bar top edges differ by ${topAlignment}px`); + + const navigationAnchoredTop = await navigation + .locator('[data-turn-id="user-8"]') + .evaluate((element) => element.getBoundingClientRect().top); + const anchoredTop = await consoleScroll.evaluate((element) => { + element.scrollTop = 0; + const anchor = element.querySelector('[data-console-line-id*="user-8"]'); + const top = anchor?.getBoundingClientRect().top ?? Number.NaN; + element.dispatchEvent(new Event("scroll")); + return top; + }); + await transcript.getByText("question 3", { exact: true }).waitFor(); + await navigation.getByText("question 3", { exact: true }).waitFor(); + const restoredTop = await transcript + .locator('[data-console-line-id*="user-8"]') + .evaluate((element) => element.getBoundingClientRect().top); + assert( + Math.abs(restoredTop - anchoredTop) <= 1, + `transcript anchor moved from ${anchoredTop}px to ${restoredTop}px`, + ); + await page.waitForTimeout(250); + assertEquals( + await historyRequestCount(baseUrl), + 2, + "a stationary top sentinel must not drain another page", + ); + + const turnList = navigation.locator(".turn-list"); + const navigationAfterPrepend = await navigation + .locator('[data-turn-id="user-8"]') + .evaluate((element) => element.getBoundingClientRect().top); + assert( + Math.abs(navigationAfterPrepend - navigationAnchoredTop) <= 1, + `turn-bar anchor moved from ${navigationAnchoredTop}px to ${navigationAfterPrepend}px`, + ); + await turnList.evaluate((element) => { + element.scrollTop = Math.max(2, element.scrollTop); + element.dispatchEvent(new Event("scroll")); + element.scrollTop = 0; + element.dispatchEvent(new Event("scroll")); + }); + await transcript.getByText("question 1", { exact: true }).waitFor(); + await navigation.getByText("question 1", { exact: true }).waitFor(); + assertEquals(await historyRequestCount(baseUrl), 3); + assertEquals(await page.getByText("Start of conversation", { exact: true }).count(), 2); + + await navigation.getByRole("button", { + name: "Jump to conversation: question 3", + }).click(); + await page.waitForFunction(() => { + const document = (globalThis as any).document; + const element = document.querySelector('[data-console-line-id*="user-3"]'); + const parent = element?.closest(".console-scroll"); + if (!element || !parent) return false; + const item = element.getBoundingClientRect(); + const viewport = parent.getBoundingClientRect(); + return item.top >= viewport.top - 1 && item.top < viewport.bottom; + }); + const jumpGeometry = await transcript + .locator('[data-console-line-id*="user-3"]') + .evaluate((element) => { + const parent = element.closest(".console-scroll")!; + const item = element.getBoundingClientRect(); + const viewport = parent.getBoundingClientRect(); + return { itemTop: item.top, viewportTop: viewport.top, viewportBottom: viewport.bottom }; + }); + assert(jumpGeometry.itemTop >= jumpGeometry.viewportTop - 1); + assert(jumpGeometry.itemTop < jumpGeometry.viewportBottom); + + const centering = await question12.evaluate((button) => { + const turn = button.getBoundingClientRect(); + const bar = button.querySelector(".bar")!.getBoundingClientRect(); + return Math.abs((turn.top + turn.bottom) / 2 - (bar.top + bar.bottom) / 2); + }); + assert(centering <= 1, `turn marker is not vertically centered (${centering}px)`); + assertEquals(errors, []); + await context.close(); + } finally { + await browser.close(); + } + } finally { + server.kill("SIGTERM"); + await server.status; + } +}); diff --git a/tools/web-ux/browser-tests/memory_document_fixture_server.ts b/tools/web-ux/browser-tests/memory_document_fixture_server.ts new file mode 100644 index 000000000..ba1c24614 --- /dev/null +++ b/tools/web-ux/browser-tests/memory_document_fixture_server.ts @@ -0,0 +1,149 @@ +import { extname, join, normalize } from "jsr:@std/path@1.1.4"; + +const port = Number(Deno.args[0]); +const buildRoot = Deno.args[1]; +const sectionCount = Deno.args[2] === undefined ? 90 : Number(Deno.args[2]); +if ( + !Number.isInteger(port) || !buildRoot || !Number.isInteger(sectionCount) || sectionCount < 3 || + sectionCount > 100 +) { + throw new Error("usage: server.ts [section-count: 3..100]"); +} + +const workspaceId = "memory-review"; +const paragraphs = Array.from( + { length: sectionCount }, + (_, index) => + `## Section ${ + index + 1 + }\n\nThis is representative Memory content with **important context**, an identifier \`memory-item-${ + index + 1 + }\`, and a long URL https://example.com/${"long-segment-".repeat(12)}${ + index + 1 + }.\n\n> A durable note for section ${index + 1}.\n\n- first detail\n- second detail`, +).join("\n\n"); +const bodyMd = + `# Workspace Memory\n\nThis document is read-only.\n\n| Very wide heading | Another heading | Decision |\n| --- | --- | --- |\n| ${ + "wide-value-".repeat(16) + } | stable | keep scrolling local |\n\n##### Deep document heading\n\n###### Deepest document heading\n\n\`\`\`rust\nfn example() { println!("${ + "wide-code-".repeat(18) + }"); }\n\`\`\`\n\n[Safe link](https://example.com) [Unsafe link](javascript:alert(1))\n\n\n\n${paragraphs}\n\n## T-661 UNIQUE END MARKER`; + +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { + status, + headers: { "content-type": "application/json; charset=utf-8" }, + }); + +const mime: Record = { + ".html": "text/html; charset=utf-8", + ".js": "text/javascript; charset=utf-8", + ".css": "text/css; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".svg": "image/svg+xml", + ".png": "image/png", + ".woff2": "font/woff2", +}; + +async function staticResponse(pathname: string): Promise { + const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, ""); + const normalized = normalize(relative); + if (normalized.startsWith("..")) return new Response("not found", { status: 404 }); + let filePath = join(buildRoot, normalized); + try { + const stat = await Deno.stat(filePath); + if (stat.isDirectory) filePath = join(filePath, "index.html"); + return new Response(await Deno.readFile(filePath), { + headers: { "content-type": mime[extname(filePath)] ?? "application/octet-stream" }, + }); + } catch { + return new Response(await Deno.readFile(join(buildRoot, "index.html")), { + headers: { "content-type": "text/html; charset=utf-8" }, + }); + } +} + +Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { + const url = new URL(request.url); + if (url.pathname === "/health") return new Response("ok"); + if (url.pathname === "/api/workspaces") { + return json([{ + workspace_id: workspaceId, + owner_account_id: "owner", + display_name: "Memory Review", + state: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", + }]); + } + if (url.pathname === `/api/w/${workspaceId}/workspace`) { + return json({ + workspace_id: workspaceId, + display_name: "Memory Review", + record_authority: "fixture", + schema_version: 1, + auth: { + Passkey: { + rp_id: "127.0.0.1", + origin: `http://127.0.0.1:${port}`, + public_base_url: `http://127.0.0.1:${port}`, + cookie_name: "fixture", + }, + }, + permissions: { + manage_repositories: false, + manage_secrets: false, + manage_runtimes: false, + delete_workspace: false, + }, + extension_points: { + store: "fixture", + event_stream: { status: "ready", note: "fixture", diagnostics: [] }, + host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] }, + companion_console: { status: "ready", note: "fixture", diagnostics: [] }, + }, + }); + } + if (url.pathname === `/api/w/${workspaceId}/repositories`) { + return json({ workspace_id: workspaceId, items: [], source: "fixture", diagnostics: [] }); + } + if (url.pathname === `/api/w/${workspaceId}/working-directories`) { + return json({ workspace_id: workspaceId, items: [], diagnostics: [] }); + } + if (url.pathname === `/api/w/${workspaceId}/memory`) { + return json({ + body_md: bodyMd, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T03:04:05Z", + bytes: new TextEncoder().encode(bodyMd).byteLength, + record_source: "sqlite_workspace_authority.memory_document", + }); + } + if ( + url.pathname === `/api/w/${workspaceId}/protocol/ws` && + request.headers.get("upgrade") === "websocket" + ) { + const { socket, response } = Deno.upgradeWebSocket(request); + socket.onmessage = (event) => { + const frame = JSON.parse(String(event.data)); + if (frame?.message?.method !== "subscribe_events") return; + const requestId = frame.message.params.request_id; + socket.send(JSON.stringify({ + protocol_version: 1, + frame: "response", + message: { + result: "subscribed", + payload: { + request_id: requestId, + subscription_id: "fixture-workers", + selector: { topic: "workspace_workers" }, + snapshot_revision: 1, + snapshot: { topic: "workers", data: { workers: [] } }, + }, + }, + })); + }; + return response; + } + return await staticResponse(url.pathname); +}); diff --git a/tools/web-ux/browser-tests/memory_document_shell_test.ts b/tools/web-ux/browser-tests/memory_document_shell_test.ts new file mode 100644 index 000000000..54c8d403f --- /dev/null +++ b/tools/web-ux/browser-tests/memory_document_shell_test.ts @@ -0,0 +1,251 @@ +import { assert, assertEquals } from "@std/assert"; +import { dirname, fromFileUrl, join } from "@std/path"; +import { chromium, type Page } from "playwright"; + +const repositoryRoot = join(dirname(fromFileUrl(import.meta.url)), "../../.."); +const workspaceRoot = join(repositoryRoot, "web/workspace"); +const fixtureServer = join( + repositoryRoot, + "tools/web-ux/browser-tests/memory_document_fixture_server.ts", +); +const workspaceId = "memory-review"; + +async function freePort(): Promise { + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + return port; +} + +async function waitForServer(url: string): Promise { + for (let attempt = 0; attempt < 200; attempt += 1) { + try { + if ((await fetch(url)).ok) return; + } catch { + // Retry until the bounded deadline while the owned server starts. + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + throw new Error(`fixture server did not become ready: ${url}`); +} + +async function mainScrollState(page: Page) { + return await page.locator("main").evaluate((main) => ({ + scrollTop: main.scrollTop, + scrollHeight: main.scrollHeight, + clientHeight: main.clientHeight, + })); +} + +async function markerIsInsideMain(page: Page): Promise { + return await page.evaluate(() => { + const document = (globalThis as any).document; + const main = document.querySelector("main"); + const marker = [...document.querySelectorAll("h2")].find((heading) => + heading.textContent === "T-661 UNIQUE END MARKER" + ); + if (!main || !marker) return false; + const mainRect = main.getBoundingClientRect(); + const markerRect = marker.getBoundingClientRect(); + return markerRect.top >= mainRect.top && markerRect.bottom <= mainRect.bottom; + }); +} + +async function openMemoryPage( + page: Page, + baseUrl: string, + mobile = false, +): Promise { + await page.goto(`${baseUrl}/w/${workspaceId}/memory`); + const heading = page.locator("h1", { hasText: "Workspace Memory" }); + await heading.waitFor({ state: "attached" }); + if (mobile) await page.getByRole("button", { name: "Hide sidebar" }).click(); + await heading.waitFor(); +} + +async function swipeMainToEnd(page: Page): Promise { + const session = await page.context().newCDPSession(page); + for (let attempt = 0; attempt < 100; attempt += 1) { + const state = await mainScrollState(page); + if (state.scrollTop + state.clientHeight >= state.scrollHeight - 1) return; + await session.send("Input.dispatchTouchEvent", { + type: "touchStart", + touchPoints: [{ x: 300, y: 700, id: 1 }], + }); + for (const y of [580, 460, 340, 220, 100]) { + await session.send("Input.dispatchTouchEvent", { + type: "touchMove", + touchPoints: [{ x: 300, y, id: 1 }], + }); + } + await session.send("Input.dispatchTouchEvent", { + type: "touchEnd", + touchPoints: [], + }); + await page.waitForTimeout(20); + } + throw new Error("touch input did not reach the end of the Memory document"); +} + +Deno.test("production Memory shell renders safe Markdown and scrolls to its end by wheel, keyboard, and touch", async () => { + const build = await new Deno.Command(Deno.execPath(), { + args: ["task", "build"], + cwd: workspaceRoot, + stdout: "piped", + stderr: "piped", + }).output(); + if (!build.success) { + throw new Error( + `Workspace production build failed:\n${new TextDecoder().decode(build.stderr)}`, + ); + } + + const port = await freePort(); + const baseUrl = `http://127.0.0.1:${port}`; + const server = new Deno.Command(Deno.execPath(), { + args: [ + "run", + "--allow-net", + "--allow-read", + fixtureServer, + String(port), + join(workspaceRoot, "build"), + ], + stdout: "null", + stderr: "null", + }).spawn(); + + try { + await waitForServer(`${baseUrl}/health`); + const browser = await chromium.launch({ headless: true }); + try { + for ( + const scenario of [ + { label: "wheel", width: 1440, height: 900, colorScheme: "light" as const }, + { label: "keyboard", width: 768, height: 900, colorScheme: "dark" as const }, + { + label: "touch", + width: 390, + height: 844, + colorScheme: "light" as const, + mobile: true, + }, + ] + ) { + const context = await browser.newContext({ + viewport: { width: scenario.width, height: scenario.height }, + colorScheme: scenario.colorScheme, + hasTouch: scenario.mobile ?? false, + isMobile: scenario.mobile ?? false, + }); + const page = await context.newPage(); + const errors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(String(error))); + await openMemoryPage(page, baseUrl, scenario.mobile); + + const initial = await mainScrollState(page); + assert(initial.scrollHeight > initial.clientHeight); + assertEquals(await markerIsInsideMain(page), false); + assertEquals( + await page.evaluate(() => { + const document = (globalThis as any).document; + return document.documentElement.scrollWidth === document.documentElement.clientWidth; + }), + true, + ); + + if (scenario.label === "wheel") { + assertEquals(await page.locator("h1").allTextContents(), ["Workspace Memory"]); + assertEquals( + await page.locator("strong").filter({ hasText: "important context" }).count() > 0, + true, + ); + assertEquals(await page.locator("blockquote").count() > 0, true); + assertEquals(await page.locator("ul li").count() > 0, true); + for ( + const heading of [ + { selector: "h5", text: "Deep document heading" }, + { selector: "h6", text: "Deepest document heading" }, + ] + ) { + const element = page.locator(heading.selector, { hasText: heading.text }); + assertEquals(await element.count(), 1); + assert( + await element.evaluate((node) => + Number.parseFloat((globalThis as any).getComputedStyle(node).fontSize) >= 12 + ), + `${heading.selector} fell below the design language minimum text size`, + ); + } + assertEquals(await page.locator('a[href="https://example.com"]').count(), 1); + assertEquals(await page.locator('a[href^="javascript:"]').count(), 0); + assertEquals(await page.locator("article img, article script").count(), 0); + assertEquals(await page.getByRole("heading", { name: "Memory Document" }).count(), 0); + + for (const label of ["Markdown table", "rust code block"]) { + const region = page.getByRole("region", { name: label }); + assertEquals(await region.getAttribute("tabindex"), "0"); + assert(await region.evaluate((element) => element.scrollWidth > element.clientWidth)); + } + + await page.locator("main").hover(); + await page.mouse.wheel(0, 100_000); + } else if (scenario.label === "keyboard") { + await page.getByText("Document details", { exact: true }).focus(); + await page.keyboard.press("End"); + } else { + await swipeMainToEnd(page); + } + + await page.waitForTimeout(500); + const afterInteraction = await mainScrollState(page); + assert( + afterInteraction.scrollTop > 0, + `${scenario.label} did not move the main scroll owner: ${ + JSON.stringify(afterInteraction) + }`, + ); + assertEquals(await markerIsInsideMain(page), true); + assertEquals(errors, []); + await context.close(); + } + + const stateContext = await browser.newContext({ viewport: { width: 1440, height: 900 } }); + const emptyPage = await stateContext.newPage(); + await emptyPage.route(`**/api/w/${workspaceId}/memory`, async (route) => { + await route.fulfill({ + contentType: "application/json", + body: JSON.stringify({ + body_md: " \n", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T03:04:05Z", + bytes: 2, + record_source: "fixture", + }), + }); + }); + await emptyPage.goto(`${baseUrl}/w/${workspaceId}/memory`); + await emptyPage.getByText("Memory document is empty.").waitFor(); + assertEquals(await emptyPage.getByRole("alert").count(), 0); + + const errorPage = await stateContext.newPage(); + await errorPage.route(`**/api/w/${workspaceId}/memory`, async (route) => { + await route.fulfill({ status: 503, contentType: "application/json", body: "{}" }); + }); + await errorPage.goto(`${baseUrl}/w/${workspaceId}/memory`); + const alert = errorPage.getByRole("alert"); + await alert.waitFor(); + assert((await alert.textContent())?.includes("Memory document unavailable.")); + assertEquals(await errorPage.getByText("Memory document is empty.").count(), 0); + await stateContext.close(); + } finally { + await browser.close(); + } + } finally { + server.kill("SIGTERM"); + await server.status; + } +}); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 172a00ebc..48fe75c03 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/web/workspace/README.md b/web/workspace/README.md index b54b4ecf7..686d4bbe2 100644 --- a/web/workspace/README.md +++ b/web/workspace/README.md @@ -96,6 +96,7 @@ Use semantic tokens from `app.css`. Do not introduce component-local color, spac - `src/routes/w/[workspaceId]/settings/+layout.svelte`: Settings Sidebar registration. - `src/lib/workspace/header/`: Header frame, context, and overrides. - `src/lib/workspace/sidebar/`: Sidebar frame, scoped content, contexts, and override stack. +- `src/lib/workspace/markdown/`: shared safe Markdown parsing plus presentation-specific document renderers. - `src/lib/workspace/ui/`: generic presentation components such as `Tooltip`, `Bevel`, and `BevelLine`. - `src/routes/design-lab/workspace-web-ux/`: static Design Language showroom. diff --git a/web/workspace/deno.json b/web/workspace/deno.json index f9ea8ec69..d88a8cb70 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@7.2.7 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@2.49.4 sync && deno run -A npm:svelte-check@4.3.4 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts", "test:component": "deno run -A npm:vitest@3.2.4 run --config vitest.config.ts", "build": "deno run -A npm:vite@7.2.7 build", "preview": "deno run -A npm:vite@7.2.7 preview", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index df244651f..428524f66 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) +// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/protocol-validator.ts b/web/workspace/src/lib/generated/protocol-validator.ts index 5a26247bb..34f22a4da 100644 --- a/web/workspace/src/lib/generated/protocol-validator.ts +++ b/web/workspace/src/lib/generated/protocol-validator.ts @@ -21,7 +21,7 @@ const MAX_CORRELATION_ID_BYTES = 128; const MAX_RESOURCE_ID_BYTES = 256; const MAX_WORKER_IDS_PER_SELECTOR = 256; const MAX_REJECTION_MESSAGE_BYTES = 1024; -const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by the TUI when a session starts empty.\n\nBuilt once in the Worker controller from the resolved manifest and\ntransmitted alongside `Event::Snapshot` so clients don't need\ntheir own view of the manifest.","properties":{"context_tokens":{"default":0,"description":"Estimated current session context tokens at connect time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_window":{"default":0,"description":"Model context window in tokens. Always filled by the Worker greeting.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subject_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","subject_revision","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"snapshot_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot_revision","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"subject_revision":{"description":"Producer-owned monotonic revision for this Worker subject.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest revisioned foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","subject_revision","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; +const subscriptionFrameSchema: unknown = {"$defs":{"Alert":{"additionalProperties":false,"description":"User-facing alert emitted from the Worker layer.\n\nThis is a separate channel from `tracing` (developer logs): entries\nhere are assembled explicitly by the Worker when a condition should be\nsurfaced to the person driving the client. Keep messages short and\nhuman-readable.","properties":{"level":{"$ref":"#/$defs/AlertLevel"},"message":{"type":"string"},"source":{"$ref":"#/$defs/AlertSource"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"}},"required":["level","source","message","timestamp_ms"],"type":"object"},"AlertLevel":{"enum":["warn","error"],"type":"string"},"AlertSource":{"enum":["worker","engine","compactor","agents_md"],"type":"string"},"CommandEvent":{"oneOf":[{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"kind":{"const":"started","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_call_id":{"type":["string","null"]}},"required":["kind","command_id","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"kind":{"const":"output","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stream":{"$ref":"#/$defs/CommandStream"}},"required":["kind","command_id","stream","start_offset","end_offset","content","observed_at_ms"],"type":"object"},{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"kind":{"const":"terminal","type":"string"},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"stdout_end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","command_id","status","stdout_end_offset","stderr_end_offset","observed_at_ms"],"type":"object"}]},"CommandSnapshot":{"additionalProperties":false,"properties":{"command_id":{"type":"string"},"exit_code":{"format":"int32","maximum":2147483647.0,"minimum":-2147483648.0,"type":["integer","null"]},"last_output_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"observed_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"status":{"$ref":"#/$defs/CommandStatus"},"stderr":{"$ref":"#/$defs/CommandStreamSlice"},"stdout":{"$ref":"#/$defs/CommandStreamSlice"},"tool_call_id":{"type":["string","null"]}},"required":["command_id","status","started_at_ms","observed_at_ms","stdout","stderr"],"type":"object"},"CommandStatus":{"description":"Live provider-owned command status. These values are operational Console\nstate only and are never appended to Worker history.","enum":["running","completed","failed","timed_out","cancelled"],"type":"string"},"CommandStream":{"enum":["stdout","stderr"],"type":"string"},"CommandStreamSlice":{"additionalProperties":false,"properties":{"content":{"type":"string"},"end_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"start_offset":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"truncated":{"type":"boolean"}},"required":["start_offset","end_offset","content","truncated"],"type":"object"},"CompactionLifecycle":{"additionalProperties":false,"description":"Stable parent-owned lifecycle for one compaction run.","properties":{"compaction_id":{"type":"string"},"ended_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"error":{"type":["string","null"]},"internal_worker":{"anyOf":[{"$ref":"#/$defs/InternalWorkerRef"},{"type":"null"}]},"new_segment_id":{"type":["string","null"]},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"schema_version":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"started_at_ms":{"description":"Milliseconds since the Unix epoch.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/CompactionLifecycleState"},"summary":{"type":["string","null"]}},"required":["schema_version","compaction_id","revision","state","started_at_ms"],"type":"object"},"CompactionLifecycleState":{"enum":["running","done","failed","interrupted"],"type":"string"},"CompactionPhase":{"enum":["preparing","summarizing","committing"],"type":"string"},"CompactionTrigger":{"enum":["manual","pre_run","request_threshold"],"type":"string"},"CompletionEntry":{"additionalProperties":false,"description":"One completion candidate for a prefix query.\n\n`value` is a path (file kind).","properties":{"is_dir":{"default":false,"type":"boolean"},"value":{"type":"string"}},"required":["value","is_dir"],"type":"object"},"CompletionKind":{"description":"Kind of completion requested by `Method::ListCompletions`.\n\nMirrors the completion prefix sigil: `@` → `File`.","enum":["file"],"type":"string"},"ErrorCode":{"enum":["already_running","not_running","not_paused","provider_error","tool_error","invalid_request","internal"],"type":"string"},"Event":{"oneOf":[{"additionalProperties":false,"description":"Durable Submit acceptance. A `Queued` receipt follows the durable FIFO\ncheckpoint. A `Started` receipt follows the pending activation checkpoint;\nthe corresponding [`Event::UserMessage`] marks the later history commit.\nRepeating the same request id and exact payload returns the same receipt\nwithout appending or activating twice.","properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"$ref":"#/$defs/SubmissionDisposition"},"submission_id":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","submission_id","disposition"],"type":"object"},"event":{"const":"submission_accepted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Correlated rejection before durable acceptance.","properties":{"data":{"additionalProperties":false,"properties":{"message":{"type":"string"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","message"],"type":"object"},"event":{"const":"submission_rejected","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Revisioned FIFO replacement following enqueue, activation, cancel, or clear.","properties":{"data":{"additionalProperties":false,"properties":{"pending":{"$ref":"#/$defs/PendingSubmissionsSnapshot"}},"required":["pending"],"type":"object"},"event":{"const":"pending_submissions_changed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A user input message persisted as `LogEntry::AnnotatedUserInput` and\nactivated for a turn. Broadcast to every subscribed client so TUI / GUI\ninstances show the same user line that reconnect snapshots replay.","properties":{"data":{"additionalProperties":false,"properties":{"entry_id":{"description":"Stable durable history identity. Older mixed-version producers omit it.","type":["string","null"]},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"}},"required":["segments"],"type":"object"},"event":{"const":"user_message","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A canonical assistant/tool entry has committed to durable history.\nStreaming events remain the in-flight authority; this event supplies the\nstable identity used to reconcile that live block with paged history.","properties":{"data":{"additionalProperties":false,"properties":{"entry":{"$ref":"#/$defs/SessionSnapshotEntry"}},"required":["entry"],"type":"object"},"event":{"const":"session_entry_committed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One agent-injected system item committed to history.\n\nCarries the JSON form of `session_store::SystemItem`. Covers\n`Method::Notify` echoes, child-Worker lifecycle events from\n`Method::WorkerEvent`, `@` resolution payloads, and any future\nClients dispatch on the `kind` tag for typed rendering instead\nof parsing free-text prefixes like `[Notification] …` or\n`[File: …]`.\n\nOne event per `LogEntry::AnnotatedSystemItem` commit. Disk-side and\nwire-side are 1:1.","properties":{"data":{"additionalProperties":false,"properties":{"item":true},"required":["item"],"type":"object"},"event":{"const":"system_item","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A new self-driving cycle has begun (IDLE → active transition).\n\nMarker event for the start of an Invoke range; the range extends\nimplicitly until the next `InvokeStart`. Fires for every accepted\n`Method::Submit` (kind=`UserSend`), `Method::Notify` (kind=`Notify`),\n`Method::WorkerEvent` re-injection (kind=`WorkerEvent`), and any other\nIDLE-breaking trigger. Mid-run interrupts (e.g. hook output,\ntyped system reminder insertion that doesn't break IDLE) do not\nemit `InvokeStart` — they appear as `SystemItem` only.\n\nCarries `kind` only; the payload (user text / notify message /\nworker event body) is delivered separately via the immediately\nfollowing `UserMessage` / `SystemItem` event.","properties":{"data":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/InvokeKind"}},"required":["kind"],"type":"object"},"event":{"const":"invoke_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One AgentTurn boundary opened. An AgentTurn is a maximal run of\nLLM generation calls whose input messages are identical (i.e.\nretries from network errors / 5xx / stream disconnects collapse\ninto the same AgentTurn). When the input changes (a new tool\nresult lands, a user interrupts, etc.), the next LLM call belongs\nto a new AgentTurn.\n\n`turn` is the AgentTurn index from `Engine::turn_count`.\n\nCurrently retry is not yet implemented (`agen-stream-continuation`)\nso AgentTurn and `LlmCall` fire 1:1, but the contract here is\nthe AgentTurn boundary; consumers that want per-LLM-call signals\nmust subscribe to `LlmCallStart` / `LlmCallEnd` instead.","properties":{"data":{"additionalProperties":false,"properties":{"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn"],"type":"object"},"event":{"const":"turn_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"AgentTurn closed.","properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/TurnResult"},"turn":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["turn","result"],"type":"object"},"event":{"const":"turn_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"One LLM generation call started (1 request → 1 generation, retry\nincluded). Multiple `LlmCall*` pairs may fire inside a single\n`TurnStart` / `TurnEnd` pair when a request is retried; today\nthey fire 1:1 because retry is not implemented.\n\n`llm_call` is the worker-wide running counter from\n`Engine::llm_call_count`.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"LLM generation call ended.","properties":{"data":{"additionalProperties":false,"properties":{"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call"],"type":"object"},"event":{"const":"llm_call_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A transport-level LLM request retry has been scheduled.\n\nThis is operational state for clients to render while the worker is\nwaiting in backoff. It is not part of conversation history.","properties":{"data":{"additionalProperties":false,"properties":{"elapsed_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"error":{"type":"string"},"failed_attempt":{"description":"The attempt that just failed. 1 origin.","format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"status":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":["integer","null"]},"wait_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["llm_call","failed_attempt","max_attempts","wait_ms","elapsed_ms","error"],"type":"object"},"event":{"const":"llm_retry","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Stream generation was interrupted after events had begun and the worker\nis continuing with a follow-up LLM request.","properties":{"data":{"additionalProperties":false,"properties":{"attempt":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"llm_call":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"max_attempts":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"reason":{"type":"string"}},"required":["llm_call","attempt","max_attempts","reason"],"type":"object"},"event":{"const":"llm_continuation","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"text_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A reasoning / thinking block has started.\n\nAlways paired with a `ThinkingDone`. `ThinkingDelta` is optional:\nsome providers (or some configurations) emit thinking metadata\nwithout plaintext, in which case Start → Done arrive with no\ndeltas in between. Multiple thinking blocks per turn are allowed.","properties":{"event":{"const":"thinking_start","type":"string"}},"required":["event"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Thinking block completed. `text` is the full accumulated body\n(empty string when the provider didn't emit plaintext).","properties":{"data":{"additionalProperties":false,"properties":{"text":{"type":"string"}},"required":["text"],"type":"object"},"event":{"const":"thinking_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name"],"type":"object"},"event":{"const":"tool_call_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"id":{"type":"string"},"json":{"type":"string"}},"required":["id","json"],"type":"object"},"event":{"const":"tool_call_args_delta","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}},"required":["id","name","arguments"],"type":"object"},"event":{"const":"tool_call_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"disposition":{"anyOf":[{"$ref":"#/$defs/ToolResultDisposition"},{"type":"null"}]},"id":{"type":"string"},"is_error":{"default":false,"type":"boolean"},"output":{"description":"Full tool output. Absent when the tool chose to return\nsummary-only, or when the result was pruned.","type":["string","null"]},"summary":{"description":"Short human-readable summary. Always present; used by clients\nthat only want a 1-line rendering (e.g. collapsed views).","type":"string"}},"required":["id","summary","is_error"],"type":"object"},"event":{"const":"tool_result","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Token accounting for one LLM request.\n\n`input_tokens` is the prompt prefix occupancy (cache reads /\ncache writes included), as normalised by the worker layer.\n`cache_read_input_tokens` is the cache-hit subset of that\noccupancy; subtracting it yields the \"net upload\" the client\nactually paid full price to send on this request, which is what\nthe TUI status line accumulates per turn.","properties":{"data":{"additionalProperties":false,"properties":{"cache_read_input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"input_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"output_tokens":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]}},"type":"object"},"event":{"const":"usage","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"result":{"$ref":"#/$defs/RunResult"}},"required":["result"],"type":"object"},"event":{"const":"run_end","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/ErrorCode"},"message":{"type":"string"}},"required":["code","message"],"type":"object"},"event":{"const":"error","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Sent exactly once at the start of every client connection.\n\n`entries` is the session-log mirror at subscribe time, serialised\nas the JSON form of `session_store::LogEntry`. This is the\nbulk-reconstruction lane: clients walk the entries to seed their\nderived view.\n\n`greeting` and `status` accompany the snapshot so clients render\nworker identity and current controller state without an extra round\ntrip.\n\nLive updates after the snapshot arrive through the streaming\nevents (`TextDelta` / `ToolCall*` / `ToolResult` / etc.) plus\nrole-specific entry events (`SegmentRotated` / `SystemItem`) —\nthere is no generic \"every committed entry\" broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"greeting":{"$ref":"#/$defs/Greeting"},"in_flight":{"$ref":"#/$defs/InFlightSnapshot","description":"Unfinished model output that has already streamed in the current\nrun but is not yet represented by committed snapshot entries."},"internal_workers":{"description":"Parent-owned Internal Worker sessions visible to this client.\nService-private Internal Workers are deliberately excluded.","items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"Full revisioned live execution state. `Stopped` remains Runtime\ncatalog authority and is deliberately not represented here."}},"required":["session","greeting","state"],"type":"object"},"event":{"const":"snapshot","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A live event from a parent-owned Internal Worker. The payload reuses the\nnormal Worker event vocabulary while the wrapper carries stable origin\nidentity and a per-child revision fence.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","event"],"type":"object"},"event":{"const":"internal_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Terminal removal fence for one parent-owned Internal Worker session.\n\nClients discard the matching child and descendants, then ignore later\nnested events for this identity until an authoritative snapshot replaces\nthe projection.","properties":{"data":{"additionalProperties":false,"properties":{"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision"],"type":"object"},"event":{"const":"internal_worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Server-side segment log rotated to a fresh `SegmentStart`.\n\nFires on compaction and on auto-fork when the store head drifts\nA compaction/fork has replaced the authoritative segment. Clients drop\ntheir derived view and reseed from the canonical committed snapshot.","properties":{"data":{"additionalProperties":false,"properties":{"session":{"$ref":"#/$defs/SessionSnapshot"}},"required":["session"],"type":"object"},"event":{"const":"segment_rotated","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Current Worker controller status. Broadcast on every controller-level\ntransition and included in `History` snapshots for late attach.","properties":{"data":{"additionalProperties":false,"properties":{"snapshot":{"$ref":"#/$defs/WorkerStateSnapshot"}},"required":["snapshot"],"type":"object"},"event":{"const":"worker_state","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"acknowledgement":{"$ref":"#/$defs/WorkerCommandAcknowledgement"}},"required":["acknowledgement"],"type":"object"},"event":{"const":"command_acknowledged","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Bounded, provider-owned command telemetry for the live Console. This is\nintentionally not a history entry and is reconstructed from\n`Snapshot.in_flight.commands` after reconnect.","properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/CommandEvent"}},"required":["event"],"type":"object"},"event":{"const":"command","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListCompletions`. Delivered only to the\nrequesting socket (not broadcast). `entries` is empty when no\ncandidates match or when the requested kind has no resolver\nwired up.","properties":{"data":{"additionalProperties":false,"properties":{"entries":{"items":{"$ref":"#/$defs/CompletionEntry"},"type":"array"},"kind":{"$ref":"#/$defs/CompletionKind"}},"required":["kind","entries"],"type":"object"},"event":{"const":"completions","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListRewindTargets`. Clients should only open a picker\nin response to their own pending request; the event may be broadcast.","properties":{"data":{"additionalProperties":false,"properties":{"head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"targets":{"items":{"$ref":"#/$defs/RewindTarget"},"type":"array"}},"required":["head_entries","targets"],"type":"object"},"event":{"const":"rewind_targets","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"A rewind has truncated the authoritative session. `session` is the\nretained canonical snapshot clients should use to reseed display state.","properties":{"data":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"session":{"$ref":"#/$defs/SessionSnapshot"},"summary":{"$ref":"#/$defs/RewindSummary"}},"required":["session","input","summary"],"type":"object"},"event":{"const":"rewind_applied","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::ListWorkers`. Payload is a stable JSON value so the Worker\ncrate can evolve discovery fields without introducing a protocol\ndependency on session-store.","properties":{"data":{"additionalProperties":false,"properties":{"workers":true},"required":["workers"],"type":"object"},"event":{"const":"workers_listed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RestoreWorker`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"worker_restored","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Reply to `Method::RegisterPeer`.","properties":{"data":{"additionalProperties":false,"properties":{"result":true},"required":["result"],"type":"object"},"event":{"const":"peer_registered","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"$ref":"#/$defs/Alert"},"event":{"const":"alert","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Latest memory extract/consolidation lifecycle event for UI observability.\n\nThis is not part of LLM history or prompt context; clients may display it\nbriefly as operational status.","properties":{"data":{"$ref":"#/$defs/MemoryWorkerEvent"},"event":{"const":"memory_worker","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Runtime-only compaction progress. `None` clears the current status.\nThis never enters Session history and carries no operation or Segment identity.","properties":{"data":{"additionalProperties":false,"properties":{"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}]}},"type":"object"},"event":{"const":"compaction_progress","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Legacy compaction lifecycle event retained for wire read compatibility.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_start","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction completed and the session was rotated.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_done","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"description":"Compaction failed or was cancelled. The session is unchanged.","properties":{"data":{"additionalProperties":false,"properties":{"lifecycle":{"$ref":"#/$defs/CompactionLifecycle"}},"required":["lifecycle"],"type":"object"},"event":{"const":"compact_failed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"event":{"const":"shutdown","type":"string"}},"required":["event"],"type":"object"}]},"EventSubscriptionSelector":{"oneOf":[{"additionalProperties":false,"properties":{"topic":{"const":"runtime_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"properties":{"topic":{"const":"worker_lifecycle","type":"string"},"worker_ids":{"$ref":"#/$defs/SubscriptionWorkerIds"}},"required":["topic","worker_ids"],"type":"object"},{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"topic":{"const":"worker_protocol","type":"string"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["topic","worker_id"],"type":"object"},{"additionalProperties":false,"description":"Server-derived Workspace projection. Workspace identity comes from the\nauthenticated connection and is deliberately absent from this selector.","properties":{"topic":{"const":"workspace_workers","type":"string"}},"required":["topic"],"type":"object"},{"additionalProperties":false,"description":"Typed extension point for the later Workdir subscription slice.","properties":{"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic"],"type":"object"}]},"Greeting":{"additionalProperties":false,"description":"Worker self-description rendered by the TUI when a session starts empty.\n\nBuilt once in the Worker controller from the resolved manifest and\ntransmitted alongside `Event::Snapshot` so clients don't need\ntheir own view of the manifest.","properties":{"context_tokens":{"default":0,"description":"Estimated current session context tokens at connect time.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"context_window":{"default":0,"description":"Model context window in tokens. Always filled by the Worker greeting.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"cwd":{"type":"string"},"model":{"type":"string"},"provider":{"type":"string"},"scope_summary":{"type":"string"},"tools":{"items":{"type":"string"},"type":"array"},"worker_name":{"type":"string"}},"required":["worker_name","cwd","provider","model","scope_summary","tools","context_tokens","context_window"],"type":"object"},"InFlightBlock":{"oneOf":[{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"finished":{"type":"boolean"},"kind":{"const":"thinking","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"args":{"type":"string"},"id":{"type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"state":{"$ref":"#/$defs/InFlightToolCallState"}},"required":["kind","id","name","args"],"type":"object"}]},"InFlightCompaction":{"additionalProperties":false,"properties":{"phase":{"$ref":"#/$defs/CompactionPhase"},"started_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"trigger":{"$ref":"#/$defs/CompactionTrigger"}},"required":["phase","started_at_ms","trigger"],"type":"object"},"InFlightSnapshot":{"additionalProperties":false,"description":"Unfinished model output and active command state included in\n`Event::Snapshot` for clients that attach while work is still streaming.\n\nThese blocks are presentation state only: they are reconstructed from the\nactive Worker controller and must not be treated as committed assistant\nhistory. Finalized assistant items continue to come from ordinary snapshot\nentries.","properties":{"blocks":{"items":{"$ref":"#/$defs/InFlightBlock"},"type":"array"},"commands":{"items":{"$ref":"#/$defs/CommandSnapshot"},"type":"array"},"compaction":{"anyOf":[{"$ref":"#/$defs/InFlightCompaction"},{"type":"null"}],"description":"The currently running compaction, if any.\n\nThis is lifecycle progress only. Candidate history and the staged\nSegment remain private until the Segment is activated atomically."}},"type":"object"},"InFlightToolCallState":{"enum":["pending","streaming_args","done"],"type":"string"},"InternalWorkerKind":{"description":"Presentation category for an Internal Worker exposed through its parent's\nprotocol stream. Internal Workers never become independently addressable\nprotocol subjects.","oneOf":[{"enum":["sub_worker"],"type":"string"},{"additionalProperties":false,"properties":{"service":{"additionalProperties":false,"properties":{"kind":{"type":"string"}},"required":["kind"],"type":"object"}},"required":["service"],"type":"object"}]},"InternalWorkerRef":{"additionalProperties":false,"description":"Stable presentation identity for one parent-owned Internal Worker session.\n`name` is display-only; `session_id` is the identity used by clients.","properties":{"kind":{"$ref":"#/$defs/InternalWorkerKind"},"name":{"type":"string"},"parent_session_id":{"type":["string","null"]},"session_id":{"type":"string"}},"required":["session_id","name","kind"],"type":"object"},"InternalWorkerSnapshot":{"additionalProperties":false,"description":"Reconnect state for one visible Internal Worker. The revision fences live\n`Event::InternalWorker` updates that raced with parent snapshot assembly.","properties":{"error":{"type":["string","null"]},"in_flight":{"$ref":"#/$defs/InFlightSnapshot"},"internal_workers":{"items":{"$ref":"#/$defs/InternalWorkerSnapshot"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"session":{"$ref":"#/$defs/SessionSnapshot"},"status":{"$ref":"#/$defs/WorkerStatus","default":"idle"},"worker":{"$ref":"#/$defs/InternalWorkerRef"}},"required":["worker","revision","session","status"],"type":"object"},"InvokeKind":{"description":"Kind of trigger that opened a new Invoke (IDLE → active) range.\n\nOne Invoke groups all entries from this trigger up to the next\n`Invoke` marker. The kind is the only payload — content (user text,\nnotify message, worker event body) is delivered by the immediately\nfollowing Turn entry, not by the marker itself.","oneOf":[{"const":"user_send","description":"`Method::Submit` — a user submission.","type":"string"},{"const":"notify","description":"`Method::Notify` — free-text notification injected into history.","type":"string"},{"const":"worker_event","description":"`Method::WorkerEvent` — typed lifecycle report from a child Worker.","type":"string"},{"const":"system_reminder","description":"A typed system reminder that crosses an IDLE boundary (mid-run\nreminders that don't break IDLE are SystemItem-only and do not\nopen a new Invoke).","type":"string"},{"const":"wakeup","description":"Cron / RemoteTrigger style scheduled wake-up. Reserved; no\nproducer is wired today but the variant is part of the initial\nset so future schedulers don't have to amend the wire enum.","type":"string"}]},"MemoryWorkerEvent":{"additionalProperties":false,"properties":{"message":{"description":"Human-readable compact form for actionbar rendering.","type":"string"},"reason":{"type":"string"},"run_id":{"type":"string"},"status":{"type":"string"},"timestamp_ms":{"description":"Milliseconds since the Unix epoch.","format":"int64","maximum":9007199254740991.0,"minimum":-9007199254740991.0,"type":"integer"},"trigger":{"type":"string"},"worker":{"type":"string"}},"required":["worker","status","run_id","trigger","reason","message","timestamp_ms"],"type":"object"},"Method":{"oneOf":[{"additionalProperties":false,"description":"Durably accept typed input for immediate activation or the session FIFO.\nRunning or Paused Workers queue Submit for a later turn; use Notify for\nadvisory information to incorporate into work already in progress.\n\n`submission_request_id` is generated by the authenticated caller and is\nused only for idempotent retry. Worker allocates the durable\n`submission_id` returned by [`Event::SubmissionAccepted`].","properties":{"method":{"const":"submit","type":"string"},"params":{"additionalProperties":false,"properties":{"input":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"submission_request_id":{"type":"string"}},"required":["submission_request_id","input"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Human-readable text injected into the target Worker's LLM context as a\nnon-blocking system message. Accepted notifications are delivered at the\nnext between-turn boundary, or start a coalesced notification run while\nthe Worker is idle. No side effects beyond LLM context; use\n`WorkerEvent` for typed lifecycle reports.","properties":{"method":{"const":"notify","type":"string"},"params":{"additionalProperties":false,"properties":{"message":{"type":"string"},"notification_request_id":{"type":"string"}},"required":["notification_request_id","message"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Typed lifecycle report from a child Worker to its direct parent.","properties":{"method":{"const":"worker_event","type":"string"},"params":{"$ref":"#/$defs/WorkerEvent"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Return the authoritative FIFO summary without exposing queued payloads.","properties":{"method":{"const":"list_pending_submissions","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Remove one queued submission. Running or already activated submissions\nare immutable and therefore cannot be cancelled here.","properties":{"method":{"const":"cancel_pending_submission","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Remove every queued submission while preserving the active run.","properties":{"method":{"const":"clear_pending_submissions","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Activate the next queued submission while the Worker is idle. This is an\nexplicit recovery operation and never resumes a paused run implicitly.","properties":{"method":{"const":"continue_pending","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_id":{"type":"string"},"expected_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["expected_revision","expected_head_id"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"resume","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"cancel","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Stop the in-flight turn and transition to `Paused`.\n\nUnlike `Cancel` (which discards and returns to `Idle`), a paused\nWorker can resume the interrupted work via `Resume`, or accept a\nfresh `Submit` (orphan `tool_use` items are closed with a\nsynthetic tool result before the new user message is appended).","properties":{"method":{"const":"pause","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request an explicit compaction while the Worker is otherwise idle.\n\nThis is a typed control method: clients must not send `compact` as a\n`Method::Submit` user message.","properties":{"method":{"const":"compact","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Ask the Worker to list valid rewind targets from its authoritative session log.","properties":{"method":{"const":"list_rewind_targets","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Truncate the current session back to the selected rewind target and\nreturn the selected user input to the client composer.","properties":{"method":{"const":"rewind_to","type":"string"},"params":{"additionalProperties":false,"properties":{"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"target":{"$ref":"#/$defs/RewindTargetId"}},"required":["target","expected_head_entries"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"shutdown","type":"string"},"params":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandEnvelope"}},"required":["command"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Request a list of completion candidates from the Worker.\n\nReply is sent on the same socket as `Event::Completions` (not\nbroadcast). The IPC server handles this directly and writes\nthe response straight back to the requesting socket.","properties":{"method":{"const":"list_completions","type":"string"},"params":{"additionalProperties":false,"properties":{"kind":{"$ref":"#/$defs/CompletionKind"},"prefix":{"type":"string"}},"required":["kind","prefix"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"List Workers visible to this Worker from durable Worker state and the spawned-child\nregistry. This is not a host-wide Worker universe query.","properties":{"method":{"const":"list_workers","type":"string"}},"required":["method"],"type":"object"},{"additionalProperties":false,"description":"Restore a visible stopped/restorable Worker, or report that it is already\nlive. Missing state and not-visible state are distinct errors.","properties":{"method":{"const":"restore_worker","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"description":"Register another existing Worker as a reciprocal peer of this Worker.\n\nThis is metadata/control state only: it does not ask the target's live\ncontroller for consent, and it must not grant delegated scope,\nspawned-child ownership, output cursors, or child lifecycle authority.","properties":{"method":{"const":"register_peer","type":"string"},"params":{"additionalProperties":false,"properties":{"name":{"type":"string"}},"required":["name"],"type":"object"}},"required":["method","params"],"type":"object"}]},"PasteArtifactAvailability":{"enum":["available","unavailable","integrity_failed"],"type":"string"},"PasteArtifactMediaType":{"description":"One typed piece of a user submission.\n\n`Method::Submit` and `Event::UserMessage` carry `Vec`. Dumb\nclients (CLI piping, scripts) only need to produce a single\n`Segment::Text`; richer clients (TUI / GUI) construct typed atoms\n(paste chips, file refs) and\nsend them through directly so the Worker side never has to re-parse a\nflattened string.\n\nForward compat: payloads with unknown `kind` deserialize to\n`Segment::Unknown`. Worker treats this the same as known-but-unresolved\nvariants — emits an alert and inserts a `[unknown input segment]`\nplaceholder into the LLM context so neither user nor LLM is blind to\nthe dropped intent.","enum":["text_plain_utf8"],"type":"string"},"PasteArtifactRef":{"additionalProperties":false,"description":"Session-owned reference to a large pasted-input artifact.\n\nThe reference contains only bounded integrity and provenance metadata. The\nartifact body remains in session storage and is available to the model only\nthrough the scoped paste-artifact tools installed by Worker.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/PasteArtifactAvailability","description":"Availability observed when this immutable reference was committed.\nReads revalidate storage and integrity rather than trusting this field."},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"char_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"line_count":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"media_type":{"$ref":"#/$defs/PasteArtifactMediaType"},"sha256":{"type":"string"},"source_entry_id":{"type":"string"}},"required":["artifact_id","created_at_ms","media_type","availability","byte_len","char_count","line_count","sha256","source_entry_id"],"type":"object"},"PendingSubmissionSummary":{"additionalProperties":false,"description":"Bounded public projection of one pending submission. Only a short display\npreview is exposed; full payload segments and provenance remain in the log.","properties":{"accepted_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"preview":{"description":"Single-line display text, at most 240 Unicode scalars plus an ellipsis.\nAbsent on older runtimes. Never used to reconstruct the submitted input.","type":["string","null"]},"segment_count":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"submission_id":{"type":"string"}},"required":["submission_id","accepted_at_ms","segment_count","byte_len"],"type":"object"},"PendingSubmissionsSnapshot":{"additionalProperties":false,"description":"Revisioned session-owned FIFO projection used by snapshots and live events.","properties":{"head_id":{"default":null,"type":["string","null"]},"notification_count":{"default":0,"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"notification_previews":{"description":"Ordered display-only previews of waiting notifications, each bounded like\nPendingSubmissionSummary::preview. Older runtimes omit this field.","items":{"type":"string"},"type":"array"},"revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"submissions":{"default":[],"items":{"$ref":"#/$defs/PendingSubmissionSummary"},"type":"array"}},"required":["revision","head_id","notification_count","submissions"],"type":"object"},"Permission":{"description":"Permission lattice used by [`ScopeRule`].\n\nThe derived `Ord` instance follows declaration order, so\n`Read < Write`. Allow rules grant the stated level (and by extension\neverything below); deny rules cap the effective level **strictly\nbelow** the stated level.","enum":["read","write"],"type":"string"},"RewindSummary":{"additionalProperties":false,"properties":{"discarded_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"tool_side_effect_warning":{"type":"boolean"},"truncated_to_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["truncated_to_entries","discarded_entries","tool_side_effect_warning"],"type":"object"},"RewindTarget":{"additionalProperties":false,"properties":{"disabled_reason":{"type":["string","null"]},"eligible":{"type":"boolean"},"expected_head_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"id":{"$ref":"#/$defs/RewindTargetId"},"preview":{"type":"string"},"timestamp_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":["integer","null"]},"truncate_entries":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"turn_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"warning":{"type":["string","null"]}},"required":["id","expected_head_entries","truncate_entries","turn_index","preview","eligible"],"type":"object"},"RewindTargetId":{"additionalProperties":false,"properties":{"segment_id":{"type":"string"},"user_input_entry_index":{"format":"uint","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["segment_id","user_input_entry_index"],"type":"object"},"RunResult":{"oneOf":[{"enum":["finished","paused","limit_reached"],"type":"string"},{"const":"rolled_back","description":"The accepted Method::Submit produced no assistant/tool output before\nuser interruption, so the Worker rolled the submit-time turn state back\nto its pre-submit snapshot. Clients should treat the Worker as Idle and\nrestore the just-submitted input into the editable composer if desired.","type":"string"}]},"ScopeRule":{"additionalProperties":false,"description":"A single allow or deny rule inside a scope configuration.","properties":{"permission":{"$ref":"#/$defs/Permission","description":"Permission level this rule grants (allow) or caps strictly below\n(deny)."},"recursive":{"default":true,"description":"When `false`, the rule only matches the target itself and its\ndirect children. Defaults to `true`.","type":"boolean"},"symlink_policy":{"$ref":"#/$defs/SymlinkPolicy","default":"resolved","description":"Which path identity an allow rule uses when symbolic links are\nencountered. Deny rules always inspect both identities."},"target":{"description":"Target path. Must be absolute by the time a `Scope` is built from\nthis rule — relative paths are resolved per-layer against the\nmanifest file's directory (cwd for overlay layers) before cascade\nmerge.","type":"string"}},"required":["target","permission","recursive","symlink_policy"],"type":"object"},"Segment":{"oneOf":[{"additionalProperties":false,"description":"Free-form text. The fallback every client can produce.","properties":{"content":{"type":"string"},"kind":{"const":"text","type":"string"}},"required":["kind","content"],"type":"object"},{"additionalProperties":false,"description":"Bracketed-paste capture from a TUI-style client. `id`, `chars`\nand `lines` carry the metadata needed to re-render a\n`[Clipboard #N | X chars, Y lines]` chip in `Event::UserMessage`\nre-broadcast.","properties":{"chars":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"content":{"type":"string"},"id":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"},"kind":{"const":"paste","type":"string"},"lines":{"format":"uint32","maximum":4294967295.0,"minimum":0.0,"type":"integer"}},"required":["kind","id","chars","lines","content"],"type":"object"},{"additionalProperties":false,"description":"Internal reference produced when Worker stores a large `Paste` before\ncommitting input. Clients may receive this in history/event projections;\nthe body is intentionally absent.","properties":{"artifact":{"$ref":"#/$defs/PasteArtifactRef"},"kind":{"const":"paste_artifact","type":"string"}},"required":["kind","artifact"],"type":"object"},{"additionalProperties":false,"description":"Client-local file uploaded into the owning Worker session before submit.\nThe Session Log stores only this immutable reference, never file bytes or\nthe client's local path.","properties":{"file":{"$ref":"#/$defs/UploadedFileRef"},"kind":{"const":"uploaded_file","type":"string"}},"required":["kind","file"],"type":"object"},{"additionalProperties":false,"description":"`@` file-system reference. Worker resolves readable files to\n`[File: ]` attachments and readable normal directories to shallow\n`[Dir: ]` listings; the flattened user text keeps the literal\n`@` placeholder either way.","properties":{"kind":{"const":"file_ref","type":"string"},"path":{"type":"string"}},"required":["kind","path"],"type":"object"},{"additionalProperties":false,"description":"Source-qualified request for the host to start a Flow before committing\nthis user input. Runtime resolves it through Workspace authority and\nreplaces it with the entered state's instructions before Worker-side\ninput resolution.","properties":{"kind":{"const":"flow","type":"string"},"selector":{"type":"string"}},"required":["kind","selector"],"type":"object"},{"additionalProperties":false,"description":"Unknown variant from a newer client. Worker treats this as an\nunresolved input — surfaces an alert and inserts a placeholder.\nRound-trip is lossy: re-serializing yields `{\"kind\":\"unknown\"}`.","properties":{"kind":{"const":"unknown","type":"string"}},"required":["kind"],"type":"object"}]},"SessionContentPart":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"text","type":"string"},"text":{"type":"string"}},"required":["kind","text"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"refusal","type":"string"},"refusal":{"type":"string"}},"required":["kind","refusal"],"type":"object"}]},"SessionEntryProvenance":{"enum":["human_input","worker_input","flow_instruction","backend_instruction","model_output","tool_output","derived_summary","legacy_unknown"],"type":"string"},"SessionMessageRole":{"enum":["user","assistant"],"type":"string"},"SessionSnapshot":{"additionalProperties":false,"description":"Canonical, storage-independent projection of committed session history.\n\nWorker protocols expose this DTO instead of append-log records. New\nstorage variants can therefore be added without teaching every client how\nto replay the durable log format.","properties":{"entries":{"items":{"$ref":"#/$defs/SessionSnapshotEntry"},"type":"array"},"pending_submissions":{"$ref":"#/$defs/PendingSubmissionsSnapshot","default":{"head_id":null,"notification_count":0,"revision":0,"submissions":[]}}},"required":["entries","pending_submissions"],"type":"object"},"SessionSnapshotEntry":{"oneOf":[{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"user_input","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"segments":{"items":{"$ref":"#/$defs/Segment"},"type":"array"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","segments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"items":{"$ref":"#/$defs/SessionContentPart"},"type":"array"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"message","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"role":{"$ref":"#/$defs/SessionMessageRole"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","role","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"arguments":{"type":"string"},"call_id":{"type":"string"},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"tool_call","type":"string"},"name":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","name","arguments","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"attachments":{"items":{"$ref":"#/$defs/SessionToolAttachment"},"type":"array"},"call_id":{"type":"string"},"content":{"type":["string","null"]},"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"is_error":{"type":"boolean"},"kind":{"const":"tool_result","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"summary":{"type":"string"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","call_id","summary","is_error","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"content":{"type":"string"},"data":true,"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"item_kind":{"type":"string"},"kind":{"const":"system_item","type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","item_kind","content","entry_id","timestamp","provenance"],"type":"object"},{"additionalProperties":false,"properties":{"derived_from":{"items":{"type":"string"},"type":"array"},"entry_id":{"description":"Stable identity from durable history metadata, or a deterministic\nidentity derived from the legacy segment and log position.","type":"string"},"kind":{"const":"run_error","type":"string"},"message":{"type":"string"},"provenance":{"$ref":"#/$defs/SessionEntryProvenance"},"timestamp":{"description":"Timestamp copied from the durable log record that commits this entry.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["kind","message","entry_id","timestamp","provenance"],"type":"object"}]},"SessionToolAttachment":{"additionalProperties":false,"properties":{"data_base64":{"description":"Base64-encoded durable attachment body. Public snapshots preserve the\ncommitted multimodal value instead of replacing it with placeholder text.","type":"string"},"media_type":{"type":"string"}},"required":["media_type","data_base64"],"type":"object"},"SubmissionDisposition":{"description":"Durable acceptance result for one idempotent Submit request.","oneOf":[{"enum":["started"],"type":"string"},{"const":"queued","description":"Input is durably held in the session FIFO until eligible for activation.","type":"string"}]},"SubscriptionEvent":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"payload":{"$ref":"#/$defs/SubscriptionEventPayload"},"subject_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","subject_revision","payload"],"type":"object"},"event":{"const":"event","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionTerminationCode"},"message":{"type":"string"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","code","message"],"type":"object"},"event":{"const":"subscription_closed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionEventPayload":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"worker":{"$ref":"#/$defs/SubscriptionWorker"}},"required":["worker"],"type":"object"},"event":{"const":"worker_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"runtime_id":{"type":["string","null"]},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id"],"type":"object"},"event":{"const":"worker_removed","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"event":{"$ref":"#/$defs/Event"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","event"],"type":"object"},"event":{"const":"worker_protocol","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdir":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"}},"required":["workdir"],"type":"object"},"event":{"const":"workdir_upserted","type":"string"}},"required":["event","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id"],"type":"object"},"event":{"const":"workdir_removed","type":"string"}},"required":["event","data"],"type":"object"}]},"SubscriptionId":{"type":"string"},"SubscriptionRejectionCode":{"enum":["invalid_request","unsupported_protocol_version","unsupported_selector","unauthorized","resource_not_found","capacity_exceeded","internal"],"type":"string"},"SubscriptionRequest":{"oneOf":[{"additionalProperties":false,"properties":{"method":{"const":"subscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"}},"required":["request_id","selector"],"type":"object"}},"required":["method","params"],"type":"object"},{"additionalProperties":false,"properties":{"method":{"const":"unsubscribe_events","type":"string"},"params":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"}},"required":["method","params"],"type":"object"}]},"SubscriptionRequestId":{"type":"string"},"SubscriptionResponse":{"oneOf":[{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"selector":{"$ref":"#/$defs/EventSubscriptionSelector"},"snapshot":{"$ref":"#/$defs/SubscriptionSnapshot"},"snapshot_revision":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id","selector","snapshot_revision","snapshot"],"type":"object"},"result":{"const":"subscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["request_id","subscription_id"],"type":"object"},"result":{"const":"unsubscribed","type":"string"}},"required":["result","payload"],"type":"object"},{"additionalProperties":false,"properties":{"payload":{"additionalProperties":false,"properties":{"code":{"$ref":"#/$defs/SubscriptionRejectionCode"},"message":{"type":"string"},"request_id":{"$ref":"#/$defs/SubscriptionRequestId"},"subscription_id":{"anyOf":[{"$ref":"#/$defs/SubscriptionId"},{"type":"null"}]}},"required":["request_id","code","message"],"type":"object"},"result":{"const":"subscription_rejected","type":"string"}},"required":["result","payload"],"type":"object"}]},"SubscriptionSnapshot":{"oneOf":[{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workers":{"items":{"$ref":"#/$defs/SubscriptionWorker"},"type":"array"}},"required":["workers"],"type":"object"},"topic":{"const":"workers","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"events":{"items":{"$ref":"#/$defs/Event"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"}},"required":["worker_id","events"],"type":"object"},"topic":{"const":"worker_protocol","type":"string"}},"required":["topic","data"],"type":"object"},{"additionalProperties":false,"properties":{"data":{"additionalProperties":false,"properties":{"workdirs":{"items":{"$ref":"#/$defs/WorkspaceSubscriptionWorkdir"},"type":"array"}},"required":["workdirs"],"type":"object"},"topic":{"const":"workspace_workdirs","type":"string"}},"required":["topic","data"],"type":"object"}]},"SubscriptionTerminationCode":{"enum":["lagged","resource_gone","unauthorized","server_shutdown"],"type":"string"},"SubscriptionWorkdirId":{"type":"string"},"SubscriptionWorker":{"additionalProperties":false,"properties":{"availability":{"$ref":"#/$defs/SubscriptionWorkerAvailability","default":"observed","description":"Freshness of the Runtime-backed observation carried by this projection.\n`Unavailable` preserves catalog membership without claiming that execution stopped."},"display_name":{"type":["string","null"]},"has_running_internal_workers":{"default":false,"type":"boolean"},"profile":{"type":["string","null"]},"resource_key":{"description":"Workspace-scoped canonical resource key. Runtime producers leave this unset;\nWorkspace-facing projections must populate it before publishing the Worker.","type":["string","null"]},"runtime_id":{"description":"Set by the Workspace Server when projecting a Runtime-owned Worker to clients.\nRuntime producers leave this unset because the connection identifies the Runtime.","type":["string","null"]},"state":{"$ref":"#/$defs/SubscriptionWorkerState","description":"Runtime catalog lifecycle compatibility projection; not foreground-state authority."},"subject_revision":{"description":"Producer-owned monotonic revision for this Worker subject.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"workdir_attachments":{"items":{"$ref":"#/$defs/SubscriptionWorkerWorkdirAttachment"},"type":"array"},"worker_id":{"$ref":"#/$defs/SubscriptionWorkerId"},"worker_state":{"anyOf":[{"$ref":"#/$defs/WorkerStateSnapshot"},{"type":"null"}],"description":"Latest revisioned foreground state observed from the Worker. This remains\nabsent until an authoritative Worker snapshot/event has been applied."},"workspace_id":{"type":["string","null"]}},"required":["worker_id","subject_revision","state","availability","has_running_internal_workers"],"type":"object"},"SubscriptionWorkerAvailability":{"description":"Whether the Workspace Server has a current Runtime observation for a Worker.\n\n`Unavailable` deliberately does not imply `Stopped`: it means the last observed\nlifecycle state is retained while the Runtime observation source is unavailable.","enum":["observed","unavailable"],"type":"string"},"SubscriptionWorkerId":{"type":"string"},"SubscriptionWorkerIds":{"description":"A bounded, canonical worker-id set. Ordering on the wire and in equality/hash\nsemantics is lexical, so equivalent selectors aggregate to one upstream key.","items":{"$ref":"#/$defs/SubscriptionWorkerId"},"type":"array"},"SubscriptionWorkerProtocolMethod":{"additionalProperties":false,"properties":{"method":{"$ref":"#/$defs/Method"},"subscription_id":{"$ref":"#/$defs/SubscriptionId"}},"required":["subscription_id","method"],"type":"object"},"SubscriptionWorkerState":{"enum":["idle","running","paused","stopped"],"type":"string"},"SubscriptionWorkerWorkdirAttachment":{"additionalProperties":false,"properties":{"alias":{"description":"Stable Worker-local routing alias.","type":"string"},"repository_key":{"type":["string","null"]},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["alias","working_directory_id"],"type":"object"},"SymlinkPolicy":{"description":"Symbolic-link identity used by one filesystem allow rule.\n\n`Resolved` is the least authority and the default: access is matched\nagainst the provider-resolved target. `Logical` intentionally grants the\npath as presented through the Workdir, even when it aliases another target.","enum":["resolved","logical"],"type":"string"},"ToolResultDisposition":{"enum":["success","error","interrupted","cancelled","outcome_unknown"],"type":"string"},"TurnResult":{"enum":["finished","paused"],"type":"string"},"UploadedFileAvailability":{"description":"Availability recorded for an uploaded client-local file.","enum":["available","unavailable","integrity_failed"],"type":"string"},"UploadedFileRef":{"additionalProperties":false,"description":"Session-owned immutable reference to a client-local uploaded file.\n\nUpload transports return an unbound reference. Worker fills\n`source_entry_id` immediately before the containing user input is committed;\ncommitted Session Log and public snapshot records therefore always retain\nthe durable source-entry identity without storing the file body.","properties":{"artifact_id":{"type":"string"},"availability":{"$ref":"#/$defs/UploadedFileAvailability"},"byte_len":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"created_at_ms":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"file_name":{"type":"string"},"media_type":{"type":"string"},"sha256":{"type":"string"},"source_entry_id":{"type":["string","null"]}},"required":["artifact_id","file_name","media_type","created_at_ms","availability","byte_len","sha256"],"type":"object"},"WorkerBusyState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"run","type":"string"},"state":{"$ref":"#/$defs/WorkerRunState"}},"required":["kind","state"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"maintenance","type":"string"},"state":{"$ref":"#/$defs/WorkerMaintenanceState"}},"required":["kind","state"],"type":"object"}]},"WorkerCommandAcknowledgement":{"additionalProperties":false,"properties":{"command":{"$ref":"#/$defs/WorkerCommandKind"},"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"disposition":{"$ref":"#/$defs/WorkerCommandDisposition"},"state":{"$ref":"#/$defs/WorkerStateSnapshot","description":"The complete authoritative state observed after command admission."}},"required":["command_id","command","disposition","state"],"type":"object"},"WorkerCommandDisposition":{"enum":["accepted","stale_command_id","conflict","invalid_state"],"type":"string"},"WorkerCommandEnvelope":{"additionalProperties":false,"description":"Caller-owned identity for one state-changing Worker command.\n\nA controller accepts command ids in strictly increasing order. Exact retries\nof an accepted id must retain the same command kind.","properties":{"command_id":{"format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"}},"required":["command_id"],"type":"object"},"WorkerCommandKind":{"enum":["resume","cancel","pause","compact","shutdown"],"type":"string"},"WorkerEvent":{"description":"Typed lifecycle events sent from a child Worker to its parent.\n\nDelivered as `Method::WorkerEvent` over the parent's Unix socket. The\nparent Controller always applies variant-specific side effects\n(registry / worker-allocation updates). Agent-visible variants are also\nqueued into the notification buffer; control-plane-only variants are\nnot injected into the parent's LLM context.\n\nTransport is fire-and-forget; receivers must tolerate out-of-order\ndelivery (e.g. `TurnEnded` arriving after `ShutDown` for the same\nchild Worker).","oneOf":[{"additionalProperties":false,"description":"Child finished one turn and is back to IDLE.","properties":{"kind":{"const":"turn_ended","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Engine execution error occurred inside the child's turn.\n\nLimited to worker runtime failures (provider / tool errors) —\ndoes not include transient method-rejection responses such as\n`AlreadyRunning`.","properties":{"kind":{"const":"errored","type":"string"},"message":{"type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name","message"],"type":"object"},{"additionalProperties":false,"description":"Child has stopped (controller loop is exiting).","properties":{"kind":{"const":"shut_down","type":"string"},"worker_name":{"type":"string"}},"required":["kind","worker_name"],"type":"object"},{"additionalProperties":false,"description":"Child SubWorker sub-delegated scope to a grandchild SubWorker via `SubWorkerSpawn`.\n\nControl-plane only: receivers apply registry side effects and\npropagate upward, but do not expose this as an agent notification.\n\nThe parent uses this to add the grandchild to its own\n`spawned_workers.json` so it can manage the grandchild directly\neven if the intermediate child dies. The parent then re-fires\nthis event upward (if it has a parent of its own) to maintain\nthe chain to root.","properties":{"kind":{"const":"scope_sub_delegated","type":"string"},"parent_worker":{"description":"Sub-delegating Worker (= the sender itself).","type":"string"},"scope":{"description":"Scope delegated to the grandchild.","items":{"$ref":"#/$defs/ScopeRule"},"type":"array"},"sub_socket":{"description":"Unix-socket path where the grandchild is reachable.","type":"string"},"sub_worker":{"description":"Name of the grandchild Worker.","type":"string"}},"required":["kind","parent_worker","sub_worker","sub_socket","scope"],"type":"object"}]},"WorkerMaintenanceState":{"enum":["compacting"],"type":"string"},"WorkerRunState":{"enum":["running","pausing","paused","cancelling"],"type":"string"},"WorkerState":{"oneOf":[{"additionalProperties":false,"properties":{"kind":{"const":"idle","type":"string"}},"required":["kind"],"type":"object"},{"additionalProperties":false,"properties":{"kind":{"const":"busy","type":"string"},"state":{"$ref":"#/$defs/WorkerBusyState"}},"required":["kind","state"],"type":"object"}]},"WorkerStateSnapshot":{"additionalProperties":false,"properties":{"last_command_id":{"description":"Highest lifecycle command id observed by this controller instance.","format":"uint64","maximum":9007199254740991.0,"minimum":0.0,"type":"integer"},"state":{"$ref":"#/$defs/WorkerState"}},"required":["last_command_id","state"],"type":"object"},"WorkerStatus":{"enum":["idle","running","paused","stopped"],"type":"string"},"WorkspaceSubscriptionWorkdir":{"additionalProperties":false,"description":"Workspace-facing Workdir summary. Backend-generated Repository UUIDs never\nenter this DTO; Workspace Server must resolve the required Repository key.","properties":{"repository_key":{"type":"string"},"state":{"type":"string"},"working_directory_id":{"$ref":"#/$defs/SubscriptionWorkdirId"}},"required":["working_directory_id","repository_key","state"],"type":"object"}},"$schema":"https://json-schema.org/draft/2020-12/schema","description":"Direction is explicit at the outer envelope, while concrete message kinds\nremain typed within each lane.","oneOf":[{"additionalProperties":false,"properties":{"frame":{"const":"request","type":"string"},"message":{"$ref":"#/$defs/SubscriptionRequest"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"response","type":"string"},"message":{"$ref":"#/$defs/SubscriptionResponse"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"event","type":"string"},"message":{"$ref":"#/$defs/SubscriptionEvent"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"},{"additionalProperties":false,"properties":{"frame":{"const":"worker_protocol","type":"string"},"message":{"$ref":"#/$defs/SubscriptionWorkerProtocolMethod"},"protocol_version":{"format":"uint16","maximum":65535.0,"minimum":0.0,"type":"integer"}},"required":["frame","message","protocol_version"],"type":"object"}],"title":"SubscriptionFrame"}; export const RUST_SERIALIZED_SUBSCRIPTION_FRAME_FIXTURES: readonly unknown[] = [{"protocol_version":1,"frame":"response","message":{"result":"subscribed","payload":{"request_id":"fixture-request","subscription_id":"fixture-subscription","selector":{"topic":"workspace_workers"},"snapshot_revision":1,"snapshot":{"topic":"workers","data":{"workers":[{"worker_id":"fixture-worker","runtime_id":"fixture-runtime","resource_key":"fixture-worker-resource","availability":"observed","subject_revision":1,"state":"idle","has_running_internal_workers":false,"workspace_id":"fixture-workspace","workdir_attachments":[{"alias":"external","working_directory_id":"fixture-external-workdir"}]}]}}}}},{"protocol_version":1,"frame":"event","message":{"event":"event","data":{"subscription_id":"fixture-subscription","subject_revision":2,"payload":{"event":"worker_protocol","data":{"worker_id":"fixture-worker","event":{"event":"pending_submissions_changed","data":{"pending":{"revision":0,"notification_count":0,"head_id":null,"submissions":[]}}}}}}}}]; const utf8 = new TextEncoder(); diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index ba99a1073..5f1a8d336 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -149,6 +149,33 @@ entry_id: string, */ timestamp: number, provenance: SessionEntryProvenance, derived_from?: Array, } & ({ "kind": "user_input", segments: Array, } | { "kind": "message", role: SessionMessageRole, content: Array, } | { "kind": "tool_call", call_id: string, name: string, arguments: string, } | { "kind": "tool_result", call_id: string, summary: string, content?: string | null, is_error: boolean, attachments?: Array, } | { "kind": "system_item", item_kind: string, content: string, data?: unknown, } | { "kind": "run_error", message: string, }); +export type SessionConversationTurn = { turn_id: string, entries: Array, }; + +export type SessionHistoryLineageBoundary = { lineage_id: string, +/** + * Canonical public form of the final user-visible turn at the exact + * provider boundary. Entries after that boundary are excluded. + */ +adopted_through_turn?: SessionConversationTurn | null, }; + +export type SessionHistoryPage = { session_id: string, +/** + * Stable identity of the adopted active lineage used to fence stale pages. + */ +lineage_id: string, +/** + * Older lineage identities that the active lineage fully adopted through + * one or more consecutive Compact rotations. Clients may retain already + * loaded turns when their prior lineage appears here, while replacing the + * cursor with this page's boundary. + */ +compact_ancestor_lineage_ids?: Array, +/** + * Immediate parent relationship used to retain only the adopted prefix + * across fork/rewind refreshes that have no newest-page overlap. + */ +parent_lineage?: SessionHistoryLineageBoundary | null, turns: Array, next_cursor?: string | null, has_more: boolean, }; + export type PendingSubmissionSummary = { submission_id: string, /** * Single-line display text, at most 240 Unicode scalars plus an ellipsis. @@ -306,7 +333,11 @@ export type SubscriptionFrame = { protocol_version: number, } & ({ "frame": "req export type Method = { "method": "submit", "params": { submission_request_id: string, input: Array, } } | { "method": "notify", "params": { notification_request_id: string, message: string, } } | { "method": "worker_event", "params": WorkerEvent } | { "method": "list_pending_submissions" } | { "method": "cancel_pending_submission", "params": { submission_id: string, expected_revision: number, } } | { "method": "clear_pending_submissions", "params": { expected_revision: number, } } | { "method": "continue_pending", "params": { expected_revision: number, expected_head_id: string, } } | { "method": "resume", "params": { command: WorkerCommandEnvelope, } } | { "method": "cancel", "params": { command: WorkerCommandEnvelope, } } | { "method": "pause", "params": { command: WorkerCommandEnvelope, } } | { "method": "compact", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_rewind_targets" } | { "method": "rewind_to", "params": { target: RewindTargetId, expected_head_entries: number, } } | { "method": "shutdown", "params": { command: WorkerCommandEnvelope, } } | { "method": "list_completions", "params": { kind: CompletionKind, prefix: string, } } | { "method": "list_workers" } | { "method": "restore_worker", "params": { name: string, } } | { "method": "register_peer", "params": { name: string, } }; -export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { segments: Array, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number, +export type Event = { "event": "submission_accepted", "data": { submission_request_id: string, submission_id: string, disposition: SubmissionDisposition, } } | { "event": "submission_rejected", "data": { submission_request_id: string, message: string, } } | { "event": "pending_submissions_changed", "data": { pending: PendingSubmissionsSnapshot, } } | { "event": "user_message", "data": { +/** + * Stable durable history identity. Older mixed-version producers omit it. + */ +entry_id?: string | null, segments: Array, } } | { "event": "session_entry_committed", "data": { entry: SessionSnapshotEntry, } } | { "event": "system_item", "data": { item: unknown, } } | { "event": "invoke_start", "data": { kind: InvokeKind, } } | { "event": "turn_start", "data": { turn: number, } } | { "event": "turn_end", "data": { turn: number, result: TurnResult, } } | { "event": "llm_call_start", "data": { llm_call: number, } } | { "event": "llm_call_end", "data": { llm_call: number, } } | { "event": "llm_retry", "data": { llm_call: number, /** * The attempt that just failed. 1 origin. */ diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 04e182360..8f11ef780 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) +// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 83d4c91a0..686626a14 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) +// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 4e8e5ad7d..875171af6 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) +// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index c784459f9..ce1b2a795 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) +// Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index ce3cb7aa0..8692625e9 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) +// Input contract: 0.1.0 (sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/workdirs.ts b/web/workspace/src/lib/workspace/api/workdirs.ts index f22724f07..8df77af4a 100644 --- a/web/workspace/src/lib/workspace/api/workdirs.ts +++ b/web/workspace/src/lib/workspace/api/workdirs.ts @@ -182,7 +182,9 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource { ); const kind = stringField(source, "kind"); if (kind === "repository") { - if (source.grant_id !== undefined || source.grant_permissions !== undefined) { + if ( + source.grant_id !== undefined || source.grant_permissions !== undefined + ) { throw new Error( "Repository Workdir source must not contain External grant authority", ); @@ -201,7 +203,7 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource { return { kind, grant_id: stringField(source, "grant_id"), - grant_permissions: parseExternalWorkdirPermissions( + grant_permissions: parseExternalWorkdirGrantPermissions( source.grant_permissions, "External Workdir grant permissions", ), @@ -226,6 +228,21 @@ export function parseExternalWorkdirPermissions( }; } +export function parseExternalWorkdirGrantPermissions( + value: unknown, + label = "External Workdir grant permissions", +): ExternalWorkdirPermissions { + const permissions = parseExternalWorkdirPermissions(value, label); + const hierarchical = permissions.read && + (!permissions.command || permissions.write); + if (!hierarchical) { + throw new Error( + `${label} must be READ=(true,false,false), WRITE=(true,true,false), or COMMAND=(true,true,true)`, + ); + } + return permissions; +} + function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget { const record = exactRecord( value, diff --git a/web/workspace/src/lib/workspace/api/workers.test.ts b/web/workspace/src/lib/workspace/api/workers.test.ts index f6b703159..66b1bada0 100644 --- a/web/workspace/src/lib/workspace/api/workers.test.ts +++ b/web/workspace/src/lib/workspace/api/workers.test.ts @@ -106,7 +106,7 @@ Deno.test("Worker summary parser preserves attachment-effective External permiss source: { kind: "external_grant", grant_id: "grant-1", - grant_permissions: { read: true, write: false, command: true }, + grant_permissions: { read: true, write: true, command: true }, }, materializer_kind: "client_hosted_external", status: "active", diff --git a/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.browser.spec.ts b/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.browser.spec.ts index 923e8890e..ccaabb41b 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.browser.spec.ts +++ b/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.browser.spec.ts @@ -4,15 +4,15 @@ import { cleanup, fireEvent, render } from "@testing-library/svelte"; import { afterEach, expect, test, vi } from "vitest"; import ConsoleTurnNavigation from "./ConsoleTurnNavigation.svelte"; -const turns = [ - { id: "u1", user: "First question", assistant: "First answer" }, - { id: "u2", user: "Second question", assistant: "" }, +const items = [ + { turnId: "u1", lineId: "u1", user: "First question", assistant: ["First answer"] }, + { turnId: "u2", lineId: "u2", user: "Second question", assistant: [] }, ]; afterEach(cleanup); test("renders one bar per turn and previews both roles on hover", async () => { - const view = render(ConsoleTurnNavigation, { turns, onSelect: vi.fn() }); + const view = render(ConsoleTurnNavigation, { items, onTurnClick: vi.fn() }); expect(view.getAllByRole("button")).toHaveLength(2); expect(view.queryByRole("tooltip")).toBeNull(); const button = view.getAllByRole("button")[0]; @@ -25,14 +25,14 @@ test("renders one bar per turn and previews both roles on hover", async () => { expect(view.queryByRole("tooltip")).toBeNull(); }); -test("focus previews unanswered turns, click selects the user anchor, Escape dismisses", async () => { - const onSelect = vi.fn(); - const view = render(ConsoleTurnNavigation, { turns, onSelect }); +test("focus previews unanswered items, click selects the user anchor, Escape dismisses", async () => { + const onTurnClick = vi.fn(); + const view = render(ConsoleTurnNavigation, { items, onTurnClick }); const button = view.getAllByRole("button")[1]; await fireEvent.focus(button); expect(view.getByRole("tooltip").textContent).toContain("No response yet"); await fireEvent.click(button); - expect(onSelect).toHaveBeenCalledWith("u2"); + expect(onTurnClick).toHaveBeenCalledWith(items[1]); await fireEvent.keyDown(window, { key: "Escape" }); expect(view.queryByRole("tooltip")).toBeNull(); await fireEvent.blur(button); @@ -43,21 +43,21 @@ test("focus previews unanswered turns, click selects the user anchor, Escape dis }); test("updates the visible response in place and removes stale previews on history replacement", async () => { - const onSelect = vi.fn(); - const view = render(ConsoleTurnNavigation, { turns, onSelect }); + const onTurnClick = vi.fn(); + const view = render(ConsoleTurnNavigation, { items, onTurnClick }); await fireEvent.mouseEnter(view.getAllByRole("button")[1]); - await view.rerender({ turns: [turns[0], { ...turns[1], assistant: "Streaming answer" }], onSelect }); + await view.rerender({ items: [items[0], { ...items[1], assistant: ["Streaming answer"] }], onTurnClick }); expect(view.getByRole("tooltip").textContent).toContain("Streaming answer"); expect(view.getAllByRole("button")).toHaveLength(2); - await view.rerender({ turns: [], onSelect }); + await view.rerender({ items: [], onTurnClick }); expect(view.queryByRole("tooltip")).toBeNull(); expect(view.queryAllByRole("button")).toHaveLength(0); }); test("preview content stays text and scrolling the rail dismisses its old position", async () => { const view = render(ConsoleTurnNavigation, { - turns: [{ id: "u", user: "", assistant: "" }], - onSelect: vi.fn(), + items: [{ turnId: "u", lineId: "u", user: "", assistant: [""] }], + onTurnClick: vi.fn(), }); await fireEvent.mouseEnter(view.getByRole("button")); const tooltip = view.getByRole("tooltip"); diff --git a/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.svelte b/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.svelte index 514bab8a1..a88239e82 100644 --- a/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.svelte +++ b/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.svelte @@ -1,9 +1,20 @@ -
- -
+ diff --git a/web/workspace/src/lib/workspace/console/MarkdownLink.svelte b/web/workspace/src/lib/workspace/markdown/MarkdownLink.svelte similarity index 100% rename from web/workspace/src/lib/workspace/console/MarkdownLink.svelte rename to web/workspace/src/lib/workspace/markdown/MarkdownLink.svelte diff --git a/web/workspace/src/lib/workspace/markdown/MarkdownTable.svelte b/web/workspace/src/lib/workspace/markdown/MarkdownTable.svelte new file mode 100644 index 000000000..6753c32ef --- /dev/null +++ b/web/workspace/src/lib/workspace/markdown/MarkdownTable.svelte @@ -0,0 +1,10 @@ + + + +
+ {@render children?.()}
+
diff --git a/web/workspace/src/lib/workspace/markdown/SafeMarkdown.svelte b/web/workspace/src/lib/workspace/markdown/SafeMarkdown.svelte new file mode 100644 index 000000000..576e7d09c --- /dev/null +++ b/web/workspace/src/lib/workspace/markdown/SafeMarkdown.svelte @@ -0,0 +1,39 @@ + + +
+ +
diff --git a/web/workspace/src/lib/workspace/settings/workdir-permissions.ts b/web/workspace/src/lib/workspace/settings/workdir-permissions.ts index 7942e324d..4024a21f5 100644 --- a/web/workspace/src/lib/workspace/settings/workdir-permissions.ts +++ b/web/workspace/src/lib/workspace/settings/workdir-permissions.ts @@ -1,6 +1,6 @@ import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api"; -/** Render independent Workdir authority categories in a stable, non-conflating order. */ +/** Render an effective attachment/session capability intersection without widening it. */ export function formatWorkdirPermissions( permissions: ExternalWorkdirPermissions, ): string { @@ -10,3 +10,21 @@ export function formatWorkdirPermissions( if (permissions.command) categories.push("COMMAND"); return categories.length > 0 ? categories.join(" · ") : "NONE"; } + +/** Render one validated External grant level with its inherited authority. */ +export function formatExternalGrantPermissionLevel( + permissions: ExternalWorkdirPermissions, +): string { + if (permissions.read && permissions.write && permissions.command) { + return "COMMAND (includes WRITE + READ)"; + } + if (permissions.read && permissions.write && !permissions.command) { + return "WRITE (includes READ)"; + } + if (permissions.read && !permissions.write && !permissions.command) { + return "READ"; + } + throw new Error( + "External Workdir grant has a non-hierarchical permission set", + ); +} diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte index d852919c8..d8d7aee70 100644 --- a/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/memory/+page.svelte @@ -1,123 +1,165 @@ - Memory Document · Yoi Workspace - + Memory · Yoi Workspace + -
-
-
-

Workspace memory

-

Memory Document

-
- {#if data.memory.data} - {data.memory.data.bytes} bytes - {/if} -
- -

- Durable workspace Memory as a single Markdown document. This view is read-only. -

- +
{#if data.memory.data} -
-
- Updated - {formatDate(data.memory.data.updated_at)} -
-
- Created - {formatDate(data.memory.data.created_at)} -
-
- Lines - {lineCount} -
-
- Source - {data.memory.data.record_source} -
+
+

+ Read-only + + Updated +

+
+ Document details +
+
+
Created
+
+
+
+
Size
+
{data.memory.data.bytes} bytes
+
+
+
Source
+
{data.memory.data.record_source}
+
+
+
{#if data.memory.data.body_md.trim().length === 0} -

No Memory document content is present.

+
+ Memory document is empty. +

Durable Workspace context will appear here when it is available.

+
{:else} -
{data.memory.data.body_md}
+
+ +
{/if} {:else if data.memory.error} -

{data.memory.error}

+
+

Memory document unavailable. {data.memory.error}

+
{:else} -

Waiting for /api/w/{data.workspaceId}/memory…

+
+

Memory document data is unavailable.

+
{/if}
diff --git a/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts new file mode 100644 index 000000000..f1152218d --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/memory/memory-page.browser.spec.ts @@ -0,0 +1,76 @@ +// @vitest-environment happy-dom + +import { cleanup, render, screen, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test } from "vitest"; +import MemoryPage from "./+page.svelte"; +import type { PageProps } from "./$types"; + +function pageProps(memory: { + data: { + body_md: string; + created_at: string; + updated_at: string; + bytes: number; + record_source: string; + } | null; + error: string | null; +}): PageProps { + return { + data: { workspaceId: "workspace-1", memory }, + params: { workspaceId: "workspace-1" }, + } as unknown as PageProps; +} + +afterEach(cleanup); + +test("makes the Markdown document primary and keeps technical metadata in disclosure", async () => { + const { container } = render( + MemoryPage, + pageProps({ + data: { + body_md: "# Durable context\n\nRead the **current** document.", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T03:04:05Z", + bytes: 48, + record_source: "sqlite_workspace_authority.memory_document", + }, + error: null, + }), + ); + + await waitFor(() => { + expect(screen.getByRole("heading", { name: "Durable context", level: 1 })).not.toBeNull(); + }); + expect(screen.getByText("Read-only")).not.toBeNull(); + expect(screen.getByText("Document details")).not.toBeNull(); + expect(container.querySelector(".memory-document-page")?.classList.contains("card")).toBe(false); + expect(screen.queryByRole("heading", { name: "Memory Document" })).toBeNull(); + expect(screen.getByRole("article", { name: "Memory document content" })).not.toBeNull(); +}); + +test("distinguishes an empty document from request failure and unavailable data", async () => { + const view = render( + MemoryPage, + pageProps({ + data: { + body_md: " \n", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T03:04:05Z", + bytes: 3, + record_source: "fixture", + }, + error: null, + }), + ); + expect(screen.getByRole("status").textContent).toContain("Memory document is empty."); + expect(screen.queryByRole("alert")).toBeNull(); + + await view.rerender(pageProps({ data: null, error: "Memory API request failed" })); + expect(screen.getByRole("alert").textContent).toContain("Memory document unavailable."); + expect(screen.getByRole("alert").textContent).toContain("Memory API request failed"); + expect(screen.queryByText("Memory document is empty.")).toBeNull(); + + await view.rerender(pageProps({ data: null, error: null })); + expect(screen.getByRole("status").textContent).toBe("Memory document data is unavailable."); + expect(screen.queryByRole("alert")).toBeNull(); +}); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte index 8135d868e..3cb6019b1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte @@ -3,7 +3,6 @@ import { tick, untrack, type SvelteComponent } from "svelte"; import ConsoleLineItem from "$lib/workspace/console/ConsoleLineItem.svelte"; import ConsoleTurnNavigation from "$lib/workspace/console/ConsoleTurnNavigation.svelte"; - import { consoleTurns } from "$lib/workspace/console/turn-navigation"; import ConsoleDisplayStateView from "$lib/workspace/console/ConsoleDisplayState.svelte"; import ConsoleTasks from "$lib/workspace/console/ConsoleTasks.svelte"; import { namePastedImage } from "$lib/workspace/console/composer-paste"; @@ -30,7 +29,9 @@ consoleWorkerViews, createConsoleProjector, isConsoleProjectionEvent, + mergeCommittedHistoryLines, projectConsoleLines, + projectSessionHistoryEntries, resolveConsoleViewScrollTop, resolveConsoleWorkerView, type ConsoleEventInput, @@ -44,6 +45,7 @@ PendingSubmissionsSnapshot, RewindTarget, Segment, + SessionHistoryPage, } from "$lib/generated/protocol"; import { MAX_FILES_PER_SUBMISSION, @@ -69,6 +71,18 @@ type WorkerSessionRequestIdentity, type WorkerSessionTarget, } from "$lib/workspace/session-observation"; + import { + applyConsoleHistoryPage, + beginConsoleHistoryRequest, + conversationTurnPreviewsFromLines, + emptyConsoleHistoryState, + failConsoleHistoryRequest, + historyEntries, + setConsoleHistoryTopEdge, + shouldLoadHistoryAtTop, + type ConsoleHistoryState, + } from "$lib/workspace/console/history"; + import type { TurnNavigationItem } from "$lib/workspace/console/turn-navigation"; import type { Diagnostic, Worker } from "$lib/workspace/sidebar/types"; type Props = { @@ -162,6 +176,7 @@ let workerViewSelectionGeneration = 0; let consoleViewMode = $state("overview"); let consoleBodyElement: HTMLElement | null = null; + let turnNavigationElement = $state(null); let composerInputElement = $state< (SvelteComponent & ComposerInputHandle) | null >(null); @@ -176,6 +191,8 @@ let consoleProjection = $state.raw( consoleProjector.snapshot(), ); + let historyByView = $state.raw>({}); + const pendingHistoryRefreshes = new Set(); let pendingObservationEvents: ConsoleEventInput[] = []; let pendingInitialSnapshotApplication: { eventId: string; @@ -205,10 +222,30 @@ ), ); const selectedConsoleProjection = $derived(selectedWorkerView.console); - const lines = $derived( + const selectedHistoryKey = $derived( + `${workspaceId}:${runtimeId ?? ""}:${workerId ?? ""}:${selectedWorkerViewSessionId ?? "main"}`, + ); + const selectedHistory = $derived( + historyByView[selectedHistoryKey] ?? emptyConsoleHistoryState(), + ); + const currentLines = $derived( projectConsoleLines(selectedConsoleProjection.lines, consoleViewMode), ); - const turns = $derived(consoleTurns(lines)); + const committedHistoryLines = $derived( + projectConsoleLines( + projectSessionHistoryEntries( + historyEntries(selectedHistory), + selectedConsoleProjection.cwd, + ), + consoleViewMode, + ), + ); + const lines = $derived( + mergeCommittedHistoryLines(committedHistoryLines, currentLines), + ); + const turnNavigationItems = $derived( + conversationTurnPreviewsFromLines(lines), + ); const tasks = $derived(selectedConsoleProjection.tasks); const diagnostics = $derived( mergeDiagnostics(worker?.diagnostics ?? [], streamDiagnostics), @@ -264,6 +301,233 @@ return response.json() as Promise; } + type WorkerSessionHistoryResponse = + | { availability: "page"; page: SessionHistoryPage } + | { + availability: "unavailable"; + reason: string; + message: string; + }; + + function mainHistoryKey(target: ConsoleTarget): string { + return `${target.workspaceId}:${target.runtimeId}:${target.workerId}:main`; + } + + function historyStateFor(key: string): ConsoleHistoryState { + return historyByView[key] ?? emptyConsoleHistoryState(); + } + + function setHistoryState(key: string, state: ConsoleHistoryState) { + historyByView = { ...historyByView, [key]: state }; + } + + async function requestHistoryPage( + target: ConsoleTarget, + key: string, + cursor: string | null, + token: number, + ) { + const started = beginConsoleHistoryRequest(historyStateFor(key), cursor); + if (!started) return; + setHistoryState(key, started); + + const anchors = cursor === null ? null : captureHistoryAnchors(); + const query = new URLSearchParams({ limit: "5" }); + if (cursor) query.set("cursor", cursor); + const path = workerApiPath( + `/runtimes/${encodeURIComponent(target.runtimeId)}/workers/${encodeURIComponent(target.workerId)}/session/history?${query}`, + ); + try { + const response = await fetch(path, { + credentials: "same-origin", + headers: { accept: "application/json" }, + }); + if (response.status === 404 || response.status === 405) { + throw new Error("Earlier conversation is not supported by this Runtime version."); + } + if (!response.ok) { + throw new Error(`History request failed (${response.status}).`); + } + const payload = (await response.json()) as WorkerSessionHistoryResponse; + if (token !== reloadToken || mainHistoryKey(target) !== key) return; + if (pendingHistoryRefreshes.has(key)) return; + if (payload.availability === "unavailable") { + setHistoryState( + key, + failConsoleHistoryRequest(historyStateFor(key), payload.message), + ); + return; + } + setHistoryState( + key, + applyConsoleHistoryPage(historyStateFor(key), payload.page, cursor), + ); + await tick(); + restoreHistoryAnchors(anchors); + } catch (error) { + if ( + token !== reloadToken || + mainHistoryKey(target) !== key || + pendingHistoryRefreshes.has(key) + ) + return; + setHistoryState( + key, + failConsoleHistoryRequest( + historyStateFor(key), + error instanceof Error ? error.message : String(error), + ), + ); + } finally { + const refreshAgain = pendingHistoryRefreshes.delete(key); + if ( + refreshAgain && + token === reloadToken && + mainHistoryKey(target) === key + ) { + const stale = historyStateFor(key); + setHistoryState(key, { + ...stale, + status: stale.turns.length > 0 ? "ready" : "idle", + error: null, + requestedCursor: null, + }); + void requestHistoryPage(target, key, null, token); + } + } + } + + type HistoryAnchor = { id: string; top: number }; + type HistoryAnchors = { + transcript: HistoryAnchor | null; + navigation: HistoryAnchor | null; + }; + + function captureElementAnchor( + container: HTMLElement | null, + selector: string, + dataKey: "consoleLineId" | "turnId", + ): HistoryAnchor | null { + const first = container?.querySelector(selector); + const id = first?.dataset[dataKey] ?? ""; + return first && id ? { id, top: first.getBoundingClientRect().top } : null; + } + + function captureHistoryAnchors(): HistoryAnchors { + return { + transcript: captureElementAnchor( + consoleBodyElement, + "[data-console-line-id]", + "consoleLineId", + ), + navigation: captureElementAnchor( + turnNavigationElement, + "[data-turn-id]", + "turnId", + ), + }; + } + + function restoreElementAnchor( + container: HTMLElement | null, + selector: string, + anchor: HistoryAnchor | null, + ) { + if (!anchor || !container) return; + const target = container.querySelector(selector); + if (!target) return; + container.scrollTop += target.getBoundingClientRect().top - anchor.top; + } + + function restoreHistoryAnchors(anchors: HistoryAnchors | null) { + if (!anchors) return; + restoreElementAnchor( + consoleBodyElement, + `[data-console-line-id="${CSS.escape(anchors.transcript?.id ?? "")}"]`, + anchors.transcript, + ); + restoreElementAnchor( + turnNavigationElement, + `[data-turn-id="${CSS.escape(anchors.navigation?.id ?? "")}"]`, + anchors.navigation, + ); + } + + function loadEarlierHistory() { + const target = consoleTarget; + if (!target || selectedWorkerViewSessionId !== null) return; + const key = mainHistoryKey(target); + const state = historyStateFor(key); + if (!state.hasMore || !state.cursor) return; + void requestHistoryPage(target, key, state.cursor, reloadToken); + } + + function retryHistoryLoad() { + const target = consoleTarget; + if (!target || selectedWorkerViewSessionId !== null) return; + const key = mainHistoryKey(target); + const state = historyStateFor(key); + void requestHistoryPage( + target, + key, + state.hasMore ? state.cursor : null, + reloadToken, + ); + } + + function fenceCurrentHistoryForRewind() { + const target = consoleTarget; + if (!target) return; + const key = mainHistoryKey(target); + const current = historyStateFor(key); + const loading = current.status === "loading"; + const reset = emptyConsoleHistoryState(); + setHistoryState(key, { + ...reset, + status: loading ? "loading" : "idle", + requestedCursor: loading ? current.requestedCursor : null, + topEdgeArmed: current.topEdgeArmed, + }); + if (loading) pendingHistoryRefreshes.add(key); + } + + function refreshCurrentHistory() { + const target = consoleTarget; + if (!target) return; + const key = mainHistoryKey(target); + if (historyStateFor(key).status === "loading") { + pendingHistoryRefreshes.add(key); + return; + } + void requestHistoryPage(target, key, null, reloadToken); + } + + function handleHistoryTopEdge(atTop: boolean) { + if (selectedWorkerViewSessionId !== null) return; + const key = selectedHistoryKey; + const state = historyStateFor(key); + if (shouldLoadHistoryAtTop(state, atTop)) { + loadEarlierHistory(); + } else if (!atTop) { + const rearmed = setConsoleHistoryTopEdge(state, false); + if (rearmed !== state) setHistoryState(key, rearmed); + } + } + + function jumpToConversationTurn(item: TurnNavigationItem) { + const line = lines.find( + (candidate) => candidate.entryId === item.lineId || candidate.id === item.lineId, + ); + const target = line + ? consoleBodyElement?.querySelector( + `[data-console-line-id="${CSS.escape(line.id)}"]`, + ) + : null; + if (target instanceof HTMLElement && line) { + selectConsoleTurn(line.id); + } + } + async function loadWorker(target: ConsoleTarget, token: number) { workerError = null; try { @@ -406,6 +670,22 @@ liveWorkerState = consoleProjection.status === "shutdown" ? "shutdown" : workerStateFromSnapshot(consoleProjection.workerState); + if ( + eventBatch.some((event) => event.event.event === "rewind_applied") + ) { + fenceCurrentHistoryForRewind(); + } + if ( + eventBatch.some((event) => + event.event.event === "snapshot" || + event.event.event === "segment_rotated" || + event.event.event === "rewind_applied" || + event.event.event === "user_message" || + event.event.event === "session_entry_committed" + ) + ) { + refreshCurrentHistory(); + } if ( initialSnapshotApplication && eventBatch.some( @@ -1338,6 +1618,7 @@ } autoFollowConsole = isNearConsoleBottom(consoleBodyElement); rememberConsoleWorkerViewScroll(); + handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1); } async function scrollConsoleToBottom() { @@ -1543,40 +1824,67 @@
-
+
-
- {#if workerError} -

{workerError}

- {/if} + {#if workerError} +

{workerError}

+ {/if} - 0} - hasUnfilteredContent={selectedConsoleProjection.lines.length > 0} - onRetry={retryConsoleLoad} - /> + {#if selectedWorkerViewSessionId === null && (selectedHistory.hasMore || selectedHistory.error || selectedHistory.status === "loading")} +
+ +
+ {:else if selectedWorkerViewSessionId === null && selectedHistory.status === "ready"} +
Start of conversation
+ {/if} - {#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0} -
    - {#each lines as item (item.id)} - - {/each} -
- {/if} -
-
- {#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && turns.length > 0} - {#key `${workerId}:${selectedWorkerView.sessionId}`} - - {/key} - {/if} + 0} + hasUnfilteredContent={selectedConsoleProjection.lines.length > 0} + onRetry={retryConsoleLoad} + /> + + {#if (consoleDisplayState.kind === "ready" || consoleDisplayState.kind === "stale") && lines.length > 0} +
    + {#each lines as item (item.id)} + + {/each} +
+ {/if} + +
+ +
{#if taskPaneOpen} @@ -2041,6 +2349,36 @@ display: none; } + .conversation-history-boundary { + display: flex; + justify-content: center; + color: var(--text-muted); + font-size: var(--font-size-compact); + padding-bottom: var(--space-3); + } + + .conversation-history-boundary button { + border: 0; + background: transparent; + color: var(--tui-cyan); + cursor: pointer; + font: inherit; + } + + .conversation-history-boundary button:disabled { + cursor: wait; + opacity: 0.65; + } + + .conversation-history-boundary.complete::before, + .conversation-history-boundary.complete::after { + content: ""; + align-self: center; + width: 2rem; + margin: 0 var(--space-2); + border-top: 1px solid var(--line); + } + .pending-submissions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts index eb242e003..bebe3c044 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/workers/[workerRef]/console/console-display.browser.spec.ts @@ -6,10 +6,12 @@ import { render, screen, waitFor, + within, } from "@testing-library/svelte"; import { afterEach, beforeEach, expect, test, vi } from "vitest"; import type { Event as ProtocolEvent, + SessionConversationTurn, SessionSnapshot, } from "$lib/generated/protocol"; import type { Worker } from "$lib/workspace/sidebar/types"; @@ -106,6 +108,63 @@ function sessionWithUserMessage(content: string): SessionSnapshot { }; } +function emptyHistoryPage() { + return { + availability: "page", + page: { + session_id: "session-a", + lineage_id: "lineage-a", + compact_ancestor_lineage_ids: [], + turns: [], + next_cursor: null, + has_more: false, + }, + }; +} + +function historyPage( + indices: number[], + cursor: string | null, + lineageId = "lineage-a", + parentLineage?: { + lineage_id: string; + adopted_through_turn?: SessionConversationTurn | null; + }, +) { + return { + availability: "page", + page: { + session_id: "session-a", + lineage_id: lineageId, + compact_ancestor_lineage_ids: [], + parent_lineage: parentLineage, + turns: indices.map((index) => ({ + turn_id: `user-${index}`, + entries: [ + { + entry_id: `user-${index}`, + timestamp: index, + provenance: "human_input", + kind: "message", + role: "user", + content: [{ kind: "text", text: `question ${index}` }], + }, + { + entry_id: `assistant-${index}`, + timestamp: index, + provenance: "model_output", + kind: "message", + role: "assistant", + content: [{ kind: "text", text: `done ${index}` }], + }, + ], + })), + next_cursor: cursor, + has_more: cursor !== null, + }, + }; +} + function snapshotEvent(session = emptySession()): ProtocolEvent { return { event: "snapshot", @@ -367,18 +426,30 @@ test("applies retained empty and nonempty snapshots before showing their read-on snapshot: sessionWithUserMessage("retained message"), })); - expect(await screen.findByText("retained message")).not.toBeNull(); + expect( + await within(screen.getByRole("article", { name: "main transcript" })) + .findByText("retained message"), + ).not.toBeNull(); expect(screen.getByText("Read-only retained conversation")).not.toBeNull(); expect(screen.queryByText("No conversation to display")).toBeNull(); }); test("shows bounded initial failures and retries explicitly", async () => { - const fetchMock = vi.fn() - .mockResolvedValueOnce(new Response(null, { status: 503 })) - .mockResolvedValueOnce(Response.json({ - availability: "retained_snapshot", - snapshot: emptySession(), - })); + let sessionRequests = 0; + const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).includes("/session/history")) { + return Promise.resolve(Response.json(emptyHistoryPage())); + } + sessionRequests += 1; + return Promise.resolve( + sessionRequests === 1 + ? new Response(null, { status: 503 }) + : Response.json({ + availability: "retained_snapshot", + snapshot: emptySession(), + }), + ); + }); vi.stubGlobal("fetch", fetchMock); render(ConsolePage, { data: pageData() }); @@ -388,7 +459,7 @@ test("shows bounded initial failures and retries explicitly", async () => { await fireEvent.click(screen.getByRole("button", { name: "Retry" })); expect(await screen.findByText("No conversation to display")).not.toBeNull(); - expect(fetchMock).toHaveBeenCalledTimes(2); + expect(sessionRequests).toBe(2); }); test("distinguishes typed unavailability and an initial live connection closure", async () => { @@ -426,17 +497,165 @@ test("preserves rendered content while reconnecting", async () => { latestListener().onFrame( subscribedFrame(sessionWithUserMessage("kept message")), ); - expect(await screen.findByText("kept message")).not.toBeNull(); + expect( + await within(screen.getByRole("article", { name: "main transcript" })) + .findByText("kept message"), + ).not.toBeNull(); latestListener().onStatus?.("closed", "Workspace subscription disconnected"); expect(await screen.findByText("Conversation updates are unavailable")).not .toBeNull(); - expect(screen.getByText("kept message")).not.toBeNull(); + expect( + within(screen.getByRole("article", { name: "main transcript" })).getByText( + "kept message", + ), + ).not.toBeNull(); expect(screen.queryByText("No conversation to display")).toBeNull(); latestListener().onStatus?.("connecting", "resubscribing"); expect(await screen.findByText("Refreshing conversation")).not.toBeNull(); - expect(screen.getByText("kept message")).not.toBeNull(); + expect( + within(screen.getByRole("article", { name: "main transcript" })).getByText( + "kept message", + ), + ).not.toBeNull(); +}); + +test("reconnect snapshot refreshes lineage and removes stale sibling history", async () => { + let historyRequests = 0; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + if (!String(input).includes("/session/history")) { + return Promise.resolve( + Response.json({ availability: "live_protocol" }), + ); + } + historyRequests += 1; + const response = historyPage([historyRequests], null); + if (historyRequests === 2) { + response.page.lineage_id = "lineage-b"; + } + return Promise.resolve(Response.json(response)); + }), + ); + + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("lineage A current")), + ); + await waitFor(() => + expectHistoryTurn("question 1") + ); + + latestListener().onStatus?.("closed", "disconnected during rewind"); + latestListener().onStatus?.("connecting", "reconnecting"); + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("lineage B current")), + ); + + await waitFor(() => + expectHistoryTurn("question 2") + ); + expect(screen.queryByText("question 1")).toBeNull(); + expect(historyRequests).toBe(2); +}); + +test("snapshot supersedes an in-flight page before stale lineage can render", async () => { + const staleHistory = deferred(); + let historyRequests = 0; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + if (!String(input).includes("/session/history")) { + return Promise.resolve( + Response.json({ availability: "live_protocol" }), + ); + } + historyRequests += 1; + if (historyRequests === 1) return staleHistory.promise; + const response = historyPage([2], null); + response.page.lineage_id = "lineage-b"; + return Promise.resolve(Response.json(response)); + }), + ); + + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("lineage A current")), + ); + await waitFor(() => expect(historyRequests).toBe(1)); + + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("lineage B current")), + ); + staleHistory.resolve(Response.json(historyPage([1], null))); + + await waitFor(() => + expectHistoryTurn("question 2") + ); + expect(screen.queryByText("question 1")).toBeNull(); + expect(historyRequests).toBe(2); +}); + +test("fork refresh reconciles an in-flight older page to the adopted prefix", async () => { + const staleOlder = deferred(); + let historyRequests = 0; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + if (!String(input).includes("/session/history")) { + return Promise.resolve( + Response.json({ availability: "live_protocol" }), + ); + } + historyRequests += 1; + if (historyRequests === 1) { + return Promise.resolve( + Response.json(historyPage([46, 47, 48, 49, 50], "older-46")), + ); + } + if (historyRequests === 2) return staleOlder.promise; + return Promise.resolve(Response.json(historyPage( + [101, 102, 103, 104, 105], + "older-101", + "lineage-fork", + { + lineage_id: "lineage-a", + adopted_through_turn: historyPage([48], null).page + .turns[0] as SessionConversationTurn, + }, + ))); + }), + ); + + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame(subscribedFrame()); + await waitFor(() => + expectHistoryTurn("question 50") + ); + + const transcriptScroller = screen.getByLabelText("main transcript") + .parentElement as HTMLElement; + transcriptScroller.scrollTop = 0; + await fireEvent.scroll(transcriptScroller); + await waitFor(() => expect(historyRequests).toBe(2)); + + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("fork current")), + ); + staleOlder.resolve(Response.json(historyPage([41, 42, 43, 44, 45], null))); + + await waitFor(() => + expectHistoryTurn("question 101") + ); + expectHistoryTurn("question 48"); + expect(screen.queryByText("question 49")).toBeNull(); + expectHistoryTurn("question 41"); + expect(historyRequests).toBe(3); }); test("route changes clear prior content and fence stale Session responses", async () => { @@ -444,6 +663,9 @@ test("route changes clear prior content and fence stale Session responses", asyn const current = deferred(); let firstWorkerLoads = 0; const fetchMock = vi.fn((input: RequestInfo | URL) => { + if (String(input).includes("/session/history")) { + return Promise.resolve(Response.json(emptyHistoryPage())); + } if (String(input).includes("worker-b")) { return current.promise.then((value) => value.clone()); } @@ -459,7 +681,10 @@ test("route changes clear prior content and fence stale Session responses", asyn latestListener().onFrame( subscribedFrame(sessionWithUserMessage("previous worker content")), ); - expect(await screen.findByText("previous worker content")).not.toBeNull(); + expect( + await within(screen.getByRole("article", { name: "main transcript" })) + .findByText("previous worker content"), + ).not.toBeNull(); latestListener().onStatus?.("closed", "reload old worker"); await fireEvent.click( @@ -467,7 +692,10 @@ test("route changes clear prior content and fence stale Session responses", asyn ); await waitFor(() => expect(firstWorkerLoads).toBe(2)); await view.rerender({ data: pageData("worker-b", "runtime-b") }); - expect(screen.queryByText("previous worker content")).toBeNull(); + expect( + within(screen.getByRole("article", { name: "main transcript" })) + .queryByText("previous worker content"), + ).toBeNull(); expect(screen.getByRole("status").textContent).toContain( "Loading conversation", ); @@ -476,7 +704,12 @@ test("route changes clear prior content and fence stale Session responses", asyn availability: "retained_snapshot", snapshot: sessionWithUserMessage("current worker content"), })); - expect(await screen.findByText("current worker content")).not.toBeNull(); + expect( + await within(screen.getByRole("article", { name: "main transcript" })) + .findByText( + "current worker content", + ), + ).not.toBeNull(); staleReload.resolve(Response.json({ availability: "unavailable", @@ -484,6 +717,213 @@ test("route changes clear prior content and fence stale Session responses", asyn })); await settleMicrotasks(); expect(screen.queryByText("stale worker error")).toBeNull(); - expect(screen.queryByText("previous worker content")).toBeNull(); - expect(screen.getByText("current worker content")).not.toBeNull(); + expect( + within(screen.getByRole("article", { name: "main transcript" })) + .queryByText("previous worker content"), + ).toBeNull(); + expect( + within(screen.getByRole("article", { name: "main transcript" })).getByText( + "current worker content", + ), + ).not.toBeNull(); }); + +test("loads retained conversation turns five at a time into the log and turn bar", async () => { + const historyPages = [ + { + availability: "page", + page: { + session_id: "session-a", + lineage_id: "lineage-a", + compact_ancestor_lineage_ids: [], + turns: [8, 9, 10, 11, 12].map((index) => ({ + turn_id: `user-${index}`, + entries: [ + { + entry_id: `user-${index}`, + timestamp: index, + provenance: "human_input", + kind: "message", + role: "user", + content: [{ kind: "text", text: `question ${index}` }], + }, + { + entry_id: `assistant-${index}`, + timestamp: index, + provenance: "model_output", + kind: "message", + role: "assistant", + content: [{ kind: "text", text: `done ${index}` }], + }, + ], + })), + next_cursor: "older-8", + has_more: true, + }, + }, + { + availability: "page", + page: { + session_id: "session-a", + lineage_id: "lineage-a", + compact_ancestor_lineage_ids: [], + turns: [3, 4, 5, 6, 7].map((index) => ({ + turn_id: `user-${index}`, + entries: [{ + entry_id: `user-${index}`, + timestamp: index, + provenance: "human_input", + kind: "message", + role: "user", + content: [{ kind: "text", text: `question ${index}` }], + }], + })), + next_cursor: "older-3", + has_more: true, + }, + }, + historyPage([1, 2], null), + ]; + let historyRequests = 0; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + if (String(input).includes("/session/history")) { + return Promise.resolve(Response.json(historyPages[historyRequests++])); + } + return Promise.resolve(Response.json({ availability: "live_protocol" })); + }), + ); + + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame(subscribedFrame()); + + await waitFor(() => + expectHistoryTurn("question 8") + ); + expect(screen.getAllByText("done 12")).toHaveLength(1); + const transcriptScroller = screen.getByLabelText("main transcript") + .parentElement as HTMLElement; + transcriptScroller.scrollTop = 0; + await fireEvent.scroll(transcriptScroller); + await waitFor(() => + expectHistoryTurn("question 3") + ); + expect(historyRequests).toBe(2); + + const navigation = screen.getByLabelText("Conversation turns"); + const navigationList = navigation.firstElementChild as HTMLElement; + navigationList.scrollTop = 0; + await fireEvent.scroll(navigationList); + await fireEvent.scroll(navigationList); + transcriptScroller.scrollTop = 0; + await fireEvent.scroll(transcriptScroller); + await settleMicrotasks(); + expect(historyRequests).toBe(2); + + await fireEvent.click( + screen.getByRole("button", { + name: "Earlier conversation available · load 5 turns", + }), + ); + await waitFor(() => + expectHistoryTurn("question 1") + ); + expect(screen.getAllByText("Start of conversation")).toHaveLength(1); + expect(historyRequests).toBe(3); + + const target = screen.getByText("question 8").closest(".console-line") as HTMLElement; + vi.spyOn(target, "getBoundingClientRect").mockReturnValue({ top: 600 } as DOMRect); + vi.spyOn(transcriptScroller, "getBoundingClientRect").mockReturnValue({ top: 200 } as DOMRect); + Object.defineProperties(transcriptScroller, { + scrollHeight: { configurable: true, value: 2000 }, + clientHeight: { configurable: true, value: 400 }, + }); + transcriptScroller.scrollTop = 400; + await fireEvent.click( + screen.getByRole("button", { + name: /^Turn \d+: question 8$/, + }), + ); + expect(transcriptScroller.scrollTop).toBe(800); +}); + +test("keeps live content when history fails and retries the bounded page explicitly", async () => { + let historyRequests = 0; + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + if (String(input).includes("/session/history")) { + historyRequests += 1; + return Promise.resolve( + historyRequests === 1 + ? new Response(null, { status: 503 }) + : Response.json(historyPage([1, 2, 3, 4], null)), + ); + } + return Promise.resolve(Response.json({ availability: "live_protocol" })); + }), + ); + + render(ConsolePage, { data: pageData() }); + await waitFor(() => expect(multiplexer.subscribe).toHaveBeenCalledOnce()); + latestListener().onFrame( + subscribedFrame(sessionWithUserMessage("current response remains")), + ); + + expect( + await within(screen.getByRole("article", { name: "main transcript" })) + .findByText("current response remains"), + ).not.toBeNull(); + await fireEvent.click( + await screen.findByRole("button", { + name: "Earlier conversation unavailable · retry", + }), + ); + await waitFor(() => + expectHistoryTurn("question 1") + ); + expect(historyRequests).toBe(2); + expect( + within(screen.getByRole("article", { name: "main transcript" })).getByText( + "current response remains", + ), + ).not.toBeNull(); + expect(screen.getAllByText("Start of conversation")).toHaveLength(1); +}); + +test("fences delayed history pages when the selected Worker route changes", async () => { + const stale = deferred(); + vi.stubGlobal( + "fetch", + vi.fn((input: RequestInfo | URL) => { + const path = String(input); + if (path.includes("/session/history")) { + return path.includes("worker-b") + ? Promise.resolve(Response.json(historyPage([21], null))) + : stale.promise; + } + return Promise.resolve(Response.json({ + availability: "retained_snapshot", + snapshot: emptySession(), + })); + }), + ); + + const view = render(ConsolePage, { data: pageData() }); + await view.rerender({ data: pageData("worker-b", "runtime-b") }); + await waitFor(() => + expectHistoryTurn("question 21") + ); + + stale.resolve(Response.json(historyPage([99], null))); + await settleMicrotasks(); + expect(screen.queryByText("question 99")).toBeNull(); + expectHistoryTurn("question 21"); +}); + +function expectHistoryTurn(text: string) { + expect(screen.getAllByText(text)).toHaveLength(1); + expect(screen.getByRole("button", { name: new RegExp("^Turn \\d+: " + text + "$") })).not.toBeNull(); +} diff --git a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte index 3c2e82aa1..da2d95782 100644 --- a/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/workers/new/+page.svelte @@ -12,7 +12,7 @@ parseWorkerLaunchOptionsResponse, } from '$lib/workspace/api/workers'; import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision'; - import { formatWorkdirPermissions } from '$lib/workspace/settings/workdir-permissions'; + import { formatExternalGrantPermissionLevel } from '$lib/workspace/settings/workdir-permissions'; import { buildCreateWorkspaceWorkerRequest, defaultWorkerLaunchForm, @@ -40,7 +40,7 @@ : null; const label = directory.display_name ?? repositoryKey ?? 'External Workdir'; if (directory.source.kind === 'external_grant') { - return `${label} · External · ${formatWorkdirPermissions(directory.source.grant_permissions)}`; + return `${label} · External · ${formatExternalGrantPermissionLevel(directory.source.grant_permissions)}`; } return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`; } diff --git a/web/workspace/tests/workdir-api.test.ts b/web/workspace/tests/workdir-api.test.ts index abb9d8963..c1d941a44 100644 --- a/web/workspace/tests/workdir-api.test.ts +++ b/web/workspace/tests/workdir-api.test.ts @@ -56,7 +56,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim source: { kind: "external_grant", grant_id: "grant-1", - grant_permissions: { read: true, write: false, command: true }, + grant_permissions: { read: true, write: true, command: true }, }, materializer_kind: "client_hosted_external", status: "active", @@ -72,7 +72,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim } if ( !detail.item.source.grant_permissions.read || - detail.item.source.grant_permissions.write || + !detail.item.source.grant_permissions.write || !detail.item.source.grant_permissions.command ) { throw new Error("External Workdir grant permissions were not preserved"); @@ -83,6 +83,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p for ( const source of [ { kind: "external_grant", grant_id: "grant-1" }, + { + kind: "external_grant", + grant_id: "grant-1", + grant_permissions: { read: false, write: false, command: true }, + }, { kind: "external_grant", grant_id: "grant-1", @@ -113,7 +118,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p rejected = true; } if (!rejected) { - throw new Error(`invalid External permission projection was accepted: ${JSON.stringify(source)}`); + throw new Error( + `invalid External permission projection was accepted: ${ + JSON.stringify(source) + }`, + ); } } }); diff --git a/web/workspace/tests/workdir-permissions.test.ts b/web/workspace/tests/workdir-permissions.test.ts index 244cb4e47..db16181d0 100644 --- a/web/workspace/tests/workdir-permissions.test.ts +++ b/web/workspace/tests/workdir-permissions.test.ts @@ -2,9 +2,12 @@ declare const Deno: { test(name: string, fn: () => void | Promise): void; }; -import { formatWorkdirPermissions } from "../src/lib/workspace/settings/workdir-permissions.ts"; +import { + formatExternalGrantPermissionLevel, + formatWorkdirPermissions, +} from "../src/lib/workspace/settings/workdir-permissions.ts"; -Deno.test("Workdir permissions render independent categories in stable order", () => { +Deno.test("effective Workdir permissions render partial intersections without widening", () => { const cases = [ [{ read: true, write: false, command: false }, "READ"], [{ read: true, write: true, command: false }, "READ · WRITE"], @@ -20,3 +23,32 @@ Deno.test("Workdir permissions render independent categories in stable order", ( } } }); + +Deno.test("External grant levels show inherited authority and reject partial grants", () => { + const cases = [ + [{ read: true, write: false, command: false }, "READ"], + [{ read: true, write: true, command: false }, "WRITE (includes READ)"], + [ + { read: true, write: true, command: true }, + "COMMAND (includes WRITE + READ)", + ], + ] as const; + for (const [permissions, expected] of cases) { + const actual = formatExternalGrantPermissionLevel(permissions); + if (actual !== expected) { + throw new Error(`expected ${expected}, got ${actual}`); + } + } + + let rejected = false; + try { + formatExternalGrantPermissionLevel({ + read: false, + write: false, + command: true, + }); + } catch { + rejected = true; + } + if (!rejected) throw new Error("command-only External grant was accepted"); +});