diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index a9bcc7a5d..11e1ca1ba 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -831,6 +831,34 @@ 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, +} + +/// 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, + 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))] @@ -957,8 +985,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 @@ -2759,6 +2796,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(); @@ -2769,7 +2807,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..e9a725148 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, + 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,8 @@ 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); 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 ac82de16a..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, @@ -11506,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..1b68daebd 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, - SessionSnapshotEntry, SessionSnapshotEntryData, SessionToolAttachment, + Segment, SessionContentPart, SessionConversationTurn, SessionEntryProvenance, + SessionHistoryPage, SessionMessageRole, SessionSnapshot, SessionSnapshotEntry, + SessionSnapshotEntryData, SessionToolAttachment, }; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, 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,83 @@ 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. A lineage 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, + lineage_id: String, + before_turn_id: String, +} + +#[derive(Debug)] +struct LoadedLineageSegment { + segment_id: SegmentId, + adopted_through_turn: Option, + entries: Vec, +} + +#[derive(Debug, Clone, Copy)] +enum LineageOriginKind { + Fork, + Compact, +} + +#[derive(Debug)] +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 +176,400 @@ 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) = + load_adopted_lineage(&store, active.session_id, active_segment_id, limits)?; + let entries = project_adopted_lineage(active.session_id, &lineage)?; + let turns = group_conversation_turns(entries); + + let page_end = match cursor { + Some(cursor) => { + let cursor = decode_history_cursor(cursor)?; + if cursor.version != 1 + || cursor.worker_name != worker_name + || cursor.session_id != active.session_id.to_string() + || cursor.lineage_id != lineage_id + { + return Err(RetainedHistoryReadError::InvalidCursor); + } + turns + .iter() + .position(|turn| turn.turn_id == cursor.before_turn_id) + .ok_or(RetainedHistoryReadError::InvalidCursor)? + } + None => turns.len(), + }; + let page_start = page_end.saturating_sub(page_limit); + let page_turns = turns[page_start..page_end].to_vec(); + let has_more = page_start > 0; + let next_cursor = has_more + .then(|| { + encode_history_cursor(&RetainedHistoryCursor { + version: 1, + worker_name: worker_name.to_owned(), + session_id: active.session_id.to_string(), + lineage_id: lineage_id.clone(), + before_turn_id: page_turns + .first() + .expect("a page with earlier history cannot be empty") + .turn_id + .clone(), + }) + }) + .transpose()?; + let page = SessionHistoryPage { + session_id: active.session_id.to_string(), + lineage_id, + turns: page_turns, + 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), 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; + let mut lineage_hash = Sha256::new(); + lineage_hash.update(b"yoi-retained-history-lineage-v1\0"); + lineage_hash.update(session_id.as_bytes()); + lineage_hash.update(active_segment_id.as_bytes()); + + 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 (entries, segment_bytes) = store + .read_all_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)?; + scanned_entries = scanned_entries + .checked_add(persisted_entry_units(&entries)) + .ok_or(RetainedHistoryReadError::ResourceLimit)?; + if scanned_entries > limits.max_entries { + return Err(RetainedHistoryReadError::ResourceLimit); + } + let origin = validate_segment_start(session_id, &entries)?; + + lineage_hash.update(segment_id.as_bytes()); + match adopted_through_turn { + Some(turn) => { + lineage_hash.update([1]); + lineage_hash.update((turn as u64).to_be_bytes()); + } + None => lineage_hash.update([0]), + } + if let Some(origin) = &origin { + lineage_hash.update(match origin.kind { + LineageOriginKind::Fork => [1], + LineageOriginKind::Compact => [2], + }); + lineage_hash.update(origin.origin.segment_id.as_bytes()); + lineage_hash.update((origin.origin.at_turn_index as u64).to_be_bytes()); + } else { + lineage_hash.update([0]); + } + + lineage.push(LoadedLineageSegment { + segment_id, + adopted_through_turn, + entries, + }); + let Some(origin) = origin else { + break; + }; + segment_id = origin.origin.segment_id; + adopted_through_turn = Some( + adopted_through_turn + .map(|boundary| boundary.min(origin.origin.at_turn_index)) + .unwrap_or(origin.origin.at_turn_index), + ); + } + + lineage.reverse(); + Ok((lineage, URL_SAFE_NO_PAD.encode(lineage_hash.finalize()))) +} + +fn validate_segment_start( + expected_session_id: SessionId, + entries: &[LogEntry], +) -> Result, RetainedHistoryReadError> { + if entries.is_empty() { + // A freshly reserved active Segment can be empty until its first turn + // materializes SegmentStart. The Session manifest still supplies the + // same-Session fence, and an empty Segment has no parent lineage. + return Ok(None); + } + let Some(LogEntry::AnnotatedSegmentStart { + session_id, + forked_from, + compacted_from, + .. + }) = entries.first() + else { + return Err(RetainedHistoryReadError::CorruptLog); + }; + if *session_id != expected_session_id + || entries + .iter() + .skip(1) + .any(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { .. })) + { + 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 project_adopted_lineage( + session_id: SessionId, + lineage: &[LoadedLineageSegment], +) -> Result, RetainedHistoryReadError> { + let mut entries = Vec::::new(); + let mut positions = HashMap::::new(); + let mut excluded_by_boundary = HashSet::::new(); + + for segment in lineage { + let prefix = adopted_segment_prefix(&segment.entries, segment.adopted_through_turn)?; + let snapshot = + project_session_snapshot_for_segment(session_id, Some(segment.segment_id), prefix); + if prefix.len() < segment.entries.len() { + let adopted_ids = snapshot + .entries + .iter() + .map(|entry| entry.entry_id.as_str()) + .collect::>(); + for entry in project_session_snapshot_for_segment( + session_id, + Some(segment.segment_id), + &segment.entries, + ) + .entries + { + if !adopted_ids.contains(entry.entry_id.as_str()) { + excluded_by_boundary.insert(entry.entry_id); + } + } + } + for mut entry in snapshot.entries { + if entry.provenance == SessionEntryProvenance::DerivedSummary + || excluded_by_boundary.contains(&entry.entry_id) + { + continue; + } + if let Some(position) = positions.get(&entry.entry_id).copied() { + // A newer seed may carry a richer canonical projection (notably + // typed input segments). Keep the original durable timestamp and + // ordering while replacing the duplicate representation. + entry.timestamp = entries[position].timestamp; + entries[position] = entry; + } else { + positions.insert(entry.entry_id.clone(), entries.len()); + entries.push(entry); + } + } + } + Ok(entries) +} + +fn adopted_segment_prefix( + entries: &[LogEntry], + adopted_through_turn: Option, +) -> Result<&[LogEntry], RetainedHistoryReadError> { + let Some(boundary) = adopted_through_turn else { + return Ok(entries); + }; + let seed_end = entries + .iter() + .position(|entry| { + !matches!( + entry, + LogEntry::AnnotatedSegmentStart { .. } | LogEntry::InputSegmentsCheckpoint { .. } + ) + }) + .unwrap_or(entries.len()); + if boundary == 0 { + return Ok(&entries[..seed_end]); + } + + let mut current_turn_start = seed_end; + for (index, entry) in entries.iter().enumerate().skip(seed_end) { + match entry { + LogEntry::Invoke { .. } => current_turn_start = index, + LogEntry::TurnEnd { turn_count, .. } if *turn_count == boundary => { + return Ok(&entries[..=index]); + } + LogEntry::TurnEnd { turn_count, .. } if *turn_count > boundary => { + return Ok(&entries[..current_turn_start]); + } + _ => {} + } + } + Ok(entries) +} + +fn group_conversation_turns(entries: Vec) -> Vec { + let mut turns = Vec::new(); + let mut current: Option = None; + for entry in entries { + if is_user_entry(&entry) { + if let Some(turn) = current.take() { + turns.push(turn); + } + current = Some(SessionConversationTurn { + turn_id: entry.entry_id.clone(), + entries: vec![entry], + }); + } else if let Some(turn) = current.as_mut() { + turn.entries.push(entry); + } + } + if let Some(turn) = current { + turns.push(turn); + } + turns +} + +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_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 +610,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 +672,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 +772,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 +787,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 +917,7 @@ fn system_entry( #[cfg(test)] mod tests { use agen::llm_client::RequestConfig; + use protocol::InvokeKind; use super::*; use crate::{ @@ -431,6 +925,705 @@ 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.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")); + } + + #[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 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 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_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(4096); + 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 +1901,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 +1943,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 +1955,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 a30aedde2..9f46a5b2a 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -111,6 +111,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(), @@ -123,14 +136,18 @@ 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 fn segment_log_len(&self, segment_id: SegmentId) -> Result { 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/tui/src/app.rs b/crates/tui/src/app.rs index 0c0147387..0ada55bb0 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1156,7 +1156,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, @@ -1176,6 +1176,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; @@ -3399,6 +3402,7 @@ mod completion_flow_tests { ]), }); app.handle_worker_event(Event::UserMessage { + entry_id: None, segments: vec![Segment::text("first persisted message")], }); @@ -3417,6 +3421,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. @@ -3457,6 +3462,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() { @@ -3485,6 +3491,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 93ca9251a..87b3ca8df 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()); @@ -1776,7 +1777,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/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 4f62c9199..b9bfcf554 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/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/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 31649fcb3..47fa679c7 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -4584,6 +4584,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(), )) @@ -6248,6 +6251,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 { diff --git a/openapi/server-api.json b/openapi/server-api.json index 3e9bd5063..bc652123f 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -14020,7 +14020,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052" + "x-yoi-source-digest": "sha256:49856606e4a16d138cc04a62a6096fd40fff213587b1cbf7f0594f28994a45b0" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 8836db08e..f4f6d383a 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/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/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 9135fc09d..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:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052) +// 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 2927c4ac1..32e4e3972 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. New submissions are accepted only while Idle,\nand a `Started` receipt follows the pending activation checkpoint; the\ncorresponding [`Event::UserMessage`] marks the later history commit.\n`Queued` remains readable for receipts persisted by older runtimes.\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 while the Worker is Idle.\nRunning or Paused Workers reject Submit; use Notify for advisory information\nthat should be incorporated 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. Payload segments and\nprovenance remain in the session log and are intentionally not exposed.","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"},"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"},"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":"Compatibility value for durable receipts created before Submit became\nIdle-only. New submissions are never accepted with this disposition.","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. New submissions are accepted only while Idle,\nand a `Started` receipt follows the pending activation checkpoint; the\ncorresponding [`Event::UserMessage`] marks the later history commit.\n`Queued` remains readable for receipts persisted by older runtimes.\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 while the Worker is Idle.\nRunning or Paused Workers reject Submit; use Notify for advisory information\nthat should be incorporated 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. Payload segments and\nprovenance remain in the session log and are intentionally not exposed.","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"},"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"},"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":"Compatibility value for durable receipts created before Submit became\nIdle-only. New submissions are never accepted with this disposition.","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 200a59665..c8180688d 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -149,6 +149,14 @@ 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 SessionHistoryPage = { session_id: string, +/** + * Stable identity of the adopted active lineage used to fence stale pages. + */ +lineage_id: string, turns: Array, next_cursor?: string | null, has_more: boolean, }; + export type PendingSubmissionSummary = { submission_id: string, accepted_at_ms: number, segment_count: number, byte_len: number, }; export type PendingSubmissionsSnapshot = { revision: number, notification_count: number, head_id: string | null, submissions: Array, }; @@ -296,7 +304,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 227916e94..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:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052) +// 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 6359c3604..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:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052) +// 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 58567530f..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:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052) +// 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/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index d07ed1ff2..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:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052) +// 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/console/ConsoleTurnNavigation.svelte b/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.svelte new file mode 100644 index 000000000..fc03c7147 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/ConsoleTurnNavigation.svelte @@ -0,0 +1,166 @@ + + + + + diff --git a/web/workspace/src/lib/workspace/console/history.test.ts b/web/workspace/src/lib/workspace/console/history.test.ts new file mode 100644 index 000000000..fb7674d59 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/history.test.ts @@ -0,0 +1,186 @@ +import type { + SessionConversationTurn, + SessionHistoryPage, + SessionSnapshotEntry, +} from "$lib/generated/protocol"; +import { + applyConsoleHistoryPage, + beginConsoleHistoryRequest, + type ConsoleHistoryState, + conversationTurnPreviews, + emptyConsoleHistoryState, + historyEntries, + setConsoleHistoryTopEdge, + shouldLoadHistoryAtTop, +} from "./history"; + +declare const Deno: { test(name: string, fn: () => void): void }; + +function assert(condition: boolean, message: string): void { + if (!condition) throw new Error(message); +} + +function message( + id: string, + role: "user" | "assistant", + text: string, +): SessionSnapshotEntry { + return { + entry_id: id, + timestamp: 1, + provenance: role === "user" ? "human_input" : "model_output", + kind: "message", + role, + content: [{ kind: "text", text }], + }; +} + +function turn(index: number): SessionConversationTurn { + return { + turn_id: `u-${index}`, + entries: [ + message(`u-${index}`, "user", `question ${index}\nignored`), + message(`a-progress-${index}`, "assistant", `working ${index}`), + message( + `a-final-${index}`, + "assistant", + `done ${index}\ndetail a\ndetail b\ndetail c`, + ), + ], + }; +} + +function page( + turns: SessionConversationTurn[], + cursor: string | null, +): SessionHistoryPage { + return { + session_id: "session-a", + lineage_id: "lineage-a", + turns, + next_cursor: cursor, + has_more: cursor !== null, + }; +} + +Deno.test("history prepends 12 turns as 5 then 5 then 2 without duplicates", () => { + let state = emptyConsoleHistoryState(); + state = beginConsoleHistoryRequest(state, null)!; + state = applyConsoleHistoryPage( + state, + page([8, 9, 10, 11, 12].map(turn), "cursor-8"), + null, + ); + state = beginConsoleHistoryRequest(state, "cursor-8")!; + state = applyConsoleHistoryPage( + state, + page([3, 4, 5, 6, 7].map(turn), "cursor-3"), + "cursor-8", + ); + state = beginConsoleHistoryRequest(state, "cursor-3")!; + state = applyConsoleHistoryPage( + state, + page([1, 2].map(turn), null), + "cursor-3", + ); + assert(state.turns.length === 12, "all real turns must be retained"); + assert(state.turns[0].turn_id === "u-1", "pages must remain chronological"); + assert( + !state.hasMore && state.cursor === null, + "final short page must end history", + ); + + state = { ...state, requestedCursor: "__initial__", status: "loading" }; + state = applyConsoleHistoryPage( + state, + page([8, 9, 10, 11, 12].map(turn), "cursor-8"), + null, + ); + assert( + state.turns.length === 12, + "refresh must dedupe stable turn identities", + ); + assert( + historyEntries(state).length === 36, + "entries must dedupe by stable identity", + ); +}); + +Deno.test("refresh replaces overlapping latest turns while retaining loaded earlier turns", () => { + let state = emptyConsoleHistoryState(); + state = beginConsoleHistoryRequest(state, null)!; + state = applyConsoleHistoryPage( + state, + page([8, 9, 10, 11, 12].map(turn), "cursor-8"), + null, + ); + state = beginConsoleHistoryRequest(state, "cursor-8")!; + state = applyConsoleHistoryPage( + state, + page([3, 4, 5, 6, 7].map(turn), "cursor-3"), + "cursor-8", + ); + + const updated = turn(12); + updated.entries.push(message("a-later-12", "assistant", "actually finished")); + state = beginConsoleHistoryRequest(state, null)!; + state = applyConsoleHistoryPage( + state, + page([8, 9, 10, 11].map(turn).concat(updated), "stale-cursor-is-ignored"), + null, + ); + + assert( + state.turns.length === 10, + "refresh must not discard loaded earlier turns", + ); + assert( + state.turns.at(-1)?.entries.at(-1)?.entry_id === "a-later-12", + "refresh must replace an overlapping turn with its latest committed entries", + ); + assert( + state.cursor === "cursor-3", + "refresh must retain the oldest paging boundary", + ); +}); + +Deno.test("turn preview uses one user line and final non-empty assistant up to three lines", () => { + const preview = conversationTurnPreviews([turn(4)])[0]; + assert(preview.user === "question 4", "user preview must use its first line"); + assert( + preview.assistant.join("|") === "done 4|detail a|detail b", + "preview must choose the final assistant and cap it at three lines", + ); +}); + +Deno.test("top sentinel requests only once until it leaves the edge", () => { + let state: ConsoleHistoryState = { + ...emptyConsoleHistoryState(), + status: "ready", + cursor: "older", + hasMore: true, + }; + assert( + shouldLoadHistoryAtTop(state, true), + "armed top edge should request a page", + ); + state = beginConsoleHistoryRequest(state, "older")!; + assert( + !shouldLoadHistoryAtTop(state, true), + "in-flight page must suppress duplicates", + ); + state = { + ...state, + status: "ready", + requestedCursor: null, + }; + assert( + !shouldLoadHistoryAtTop(state, true), + "stationary sentinel must not drain history", + ); + state = setConsoleHistoryTopEdge(state, false); + assert( + shouldLoadHistoryAtTop(state, true), + "leaving the top must rearm one request", + ); +}); diff --git a/web/workspace/src/lib/workspace/console/history.ts b/web/workspace/src/lib/workspace/console/history.ts new file mode 100644 index 000000000..def1a88b5 --- /dev/null +++ b/web/workspace/src/lib/workspace/console/history.ts @@ -0,0 +1,228 @@ +import type { + SessionConversationTurn, + SessionHistoryPage, + SessionSnapshotEntry, +} from "$lib/generated/protocol"; + +export const CONSOLE_HISTORY_PAGE_TURNS = 5; + +export type ConsoleHistoryStatus = "idle" | "loading" | "ready" | "error"; + +export type ConsoleHistoryState = { + sessionId: string | null; + lineageId: string | null; + turns: SessionConversationTurn[]; + cursor: string | null; + hasMore: boolean; + status: ConsoleHistoryStatus; + error: string | null; + /** Cursor currently in flight; prevents duplicate sentinel requests. */ + requestedCursor: string | null; + /** Rearmed only after leaving the top edge or an explicit user action. */ + topEdgeArmed: boolean; +}; + +export type ConsoleTurnPreview = { + turnId: string; + lineId: string; + user: string; + assistant: string[]; +}; + +export function emptyConsoleHistoryState(): ConsoleHistoryState { + return { + sessionId: null, + lineageId: null, + turns: [], + cursor: null, + hasMore: false, + status: "idle", + error: null, + requestedCursor: null, + topEdgeArmed: true, + }; +} + +export function beginConsoleHistoryRequest( + state: ConsoleHistoryState, + cursor: string | null, +): ConsoleHistoryState | null { + if (state.status === "loading" || state.requestedCursor !== null) return null; + if (cursor !== null && (!state.hasMore || cursor !== state.cursor)) { + return null; + } + return { + ...state, + status: "loading", + error: null, + requestedCursor: cursor ?? "__initial__", + topEdgeArmed: cursor === null ? state.topEdgeArmed : false, + }; +} + +export function applyConsoleHistoryPage( + state: ConsoleHistoryState, + page: SessionHistoryPage, + requestedCursor: string | null, +): ConsoleHistoryState { + const requestKey = requestedCursor ?? "__initial__"; + if (state.requestedCursor !== requestKey) return state; + + const initial = requestedCursor === null; + if ( + !initial && + (state.sessionId !== page.session_id || state.lineageId !== page.lineage_id) + ) { + return failConsoleHistoryRequest( + state, + "The conversation changed while earlier history was loading. Retry from the current view.", + ); + } + + const incoming = page.turns.filter((turn) => turn.entries.length > 0); + const refreshingSameSession = initial && + state.sessionId === page.session_id && + state.turns.length > 0; + const turns = initial + ? refreshingSameSession + ? mergeRefreshedTurns(state.turns, incoming) + : dedupeTurns(incoming) + : dedupeTurns([...incoming, ...state.turns]); + return { + ...state, + sessionId: page.session_id, + lineageId: page.lineage_id, + turns, + cursor: refreshingSameSession ? state.cursor : (page.next_cursor ?? null), + hasMore: refreshingSameSession ? state.hasMore : page.has_more, + status: "ready", + error: null, + requestedCursor: null, + }; +} + +export function failConsoleHistoryRequest( + state: ConsoleHistoryState, + message: string, +): ConsoleHistoryState { + return { + ...state, + status: "error", + error: message.slice(0, 512), + requestedCursor: null, + }; +} + +export function setConsoleHistoryTopEdge( + state: ConsoleHistoryState, + atTop: boolean, +): ConsoleHistoryState { + if (!atTop && !state.topEdgeArmed) return { ...state, topEdgeArmed: true }; + return state; +} + +export function shouldLoadHistoryAtTop( + state: ConsoleHistoryState, + atTop: boolean, +): boolean { + return atTop && state.topEdgeArmed && state.hasMore && + state.status !== "loading" && state.requestedCursor === null; +} + +export function historyEntries( + state: ConsoleHistoryState, +): SessionSnapshotEntry[] { + return state.turns.flatMap((turn) => turn.entries); +} + +export function conversationTurnPreviews( + turns: readonly SessionConversationTurn[], +): ConsoleTurnPreview[] { + return turns.map((turn) => { + const userEntry = turn.entries.find(isUserEntry); + const assistants = turn.entries + .filter(isAssistantEntry) + .map(entryText) + .filter((text) => text.trim().length > 0); + const assistant = (assistants.at(-1) ?? "") + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, 3); + return { + turnId: turn.turn_id, + lineId: userEntry?.entry_id ?? turn.turn_id, + user: firstNonEmptyLine(userEntry ? entryText(userEntry) : ""), + assistant, + }; + }); +} + +function mergeRefreshedTurns( + retained: readonly SessionConversationTurn[], + refreshed: readonly SessionConversationTurn[], +): SessionConversationTurn[] { + const refreshedIds = new Set(refreshed.map((turn) => turn.turn_id)); + return dedupeTurns([ + ...retained.filter((turn) => !refreshedIds.has(turn.turn_id)), + ...refreshed, + ]); +} + +function dedupeTurns( + turns: readonly SessionConversationTurn[], +): SessionConversationTurn[] { + const seenTurns = new Set(); + const seenEntries = new Set(); + const result: SessionConversationTurn[] = []; + for (const turn of turns) { + if (seenTurns.has(turn.turn_id)) continue; + const entries = turn.entries.filter((entry) => { + if (seenEntries.has(entry.entry_id)) return false; + seenEntries.add(entry.entry_id); + return true; + }); + if (entries.length === 0) continue; + seenTurns.add(turn.turn_id); + result.push({ ...turn, entries }); + } + return result; +} + +function isUserEntry(entry: SessionSnapshotEntry): boolean { + return entry.kind === "user_input" || + (entry.kind === "message" && entry.role === "user"); +} + +function isAssistantEntry(entry: SessionSnapshotEntry): boolean { + return entry.kind === "message" && entry.role === "assistant"; +} + +function entryText(entry: SessionSnapshotEntry): string { + switch (entry.kind) { + case "user_input": + return entry.segments.map((segment) => { + if (segment.kind === "text" || segment.kind === "paste") { + return segment.content; + } + if (segment.kind === "file_ref") return `@file ${segment.path}`; + if (segment.kind === "uploaded_file") { + return `[Attachment: ${segment.file.file_name}]`; + } + if (segment.kind === "paste_artifact") { + return `[Large paste ${segment.artifact.artifact_id}]`; + } + return ""; + }).filter(Boolean).join("\n"); + case "message": + return entry.content.map((part) => + part.kind === "text" ? part.text : part.refusal + ).join("\n"); + default: + return ""; + } +} + +function firstNonEmptyLine(value: string): string { + return value.split(/\r?\n/).map((line) => line.trim()).find(Boolean) ?? "—"; +} diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 139372b9d..e271ab5a0 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -2970,3 +2970,113 @@ Deno.test("new invoke resets stats before the next RunEnd", () => { assertEquals(projection.lines.at(-1)?.body, "0s ・0 reqs ↑0/↓0"); }); + +Deno.test("committed assistant identity reconciles the live block without text matching", () => { + const lines = projectConsole([ + { + eventId: "user-live", + event: { + event: "user_message", + data: { + entry_id: "user-entry", + segments: [{ kind: "text", content: "question" }], + }, + }, + }, + { + eventId: "assistant-live", + event: { event: "text_done", data: { text: "final answer" } }, + }, + { + eventId: "assistant-commit", + event: { + event: "session_entry_committed", + data: { + entry: { + entry_id: "assistant-entry", + timestamp: 7, + provenance: "model_output", + kind: "message", + role: "assistant", + content: [{ kind: "text", text: "final answer" }], + }, + }, + }, + }, + ]).lines; + assertEquals(lines.filter((line) => line.kind === "assistant").length, 1); + assertEquals( + lines.find((line) => line.kind === "assistant")?.entryId, + "assistant-entry", + ); + assertEquals( + lines.find((line) => line.kind === "user")?.entryId, + "user-entry", + ); +}); + +Deno.test("committed tool identities reconcile one live call block", () => { + const lines = projectConsole([ + { + eventId: "tool-live", + event: { + event: "tool_call_done", + data: { id: "call-1", name: "Read", arguments: '{"file_path":"a"}' }, + }, + }, + { + eventId: "tool-call-commit", + event: { + event: "session_entry_committed", + data: { + entry: { + entry_id: "tool-call-entry", + timestamp: 8, + provenance: "model_output", + kind: "tool_call", + call_id: "call-1", + name: "Read", + arguments: '{"file_path":"a"}', + }, + }, + }, + }, + { + eventId: "tool-result-live", + event: { + event: "tool_result", + data: { + id: "call-1", + summary: "read", + output: "contents", + is_error: false, + }, + }, + }, + { + eventId: "tool-result-commit", + event: { + event: "session_entry_committed", + data: { + entry: { + entry_id: "tool-result-entry", + timestamp: 9, + provenance: "tool_output", + kind: "tool_result", + call_id: "call-1", + summary: "read", + content: "contents", + is_error: false, + }, + }, + }, + }, + ]).lines; + + assertEquals( + lines.filter((line) => line.toolCall?.id === "call-1").length, + 1, + ); + assertEquals(lines[0].entryId, "tool-result-entry"); + assertEquals(lines[0].toolCall?.state, "done"); +}); diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 08757ef7b..2a047944d 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -10,6 +10,7 @@ import type { InternalWorkerRef, InternalWorkerSnapshot, Segment, + SessionSnapshotEntry, WorkerState, WorkerStateSnapshot, WorkerStatus, @@ -90,6 +91,8 @@ export type ConsoleLine = { compaction?: ConsoleCompaction; diff?: ConsoleDiffLine[]; eventId?: string | null; + /** Stable durable identity when this line originates from a committed entry. */ + entryId?: string; source: "event"; streaming?: boolean; error?: boolean; @@ -279,6 +282,30 @@ export function createConsoleProjector() { }; } +export function mergeCommittedHistoryLines( + history: ConsoleLine[], + current: ConsoleLine[], +): ConsoleLine[] { + const committed = new Set( + history.map((line) => line.entryId).filter((id): id is string => + Boolean(id) + ), + ); + return [ + ...history, + ...current.filter((line) => !line.entryId || !committed.has(line.entryId)), + ]; +} + +export function projectSessionHistoryEntries( + entries: SessionSnapshotEntry[], + cwd: string | null, +): ConsoleLine[] { + return projectVisibleConsole( + snapshotProjectionFromSession("history", { entries }, cwd), + ).lines; +} + function projectVisibleConsole( projection: ConsoleProjection, ): ConsoleProjection { @@ -846,14 +873,19 @@ export function applyProtocolEvent( switch (event.event) { case "user_message": - next.lines.push( - line( + next.lines.push({ + ...line( envelope.eventId, "user", "User", segmentsToText(event.data.segments), ), - ); + entryId: event.data.entry_id ?? undefined, + }); + break; + case "session_entry_committed": + reconcileCommittedSessionEntry(next, event.data.entry); + applySessionEntry(next, envelope.eventId, event.data.entry); break; case "system_item": next.lines.push(systemItemLine(envelope.eventId, event.data.item)); @@ -1540,6 +1572,7 @@ function readAggregateLine(group: ConsoleLine[]): ConsoleLine { toolStatus: hasError ? "failed" : inProgress ? "reading…" : "done", detail: calls.map(readDetail).join("\n\n"), eventId: group.at(-1)?.eventId, + entryId: group.at(-1)?.entryId, source: "event", streaming: inProgress, error: hasError, @@ -2004,6 +2037,22 @@ function snapshotProjectionFromSession( return projection; } +function reconcileCommittedSessionEntry( + projection: ConsoleProjection, + entry: SessionSnapshotEntry, +): void { + if (entry.kind !== "message" || entry.role !== "assistant") return; + for (let index = projection.lines.length - 1; index >= 0; index -= 1) { + const line = projection.lines[index]; + if (line.entryId) continue; + if (line.kind === "assistant" || line.kind === "in_flight") { + projection.lines.splice(index, 1); + return; + } + if (line.kind === "user") return; + } +} + function applySessionEntry( projection: ConsoleProjection, fallbackEventId: string, @@ -2011,6 +2060,7 @@ function applySessionEntry( ): void { if (!isRecord(value)) return; const eventId = stringField(value, "entry_id") ?? fallbackEventId; + const lineCountBefore = projection.lines.length; switch (stringField(value, "kind")) { case "user_input": projection.lines.push( @@ -2068,6 +2118,18 @@ function applySessionEntry( default: break; } + const entryId = stringField(value, "entry_id"); + if (entryId) { + for ( + let index = Math.max(0, lineCountBefore - 1); + index < projection.lines.length; + index += 1 + ) { + if (projection.lines[index].eventId === eventId) { + projection.lines[index].entryId = entryId; + } + } + } } function applyLoggedUserInput( diff --git a/web/workspace/src/lib/workspace/console/turn-navigation.ts b/web/workspace/src/lib/workspace/console/turn-navigation.ts new file mode 100644 index 000000000..277777b2b --- /dev/null +++ b/web/workspace/src/lib/workspace/console/turn-navigation.ts @@ -0,0 +1,10 @@ +import type { SessionConversationTurn } from "$lib/generated/protocol"; +import { type ConsoleTurnPreview, conversationTurnPreviews } from "./history"; + +export type TurnNavigationItem = ConsoleTurnPreview; + +export function buildTurnNavigationItems( + turns: readonly SessionConversationTurn[], +): TurnNavigationItem[] { + return conversationTurnPreviews(turns); +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 245b47400..ca8fdb2e8 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -720,7 +720,7 @@ Deno.test("Worker Console renders Edit diffs without preformatted template gaps" ); }); -Deno.test("Worker Console exposes a foldable timeline beside the scroll body", async () => { +Deno.test("Worker Console exposes paged conversation navigation beside the scroll body", async () => { const consolePage = await Deno.readTextFile( new URL( "./../../../routes/w/[workspaceId]/workers/[workerRef]/console/+page.svelte", @@ -730,28 +730,24 @@ Deno.test("Worker Console exposes a foldable timeline beside the scroll body", a const consoleLine = await Deno.readTextFile( new URL("./ConsoleLineItem.svelte", import.meta.url), ); - const consoleTimeline = await Deno.readTextFile( - new URL("./ConsoleTimeline.svelte", import.meta.url), + const turnNavigation = await Deno.readTextFile( + new URL("./ConsoleTurnNavigation.svelte", import.meta.url), ); assert( - consoleTimeline.includes('class="console-timeline"') && - consolePage.includes("timelineMarks") && - consolePage.includes("jumpToTimelineMark") && + turnNavigation.includes('class="turn-navigation"') && + turnNavigation.includes('class="turn-list"') && + turnNavigation.includes("data-turn-id={item.turnId}") && + turnNavigation.includes('class="user-preview"') && + turnNavigation.includes('class="assistant-preview"') && + turnNavigation.includes("onTopEdgeChange(target.scrollTop <= 1)") && + consolePage.includes("("overview"); let consoleBodyElement: HTMLElement | null = null; + let turnNavigationElement = $state(null); let composerInputElement = $state< (SvelteComponent & ComposerInputHandle) | null >(null); @@ -222,6 +237,8 @@ let consoleProjection = $state.raw( consoleProjector.snapshot(), ); + let historyByView = $state.raw>({}); + const pendingHistoryRefreshes = new Set(); let pendingObservationEvents: ConsoleEventInput[] = []; let pendingInitialSnapshotApplication: { eventId: string; @@ -251,9 +268,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 committedHistoryLines = $derived( + projectConsoleLines( + projectSessionHistoryEntries( + historyEntries(selectedHistory), + selectedConsoleProjection.cwd, + ), + consoleViewMode, + ), + ); + const lines = $derived( + mergeCommittedHistoryLines(committedHistoryLines, currentLines), + ); + const turnNavigationItems = $derived( + buildTurnNavigationItems(selectedHistory.turns), + ); const tasks = $derived(selectedConsoleProjection.tasks); const timelineLayout = $derived( buildTimelineLayout(lines, eventObservedAtVersion, consoleScroll), @@ -309,6 +347,203 @@ 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 (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) 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 + ) { + 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="${cssEscape(anchors.transcript?.id ?? "")}"]`, + anchors.transcript, + ); + restoreElementAnchor( + turnNavigationElement, + `[data-turn-id="${cssEscape(anchors.navigation?.id ?? "")}"]`, + anchors.navigation, + ); + updateConsoleScrollMetrics(); + } + + 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 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); + const target = line + ? consoleBodyElement?.querySelector( + `[data-console-line-id="${cssEscape(line.id)}"]`, + ) + : null; + if (target instanceof HTMLElement) { + target.scrollIntoView({ block: "start", behavior: "smooth" }); + } + } + async function loadWorker(target: ConsoleTarget, token: number) { workerError = null; try { @@ -489,6 +724,13 @@ } else if (payload.event === "pending_submissions_changed") { pendingSubmissions = payload.data.pending; } + if ( + payload.event === "segment_rotated" || + payload.event === "user_message" || + payload.event === "session_entry_committed" + ) { + refreshCurrentHistory(); + } if (payload.event === "error") { queueObservationDiagnostic({ code: payload.data.code, @@ -1093,6 +1335,9 @@ ]; return; } + untrack(() => { + void requestHistoryPage(target, mainHistoryKey(target), null, token); + }); if ( action.kind === "subscribe_live" && targetWorker && @@ -1769,6 +2014,7 @@ autoFollowConsole = isNearConsoleBottom(consoleBodyElement); updateConsoleScrollMetrics(); rememberConsoleWorkerViewScroll(); + handleHistoryTopEdge(consoleBodyElement.scrollTop <= 1); } async function scrollConsoleToBottom() { @@ -1988,19 +2234,7 @@ {/if}
-
- -
- -
+
{workerError}

{/if} + {#if selectedWorkerViewSessionId === null && (selectedHistory.hasMore || selectedHistory.error || selectedHistory.status === "loading")} +
+ +
+ {:else if selectedWorkerViewSessionId === null && selectedHistory.status === "ready"} +
Start of conversation
+ {/if} + 0} @@ -2031,13 +2283,18 @@
-
@@ -2446,57 +2703,15 @@ } .console-body { - --console-timeline-width: 12rem; - --console-timeline-fold-width: 2.25rem; - display: grid; - grid-template-columns: minmax(0, 1fr) var(--console-timeline-fold-width); - grid-template-rows: auto minmax(0, 1fr); + grid-template-columns: minmax(0, 1fr) minmax(10rem, 12rem); gap: 0 var(--space-3); min-height: 0; overflow: hidden; - transition: grid-template-columns 160ms ease; - } - - .console-body.timeline-open { - grid-template-columns: minmax(0, 1fr) var(--console-timeline-width); - } - - .console-timeline-spacer { - grid-column: 1; - grid-row: 1; - } - - .timeline-fold-cell { - grid-column: 2; - grid-row: 1; - display: flex; - justify-content: flex-start; - padding-bottom: var(--space-2); - } - - .timeline-fold { - width: 100%; - border: 1px solid var(--line); - border-radius: 999px; - background: var(--bg-raised); - color: var(--text-muted); - cursor: pointer; - font-size: var(--font-size-compact); - font-weight: 800; - padding: 0.35rem 0.5rem; - text-align: left; - white-space: nowrap; - } - - .timeline-fold:focus-visible { - border-color: var(--tui-cyan); - color: var(--text-strong); } .console-scroll { grid-column: 1; - grid-row: 2; height: 100%; min-width: 0; min-height: 0; @@ -2510,6 +2725,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 { margin: 0 var(--space-3); color: var(--muted); 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 552997e62..cfc3868df 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 @@ -106,6 +106,52 @@ function sessionWithUserMessage(content: string): SessionSnapshot { }; } +function emptyHistoryPage() { + return { + availability: "page", + page: { + session_id: "session-a", + lineage_id: "lineage-a", + turns: [], + next_cursor: null, + has_more: false, + }, + }; +} + +function historyPage(indices: number[], cursor: string | null) { + return { + availability: "page", + page: { + session_id: "session-a", + lineage_id: "lineage-a", + 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", @@ -256,12 +302,21 @@ test("applies retained empty and nonempty snapshots before showing their read-on }); 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() }); @@ -271,7 +326,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 () => { @@ -327,6 +382,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()); } @@ -370,3 +428,185 @@ test("route changes clear prior content and fence stale Session responses", asyn expect(screen.queryByText("previous worker content")).toBeNull(); expect(screen.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", + 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", + 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(() => + expect(screen.getAllByText("question 8")).toHaveLength(2) + ); + expect(screen.getAllByText("done 12")).toHaveLength(2); + const transcriptScroller = screen.getByLabelText("main transcript") + .parentElement as HTMLElement; + transcriptScroller.scrollTop = 0; + await fireEvent.scroll(transcriptScroller); + await waitFor(() => + expect(screen.getAllByText("question 3")).toHaveLength(2) + ); + 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(() => + expect(screen.getAllByText("question 1")).toHaveLength(2) + ); + expect(screen.getAllByText("Start of conversation")).toHaveLength(2); + expect(historyRequests).toBe(3); + + const scrollIntoView = vi.fn(); + vi.spyOn(HTMLElement.prototype, "scrollIntoView").mockImplementation( + scrollIntoView, + ); + await fireEvent.click( + screen.getByRole("button", { + name: "Jump to conversation: question 8", + }), + ); + expect(scrollIntoView).toHaveBeenCalledOnce(); +}); + +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 screen.findByText("current response remains")).not.toBeNull(); + await fireEvent.click( + await screen.findByRole("button", { + name: "Earlier conversation unavailable · retry", + }), + ); + await waitFor(() => + expect(screen.getAllByText("question 1")).toHaveLength(2) + ); + expect(historyRequests).toBe(2); + expect(screen.getByText("current response remains")).not.toBeNull(); + expect(screen.getAllByText("Start of conversation")).toHaveLength(2); +}); + +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(() => + expect(screen.getAllByText("question 21")).toHaveLength(2) + ); + + stale.resolve(Response.json(historyPage([99], null))); + await settleMicrotasks(); + expect(screen.queryByText("question 99")).toBeNull(); + expect(screen.getAllByText("question 21")).toHaveLength(2); +});