From ee6aa9de412af53aba631f69e65695538dc7bee7 Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 2 Oct 2026 16:41:29 +0900 Subject: [PATCH] feat: discover subject session history --- crates/runtime-api/src/lib.rs | 206 ++- crates/server-api/src/lib.rs | 289 ++++ crates/session-store/src/lib.rs | 9 + crates/session-store/src/public_index.rs | 1194 ++++++++++++++ crates/session-store/src/public_snapshot.rs | 2 +- .../session-store/src/worker_session_store.rs | 17 + crates/worker-runtime/src/http_server.rs | 3 + .../src/http_server/runtime_management_api.rs | 74 + crates/worker-runtime/src/lib.rs | 2 + crates/worker-runtime/src/retention.rs | 20 + crates/worker-runtime/src/runtime.rs | 152 ++ crates/worker-runtime/src/session_public.rs | 357 +++++ crates/worker-runtime/src/worker_source.rs | 7 + crates/worker/src/controller.rs | 9 + crates/worker/src/feature/builtin.rs | 1 + .../src/feature/builtin/memory_lifecycle.rs | 1 + .../src/feature/builtin/subjektiv_memory.rs | 4 + .../src/feature/builtin/subjektiv_session.rs | 817 ++++++++++ crates/worker/src/feature/session.rs | 3 + crates/worker/src/worker.rs | 11 + crates/workspace-server/src/hosts.rs | 107 ++ crates/workspace-server/src/latest_schema.sql | 11 + crates/workspace-server/src/retention.rs | 131 ++ crates/workspace-server/src/server.rs | 1425 ++++++++++++++++- crates/workspace-server/src/store.rs | 59 +- crates/workspace-server/src/subjektiv.rs | 175 ++ .../src/workspace_deletion.rs | 1 + docs/design/subjektiv-store.md | 40 + openapi/server-api.json | 42 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 34 files changed, 5167 insertions(+), 12 deletions(-) create mode 100644 crates/session-store/src/public_index.rs create mode 100644 crates/worker-runtime/src/session_public.rs create mode 100644 crates/worker/src/feature/builtin/subjektiv_session.rs diff --git a/crates/runtime-api/src/lib.rs b/crates/runtime-api/src/lib.rs index 757ab0ee0..7eac0c840 100644 --- a/crates/runtime-api/src/lib.rs +++ b/crates/runtime-api/src/lib.rs @@ -692,6 +692,178 @@ pub enum WorkerSessionHistoryAvailability { }, } +/// Backend-selected storage source. This type is never exposed as model tool +/// input; Runtime paths remain behind the authenticated Workspace capability. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "storage", rename_all = "snake_case")] +pub enum SessionPublicSource { + Retained { worker_id: WorkerId }, + Archived { archive_id: String }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicEntryKind { + User, + Assistant, + Tool, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicToolPart { + Input, + Output, + Both, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicReadMode { + Compact, + Full, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicLineageKind { + Root, + Fork, + Compact, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicLineage { + pub kind: SessionPublicLineageKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_segment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at_turn_index: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicSearchRequest { + pub workspace_id: String, + pub source: SessionPublicSource, + pub expected_session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_generation: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + pub tool_part: SessionPublicToolPart, + pub offset: usize, + pub limit: usize, + pub max_scan_bytes: u64, + pub max_segments: usize, + pub max_entries: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicReadRequest { + pub workspace_id: String, + pub source: SessionPublicSource, + pub expected_session_id: String, + pub expected_generation: Option, + pub segment_id: String, + pub entry_ref: String, + pub mode: SessionPublicReadMode, + pub byte_offset: usize, + pub max_content_bytes: usize, + pub max_scan_bytes: u64, + pub max_segments: usize, + pub max_entries: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicSearchItem { + pub segment_id: String, + pub entry_ref: String, + pub kind: SessionPublicEntryKind, + pub origin: protocol::SessionEntryProvenance, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_part: Option, + pub compact: String, + pub lineage: SessionPublicLineage, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicSearchPage { + pub session_id: String, + pub generation: String, + pub items: Vec, + pub next_offset: Option, + pub has_more: bool, + pub scanned_bytes: u64, + pub scanned_segments: usize, + pub scanned_entries: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_manifest: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicReadPage { + pub session_id: String, + pub generation: String, + pub segment_id: String, + pub entry_ref: String, + pub kind: SessionPublicEntryKind, + pub origin: protocol::SessionEntryProvenance, + pub lineage: SessionPublicLineage, + pub mode: SessionPublicReadMode, + pub content: String, + pub next_byte_offset: Option, + pub has_more: bool, + pub scanned_bytes: u64, + pub scanned_segments: usize, + pub scanned_entries: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub archive_manifest: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicUnavailableReason { + NotFound, + RetentionMissing, + RetentionExpired, + ArchiveIncomplete, + CorruptLog, + MigrationRequired, + StorageUnavailable, + InvalidCursor, + ResourceLimit, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "availability", rename_all = "snake_case")] +pub enum SessionPublicSearchAvailability { + Page { + page: SessionPublicSearchPage, + }, + Unavailable { + reason: SessionPublicUnavailableReason, + message: String, + }, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "availability", rename_all = "snake_case")] +pub enum SessionPublicReadAvailability { + Page { + page: SessionPublicReadPage, + }, + Unavailable { + reason: SessionPublicUnavailableReason, + message: String, + }, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct RetainedSessionIdentity { pub session_id: String, @@ -766,6 +938,18 @@ pub trait RuntimeApi { #[query] request: WorkerSessionHistoryRequest, ) -> Result; + #[post("/v1/session-public/search", status = 200, error_status = 400)] + async fn session_public_search( + &self, + #[body] request: SessionPublicSearchRequest, + ) -> Result; + + #[post("/v1/session-public/read", status = 200, error_status = 400)] + async fn session_public_read( + &self, + #[body] request: SessionPublicReadRequest, + ) -> Result; + #[post("/v1/workers", status = 200, error_status = 400)] async fn create_worker( &self, @@ -1112,6 +1296,26 @@ mod tests { }) } + async fn session_public_search( + &self, + _request: SessionPublicSearchRequest, + ) -> Result { + Ok(SessionPublicSearchAvailability::Unavailable { + reason: SessionPublicUnavailableReason::StorageUnavailable, + message: "unsupported".to_string(), + }) + } + + async fn session_public_read( + &self, + _request: SessionPublicReadRequest, + ) -> Result { + Ok(SessionPublicReadAvailability::Unavailable { + reason: SessionPublicUnavailableReason::StorageUnavailable, + message: "unsupported".to_string(), + }) + } + async fn create_worker( &self, _request: CreateWorkerRequest, @@ -1248,7 +1452,7 @@ mod tests { #[test] fn contract_inventory_is_complete_and_unique() { let operations = RuntimeApiMetadata::OPERATIONS; - assert_eq!(operations.len(), 17); + assert_eq!(operations.len(), 19); 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 43d855b83..73c6d0741 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1191,6 +1191,20 @@ pub trait ServerApi { #[path] workspace_id: String, #[body] request: SubjektivMemoryBackendRequest, ) -> Result; + #[post( + "/api/w/{workspace_id}/subjektiv/session-history", + operation_id = "subjektiv_session_backend", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + openapi = false, + )] + async fn subjektiv_session_backend( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: SubjektivSessionBackendRequest, + ) -> Result; #[post( "/api/w/{workspace_id}/subjektiv/consolidation", status = 200, @@ -4534,6 +4548,280 @@ pub struct SubjektivRecordSessionResponse { pub session_id: String, } +/// Model-visible storage selector for subject-attributed Sessions. Runtime, +/// Worker, archive and filesystem identities remain Host-bound. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionStorageFilter { + All, + Retained, + Archived, +} + +impl Default for SubjektivSessionStorageFilter { + fn default() -> Self { + Self::All + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionStorage { + Retained, + Archived, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionAvailability { + Available, + Unchecked, + Unavailable, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionListRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default)] + pub storage: SubjektivSessionStorageFilter, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionEntryKind { + User, + Assistant, + Tool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionToolPart { + Input, + Output, + Both, +} + +impl Default for SubjektivSessionToolPart { + fn default() -> Self { + Self::Both + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionSearchRequest { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub kind: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default)] + pub tool_part: SubjektivSessionToolPart, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schemars(range(min = 1, max = 100))] + pub limit: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionReadMode { + Compact, + Full, +} + +impl Default for SubjektivSessionReadMode { + fn default() -> Self { + Self::Compact + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionReadRequest { + pub session_id: String, + pub segment_id: String, + pub entry_ref: String, + #[serde(default)] + pub mode: SubjektivSessionReadMode, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "operation", content = "input", rename_all = "snake_case")] +pub enum SubjektivSessionBackendOperation { + List(SubjektivSessionListRequest), + Search(SubjektivSessionSearchRequest), + Read(SubjektivSessionReadRequest), +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionBackendRequest { + pub operation: SubjektivSessionBackendOperation, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionListItem { + pub session_id: String, + /// Host attribution time. This is not represented as experience time. + pub attributed_at: String, + pub storage: SubjektivSessionStorage, + pub availability: SubjektivSessionAvailability, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionListResponse { + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionLineageKind { + Root, + Fork, + Compact, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionLineage { + pub kind: SubjektivSessionLineageKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_segment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub at_turn_index: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionSearchItem { + pub session_id: String, + pub segment_id: String, + pub entry_ref: String, + pub kind: SubjektivSessionEntryKind, + pub origin: protocol::SessionEntryProvenance, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_part: Option, + pub snippet: String, + pub snippet_truncated: bool, + pub lineage: SubjektivSessionLineage, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionCoverage { + Complete, + Partial, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum SubjektivSessionDiagnosticCode { + InvalidInput, + SubjectUnavailable, + StaleCursor, + NotFoundOrNotAuthorized, + RetentionMissing, + RetentionExpired, + ArchiveIncomplete, + CorruptLog, + RuntimeUnavailable, + MigrationRequired, + ResourceLimit, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionIssue { + pub session_id: String, + pub code: SubjektivSessionDiagnosticCode, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionSearchResponse { + pub items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, + pub coverage: SubjektivSessionCoverage, + pub issues: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionReadResponse { + pub session_id: String, + pub segment_id: String, + pub entry_ref: String, + pub kind: SubjektivSessionEntryKind, + pub origin: protocol::SessionEntryProvenance, + pub lineage: SubjektivSessionLineage, + pub mode: SubjektivSessionReadMode, + pub content: String, + pub truncated: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub next_cursor: Option, + pub has_more: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct SubjektivSessionErrorResponse { + pub code: SubjektivSessionDiagnosticCode, + pub message: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "result", content = "data", rename_all = "snake_case")] +pub enum SubjektivSessionBackendResult { + List(SubjektivSessionListResponse), + Search(SubjektivSessionSearchResponse), + Read(SubjektivSessionReadResponse), +} + +/// Expected discovery/read failures remain typed model-visible data instead of +/// being collapsed into an empty success or an unstructured transport error. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "status", rename_all = "snake_case")] +pub enum SubjektivSessionBackendResponse { + Ok { + result: SubjektivSessionBackendResult, + }, + Error { + error: SubjektivSessionErrorResponse, + }, +} + +/// Maximum serialized model-facing JSON content for every subject Session tool. +pub const SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES: usize = 56 * 1024; +pub const SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES: usize = 16 * 1024; +pub const SUBJEKTIV_SESSION_MAX_SNIPPET_BYTES: usize = 512; + /// Current lifecycle state of one confirmed subject Memory revision. #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] @@ -12273,6 +12561,7 @@ mod openapi_artifact_tests { "subjektiv_stage_candidate", "subjektiv_record_session", "subjektiv_memory_backend", + "subjektiv_session_backend", "subjektiv_memory_consolidation", "workspace_worker_discovery", "workspace_worker_remove", diff --git a/crates/session-store/src/lib.rs b/crates/session-store/src/lib.rs index b1b427e37..a3f9a4207 100644 --- a/crates/session-store/src/lib.rs +++ b/crates/session-store/src/lib.rs @@ -75,6 +75,7 @@ pub mod history; mod legacy_session_log; pub mod logged_item; mod paste_artifact; +pub mod public_index; pub mod public_snapshot; pub mod segment; pub mod segment_log; @@ -95,6 +96,14 @@ pub use history::{ }; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use paste_artifact::PasteArtifactLimits; +pub use public_index::{ + DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES, + DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES, + SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, + SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, + SessionPublicIndexReadError, SessionPublicIndexSegment, SessionPublicIndexToolPart, + read_session_public_index, +}; pub use public_snapshot::{ DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS, diff --git a/crates/session-store/src/public_index.rs b/crates/session-store/src/public_index.rs new file mode 100644 index 000000000..5ed7693f5 --- /dev/null +++ b/crates/session-store/src/public_index.rs @@ -0,0 +1,1194 @@ +use std::collections::{HashMap, HashSet}; +use std::path::Path; + +use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD}; +use protocol::{ + Segment, SessionContentPart, SessionEntryProvenance, SessionMessageRole, SessionSnapshotEntry, + SessionSnapshotEntryData, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use thiserror::Error; + +use crate::{LogEntry, SegmentId, SegmentOrigin, SessionId, StoreError, WorkerSessionStore}; + +pub const DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES: u64 = 64 * 1024 * 1024; +pub const DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS: usize = 64; +pub const DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES: usize = 100_000; +pub const SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES: usize = 512; + +/// Caller-owned resource bounds for one complete public Session index read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SessionPublicIndexLimits { + pub max_bytes: u64, + pub max_segments: usize, + pub max_entries: usize, +} + +impl Default for SessionPublicIndexLimits { + fn default() -> Self { + Self { + max_bytes: DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, + max_segments: DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, + max_entries: DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES, + } + } +} + +/// Read failures are intentionally storage-oriented. Authorization and archive +/// availability remain the responsibility of the caller that selected this +/// canonical Session root. +#[derive(Debug, Error, Clone, Copy, PartialEq, Eq)] +pub enum SessionPublicIndexReadError { + #[error("Worker Session requires migration")] + MigrationRequired, + #[error("Worker Session is missing")] + Missing, + #[error("Worker Session is corrupt")] + Corrupt, + #[error("Worker Session storage is unavailable")] + Storage, + #[error("Worker Session public index exceeds a resource limit")] + ResourceLimit, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndex { + pub session_id: String, + /// Content generation over the canonical Session identity, ordered Segment + /// identities, lineage, and committed log records. + pub generation: String, + /// Every persisted Segment, including empty-public and non-active branches, + /// in ascending `segment_id` order. + pub segments: Vec, + /// Persisted bytes actually consumed while constructing this generation. + pub scanned_bytes: u64, + /// Persisted records plus embedded history entries charged to the scan. + pub scanned_entries: usize, + pub entry_count: usize, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndexSegment { + pub segment_id: String, + pub lineage: SessionPublicIndexLineage, + pub entries: Vec, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicIndexOriginKind { + Root, + Fork, + Compact, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndexLineage { + pub origin_kind: SessionPublicIndexOriginKind, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_segment_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parent_turn_index: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicIndexEntryKind { + User, + Assistant, + Tool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SessionPublicIndexToolPart { + Input, + Output, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionPublicIndexEntry { + pub segment_id: String, + /// Stable public reference. The same inherited logical entry intentionally + /// has the same ref in multiple Segments; `segment_id` disambiguates it. + pub entry_ref: String, + pub kind: SessionPublicIndexEntryKind, + pub provenance: SessionEntryProvenance, + pub timestamp: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_part: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + pub compact_text: String, + pub full_text: String, +} + +#[derive(Debug)] +struct LoadedSegment { + segment_id: SegmentId, + lineage: SessionPublicIndexLineage, + origin: Option, + entries: Vec, + max_turn_index: usize, +} + +/// Read every persisted Segment below one canonical `/session` root. +/// +/// This operation is strictly observational: it opens the current schema in +/// read-only mode, performs no migration or restore, ignores trace and artifact +/// files, and reads Segment logs without intentionally updating access time. +pub fn read_session_public_index( + session_root: &Path, + limits: SessionPublicIndexLimits, +) -> Result { + if limits.max_bytes == 0 || limits.max_segments == 0 || limits.max_entries == 0 { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + + let store = WorkerSessionStore::open_read_only(session_root).map_err(map_store_open_error)?; + let session_id = store + .session_id() + .map_err(map_store_error)? + .ok_or(SessionPublicIndexReadError::Missing)?; + let segment_ids = store + .list_segments_read_only(session_id) + .map_err(map_store_error)?; + if segment_ids.len() > limits.max_segments { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + + let mut scanned_bytes = 0_u64; + let mut scanned_entries = 0_usize; + let mut loaded = Vec::with_capacity(segment_ids.len()); + let mut generation = Sha256::new(); + generation.update(b"yoi-session-public-index-v1\0"); + generation.update(session_id.as_bytes()); + + for segment_id in segment_ids { + let remaining = limits + .max_bytes + .checked_sub(scanned_bytes) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + let (entries, bytes) = store + .read_all_read_only_bounded(session_id, segment_id, remaining) + .map_err(map_store_error)?; + scanned_bytes = scanned_bytes + .checked_add(bytes) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + let units = persisted_entry_units(&entries); + scanned_entries = scanned_entries + .checked_add(units) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + if scanned_entries > limits.max_entries { + return Err(SessionPublicIndexReadError::ResourceLimit); + } + + let (lineage, origin) = validate_segment_start(session_id, segment_id, &entries)?; + let max_turn_index = entries.iter().fold( + origin + .as_ref() + .map(|origin| origin.at_turn_index) + .unwrap_or(0), + |max_turn, entry| match entry { + LogEntry::TurnEnd { turn_count, .. } => max_turn.max(*turn_count), + _ => max_turn, + }, + ); + + generation.update(segment_id.as_bytes()); + generation.update((entries.len() as u64).to_be_bytes()); + for entry in &entries { + let encoded = + serde_json::to_vec(entry).map_err(|_| SessionPublicIndexReadError::Corrupt)?; + generation.update((encoded.len() as u64).to_be_bytes()); + generation.update(encoded); + } + + loaded.push(LoadedSegment { + segment_id, + lineage, + origin, + entries, + max_turn_index, + }); + } + + validate_lineage(&loaded)?; + + let mut entry_count = 0_usize; + let mut segments = Vec::with_capacity(loaded.len()); + for segment in loaded { + let committed = committed_conversation_records(&segment.entries); + let snapshot = crate::public_snapshot::project_session_snapshot_for_segment( + session_id, + Some(segment.segment_id), + &committed, + ); + let entries = project_index_entries(segment.segment_id, snapshot.entries)?; + entry_count = entry_count + .checked_add(entries.len()) + .ok_or(SessionPublicIndexReadError::ResourceLimit)?; + segments.push(SessionPublicIndexSegment { + segment_id: segment.segment_id.to_string(), + lineage: segment.lineage, + entries, + }); + } + + Ok(SessionPublicIndex { + session_id: session_id.to_string(), + generation: URL_SAFE_NO_PAD.encode(generation.finalize()), + segments, + scanned_bytes, + scanned_entries, + entry_count, + }) +} + +fn validate_segment_start( + expected_session_id: SessionId, + segment_id: SegmentId, + entries: &[LogEntry], +) -> Result<(SessionPublicIndexLineage, Option), SessionPublicIndexReadError> { + let Some(LogEntry::AnnotatedSegmentStart { + session_id, + forked_from, + compacted_from, + .. + }) = entries.first() + else { + return Err(SessionPublicIndexReadError::Corrupt); + }; + if *session_id != expected_session_id + || entries + .iter() + .skip(1) + .any(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { .. })) + { + return Err(SessionPublicIndexReadError::Corrupt); + } + + match (forked_from, compacted_from) { + (Some(_), Some(_)) => Err(SessionPublicIndexReadError::Corrupt), + (Some(origin), None) => validate_origin(segment_id, origin).map(|origin| { + ( + SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Fork, + parent_segment_id: Some(origin.segment_id.to_string()), + parent_turn_index: Some(origin.at_turn_index), + }, + Some(origin), + ) + }), + (None, Some(origin)) => validate_origin(segment_id, origin).map(|origin| { + ( + SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Compact, + parent_segment_id: Some(origin.segment_id.to_string()), + parent_turn_index: Some(origin.at_turn_index), + }, + Some(origin), + ) + }), + (None, None) => Ok(( + SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Root, + parent_segment_id: None, + parent_turn_index: None, + }, + None, + )), + } +} + +fn validate_origin( + segment_id: SegmentId, + origin: &SegmentOrigin, +) -> Result { + if origin.segment_id == segment_id { + return Err(SessionPublicIndexReadError::Corrupt); + } + Ok(origin.clone()) +} + +fn validate_lineage(segments: &[LoadedSegment]) -> Result<(), SessionPublicIndexReadError> { + let by_id: HashMap = segments + .iter() + .map(|segment| (segment.segment_id, segment)) + .collect(); + for segment in segments { + if let Some(origin) = &segment.origin { + let parent = by_id + .get(&origin.segment_id) + .ok_or(SessionPublicIndexReadError::Corrupt)?; + if origin.at_turn_index > parent.max_turn_index { + return Err(SessionPublicIndexReadError::Corrupt); + } + } + + let mut seen = HashSet::new(); + let mut current = segment; + while let Some(origin) = ¤t.origin { + if !seen.insert(current.segment_id) { + return Err(SessionPublicIndexReadError::Corrupt); + } + current = by_id + .get(&origin.segment_id) + .copied() + .ok_or(SessionPublicIndexReadError::Corrupt)?; + } + } + Ok(()) +} + +/// Project only conversation records whose run reached a durable public commit +/// boundary. Human input written before `Invoke` remains committed input, while +/// assistant/tool output accumulated by a cancelled, errored, abandoned, or +/// still-active run is discarded. A yielded run is a committed boundary and a +/// later `RunResumed` begins a new pending buffer. +fn committed_conversation_records(entries: &[LogEntry]) -> Vec { + let mut committed = Vec::with_capacity(entries.len()); + let mut pending_run: Option> = None; + + for entry in entries { + match entry { + LogEntry::Invoke { .. } | LogEntry::RunResumed { .. } => { + pending_run = Some(vec![entry.clone()]); + } + LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. } => { + if let Some(mut pending) = pending_run.take() { + pending.push(entry.clone()); + committed.append(&mut pending); + } else { + committed.push(entry.clone()); + } + } + LogEntry::RunCancelled { .. } + | LogEntry::RunErrored { .. } + | LogEntry::PausedTurnAbandoned { .. } => { + pending_run = None; + } + _ => { + if let Some(pending) = &mut pending_run { + pending.push(entry.clone()); + } else { + committed.push(entry.clone()); + } + } + } + } + + committed +} + +fn project_index_entries( + segment_id: SegmentId, + entries: Vec, +) -> Result, SessionPublicIndexReadError> { + let mut projected = Vec::new(); + let mut seen_refs = HashSet::new(); + let mut tool_names = HashMap::::new(); + + for entry in entries { + if entry.entry_id.is_empty() { + return Err(SessionPublicIndexReadError::Corrupt); + } + let entry_ref = format!("E{}", entry.entry_id); + if !seen_refs.insert(entry_ref.clone()) { + continue; + } + + let (kind, tool_part, tool_name, compact_source, full_text) = match entry.data { + SessionSnapshotEntryData::UserInput { segments } => { + let text = Segment::flatten_to_text(&segments); + ( + SessionPublicIndexEntryKind::User, + None, + None, + text.clone(), + text, + ) + } + SessionSnapshotEntryData::Message { role, content } => { + let text = content_parts_text(&content); + let kind = match role { + SessionMessageRole::User => SessionPublicIndexEntryKind::User, + SessionMessageRole::Assistant => SessionPublicIndexEntryKind::Assistant, + }; + (kind, None, None, text.clone(), text) + } + SessionSnapshotEntryData::ToolCall { + call_id, + name, + arguments, + } => { + tool_names.insert(call_id, name.clone()); + ( + SessionPublicIndexEntryKind::Tool, + Some(SessionPublicIndexToolPart::Input), + Some(name), + arguments.clone(), + arguments, + ) + } + SessionSnapshotEntryData::ToolResult { + call_id, + summary, + content, + .. + } => { + let full = content.unwrap_or_else(|| summary.clone()); + let compact = if summary.is_empty() { + full.clone() + } else { + summary + }; + ( + SessionPublicIndexEntryKind::Tool, + Some(SessionPublicIndexToolPart::Output), + tool_names.get(&call_id).cloned(), + compact, + full, + ) + } + // SystemItems, run transitions, and any future non-conversation + // public snapshot records do not cross this narrower index boundary. + _ => continue, + }; + + projected.push(SessionPublicIndexEntry { + segment_id: segment_id.to_string(), + entry_ref, + kind, + provenance: entry.provenance, + timestamp: entry.timestamp, + tool_part, + tool_name, + compact_text: truncate_utf8( + &compact_source, + SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES, + ), + full_text, + }); + } + + Ok(projected) +} + +fn content_parts_text(parts: &[SessionContentPart]) -> String { + let mut output = String::new(); + for part in parts { + match part { + SessionContentPart::Text { text } => output.push_str(text), + SessionContentPart::Refusal { refusal } => output.push_str(refusal), + } + } + output +} + +fn truncate_utf8(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_owned(); + } + let marker = "…"; + let content_limit = max_bytes.saturating_sub(marker.len()); + let mut end = content_limit.min(value.len()); + while !value.is_char_boundary(end) { + end -= 1; + } + let mut truncated = value[..end].to_owned(); + if max_bytes >= marker.len() { + truncated.push_str(marker); + } + truncated +} + +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 map_store_open_error(error: StoreError) -> SessionPublicIndexReadError { + match error { + StoreError::Io(error) if error.kind() == std::io::ErrorKind::NotFound => { + SessionPublicIndexReadError::Missing + } + StoreError::Corrupt { message, .. } if message.contains("requires migration") => { + SessionPublicIndexReadError::MigrationRequired + } + StoreError::Io(_) => SessionPublicIndexReadError::Storage, + StoreError::ReadLimitExceeded => SessionPublicIndexReadError::ResourceLimit, + _ => SessionPublicIndexReadError::Corrupt, + } +} + +fn map_store_error(error: StoreError) -> SessionPublicIndexReadError { + match error { + StoreError::Corrupt { message, .. } if message.contains("requires migration") => { + SessionPublicIndexReadError::MigrationRequired + } + StoreError::ReadLimitExceeded => SessionPublicIndexReadError::ResourceLimit, + StoreError::Io(_) => SessionPublicIndexReadError::Storage, + StoreError::NotFound(_) | StoreError::Serde(_) | StoreError::Corrupt { .. } => { + SessionPublicIndexReadError::Corrupt + } + _ => SessionPublicIndexReadError::Corrupt, + } +} + +#[cfg(test)] +mod tests { + use agen::EngineResult; + use agen::llm_client::RequestConfig; + use protocol::{InvokeKind, RunFailureKind, RunResumeSource, RunYieldReason}; + + use super::*; + use crate::{ + LoggedContentPart, LoggedHistoryEntry, LoggedItem, LoggedRole, LoggedSessionHistoryEntryId, + LoggedSessionHistoryMetadata, LoggedSessionHistoryOrigin, LoggedSystemHistoryEntry, Store, + SystemItem, + }; + + fn segment_id(value: u128) -> SegmentId { + uuid::Uuid::from_u128(value) + } + + fn history( + id: &str, + item: LoggedItem, + origin: LoggedSessionHistoryOrigin, + ) -> LoggedHistoryEntry { + LoggedHistoryEntry { + item, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId(id.to_owned()), + origin, + derivation: None, + }, + } + } + + fn message(id: &str, role: LoggedRole, text: &str) -> LoggedHistoryEntry { + let origin = match role { + LoggedRole::User => LoggedSessionHistoryOrigin::HumanInput { + account_id: "account-1".into(), + }, + LoggedRole::Assistant => LoggedSessionHistoryOrigin::ModelOutput { + worker: crate::LoggedWorkerSubject { + workspace_id: Some("workspace-1".into()), + runtime_id: Some("runtime-1".into()), + worker_id: "worker-1".into(), + }, + }, + LoggedRole::System => { + LoggedSessionHistoryOrigin::BackendInstruction { operation_id: None } + } + }; + history( + id, + LoggedItem::Message { + role, + content: vec![LoggedContentPart::Text { text: text.into() }], + }, + origin, + ) + } + + fn start( + session_id: SessionId, + history: Vec, + forked_from: Option, + compacted_from: Option, + ) -> LogEntry { + LogEntry::AnnotatedSegmentStart { + ts: 1, + session_id, + system_prompt: Some("hidden system prompt".into()), + config: RequestConfig::default(), + history, + forked_from, + compacted_from, + } + } + + fn create_store() -> (tempfile::TempDir, std::path::PathBuf, WorkerSessionStore) { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().join("session"); + let store = WorkerSessionStore::new(&root).unwrap(); + (temp, root, store) + } + + #[test] + fn indexes_all_segments_in_order_and_keeps_nonactive_branches() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let root_segment = segment_id(1); + let fork_segment = segment_id(2); + let compact_segment = segment_id(3); + let seed = message("seed", LoggedRole::User, "seed text"); + + store + .create_segment( + session_id, + compact_segment, + &[start( + session_id, + vec![seed.clone()], + None, + Some(SegmentOrigin { + segment_id: root_segment, + at_turn_index: 0, + }), + )], + ) + .unwrap(); + store + .create_segment( + session_id, + root_segment, + &[ + start(session_id, vec![seed.clone()], None, None), + LogEntry::AnnotatedAssistantItem { + ts: 2, + entry: message("root-answer", LoggedRole::Assistant, "root answer"), + }, + ], + ) + .unwrap(); + store + .create_segment( + session_id, + fork_segment, + &[ + start( + session_id, + vec![seed], + Some(SegmentOrigin { + segment_id: root_segment, + at_turn_index: 0, + }), + None, + ), + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message("branch-answer", LoggedRole::Assistant, "branch answer"), + }, + ], + ) + .unwrap(); + + let index = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_eq!( + index + .segments + .iter() + .map(|segment| segment.segment_id.as_str()) + .collect::>(), + vec![ + root_segment.to_string(), + fork_segment.to_string(), + compact_segment.to_string(), + ] + ); + assert!( + index.segments[0] + .entries + .iter() + .any(|entry| entry.full_text == "root answer") + ); + assert!( + index.segments[1] + .entries + .iter() + .any(|entry| entry.full_text == "branch answer") + ); + assert_eq!( + index.segments[1].lineage, + SessionPublicIndexLineage { + origin_kind: SessionPublicIndexOriginKind::Fork, + parent_segment_id: Some(root_segment.to_string()), + parent_turn_index: Some(0), + } + ); + assert_eq!( + index.segments[2].lineage.origin_kind, + SessionPublicIndexOriginKind::Compact + ); + assert_eq!(index.segments[0].entries[0].entry_ref, "Eseed"); + assert_eq!(index.segments[1].entries[0].entry_ref, "Eseed"); + assert_eq!(index.segments[2].entries[0].entry_ref, "Eseed"); + assert!( + index + .segments + .iter() + .flat_map(|segment| &segment.entries) + .all(|entry| entry.segment_id + == index + .segments + .iter() + .find(|segment| segment.entries.contains(entry)) + .unwrap() + .segment_id) + ); + } + + #[test] + fn excludes_system_reasoning_and_attachment_bodies_and_maps_tool_output_name() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let segment_id = segment_id(10); + let worker = crate::LoggedWorkerSubject { + workspace_id: Some("workspace-1".into()), + runtime_id: Some("runtime-1".into()), + worker_id: "worker-1".into(), + }; + let log = vec![ + start( + session_id, + vec![ + message("system", LoggedRole::System, "hidden system entry"), + message("user", LoggedRole::User, "visible user"), + history( + "reasoning", + LoggedItem::Reasoning { + text: "hidden reasoning".into(), + summary: vec!["hidden summary".into()], + encrypted_content: Some("hidden ciphertext".into()), + signature: None, + }, + LoggedSessionHistoryOrigin::ModelOutput { + worker: worker.clone(), + }, + ), + history( + "tool-call", + LoggedItem::ToolCall { + call_id: "call-1".into(), + name: "Read".into(), + arguments: "{\"path\":\"README.md\"}".into(), + call_index: Some(0), + execution_id: None, + status: None, + }, + LoggedSessionHistoryOrigin::ModelOutput { + worker: worker.clone(), + }, + ), + history( + "tool-output", + LoggedItem::ToolResult { + call_id: "call-1".into(), + summary: "read result".into(), + content: Some("visible output".into()), + attachments: vec![crate::logged_item::LoggedAttachment::Image { + mime_type: "image/png".into(), + data: b"secret attachment body".to_vec(), + }], + disposition: agen::tool::ToolResultDisposition::Success, + is_error: false, + }, + LoggedSessionHistoryOrigin::ToolOutput { worker }, + ), + ], + None, + None, + ), + LogEntry::AnnotatedSystemItem { + ts: 2, + entry: LoggedSystemHistoryEntry { + item: SystemItem::Notification { + message: "hidden notification".into(), + body: "hidden system body".into(), + prompt_provenance: None, + }, + metadata: LoggedSessionHistoryMetadata { + entry_id: LoggedSessionHistoryEntryId("system-item".into()), + origin: LoggedSessionHistoryOrigin::BackendInstruction { + operation_id: None, + }, + derivation: None, + }, + }, + extensions: Vec::new(), + }, + ]; + store.create_segment(session_id, segment_id, &log).unwrap(); + + let index = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + let entries = &index.segments[0].entries; + assert_eq!(entries.len(), 3); + assert_eq!(entries[0].entry_ref, "Euser"); + assert_eq!(entries[0].provenance, SessionEntryProvenance::HumanInput); + assert_eq!( + entries[1].tool_part, + Some(SessionPublicIndexToolPart::Input) + ); + assert_eq!(entries[1].tool_name.as_deref(), Some("Read")); + assert_eq!( + entries[2].tool_part, + Some(SessionPublicIndexToolPart::Output) + ); + assert_eq!(entries[2].tool_name.as_deref(), Some("Read")); + assert_eq!(entries[2].compact_text, "read result"); + assert_eq!(entries[2].full_text, "visible output"); + let json = serde_json::to_string(&index).unwrap(); + for hidden in [ + "hidden system prompt", + "hidden system entry", + "hidden reasoning", + "hidden ciphertext", + "hidden notification", + "hidden system body", + "secret attachment body", + ] { + assert!(!json.contains(hidden), "public index leaked {hidden:?}"); + } + } + + #[test] + fn excludes_uncommitted_cancelled_and_failed_outputs_but_keeps_committed_outcomes() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let outcomes = vec![ + ( + segment_id(20), + "completed", + LogEntry::RunCompleted { + ts: 4, + interrupted: false, + result: EngineResult::Finished, + active_run_turn_count: None, + }, + ), + ( + segment_id(21), + "yielded", + LogEntry::RunYielded { + ts: 4, + entry_id: Some(LoggedSessionHistoryEntryId("yielded-transition".into())), + reason: RunYieldReason::Compaction, + active_run_turn_count: 1, + }, + ), + ( + segment_id(22), + "cancelled", + LogEntry::RunCancelled { + ts: 4, + entry_id: Some(LoggedSessionHistoryEntryId("cancelled-transition".into())), + }, + ), + ( + segment_id(23), + "errored", + LogEntry::RunErrored { + ts: 4, + entry_id: Some(LoggedSessionHistoryEntryId("errored-transition".into())), + interrupted: false, + message: "run failed".into(), + failure: Some(RunFailureKind::Compaction), + }, + ), + ( + segment_id(24), + "abandoned", + LogEntry::PausedTurnAbandoned { ts: 4 }, + ), + ]; + for (segment_id, text, outcome) in outcomes { + store + .create_segment( + session_id, + segment_id, + &[ + start( + session_id, + vec![message( + &format!("seed-{text}"), + LoggedRole::User, + "committed seed", + )], + None, + None, + ), + LogEntry::Invoke { + ts: 2, + trigger: InvokeKind::UserSend, + }, + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message(text, LoggedRole::Assistant, text), + }, + outcome, + ], + ) + .unwrap(); + } + let active_segment = segment_id(25); + store + .create_segment( + session_id, + active_segment, + &[ + start( + session_id, + vec![message("active-seed", LoggedRole::User, "committed seed")], + None, + None, + ), + LogEntry::Invoke { + ts: 2, + trigger: InvokeKind::UserSend, + }, + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message( + "uncommitted-output", + LoggedRole::Assistant, + "uncommitted active output", + ), + }, + ], + ) + .unwrap(); + let resumed_segment = segment_id(26); + store + .create_segment( + session_id, + resumed_segment, + &[ + start( + session_id, + vec![message( + "yielded-seed", + LoggedRole::Assistant, + "committed yielded seed", + )], + None, + None, + ), + LogEntry::RunResumed { + ts: 2, + entry_id: Some(LoggedSessionHistoryEntryId("resumed-transition".into())), + source: RunResumeSource::Compaction, + active_run_turn_count: 1, + }, + LogEntry::AnnotatedAssistantItem { + ts: 3, + entry: message( + "uncommitted-resume", + LoggedRole::Assistant, + "uncommitted resumed output", + ), + }, + ], + ) + .unwrap(); + + let index = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + let all_text: Vec<&str> = index + .segments + .iter() + .flat_map(|segment| &segment.entries) + .map(|entry| entry.full_text.as_str()) + .collect(); + for retained in ["completed", "yielded"] { + assert!(all_text.contains(&retained)); + } + for excluded in [ + "cancelled", + "errored", + "abandoned", + "uncommitted active output", + "uncommitted resumed output", + ] { + assert!(!all_text.contains(&excluded)); + } + assert!(all_text.contains(&"committed seed")); + assert!(all_text.contains(&"committed yielded seed")); + } + + #[test] + fn generation_is_stable_and_detects_append_replacement_and_segment_addition() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let first_segment = segment_id(30); + let initial = vec![start( + session_id, + vec![message("entry-1", LoggedRole::User, "one")], + None, + None, + )]; + store + .create_segment(session_id, first_segment, &initial) + .unwrap(); + + let first = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + let unchanged = + read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_eq!(first.generation, unchanged.generation); + + store + .append( + session_id, + first_segment, + &LogEntry::AnnotatedAssistantItem { + ts: 2, + entry: message("entry-2", LoggedRole::Assistant, "two"), + }, + ) + .unwrap(); + let appended = + read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_ne!(first.generation, appended.generation); + + store + .create_segment( + session_id, + first_segment, + &[start( + session_id, + vec![message("entry-1", LoggedRole::User, "replacement")], + None, + None, + )], + ) + .unwrap(); + let replaced = + read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_ne!(appended.generation, replaced.generation); + + store + .create_segment( + session_id, + segment_id(31), + &[start(session_id, Vec::new(), None, None)], + ) + .unwrap(); + let added = read_session_public_index(&root, SessionPublicIndexLimits::default()).unwrap(); + assert_ne!(replaced.generation, added.generation); + } + + #[test] + fn enforces_byte_segment_and_entry_bounds_with_typed_errors() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + store + .create_segment( + session_id, + segment_id(40), + &[start( + session_id, + vec![message("entry-1", LoggedRole::User, "bounded")], + None, + None, + )], + ) + .unwrap(); + store + .create_segment( + session_id, + segment_id(41), + &[start(session_id, Vec::new(), None, None)], + ) + .unwrap(); + + assert_eq!( + read_session_public_index( + &root, + SessionPublicIndexLimits { + max_segments: 1, + ..SessionPublicIndexLimits::default() + }, + ), + Err(SessionPublicIndexReadError::ResourceLimit) + ); + assert_eq!( + read_session_public_index( + &root, + SessionPublicIndexLimits { + max_entries: 1, + ..SessionPublicIndexLimits::default() + }, + ), + Err(SessionPublicIndexReadError::ResourceLimit) + ); + assert_eq!( + read_session_public_index( + &root, + SessionPublicIndexLimits { + max_bytes: 1, + ..SessionPublicIndexLimits::default() + }, + ), + Err(SessionPublicIndexReadError::ResourceLimit) + ); + assert_eq!( + read_session_public_index(&root.join("missing"), SessionPublicIndexLimits::default(),), + Err(SessionPublicIndexReadError::Missing) + ); + } + + #[test] + fn rejects_invalid_session_and_lineage_origins() { + let (_temp, root, store) = create_store(); + let session_id = crate::new_session_id(); + let root_segment = segment_id(50); + let child_segment = segment_id(51); + store + .create_segment( + session_id, + root_segment, + &[start(session_id, Vec::new(), None, None)], + ) + .unwrap(); + store + .create_segment( + session_id, + child_segment, + &[start( + session_id, + Vec::new(), + Some(SegmentOrigin { + segment_id: root_segment, + at_turn_index: 1, + }), + None, + )], + ) + .unwrap(); + assert_eq!( + read_session_public_index(&root, SessionPublicIndexLimits::default()), + Err(SessionPublicIndexReadError::Corrupt) + ); + } + + #[test] + fn reports_migration_without_rewriting_the_manifest() { + let temp = tempfile::TempDir::new().unwrap(); + let root = temp.path().join("session"); + std::fs::create_dir_all(root.join("segments")).unwrap(); + let session_id = crate::new_session_id(); + let manifest = format!("{{\"schema_version\":2,\"session_id\":\"{session_id}\"}}"); + std::fs::write(root.join("session.json"), &manifest).unwrap(); + + assert_eq!( + read_session_public_index(&root, SessionPublicIndexLimits::default()), + Err(SessionPublicIndexReadError::MigrationRequired) + ); + assert_eq!( + std::fs::read_to_string(root.join("session.json")).unwrap(), + manifest + ); + } +} diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 651e8d2c4..9fdadc373 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -978,7 +978,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess project_session_snapshot_for_segment(session_id, None, log) } -fn project_session_snapshot_for_segment( +pub(crate) fn project_session_snapshot_for_segment( session_id: SessionId, segment_id: Option, log: &[LogEntry], diff --git a/crates/session-store/src/worker_session_store.rs b/crates/session-store/src/worker_session_store.rs index a23f50bb4..f6820cff1 100644 --- a/crates/session-store/src/worker_session_store.rs +++ b/crates/session-store/src/worker_session_store.rs @@ -468,6 +468,23 @@ impl WorkerSessionStore { Ok(()) } + /// Enumerate canonical Segment logs in ascending identity order without + /// creating or migrating state. Unlike the general [`Store`] listing, this + /// observation boundary rejects malformed names and non-regular files so a + /// persisted Segment cannot be silently omitted from a public index. + pub(crate) fn list_segments_read_only( + &self, + session_id: SessionId, + ) -> Result, StoreError> { + self.validate_retained_session(session_id)?; + segment_log_paths(&self.root).map(|paths| { + paths + .into_iter() + .map(|(segment_id, _)| segment_id) + .collect() + }) + } + pub fn segment_log_len(&self, segment_id: SegmentId) -> Result { Ok(fs::metadata(self.log_path(segment_id))?.len()) } diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index e3b5ca2c3..db90c1159 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -2312,6 +2312,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s if path.starts_with("/v1/workers/") && *method == Method::DELETE { return Some("workers:delete"); } + if path.starts_with("/v1/session-public/") && *method == Method::POST { + return Some("workers:read"); + } if path.starts_with("/v1/workers/") && *method == Method::GET { return Some("workers:read"); } 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 ec0045d06..c6992e09e 100644 --- a/crates/worker-runtime/src/http_server/runtime_management_api.rs +++ b/crates/worker-runtime/src/http_server/runtime_management_api.rs @@ -289,6 +289,80 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi { .map_err(api_error) } + async fn session_public_search( + &self, + request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + 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_session_public_scope_required", + "Session public search requires a Workspace-scoped capability", + ) + })?; + if scope.workspace_id != request.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_session_public_workspace_scope_mismatch", + "Session public search Workspace scope does not match the authenticated capability", + )); + } + #[cfg(feature = "fs-store")] + { + self.state + .runtime + .session_public_search_scoped(&scope, &request) + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error) + } + #[cfg(not(feature = "fs-store"))] + { + Ok(runtime_api::SessionPublicSearchAvailability::Unavailable { + reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable, + message: "Session public storage is unavailable".to_string(), + }) + } + } + + async fn session_public_read( + &self, + request: runtime_api::SessionPublicReadRequest, + ) -> Result { + 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_session_public_scope_required", + "Session public read requires a Workspace-scoped capability", + ) + })?; + if scope.workspace_id != request.workspace_id { + return Err(runtime_api::RuntimeApiError::new( + StatusCode::FORBIDDEN.as_u16(), + "runtime_session_public_workspace_scope_mismatch", + "Session public read Workspace scope does not match the authenticated capability", + )); + } + #[cfg(feature = "fs-store")] + { + self.state + .runtime + .session_public_read_scoped(&scope, &request) + .map_err(RuntimeHttpRestError::runtime) + .map_err(api_error) + } + #[cfg(not(feature = "fs-store"))] + { + Ok(runtime_api::SessionPublicReadAvailability::Unavailable { + reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable, + message: "Session public storage is unavailable".to_string(), + }) + } + } + async fn create_worker( &self, value: runtime_api::CreateWorkerRequest, diff --git a/crates/worker-runtime/src/lib.rs b/crates/worker-runtime/src/lib.rs index 9c7c3df04..202cde59e 100644 --- a/crates/worker-runtime/src/lib.rs +++ b/crates/worker-runtime/src/lib.rs @@ -25,6 +25,8 @@ pub mod resource; #[cfg(feature = "fs-store")] pub mod retention; mod runtime; +#[cfg(feature = "fs-store")] +mod session_public; pub mod ssh_host_key_probe; pub mod worker_backend; pub mod worker_source; diff --git a/crates/worker-runtime/src/retention.rs b/crates/worker-runtime/src/retention.rs index 0d02fb3e1..a956ac1bf 100644 --- a/crates/worker-runtime/src/retention.rs +++ b/crates/worker-runtime/src/retention.rs @@ -356,6 +356,26 @@ impl FsWorkerRetentionProvider { .join(archive_id)) } + pub(crate) fn session_archive( + &self, + archive_id: &str, + ) -> Result<(WorkerSessionArchiveManifest, PathBuf), RuntimeError> { + let archive_dir = self.archive_dir(archive_id)?; + let manifest: WorkerSessionArchiveManifest = read_json( + &archive_dir.join("manifest.json"), + "read Worker Session archive", + )?; + if manifest.schema_version != ARCHIVE_SCHEMA_VERSION || manifest.archive_id != archive_id { + return Err(RuntimeError::StoreCorrupt { + operation: "read Worker Session archive", + path: archive_dir.join("manifest.json"), + message: "archive manifest identity mismatch".to_string(), + }); + } + let manifest = validate_existing_archive(&archive_dir, &manifest)?; + Ok((manifest, archive_dir.join("session"))) + } + fn diagnostics_dir(&self, operation_id: &str) -> Result { validate_id("operation_id", operation_id)?; Ok(self diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index c80787cf3..e9e630069 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -77,6 +77,41 @@ impl RuntimeWorkspaceScope { } const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256; + +#[cfg(feature = "fs-store")] +fn session_public_source_unavailable( + source: &runtime_api::SessionPublicSource, + error: &RuntimeError, +) -> Option<(runtime_api::SessionPublicUnavailableReason, &'static str)> { + use runtime_api::SessionPublicUnavailableReason as Reason; + + match (source, error) { + ( + runtime_api::SessionPublicSource::Retained { .. }, + RuntimeError::WorkerNotFound { .. }, + ) => Some(( + Reason::RetentionMissing, + "retained Session storage is missing", + )), + (runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreMissing { .. }) => { + Some(( + Reason::ArchiveIncomplete, + "committed Session archive is incomplete", + )) + } + (runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreCorrupt { .. }) => { + Some(( + Reason::ArchiveIncomplete, + "committed Session archive failed integrity validation", + )) + } + (_, RuntimeError::StoreIo { .. } | RuntimeError::StoreCommitOutcomeUnknown { .. }) => { + Some((Reason::StorageUnavailable, "Session storage is unavailable")) + } + _ => None, + } +} + const WORKER_DELETE_FAILURE_DIAGNOSTIC_CODE: &str = "worker_delete_persistence_failed"; const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str = "Worker metadata deletion failed; the persisted Worker identity was retained for retry"; @@ -3543,6 +3578,123 @@ impl Runtime { ) } + /// Search the committed public projection of one Backend-selected retained + /// Session or verified archive without restoring a Worker. + #[cfg(feature = "fs-store")] + pub fn session_public_search_scoped( + &self, + scope: &RuntimeWorkspaceScope, + request: &runtime_api::SessionPublicSearchRequest, + ) -> Result { + let (session_root, archive) = + match self.resolve_session_public_source(scope, &request.source) { + Ok(source) => source, + Err(error) => { + if let Some((reason, message)) = + session_public_source_unavailable(&request.source, &error) + { + return Ok(runtime_api::SessionPublicSearchAvailability::Unavailable { + reason, + message: message.to_string(), + }); + } + return Err(error); + } + }; + Ok(crate::session_public::search( + &session_root, + request, + archive.as_ref(), + )) + } + + /// Read one exact public entry from a Backend-selected retained Session or + /// verified archive. No fallback Worker, Segment, or entry is selected. + #[cfg(feature = "fs-store")] + pub fn session_public_read_scoped( + &self, + scope: &RuntimeWorkspaceScope, + request: &runtime_api::SessionPublicReadRequest, + ) -> Result { + let (session_root, archive) = + match self.resolve_session_public_source(scope, &request.source) { + Ok(source) => source, + Err(error) => { + if let Some((reason, message)) = + session_public_source_unavailable(&request.source, &error) + { + return Ok(runtime_api::SessionPublicReadAvailability::Unavailable { + reason, + message: message.to_string(), + }); + } + return Err(error); + } + }; + Ok(crate::session_public::read( + &session_root, + request, + archive.as_ref(), + )) + } + + #[cfg(feature = "fs-store")] + fn resolve_session_public_source( + &self, + scope: &RuntimeWorkspaceScope, + source: &runtime_api::SessionPublicSource, + ) -> Result< + ( + std::path::PathBuf, + Option, + ), + RuntimeError, + > { + let state = self.lock()?; + let store = state.fs_store().ok_or_else(|| { + RuntimeError::InvalidRequest( + "Session public reads require an fs-backed Runtime".to_string(), + ) + })?; + match source { + runtime_api::SessionPublicSource::Retained { worker_id } => { + let worker_ref = WorkerRef::new(*worker_id); + let worker = state.worker(&worker_ref)?; + if worker.workspace_id.as_deref() != Some(scope.workspace_id.as_str()) { + return Err(RuntimeError::WorkerNotFound { + worker_id: *worker_id, + }); + } + Ok(( + store + .runtime_dir() + .join("workers") + .join(worker_id.to_string()) + .join("session"), + None, + )) + } + runtime_api::SessionPublicSource::Archived { archive_id } => { + let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| { + RuntimeError::InvalidRequest( + "Runtime identity is not bound for Session archive reads".to_string(), + ) + })?; + let provider = FsWorkerRetentionProvider::new(store.runtime_dir()); + let (manifest, session_root) = provider.session_archive(archive_id)?; + if manifest.workspace_id != scope.workspace_id + || manifest.source_runtime_id != runtime_id + { + return Err(RuntimeError::InvalidRequest( + "Session archive scope does not match the authenticated Runtime capability" + .to_string(), + )); + } + Ok((session_root, Some(manifest))) + } + } + } + /// Enumerate host-authoritative Runtime inventory for Backend orphan /// reconciliation. Runtime identity and Workspace scope are derived here, /// not accepted in a diagnostic payload. diff --git a/crates/worker-runtime/src/session_public.rs b/crates/worker-runtime/src/session_public.rs new file mode 100644 index 000000000..1ff39224c --- /dev/null +++ b/crates/worker-runtime/src/session_public.rs @@ -0,0 +1,357 @@ +use std::path::Path; + +use runtime_api::{ + SessionPublicEntryKind, SessionPublicLineage, SessionPublicLineageKind, + SessionPublicReadAvailability, SessionPublicReadMode, SessionPublicReadPage, + SessionPublicReadRequest, SessionPublicSearchAvailability, SessionPublicSearchItem, + SessionPublicSearchPage, SessionPublicSearchRequest, SessionPublicToolPart, + SessionPublicUnavailableReason, WorkerSessionArchiveManifest, +}; +use session_store::{ + SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind, + SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind, + SessionPublicIndexReadError, SessionPublicIndexToolPart, read_session_public_index, +}; + +use crate::retention::WorkerSessionArchiveManifest as DomainArchiveManifest; + +pub(crate) fn search( + session_root: &Path, + request: &SessionPublicSearchRequest, + archive_manifest: Option<&DomainArchiveManifest>, +) -> SessionPublicSearchAvailability { + if request.limit == 0 || request.limit > 100 { + return unavailable( + SessionPublicUnavailableReason::ResourceLimit, + "Session public search limit must be between 1 and 100", + ); + } + let index = match load_index( + session_root, + request.max_scan_bytes, + request.max_segments, + request.max_entries, + ) { + Ok(index) => index, + Err((reason, message)) => return unavailable(reason, message), + }; + if index.session_id != request.expected_session_id { + return unavailable( + SessionPublicUnavailableReason::NotFound, + "Session identity does not match the authorized source", + ); + } + if request + .expected_generation + .as_deref() + .is_some_and(|expected| expected != index.generation) + { + return unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public search generation changed", + ); + } + + let query = request + .query + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_lowercase); + let mut matches = Vec::new(); + for segment in &index.segments { + for entry in &segment.entries { + if !matches_entry(entry, request, query.as_deref()) { + continue; + } + matches.push(SessionPublicSearchItem { + segment_id: segment.segment_id.clone(), + entry_ref: entry.entry_ref.clone(), + kind: entry_kind(entry.kind), + origin: entry.provenance.clone(), + tool_name: entry.tool_name.clone(), + tool_part: entry.tool_part.map(tool_part), + compact: entry.compact_text.clone(), + lineage: lineage(&segment.lineage), + }); + } + } + if request.offset > matches.len() { + return unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public search position is stale", + ); + } + let end = request + .offset + .saturating_add(request.limit) + .min(matches.len()); + let items = matches[request.offset..end].to_vec(); + let has_more = end < matches.len(); + SessionPublicSearchAvailability::Page { + page: SessionPublicSearchPage { + session_id: index.session_id, + generation: index.generation, + items, + next_offset: has_more.then_some(end), + has_more, + scanned_bytes: index.scanned_bytes, + scanned_segments: index.segments.len(), + scanned_entries: index.scanned_entries, + archive_manifest: archive_manifest.map(api_archive_manifest), + }, + } +} + +pub(crate) fn read( + session_root: &Path, + request: &SessionPublicReadRequest, + archive_manifest: Option<&DomainArchiveManifest>, +) -> SessionPublicReadAvailability { + if request.max_content_bytes == 0 { + return read_unavailable( + SessionPublicUnavailableReason::ResourceLimit, + "Session public read content limit must be positive", + ); + } + let index = match load_index( + session_root, + request.max_scan_bytes, + request.max_segments, + request.max_entries, + ) { + Ok(index) => index, + Err((reason, message)) => return read_unavailable(reason, message), + }; + if index.session_id != request.expected_session_id { + return read_unavailable( + SessionPublicUnavailableReason::NotFound, + "Session identity does not match the authorized source", + ); + } + if request + .expected_generation + .as_deref() + .is_some_and(|expected| expected != index.generation) + { + return read_unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public read generation changed", + ); + } + let Some(segment) = index + .segments + .iter() + .find(|segment| segment.segment_id == request.segment_id) + else { + return read_unavailable( + SessionPublicUnavailableReason::NotFound, + "Session entry was not found in the authorized source", + ); + }; + let mut entries = segment + .entries + .iter() + .filter(|entry| entry.entry_ref == request.entry_ref); + let Some(entry) = entries.next() else { + return read_unavailable( + SessionPublicUnavailableReason::NotFound, + "Session entry was not found in the authorized source", + ); + }; + if entries.next().is_some() { + return read_unavailable( + SessionPublicUnavailableReason::CorruptLog, + "Session entry identity is ambiguous in its Segment", + ); + } + let content = match request.mode { + SessionPublicReadMode::Compact => &entry.compact_text, + SessionPublicReadMode::Full => &entry.full_text, + }; + if request.byte_offset > content.len() || !content.is_char_boundary(request.byte_offset) { + return read_unavailable( + SessionPublicUnavailableReason::InvalidCursor, + "Session public read position is stale", + ); + } + let mut end = request + .byte_offset + .saturating_add(request.max_content_bytes) + .min(content.len()); + while end > request.byte_offset && !content.is_char_boundary(end) { + end -= 1; + } + if end == request.byte_offset && request.byte_offset < content.len() { + return read_unavailable( + SessionPublicUnavailableReason::ResourceLimit, + "Session public read cannot fit one UTF-8 scalar in the content limit", + ); + } + let has_more = end < content.len(); + SessionPublicReadAvailability::Page { + page: SessionPublicReadPage { + session_id: index.session_id, + generation: index.generation, + segment_id: segment.segment_id.clone(), + entry_ref: entry.entry_ref.clone(), + kind: entry_kind(entry.kind), + origin: entry.provenance.clone(), + lineage: lineage(&segment.lineage), + mode: request.mode, + content: content[request.byte_offset..end].to_string(), + next_byte_offset: has_more.then_some(end), + has_more, + scanned_bytes: index.scanned_bytes, + scanned_segments: index.segments.len(), + scanned_entries: index.scanned_entries, + archive_manifest: archive_manifest.map(api_archive_manifest), + }, + } +} + +fn load_index( + session_root: &Path, + max_bytes: u64, + max_segments: usize, + max_entries: usize, +) -> Result { + read_session_public_index( + session_root, + SessionPublicIndexLimits { + max_bytes, + max_segments, + max_entries, + }, + ) + .map_err(map_index_error) +} + +fn matches_entry( + entry: &SessionPublicIndexEntry, + request: &SessionPublicSearchRequest, + query: Option<&str>, +) -> bool { + if request + .kind + .is_some_and(|kind| kind != entry_kind(entry.kind)) + { + return false; + } + if request.tool_name.is_some() || request.tool_part != SessionPublicToolPart::Both { + if entry.kind != SessionPublicIndexEntryKind::Tool { + return false; + } + } + if request + .tool_name + .as_deref() + .is_some_and(|name| entry.tool_name.as_deref() != Some(name)) + { + return false; + } + if request.tool_part != SessionPublicToolPart::Both + && entry.tool_part.map(tool_part) != Some(request.tool_part) + { + return false; + } + query.is_none_or(|query| entry.full_text.to_lowercase().contains(query)) +} + +fn entry_kind(kind: SessionPublicIndexEntryKind) -> SessionPublicEntryKind { + match kind { + SessionPublicIndexEntryKind::User => SessionPublicEntryKind::User, + SessionPublicIndexEntryKind::Assistant => SessionPublicEntryKind::Assistant, + SessionPublicIndexEntryKind::Tool => SessionPublicEntryKind::Tool, + } +} + +fn tool_part(part: SessionPublicIndexToolPart) -> SessionPublicToolPart { + match part { + SessionPublicIndexToolPart::Input => SessionPublicToolPart::Input, + SessionPublicIndexToolPart::Output => SessionPublicToolPart::Output, + } +} + +fn lineage(value: &SessionPublicIndexLineage) -> SessionPublicLineage { + SessionPublicLineage { + kind: match value.origin_kind { + SessionPublicIndexOriginKind::Root => SessionPublicLineageKind::Root, + SessionPublicIndexOriginKind::Fork => SessionPublicLineageKind::Fork, + SessionPublicIndexOriginKind::Compact => SessionPublicLineageKind::Compact, + }, + parent_segment_id: value.parent_segment_id.clone(), + at_turn_index: value.parent_turn_index, + } +} + +fn map_index_error( + error: SessionPublicIndexReadError, +) -> (SessionPublicUnavailableReason, &'static str) { + match error { + SessionPublicIndexReadError::MigrationRequired => ( + SessionPublicUnavailableReason::MigrationRequired, + "Session storage requires migration", + ), + SessionPublicIndexReadError::Missing => ( + SessionPublicUnavailableReason::RetentionMissing, + "Session storage is missing", + ), + SessionPublicIndexReadError::Corrupt => ( + SessionPublicUnavailableReason::CorruptLog, + "Session log is corrupt", + ), + SessionPublicIndexReadError::Storage => ( + SessionPublicUnavailableReason::StorageUnavailable, + "Session storage is unavailable", + ), + SessionPublicIndexReadError::ResourceLimit => ( + SessionPublicUnavailableReason::ResourceLimit, + "Session scan exceeds a resource limit", + ), + } +} + +fn unavailable( + reason: SessionPublicUnavailableReason, + message: impl Into, +) -> SessionPublicSearchAvailability { + SessionPublicSearchAvailability::Unavailable { + reason, + message: message.into(), + } +} + +fn read_unavailable( + reason: SessionPublicUnavailableReason, + message: impl Into, +) -> SessionPublicReadAvailability { + SessionPublicReadAvailability::Unavailable { + reason, + message: message.into(), + } +} + +fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiveManifest { + WorkerSessionArchiveManifest { + schema_version: manifest.schema_version, + archive_id: manifest.archive_id.clone(), + workspace_id: manifest.workspace_id.clone(), + source_runtime_id: manifest.source_runtime_id.clone(), + source_worker_id: manifest.source_worker_id, + source_session_id: manifest.source_session_id.clone(), + segment_ids: manifest.segment_ids.clone(), + source_created_at: manifest.source_created_at.clone(), + removed_at: manifest.removed_at.clone(), + archived_at_unix_seconds: manifest.archived_at_unix_seconds, + effective_profile: manifest.effective_profile.clone(), + retention_class: manifest.retention_class.clone(), + content_checksum_sha256: manifest.content_checksum_sha256.clone(), + content_bytes: manifest.content_bytes, + content_file_count: manifest.content_file_count, + policy_id: manifest.policy_id.clone(), + policy_revision: manifest.policy_revision, + operation_id: manifest.operation_id.clone(), + input_fingerprint: manifest.input_fingerprint.clone(), + } +} diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index db1ae1c11..bb700ae41 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -581,6 +581,13 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { client.subjektiv_memory_backend(workspace_id, request).await, ) } + WorkspaceServerOperation::SubjektivSession(request) => { + generated_workspace_response( + client + .subjektiv_session_backend(workspace_id, request) + .await, + ) + } } }) }) diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 4a0d99bbe..a47d06d3f 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1307,6 +1307,15 @@ where { feature_registry.add_module(subjektiv_memory); } + if let Some(subjektiv_sessions) = + crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config( + &worker.manifest().feature.subjektiv, + worker.committed_session_capture_handle(), + worker.workspace_client_handle(), + )? + { + feature_registry.add_module(subjektiv_sessions); + } if let Some(memory_config) = memory_lifecycle_config && let Some(memory_lifecycle) = crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( diff --git a/crates/worker/src/feature/builtin.rs b/crates/worker/src/feature/builtin.rs index 3adbc5987..b1206d1bc 100644 --- a/crates/worker/src/feature/builtin.rs +++ b/crates/worker/src/feature/builtin.rs @@ -19,6 +19,7 @@ pub mod orchestration; mod resource_projection; pub mod session_explore; pub(crate) mod subjektiv_memory; +pub(crate) mod subjektiv_session; pub mod task; pub mod ticket; pub mod worker_observation; diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 3bdfc9759..d645a1670 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -2078,6 +2078,7 @@ permission = "write" segment_id: "segment-1".to_string(), session_revision: history_len.try_into().unwrap(), entry_count: history_len, + has_committed_run: true, run_exit: CommittedRunExit::Finished, history: (0..history_len) .map(|index| HistoryEntry { diff --git a/crates/worker/src/feature/builtin/subjektiv_memory.rs b/crates/worker/src/feature/builtin/subjektiv_memory.rs index 9a901e1e7..15c0cc9e1 100644 --- a/crates/worker/src/feature/builtin/subjektiv_memory.rs +++ b/crates/worker/src/feature/builtin/subjektiv_memory.rs @@ -1101,6 +1101,7 @@ mod tests { segment_id: "segment-1".into(), session_revision: 1, entry_count: 0, + has_committed_run: true, run_exit: CommittedRunExit::Finished, history: Vec::new(), usage_history: Vec::new(), @@ -1351,6 +1352,7 @@ mod tests { segment_id: "segment-1".into(), session_revision: 1, entry_count: 3, + has_committed_run: true, run_exit: CommittedRunExit::Interrupted, history: vec![ crate::session_history::history_entry( @@ -1410,6 +1412,7 @@ mod tests { segment_id: "segment-1".into(), session_revision: 2, entry_count: 2, + has_committed_run: true, run_exit: CommittedRunExit::Finished, history: vec![ crate::session_history::history_entry( @@ -1487,6 +1490,7 @@ mod tests { segment_id: "segment-1".into(), session_revision: 2, entry_count: 2, + has_committed_run: true, run_exit: CommittedRunExit::Finished, history: vec![ crate::session_history::history_entry( diff --git a/crates/worker/src/feature/builtin/subjektiv_session.rs b/crates/worker/src/feature/builtin/subjektiv_session.rs new file mode 100644 index 000000000..d5f101477 --- /dev/null +++ b/crates/worker/src/feature/builtin/subjektiv_session.rs @@ -0,0 +1,817 @@ +//! Host-scoped tools for exploring subject-attributed committed Sessions. +//! +//! Subject, Runtime, Worker, archive, and filesystem authority stays bound by +//! the Workspace host. Model input contains only the selectors and filters from +//! the public `server_api` request types. + +use std::sync::Arc; + +use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput}; +use async_trait::async_trait; +use schemars::JsonSchema; +use server_api::{ + SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES, SubjektivRecordSessionRequest, + SubjektivRecordSessionResponse, SubjektivSessionBackendOperation, + SubjektivSessionBackendRequest, SubjektivSessionBackendResponse, SubjektivSessionBackendResult, + SubjektivSessionDiagnosticCode, SubjektivSessionErrorResponse, SubjektivSessionListRequest, + SubjektivSessionReadRequest, SubjektivSessionSearchRequest, +}; + +use crate::feature::session::CommittedSessionCaptureHandle; +use crate::feature::{ + FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule, + HookDeclaration, ToolContribution, ToolDeclaration, +}; +use crate::hook::{ + Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookFailurePolicy, + HookPreRequestAction, PreLlmRequest, PreRequestContext, RunCommitted, RunCommittedContext, +}; +use crate::worker::{WorkspaceClient, WorkspaceServerOperation}; + +const LIST_TOOL: &str = "SubjektivSessionList"; +const SEARCH_TOOL: &str = "SubjektivSessionSearch"; +const READ_TOOL: &str = "SubjektivSessionRead"; +const COMMIT_HOOK: &str = "record-subjektiv-session-after-commit"; +const RETRY_HOOK: &str = "retry-subjektiv-session-attribution"; + +const LIST_DESCRIPTION: &str = "List committed retained or archived Sessions attributed by the Host to the connected subject. Filter by exact session_id or storage and continue with the returned opaque cursor; subject, Runtime, Worker, archive, and filesystem authority are never model inputs."; +const SEARCH_DESCRIPTION: &str = "Search committed public entries across Host-authorized Sessions attributed to the connected subject, or list one Session's public entries when session_id is supplied and query is omitted. Search is case-insensitive literal matching; system prompts, hidden reasoning, traces, and attachment bodies are never exposed."; +const READ_DESCRIPTION: &str = "Read one exact committed public Session entry selected by session_id, segment_id, and entry_ref. Compact mode is the default; full mode remains bounded, and partial content must be continued with the returned opaque cursor."; + +#[derive(Clone)] +pub(crate) struct SubjektivSessionFeature { + state: SubjektivSessionState, +} + +#[derive(Clone)] +struct SubjektivSessionState { + client: Arc, + capture: CommittedSessionCaptureHandle, +} + +impl SubjektivSessionFeature { + pub(crate) fn from_resolved_config( + config: &manifest::ResolvedSubjektivFeatureConfig, + capture: CommittedSessionCaptureHandle, + client: Arc, + ) -> std::io::Result> { + if !config.profile.enabled { + return Ok(None); + } + config + .validate_execution() + .map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?; + if !client.is_available() || client.workspace_id().is_none() { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + "subjektiv Session tools require Backend Workspace API authority", + )); + } + Ok(Some(Self { + state: SubjektivSessionState { client, capture }, + })) + } +} + +impl FeatureModule for SubjektivSessionFeature { + fn descriptor(&self) -> FeatureDescriptor { + FeatureDescriptor::builtin("subjektiv-session-tools", "subjektiv Session Tools") + .with_description( + "Read-only discovery, search, and reading of Host-authorized subject Sessions.", + ) + .with_tool(ToolDeclaration::new(LIST_TOOL, LIST_DESCRIPTION)) + .with_tool(ToolDeclaration::new(SEARCH_TOOL, SEARCH_DESCRIPTION)) + .with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION)) + .with_hook(HookDeclaration::new( + COMMIT_HOOK, + FeatureHookPoint::RunCommitted, + )) + .with_hook(HookDeclaration::new( + RETRY_HOOK, + FeatureHookPoint::PreLlmRequest, + )) + } + + fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { + for (name, description, operation) in [ + (LIST_TOOL, LIST_DESCRIPTION, SessionOperation::List), + (SEARCH_TOOL, SEARCH_DESCRIPTION, SessionOperation::Search), + (READ_TOOL, READ_DESCRIPTION, SessionOperation::Read), + ] { + context.tools().register(ToolContribution::new( + name, + tool_definition(name, description, self.state.clone(), operation), + ))?; + } + context.hooks().add_run_committed( + COMMIT_HOOK, + HookExecutionPolicy::new(HookFailurePolicy::AttentionRequired, 30_000), + RecordCommittedSessionHook { + state: self.state.clone(), + }, + )?; + context.hooks().add_pre_request( + RETRY_HOOK, + RetryCommittedSessionHook { + state: self.state.clone(), + }, + ) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum SessionOperation { + List, + Search, + Read, +} + +fn tool_definition( + name: &'static str, + description: &'static str, + state: SubjektivSessionState, + operation: SessionOperation, +) -> ToolDefinition { + Arc::new(move || { + let schema = match operation { + SessionOperation::List => schema_for::(), + SessionOperation::Search => schema_for::(), + SessionOperation::Read => schema_for::(), + }; + let tool: Arc = Arc::new(SubjektivSessionTool { + state: state.clone(), + operation, + }); + ( + ToolMeta::new(name) + .description(description) + .input_schema(schema), + tool, + ) + }) +} + +struct SubjektivSessionTool { + state: SubjektivSessionState, + operation: SessionOperation, +} + +#[async_trait] +impl Tool for SubjektivSessionTool { + async fn execute( + &self, + input_json: &str, + _context: ToolExecutionContext, + ) -> Result { + let operation = match self.operation { + SessionOperation::List => { + SubjektivSessionBackendOperation::List(parse(input_json, LIST_TOOL)?) + } + SessionOperation::Search => { + SubjektivSessionBackendOperation::Search(parse(input_json, SEARCH_TOOL)?) + } + SessionOperation::Read => { + SubjektivSessionBackendOperation::Read(parse(input_json, READ_TOOL)?) + } + }; + let response = self.state.execute(operation)?; + model_visible_response(response) + } +} + +impl SubjektivSessionState { + fn execute( + &self, + operation: SubjektivSessionBackendOperation, + ) -> Result { + let response = self + .client + .execute_server_operation(WorkspaceServerOperation::SubjektivSession( + SubjektivSessionBackendRequest { operation }, + )) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + + if let Ok(response) = + serde_json::from_str::(&response.body) + { + return Ok(response); + } + let detail = serde_json::from_str::(&response.body) + .map(|error| error.message) + .unwrap_or(response.body); + Err(ToolError::ExecutionFailed(format!( + "subjektiv Session backend returned HTTP {}: {detail}", + response.status + ))) + } + + fn record_current_committed_session(&self) -> Result<(), HookError> { + let capture = self + .capture + .capture() + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + if !capture.has_committed_run { + return Err(HookError::new( + HookErrorCategory::Dependency, + "committed Session capture is not available yet", + )); + } + self.record_session(&capture.session_id) + } + + fn retry_current_committed_session(&self) -> Result<(), HookError> { + let capture = self + .capture + .capture() + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + if !capture.has_committed_run { + return Ok(()); + } + self.record_session(&capture.session_id) + } + + fn record_session(&self, session_id: &str) -> Result<(), HookError> { + let response = self + .client + .execute_server_operation(WorkspaceServerOperation::SubjektivRecordSession( + SubjektivRecordSessionRequest { + session_id: session_id.to_string(), + }, + )) + .map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?; + if !response.is_success() { + return Err(HookError::new( + HookErrorCategory::Dependency, + format!( + "record subjektiv Session attribution returned HTTP {}", + response.status + ), + )); + } + let output: SubjektivRecordSessionResponse = + serde_json::from_str(&response.body).map_err(|error| { + HookError::new( + HookErrorCategory::Dependency, + format!("decode subjektiv Session attribution response: {error}"), + ) + })?; + if output.session_id != session_id { + return Err(HookError::new( + HookErrorCategory::Dependency, + "subjektiv Session attribution response changed session identity", + )); + } + Ok(()) + } +} + +struct RecordCommittedSessionHook { + state: SubjektivSessionState, +} + +#[async_trait] +impl Hook for RecordCommittedSessionHook { + async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> { + self.state.record_current_committed_session() + } +} + +struct RetryCommittedSessionHook { + state: SubjektivSessionState, +} + +#[async_trait] +impl Hook for RetryCommittedSessionHook { + async fn call(&self, _input: &PreRequestContext) -> Result { + self.state.retry_current_committed_session()?; + Ok(HookPreRequestAction::Continue) + } +} + +fn parse(input: &str, tool: &str) -> Result { + serde_json::from_str(input) + .map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}"))) +} + +fn schema_for() -> serde_json::Value { + serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({})) +} + +fn model_visible_response( + response: SubjektivSessionBackendResponse, +) -> Result { + let summary = response_summary(&response); + let content = serde_json::to_string(&response) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES { + return Ok(ToolOutput { + summary, + content: Some(content), + attachments: Vec::new(), + }); + } + + let bounded = SubjektivSessionBackendResponse::Error { + error: SubjektivSessionErrorResponse { + code: SubjektivSessionDiagnosticCode::ResourceLimit, + message: "subjektiv Session response exceeded the 56 KiB model-visible JSON budget" + .to_string(), + }, + }; + let content = serde_json::to_string(&bounded) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + debug_assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES); + Ok(ToolOutput { + summary: "Subjektiv Session operation reached its output limit.".to_string(), + content: Some(content), + attachments: Vec::new(), + }) +} + +fn response_summary(response: &SubjektivSessionBackendResponse) -> String { + match response { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(value), + } => format!("Listed {} subject Session(s).", value.items.len()), + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Search(value), + } => format!("Found {} subject Session entries.", value.items.len()), + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::Read(value), + } => format!("Read subject Session entry {}.", value.entry_ref), + SubjektivSessionBackendResponse::Error { error } => { + format!("Subjektiv Session operation returned {:?}.", error.code) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + use std::sync::Mutex; + use std::sync::atomic::{AtomicUsize, Ordering}; + + use crate::feature::session::{CommittedRunExit, CommittedSessionCapture}; + use crate::hook::{HookHistoryRange, HookInvocationContext, PreRequestInfo, RunCommittedExit}; + use crate::worker::{ + WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, + }; + + #[derive(Debug)] + struct SessionToolClient { + response: Mutex, + requests: Mutex>, + } + + impl SessionToolClient { + fn new(response: SubjektivSessionBackendResponse) -> Self { + Self { + response: Mutex::new(response), + requests: Mutex::new(Vec::new()), + } + } + } + + impl WorkspaceClient for SessionToolClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "subjektiv-session-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&*self.response.lock().unwrap()).unwrap(), + }) + } + } + + #[derive(Debug, Default)] + struct AttributionClient { + requests: Mutex>, + recorded: Mutex>, + insertions: AtomicUsize, + failures: AtomicUsize, + } + + impl WorkspaceClient for AttributionClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace-test") + } + + fn kind(&self) -> &str { + "subjektiv-attribution-test" + } + + fn is_available(&self) -> bool { + true + } + + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request.clone()); + if self + .failures + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok() + { + return Ok(WorkspaceResponse { + status: 503, + body: "temporarily unavailable".into(), + }); + } + let input: SubjektivRecordSessionRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + if self + .recorded + .lock() + .unwrap() + .insert(input.session_id.clone()) + { + self.insertions.fetch_add(1, Ordering::SeqCst); + } + Ok(WorkspaceResponse { + status: 200, + body: serde_json::to_string(&SubjektivRecordSessionResponse { + subject_id: "subject-1".into(), + session_id: input.session_id, + }) + .unwrap(), + }) + } + } + + fn capture(has_committed_run: bool) -> CommittedSessionCaptureHandle { + CommittedSessionCaptureHandle::new(move || { + Ok(CommittedSessionCapture { + session_id: "session-1".into(), + segment_id: "segment-1".into(), + session_revision: usize::from(has_committed_run) as u64, + entry_count: usize::from(has_committed_run), + has_committed_run, + run_exit: CommittedRunExit::Finished, + history: Vec::new(), + usage_history: Vec::new(), + extensions: Vec::new(), + }) + }) + } + + fn list_response() -> SubjektivSessionBackendResponse { + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse { + items: Vec::new(), + next_cursor: None, + has_more: false, + }), + } + } + + fn state(client: Arc, has_committed_run: bool) -> SubjektivSessionState { + SubjektivSessionState { + client, + capture: capture(has_committed_run), + } + } + + fn feature(client: Arc) -> SubjektivSessionFeature { + SubjektivSessionFeature { + state: state(client, true), + } + } + + fn run_committed_context() -> RunCommittedContext { + RunCommittedContext { + invocation: HookInvocationContext { + workspace_id: Some("workspace-test".into()), + worker_id: "worker-1".into(), + session_id: "session-1".into(), + session_revision: 1, + run_id: Some("run-1".into()), + turn_index: Some(0), + call_id: None, + }, + exit: RunCommittedExit::Finished, + committed_history: HookHistoryRange::default(), + } + } + + fn pre_request_context() -> PreRequestContext { + PreRequestContext::new( + PreRequestInfo { + item_count: 0, + estimated_tokens: None, + turn_index: 0, + tool_calls_this_turn: 0, + }, + None, + ) + } + + #[test] + fn descriptor_and_definitions_register_exact_session_tools_and_typed_schemas() { + let feature = feature(Arc::new(SessionToolClient::new(list_response()))); + let descriptor = feature.descriptor(); + assert_eq!( + descriptor + .tools + .iter() + .map(|tool| (tool.name.as_str(), tool.description.as_str())) + .collect::>(), + vec![ + (LIST_TOOL, LIST_DESCRIPTION), + (SEARCH_TOOL, SEARCH_DESCRIPTION), + (READ_TOOL, READ_DESCRIPTION), + ] + ); + assert_eq!( + descriptor + .hooks + .iter() + .map(|hook| (hook.name.as_str(), hook.point.clone())) + .collect::>(), + vec![ + (COMMIT_HOOK, FeatureHookPoint::RunCommitted), + (RETRY_HOOK, FeatureHookPoint::PreLlmRequest), + ] + ); + + for (name, description, operation, expected_schema) in [ + ( + LIST_TOOL, + LIST_DESCRIPTION, + SessionOperation::List, + schema_for::(), + ), + ( + SEARCH_TOOL, + SEARCH_DESCRIPTION, + SessionOperation::Search, + schema_for::(), + ), + ( + READ_TOOL, + READ_DESCRIPTION, + SessionOperation::Read, + schema_for::(), + ), + ] { + let (meta, _) = tool_definition(name, description, feature.state.clone(), operation)(); + assert_eq!(meta.name, name); + assert_eq!(meta.description, description); + assert_eq!(meta.input_schema, expected_schema); + let properties = meta.input_schema["properties"].as_object().unwrap(); + for forbidden in [ + "subject_id", + "runtime_id", + "worker_id", + "archive_id", + "archive_path", + "path", + ] { + assert!(!properties.contains_key(forbidden)); + } + } + } + + #[test] + fn disabled_subjektiv_feature_registers_nothing() { + let config = manifest::ResolvedSubjektivFeatureConfig::default(); + let result = SubjektivSessionFeature::from_resolved_config( + &config, + capture(false), + Arc::new(SessionToolClient::new(list_response())), + ) + .unwrap(); + assert!(result.is_none()); + } + + #[tokio::test] + async fn tools_reject_model_supplied_host_scope_and_storage_paths() { + let client = Arc::new(SessionToolClient::new(list_response())); + for (operation, input) in [ + ( + SessionOperation::List, + r#"{"subject_id":"subject-1","archive_path":"/tmp/archive"}"#, + ), + ( + SessionOperation::Search, + r#"{"query":"needle","runtime_id":"runtime-1","worker_id":"worker-1"}"#, + ), + ( + SessionOperation::Read, + r#"{"session_id":"session-1","segment_id":"segment-1","entry_ref":"E1","path":"session.jsonl"}"#, + ), + ] { + let error = SubjektivSessionTool { + state: state(client.clone(), true), + operation, + } + .execute(input, ToolExecutionContext::new("call", "batch", 0)) + .await + .unwrap_err(); + assert!(matches!(error, ToolError::InvalidArgument(_))); + } + assert!(client.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn tool_routes_one_host_bound_operation_to_session_history() { + let client = Arc::new(SessionToolClient::new(list_response())); + let tool = SubjektivSessionTool { + state: state(client.clone(), true), + operation: SessionOperation::List, + }; + let output = tool + .execute( + r#"{"storage":"retained","limit":20}"#, + ToolExecutionContext::new("call-1", "batch-1", 0), + ) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Post); + assert_eq!( + requests[0].path, + "/api/w/workspace-test/subjektiv/session-history" + ); + let body: SubjektivSessionBackendRequest = + serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert!(matches!( + body.operation, + SubjektivSessionBackendOperation::List(SubjektivSessionListRequest { + session_id: None, + storage: server_api::SubjektivSessionStorageFilter::Retained, + limit: Some(20), + cursor: None, + }) + )); + let visible: SubjektivSessionBackendResponse = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert!(matches!( + visible, + SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(_) + } + )); + } + + #[tokio::test] + async fn typed_errors_are_visible_and_oversized_json_becomes_bounded_resource_limit() { + let typed_error = SubjektivSessionBackendResponse::Error { + error: SubjektivSessionErrorResponse { + code: SubjektivSessionDiagnosticCode::StaleCursor, + message: "restart the search".into(), + }, + }; + let error_tool = SubjektivSessionTool { + state: state(Arc::new(SessionToolClient::new(typed_error.clone())), true), + operation: SessionOperation::Search, + }; + let output = error_tool + .execute( + r#"{"query":"needle"}"#, + ToolExecutionContext::new("call-error", "batch", 0), + ) + .await + .unwrap(); + let visible: SubjektivSessionBackendResponse = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert!(matches!( + visible, + SubjektivSessionBackendResponse::Error { + error: SubjektivSessionErrorResponse { + code: SubjektivSessionDiagnosticCode::StaleCursor, + .. + } + } + )); + + let oversized = SubjektivSessionBackendResponse::Ok { + result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse { + items: vec![server_api::SubjektivSessionListItem { + session_id: "session-1".into(), + attributed_at: "2026-01-01T00:00:00Z".into(), + storage: server_api::SubjektivSessionStorage::Retained, + availability: server_api::SubjektivSessionAvailability::Unavailable, + reason: Some("\\\u{0000}".repeat(SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES)), + }], + next_cursor: Some("cursor".into()), + has_more: true, + }), + }; + let output = model_visible_response(oversized).unwrap(); + let content = output.content.unwrap(); + assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES); + let visible: SubjektivSessionBackendResponse = serde_json::from_str(&content).unwrap(); + assert!(matches!( + visible, + SubjektivSessionBackendResponse::Error { + error: SubjektivSessionErrorResponse { + code: SubjektivSessionDiagnosticCode::ResourceLimit, + .. + } + } + )); + } + + #[tokio::test] + async fn candidate_free_commit_records_session_and_pre_request_waits_for_committed_capture() { + let client = Arc::new(AttributionClient::default()); + let commit_hook = RecordCommittedSessionHook { + state: state(client.clone(), true), + }; + commit_hook.call(&run_committed_context()).await.unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].path, "/api/w/workspace-test/subjektiv/sessions"); + assert!(!requests[0].path.contains("staging")); + drop(requests); + + let before_commit = Arc::new(AttributionClient::default()); + let premature_commit_hook = RecordCommittedSessionHook { + state: state(before_commit.clone(), false), + }; + assert!( + premature_commit_hook + .call(&run_committed_context()) + .await + .is_err() + ); + let retry_hook = RetryCommittedSessionHook { + state: state(before_commit.clone(), false), + }; + assert_eq!( + retry_hook.call(&pre_request_context()).await.unwrap(), + HookPreRequestAction::Continue + ); + assert!(before_commit.requests.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn transient_attribution_failure_is_a_hook_failure_then_retries_exactly() { + let client = Arc::new(AttributionClient::default()); + client.failures.store(1, Ordering::SeqCst); + let state = state(client.clone(), true); + let commit_hook = RecordCommittedSessionHook { + state: state.clone(), + }; + let retry_hook = RetryCommittedSessionHook { state }; + + let error = commit_hook + .call(&run_committed_context()) + .await + .unwrap_err(); + assert_eq!(error.category, HookErrorCategory::Dependency); + assert_eq!( + retry_hook.call(&pre_request_context()).await.unwrap(), + HookPreRequestAction::Continue + ); + + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].body, requests[1].body); + assert_eq!(client.insertions.load(Ordering::SeqCst), 1); + } + + #[tokio::test] + async fn repeated_commit_and_pre_request_use_exact_request_and_backend_dedupes() { + let client = Arc::new(AttributionClient::default()); + let state = state(client.clone(), true); + let commit_hook = RecordCommittedSessionHook { + state: state.clone(), + }; + let retry_hook = RetryCommittedSessionHook { state }; + + commit_hook.call(&run_committed_context()).await.unwrap(); + commit_hook.call(&run_committed_context()).await.unwrap(); + retry_hook.call(&pre_request_context()).await.unwrap(); + + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 3); + assert!( + requests + .iter() + .all(|request| request.body == requests[0].body) + ); + assert_eq!(client.recorded.lock().unwrap().len(), 1); + assert_eq!(client.insertions.load(Ordering::SeqCst), 1); + } +} diff --git a/crates/worker/src/feature/session.rs b/crates/worker/src/feature/session.rs index 0729f0af5..b31a27674 100644 --- a/crates/worker/src/feature/session.rs +++ b/crates/worker/src/feature/session.rs @@ -25,6 +25,9 @@ pub(crate) struct CommittedSessionCapture { /// Monotonic committed-log revision for the captured Segment. pub(crate) session_revision: u64, pub(crate) entry_count: usize, + /// Whether the durable capture contains a committed run boundary. This is + /// false for a newly materialized Session before its first Run commits. + pub(crate) has_committed_run: bool, pub(crate) run_exit: CommittedRunExit, pub(crate) history: Vec>, pub(crate) usage_history: Vec, diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 78ffc91c7..1e0441297 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -629,6 +629,7 @@ pub enum WorkspaceServerOperation { SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest), SubjektivRecordSession(server_api::SubjektivRecordSessionRequest), SubjektivMemory(server_api::SubjektivMemoryBackendRequest), + SubjektivSession(server_api::SubjektivSessionBackendRequest), } fn workspace_server_json_request( @@ -716,6 +717,9 @@ fn workspace_server_operation_request( WorkspaceServerOperation::SubjektivMemory(request) => { workspace_server_json_request(format!("{base}/subjektiv/memory"), &request) } + WorkspaceServerOperation::SubjektivSession(request) => { + workspace_server_json_request(format!("{base}/subjektiv/session-history"), &request) + } } } @@ -3488,6 +3492,12 @@ impl Worker { let entries = store .read_all(location.session_id, location.segment_id) .map_err(|error| FeatureSessionError::Capture(error.to_string()))?; + let has_committed_run = entries.iter().any(|entry| { + matches!( + entry, + LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. } + ) + }); let run_exit = entries .iter() .rev() @@ -3514,6 +3524,7 @@ impl Worker { segment_id: location.segment_id.to_string(), session_revision: entries.len().try_into().unwrap_or(u64::MAX), entry_count: entries.len(), + has_committed_run, run_exit, history, usage_history: restored.usage_history, diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index c197071cc..d5801d0bd 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -1217,6 +1217,26 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { }) } + fn session_public_search( + &self, + _request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + Ok(runtime_api::SessionPublicSearchAvailability::Unavailable { + reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable, + message: "Session public search is not supported by this Runtime".to_string(), + }) + } + + fn session_public_read( + &self, + _request: runtime_api::SessionPublicReadRequest, + ) -> Result { + Ok(runtime_api::SessionPublicReadAvailability::Unavailable { + reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable, + message: "Session public read is not supported by this Runtime".to_string(), + }) + } + fn observation_source( &self, _worker_id: &str, @@ -2073,6 +2093,36 @@ impl RuntimeRegistry { }) } + pub fn session_public_search( + &self, + runtime_id: &str, + request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + validate_backend_identifier("runtime_id", runtime_id)?; + self.runtime(runtime_id)? + .session_public_search(request) + .map_err(|message| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "session_public_search_failed".to_string(), + message, + }) + } + + pub fn session_public_read( + &self, + runtime_id: &str, + request: runtime_api::SessionPublicReadRequest, + ) -> Result { + validate_backend_identifier("runtime_id", runtime_id)?; + self.runtime(runtime_id)? + .session_public_read(request) + .map_err(|message| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_string(), + code: "session_public_read_failed".to_string(), + message, + }) + } + pub fn observation_source( &self, worker: &RuntimeWorkerRef, @@ -2964,6 +3014,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { .map_err(|error| error.to_string()) } + fn session_public_search( + &self, + request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + if request.workspace_id != self.workspace_id { + return Err( + "Session public search Workspace does not match embedded Runtime".to_string(), + ); + } + let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend"); + self.runtime + .session_public_search_scoped(&scope, &request) + .map_err(|error| error.to_string()) + } + + fn session_public_read( + &self, + request: runtime_api::SessionPublicReadRequest, + ) -> Result { + if request.workspace_id != self.workspace_id { + return Err( + "Session public read Workspace does not match embedded Runtime".to_string(), + ); + } + let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend"); + self.runtime + .session_public_read_scoped(&scope, &request) + .map_err(|error| error.to_string()) + } + fn observation_source( &self, worker_id: &str, @@ -3669,6 +3749,9 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") { return "workers:delete"; } + if path.starts_with("/v1/session-public/") && method == "POST" { + return "workers:read"; + } if path.starts_with("/v1/workers/") && method == "GET" { return "workers:read"; } @@ -5007,6 +5090,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { .map_err(|diagnostic| diagnostic.message) } + fn session_public_search( + &self, + request: runtime_api::SessionPublicSearchRequest, + ) -> Result { + self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.session_public_search(request).await }, + ) + .map_err(|diagnostic| diagnostic.message) + } + + fn session_public_read( + &self, + request: runtime_api::SessionPublicReadRequest, + ) -> Result { + self.run_runtime_api( + self.request_timeout, + MAX_REMOTE_RUNTIME_RESPONSE_BYTES, + move |client| async move { client.session_public_read(request).await }, + ) + .map_err(|diagnostic| diagnostic.message) + } + fn observation_source( &self, worker_id: &str, diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index ab2178ed3..c640193f9 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -957,6 +957,17 @@ CREATE TABLE worker_session_archives ( committed_at TEXT NOT NULL, expires_at TEXT, FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); +CREATE TABLE worker_session_archive_observe_grants ( + workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL, + source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL, + PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id), + FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE); +CREATE INDEX worker_session_archive_observe_grants_controller + ON worker_session_archive_observe_grants( + workspace_id,controller_runtime_id,controller_worker_id,archive_id); CREATE TABLE worker_tombstones ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL, diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 9847d8b5d..cd726fa0f 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -132,6 +132,21 @@ pub struct WorkerTombstone { pub operation_id: String, } +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkerSessionArchiveRecord { + pub archive_id: String, + pub workspace_id: String, + pub worker: RuntimeWorkerRef, + pub session_id: String, + pub checksum_sha256: String, + pub content_bytes: u64, + pub policy_id: String, + pub policy_revision: u64, + pub operation_id: String, + pub committed_at: String, + pub expires_at: Option, +} + #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct WorkerOrphanDiagnostic { pub diagnostic_id: String, @@ -454,6 +469,51 @@ impl SqliteWorkspaceStore { if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));} let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} }; tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?; + let observe_grants = { + let mut statement = tx.prepare( + "SELECT grant_id, controller_runtime_id, controller_worker_id, + permissions_json, created_at + FROM worker_control_grants + WHERE workspace_id = ?1 AND subject_runtime_id = ?2 + AND subject_worker_id = ?3 AND revoked_at IS NULL", + )?; + statement + .query_map( + params![workspace_id, plan.worker.runtime_id, plan.worker.worker_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + )) + }, + )? + .collect::, _>>()? + }; + for (grant_id, controller_runtime_id, controller_worker_id, permissions, created_at) in observe_grants { + let permissions: Vec = serde_json::from_str(&permissions) + .map_err(|error| StoreError::Store(format!("invalid Worker control grant permissions: {error}")))?; + if permissions.iter().any(|permission| permission == "observe") { + tx.execute( + "INSERT OR IGNORE INTO worker_session_archive_observe_grants + (workspace_id,archive_id,controller_runtime_id,controller_worker_id, + subject_runtime_id,subject_worker_id,source_grant_id,granted_at) + VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + workspace_id, + a.archive_id, + controller_runtime_id, + controller_worker_id, + plan.worker.runtime_id, + plan.worker.worker_id, + grant_id, + created_at + ], + )?; + } + } }else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));} match plan.diagnostics_disposition { DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())), @@ -592,6 +652,77 @@ impl SqliteWorkspaceStore { ) -> crate::Result> { self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from)) } + + pub fn worker_session_archive( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + session_id: &str, + ) -> crate::Result> { + self.with_conn(|connection| { + connection + .query_row( + "SELECT archive_id, checksum_sha256, content_bytes, policy_id, + policy_revision, operation_id, committed_at, expires_at + FROM worker_session_archives + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND session_id = ?4", + params![ + workspace_id, + worker.runtime_id, + worker.worker_id, + session_id + ], + |row| { + Ok(WorkerSessionArchiveRecord { + archive_id: row.get(0)?, + workspace_id: workspace_id.to_string(), + worker: worker.clone(), + session_id: session_id.to_string(), + checksum_sha256: row.get(1)?, + content_bytes: row.get::<_, i64>(2)? as u64, + policy_id: row.get(3)?, + policy_revision: row.get::<_, i64>(4)? as u64, + operation_id: row.get(5)?, + committed_at: row.get(6)?, + expires_at: row.get(7)?, + }) + }, + ) + .optional() + .map_err(StoreError::from) + }) + } + + pub fn worker_session_archive_observable_by( + &self, + workspace_id: &str, + archive_id: &str, + controller: &RuntimeWorkerRef, + subject: &RuntimeWorkerRef, + ) -> crate::Result { + self.with_conn(|connection| { + connection + .query_row( + "SELECT EXISTS( + SELECT 1 FROM worker_session_archive_observe_grants + WHERE workspace_id = ?1 AND archive_id = ?2 + AND controller_runtime_id = ?3 AND controller_worker_id = ?4 + AND subject_runtime_id = ?5 AND subject_worker_id = ?6 + )", + params![ + workspace_id, + archive_id, + controller.runtime_id, + controller.worker_id, + subject.runtime_id, + subject.worker_id + ], + |row| row.get(0), + ) + .map_err(StoreError::from) + }) + } } fn orphan_diagnostic( diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3314c3798..f7f8832d3 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5461,6 +5461,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::subjektiv_memory_backend( service.clone(), )) + .merge(server_api::server_api_axum::subjektiv_session_backend( + service.clone(), + )) .merge(server_api::server_api_axum::skill_list(service.clone())) .merge(server_api::server_api_axum::skill_lint(service.clone())) .merge(server_api::server_api_axum::skill_get(service.clone())) @@ -7170,6 +7173,26 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn subjektiv_session_backend( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::SubjektivSessionBackendRequest, + ) -> std::result::Result< + server_api::SubjektivSessionBackendResponse, + server_api::RepositoryApiError, + > { + scoped_subjektiv_session_backend( + State(self.workspace_api()?.clone()), + AxumPath(ScopedWorkspacePath { workspace_id }), + context, + Json(request), + ) + .await + .map(|Json(response)| response) + .map_err(ApiError::into_repository_api_error) + } + async fn subjektiv_memory_consolidation( &self, context: server_api::ServerRequestContext, @@ -16958,6 +16981,1260 @@ async fn scoped_record_subjektiv_session( })) } +const SUBJEKTIV_SESSION_DEFAULT_LIMIT: usize = 20; +const SUBJEKTIV_SESSION_MAX_LIMIT: usize = 100; +const SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS: usize = 20; +// Session-public Runtime reads enforce the aggregate scan budget below. +const SUBJEKTIV_SESSION_MAX_SCAN_BYTES: u64 = 64 * 1024 * 1024; +const SUBJEKTIV_SESSION_MAX_SCAN_SEGMENTS: usize = 64; +const SUBJEKTIV_SESSION_MAX_SCAN_ENTRIES: usize = 100_000; +const SUBJEKTIV_SESSION_SEARCH_ITEM_BUDGET: usize = 36 * 1024; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSessionListCursor { + workspace_id: String, + subject_id: String, + controller_runtime_id: String, + controller_worker_id: String, + session_id: Option, + storage: server_api::SubjektivSessionStorageFilter, + snapshot_at: String, + after_attributed_at: String, + after_session_id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSessionSearchCursor { + workspace_id: String, + subject_id: String, + controller_runtime_id: String, + controller_worker_id: String, + query: Option, + session_id: Option, + kind: Option, + tool_name: Option, + tool_part: server_api::SubjektivSessionToolPart, + snapshot_at: String, + after_attributed_at: Option, + after_session_id: Option, + current: Option, + partial: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSessionSearchPosition { + attributed_at: String, + session_id: String, + runtime_id: String, + worker_id: String, + storage: server_api::SubjektivSessionStorage, + archive_id: Option, + generation: String, + offset: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct SubjektivSessionReadCursor { + workspace_id: String, + subject_id: String, + controller_runtime_id: String, + controller_worker_id: String, + session_id: String, + segment_id: String, + entry_ref: String, + mode: server_api::SubjektivSessionReadMode, + runtime_id: String, + worker_id: String, + storage: server_api::SubjektivSessionStorage, + archive_id: Option, + generation: String, + byte_offset: usize, +} + +#[derive(Debug, Clone)] +struct AuthorizedSubjectSession { + attribution: crate::subjektiv::SubjectSessionAttribution, + storage: server_api::SubjektivSessionStorage, + source: runtime_api::SessionPublicSource, + archive_id: Option, + archive: Option, + expired: bool, +} + +async fn scoped_subjektiv_session_backend( + State(api): State, + AxumPath(path): AxumPath, + context: server_api::ServerRequestContext, + Json(request): Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.workspace_id)?; + let (subject_id, controller, authority) = + subjektiv_subject_scope(&api, &path.workspace_id, &context)?; + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "Session history read", + )?; + let store = match open_subjektiv_store(&api) { + Ok(store) => store, + Err(error) => { + return Ok(Json(subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.error.to_string(), + ))); + } + }; + let response = match request.operation { + server_api::SubjektivSessionBackendOperation::List(input) => subjektiv_session_list( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ), + server_api::SubjektivSessionBackendOperation::Search(input) => subjektiv_session_search( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ), + server_api::SubjektivSessionBackendOperation::Read(input) => subjektiv_session_read( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ), + }; + Ok(Json(response)) +} + +fn subjektiv_session_list( + api: &WorkspaceApi, + store: &crate::subjektiv::SubjektivStore, + workspace_id: &str, + subject_id: &str, + controller: &RuntimeWorkerRef, + input: server_api::SubjektivSessionListRequest, +) -> server_api::SubjektivSessionBackendResponse { + let limit = match subjektiv_session_limit(input.limit) { + Ok(limit) => limit, + Err(response) => return response, + }; + let session_id = match normalize_subjektiv_session_selector(input.session_id, "session_id") { + Ok(value) => value, + Err(response) => return response, + }; + let (snapshot_at, mut after) = match input.cursor { + Some(cursor) => { + let cursor: SubjektivSessionListCursor = + match decode_subjektiv_session_cursor("session-list", &cursor) { + Ok(cursor) => cursor, + Err(response) => return response, + }; + if cursor.workspace_id != workspace_id + || cursor.subject_id != subject_id + || cursor.controller_runtime_id != controller.runtime_id + || cursor.controller_worker_id != controller.worker_id + || cursor.session_id != session_id + || cursor.storage != input.storage + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session list cursor does not match the current subject scope or filters", + ); + } + ( + cursor.snapshot_at, + Some((cursor.after_attributed_at, cursor.after_session_id)), + ) + } + None => (Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), None), + }; + + let mut items = Vec::new(); + let mut scanned = 0usize; + let mut store_has_more = false; + while items.len() < limit && scanned < SUBJEKTIV_SESSION_MAX_LIMIT { + let page = match store.subject_session_attribution_page( + subject_id, + session_id.as_deref(), + &snapshot_at, + after + .as_ref() + .map(|(attributed_at, session_id)| (attributed_at.as_str(), session_id.as_str())), + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + bounded_subjektiv_session_message(&error.to_string()), + ); + } + }; + store_has_more = page.has_more; + let Some(attribution) = page.items.into_iter().next() else { + if session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + break; + }; + scanned += 1; + let previous_after = after.clone(); + after = Some(( + attribution.attributed_at.clone(), + attribution.session_id.clone(), + )); + let source = match authorized_subjektiv_session(api, workspace_id, controller, attribution) + { + Ok(Some(source)) => source, + Ok(None) => { + if session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + continue; + } + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error, + ); + } + }; + if !subjektiv_storage_matches(input.storage, source.storage) { + if session_id.is_some() { + break; + } + continue; + } + let item = server_api::SubjektivSessionListItem { + session_id: source.attribution.session_id, + attributed_at: source.attribution.attributed_at, + storage: source.storage, + availability: if source.expired { + server_api::SubjektivSessionAvailability::Unavailable + } else { + server_api::SubjektivSessionAvailability::Unchecked + }, + reason: source + .expired + .then(|| "Committed Session archive retention has expired".to_string()), + }; + let mut bounded_items = items.clone(); + bounded_items.push(item.clone()); + if serde_json::to_vec(&bounded_items) + .map(|value| value.len() > SUBJEKTIV_SESSION_SEARCH_ITEM_BUDGET) + .unwrap_or(true) + { + if items.is_empty() || previous_after.is_none() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ResourceLimit, + "One Session list result exceeds the response budget", + ); + } + after = previous_after; + store_has_more = true; + break; + } + items.push(item); + if session_id.is_some() { + store_has_more = false; + break; + } + } + + let has_more = session_id.is_none() + && (store_has_more || scanned == SUBJEKTIV_SESSION_MAX_LIMIT) + && after.is_some(); + let next_cursor = if has_more { + let (after_attributed_at, after_session_id) = after.expect("checked above"); + match encode_subjektiv_cursor( + "session-list", + &SubjektivSessionListCursor { + workspace_id: workspace_id.to_string(), + subject_id: subject_id.to_string(), + controller_runtime_id: controller.runtime_id.clone(), + controller_worker_id: controller.worker_id.clone(), + session_id, + storage: input.storage, + snapshot_at, + after_attributed_at, + after_session_id, + }, + ) { + Ok(cursor) => Some(cursor), + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + bounded_subjektiv_session_message(&error.error.to_string()), + ); + } + } + } else { + None + }; + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::List( + server_api::SubjektivSessionListResponse { + items, + next_cursor, + has_more, + }, + ), + } +} + +fn subjektiv_session_read( + api: &WorkspaceApi, + store: &crate::subjektiv::SubjektivStore, + workspace_id: &str, + subject_id: &str, + controller: &RuntimeWorkerRef, + input: server_api::SubjektivSessionReadRequest, +) -> server_api::SubjektivSessionBackendResponse { + let session_id = + match normalize_subjektiv_session_required_selector(input.session_id, "session_id") { + Ok(value) => value, + Err(response) => return response, + }; + let segment_id = + match normalize_subjektiv_session_required_selector(input.segment_id, "segment_id") { + Ok(value) => value, + Err(response) => return response, + }; + let entry_ref = + match normalize_subjektiv_session_required_selector(input.entry_ref, "entry_ref") { + Ok(value) => value, + Err(response) => return response, + }; + let cursor = match input.cursor { + Some(cursor) => { + let cursor: SubjektivSessionReadCursor = + match decode_subjektiv_session_cursor("session-read", &cursor) { + Ok(cursor) => cursor, + Err(response) => return response, + }; + if cursor.workspace_id != workspace_id + || cursor.subject_id != subject_id + || cursor.controller_runtime_id != controller.runtime_id + || cursor.controller_worker_id != controller.worker_id + || cursor.session_id != session_id + || cursor.segment_id != segment_id + || cursor.entry_ref != entry_ref + || cursor.mode != input.mode + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session read cursor does not match the current subject scope or entry", + ); + } + Some(cursor) + } + None => None, + }; + let page = match store.subject_session_attribution_page( + subject_id, + Some(&session_id), + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + None, + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), + ); + } + }; + let Some(attribution) = page.items.into_iter().next() else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session entry was not found or is not authorized", + ); + }; + let source = + match authorized_subjektiv_session(api, workspace_id, controller, attribution.clone()) { + Ok(Some(source)) => source, + Ok(None) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session entry was not found or is not authorized", + ); + } + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error, + ); + } + }; + if source.expired { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::RetentionExpired, + "Committed Session archive retention has expired", + ); + } + if let Some(cursor) = cursor.as_ref() + && (cursor.runtime_id != attribution.runtime_id + || cursor.worker_id != attribution.worker_id + || !subjektiv_session_source_matches( + cursor.storage, + cursor.archive_id.as_deref(), + &source, + )) + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session storage changed while the read cursor was active", + ); + } + let request = runtime_api::SessionPublicReadRequest { + workspace_id: workspace_id.to_string(), + source: source.source.clone(), + expected_session_id: session_id.clone(), + expected_generation: cursor.as_ref().map(|cursor| cursor.generation.clone()), + segment_id: segment_id.clone(), + entry_ref: entry_ref.clone(), + mode: subjektiv_session_read_mode(input.mode), + byte_offset: cursor.as_ref().map_or(0, |cursor| cursor.byte_offset), + max_content_bytes: server_api::SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES, + max_scan_bytes: SUBJEKTIV_SESSION_MAX_SCAN_BYTES, + max_segments: SUBJEKTIV_SESSION_MAX_SCAN_SEGMENTS, + max_entries: SUBJEKTIV_SESSION_MAX_SCAN_ENTRIES, + }; + let availability = match api + .runtime + .session_public_read(&attribution.runtime_id, request) + { + Ok(availability) => availability, + Err(error) => return subjektiv_session_runtime_error(error), + }; + let page = match availability { + runtime_api::SessionPublicReadAvailability::Page { page } => page, + runtime_api::SessionPublicReadAvailability::Unavailable { reason, message } => { + return subjektiv_session_error(subjektiv_session_diagnostic_code(reason), message); + } + }; + if !subjektiv_session_archive_manifest_matches(&source, page.archive_manifest.as_ref()) { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete, + "Runtime Session archive does not match the committed catalog", + ); + } + if page.session_id != session_id + || page.segment_id != segment_id + || page.entry_ref != entry_ref + || page.mode != subjektiv_session_read_mode(input.mode) + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::CorruptLog, + "Runtime Session read response did not match the selected entry", + ); + } + let next_cursor = if page.has_more { + let Some(byte_offset) = page.next_byte_offset else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::CorruptLog, + "Runtime Session read response omitted its continuation position", + ); + }; + match encode_subjektiv_cursor( + "session-read", + &SubjektivSessionReadCursor { + workspace_id: workspace_id.to_string(), + subject_id: subject_id.to_string(), + controller_runtime_id: controller.runtime_id.clone(), + controller_worker_id: controller.worker_id.clone(), + session_id: session_id.clone(), + segment_id: segment_id.clone(), + entry_ref: entry_ref.clone(), + mode: input.mode, + runtime_id: attribution.runtime_id, + worker_id: attribution.worker_id, + storage: source.storage, + archive_id: source.archive_id, + generation: page.generation.clone(), + byte_offset, + }, + ) { + Ok(cursor) => Some(cursor), + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.error.to_string(), + ); + } + } + } else { + None + }; + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Read( + server_api::SubjektivSessionReadResponse { + session_id, + segment_id, + entry_ref, + kind: subjektiv_session_entry_kind(page.kind), + origin: page.origin, + lineage: subjektiv_session_lineage(page.lineage), + mode: input.mode, + content: page.content, + truncated: page.has_more, + next_cursor, + has_more: page.has_more, + }, + ), + } +} + +fn subjektiv_session_search( + api: &WorkspaceApi, + store: &crate::subjektiv::SubjektivStore, + workspace_id: &str, + subject_id: &str, + controller: &RuntimeWorkerRef, + input: server_api::SubjektivSessionSearchRequest, +) -> server_api::SubjektivSessionBackendResponse { + let limit = match subjektiv_session_limit(input.limit) { + Ok(limit) => limit, + Err(response) => return response, + }; + let session_id = match normalize_subjektiv_session_selector(input.session_id, "session_id") { + Ok(value) => value, + Err(response) => return response, + }; + let query = input + .query + .as_deref() + .map(str::trim) + .filter(|query| !query.is_empty()) + .map(str::to_string); + if query.as_ref().is_some_and(|query| query.len() > 4_096) { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + "Session search query is too large", + ); + } + if query.is_none() && session_id.is_none() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + "query is required when session_id is omitted", + ); + } + let tool_name = match normalize_subjektiv_session_selector(input.tool_name, "tool_name") { + Ok(value) => value, + Err(response) => return response, + }; + if input + .kind + .is_some_and(|kind| kind != server_api::SubjektivSessionEntryKind::Tool) + && (tool_name.is_some() || input.tool_part != server_api::SubjektivSessionToolPart::Both) + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + "tool_name and tool_part filters require kind=tool or no kind filter", + ); + } + + let mut state = match input.cursor { + Some(cursor) => { + let cursor: SubjektivSessionSearchCursor = + match decode_subjektiv_session_cursor("session-search", &cursor) { + Ok(cursor) => cursor, + Err(response) => return response, + }; + if cursor.workspace_id != workspace_id + || cursor.subject_id != subject_id + || cursor.controller_runtime_id != controller.runtime_id + || cursor.controller_worker_id != controller.worker_id + || cursor.query != query + || cursor.session_id != session_id + || cursor.kind != input.kind + || cursor.tool_name != tool_name + || cursor.tool_part != input.tool_part + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search cursor does not match the current subject scope or filters", + ); + } + cursor + } + None => SubjektivSessionSearchCursor { + workspace_id: workspace_id.to_string(), + subject_id: subject_id.to_string(), + controller_runtime_id: controller.runtime_id.clone(), + controller_worker_id: controller.worker_id.clone(), + query: query.clone(), + session_id: session_id.clone(), + kind: input.kind, + tool_name: tool_name.clone(), + tool_part: input.tool_part, + snapshot_at: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + after_attributed_at: None, + after_session_id: None, + current: None, + partial: false, + }, + }; + + let mut items = Vec::new(); + let mut issues = Vec::new(); + let mut scanned_sessions = 0usize; + let mut remaining_scan_bytes = SUBJEKTIV_SESSION_MAX_SCAN_BYTES; + let mut remaining_segments = SUBJEKTIV_SESSION_MAX_SCAN_SEGMENTS; + let mut remaining_entries = SUBJEKTIV_SESSION_MAX_SCAN_ENTRIES; + let mut has_more = false; + let mut successful_sources = 0usize; + + while items.len() < limit && scanned_sessions < SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS { + if remaining_scan_bytes == 0 || remaining_segments == 0 || remaining_entries == 0 { + has_more = true; + state.partial = true; + break; + } + let (attribution, expected_generation, offset, cursor_source) = if let Some(current) = + state.current.take() + { + let page = match store.subject_session_attribution_page( + subject_id, + Some(¤t.session_id), + &state.snapshot_at, + None, + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), + ); + } + }; + let Some(attribution) = page.items.into_iter().next() else { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search attribution is no longer available", + ); + }; + if attribution.attributed_at != current.attributed_at + || attribution.runtime_id != current.runtime_id + || attribution.worker_id != current.worker_id + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search attribution changed", + ); + } + ( + attribution, + Some(current.generation), + current.offset, + Some((current.storage, current.archive_id)), + ) + } else { + let page = match store.subject_session_attribution_page( + subject_id, + state.session_id.as_deref(), + &state.snapshot_at, + match ( + state.after_attributed_at.as_deref(), + state.after_session_id.as_deref(), + ) { + (Some(attributed_at), Some(session_id)) => Some((attributed_at, session_id)), + (None, None) => None, + _ => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session search cursor position is incomplete", + ); + } + }, + 1, + ) { + Ok(page) => page, + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.to_string(), + ); + } + }; + let Some(attribution) = page.items.into_iter().next() else { + if state.session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + break; + }; + has_more = page.has_more; + state.after_attributed_at = Some(attribution.attributed_at.clone()); + state.after_session_id = Some(attribution.session_id.clone()); + (attribution, None, 0, None) + }; + scanned_sessions += 1; + let source = match authorized_subjektiv_session( + api, + workspace_id, + controller, + attribution.clone(), + ) { + Ok(Some(source)) => source, + Ok(None) => { + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized, + "Session was not found or is not authorized", + ); + } + continue; + } + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error, + ); + } + }; + if let Some((storage, archive_id)) = cursor_source.as_ref() + && !subjektiv_session_source_matches(*storage, archive_id.as_deref(), &source) + { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session storage changed while the search cursor was active", + ); + } + if source.expired { + let issue = server_api::SubjektivSessionIssue { + session_id: attribution.session_id.clone(), + code: server_api::SubjektivSessionDiagnosticCode::RetentionExpired, + message: "Committed Session archive retention has expired".to_string(), + }; + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_error(issue.code, issue.message); + } + state.partial = true; + issues.push(issue); + continue; + } + let runtime_request = runtime_api::SessionPublicSearchRequest { + workspace_id: workspace_id.to_string(), + source: source.source.clone(), + expected_session_id: attribution.session_id.clone(), + expected_generation, + query: query.clone(), + kind: input.kind.map(runtime_session_entry_kind), + tool_name: tool_name.clone(), + tool_part: runtime_session_tool_part(input.tool_part), + offset, + limit: limit.saturating_sub(items.len()), + max_scan_bytes: remaining_scan_bytes, + max_segments: remaining_segments, + max_entries: remaining_entries, + }; + let availability = match api + .runtime + .session_public_search(&attribution.runtime_id, runtime_request) + { + Ok(availability) => availability, + Err(error) => { + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_runtime_error(error); + } + state.partial = true; + issues.push(server_api::SubjektivSessionIssue { + session_id: attribution.session_id, + code: server_api::SubjektivSessionDiagnosticCode::RuntimeUnavailable, + message: bounded_subjektiv_session_message(&error.message()), + }); + continue; + } + }; + let page = match availability { + runtime_api::SessionPublicSearchAvailability::Page { page } => page, + runtime_api::SessionPublicSearchAvailability::Unavailable { reason, message } => { + let code = subjektiv_session_diagnostic_code(reason); + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_error(code, message); + } + state.partial = true; + issues.push(server_api::SubjektivSessionIssue { + session_id: attribution.session_id, + code, + message: bounded_subjektiv_session_message(&message), + }); + continue; + } + }; + if !subjektiv_session_archive_manifest_matches(&source, page.archive_manifest.as_ref()) { + if cursor_source.is_some() || state.session_id.is_some() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete, + "Runtime Session archive does not match the committed catalog", + ); + } + state.partial = true; + issues.push(server_api::SubjektivSessionIssue { + session_id: attribution.session_id.clone(), + code: server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete, + message: "Runtime Session archive does not match the committed catalog".to_string(), + }); + continue; + } + successful_sources += 1; + remaining_scan_bytes = remaining_scan_bytes.saturating_sub(page.scanned_bytes); + remaining_segments = remaining_segments.saturating_sub(page.scanned_segments); + remaining_entries = remaining_entries.saturating_sub(page.scanned_entries); + let generation = page.generation.clone(); + let mut consumed = 0usize; + for item in page.items { + let (snippet, snippet_truncated) = truncate_subjektiv_session_snippet(&item.compact); + let projected = server_api::SubjektivSessionSearchItem { + session_id: attribution.session_id.clone(), + segment_id: item.segment_id, + entry_ref: item.entry_ref, + kind: subjektiv_session_entry_kind(item.kind), + origin: item.origin, + tool_name: item.tool_name, + tool_part: item.tool_part.map(subjektiv_session_tool_part), + snippet, + snippet_truncated, + lineage: subjektiv_session_lineage(item.lineage), + }; + let mut bounded_items = items.clone(); + bounded_items.push(projected.clone()); + if serde_json::to_vec(&(bounded_items, &issues)) + .map(|value| value.len() > SUBJEKTIV_SESSION_SEARCH_ITEM_BUDGET) + .unwrap_or(true) + { + if consumed == 0 && items.is_empty() { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::ResourceLimit, + "One Session search result exceeds the response budget", + ); + } + has_more = true; + state.partial = true; + state.current = Some(SubjektivSessionSearchPosition { + attributed_at: attribution.attributed_at.clone(), + session_id: attribution.session_id.clone(), + runtime_id: attribution.runtime_id.clone(), + worker_id: attribution.worker_id.clone(), + storage: source.storage, + archive_id: source.archive_id.clone(), + generation: generation.clone(), + offset: offset.saturating_add(consumed), + }); + break; + } + items.push(projected); + consumed += 1; + } + if state.current.is_some() { + break; + } + if page.has_more { + let next_offset = page + .next_offset + .unwrap_or_else(|| offset.saturating_add(consumed)); + has_more = true; + state.current = Some(SubjektivSessionSearchPosition { + attributed_at: attribution.attributed_at, + session_id: attribution.session_id, + runtime_id: attribution.runtime_id, + worker_id: attribution.worker_id, + storage: source.storage, + archive_id: source.archive_id, + generation, + offset: next_offset, + }); + break; + } + if state.session_id.is_some() { + has_more = false; + break; + } + if items.len() >= limit { + has_more = true; + break; + } + } + + if scanned_sessions >= SUBJEKTIV_SESSION_MAX_SCANNED_SESSIONS && state.session_id.is_none() { + has_more = true; + state.partial = true; + } + if !issues.is_empty() && successful_sources == 0 && !has_more { + let issue = issues.remove(0); + return subjektiv_session_error(issue.code, issue.message); + } + let next_cursor = if has_more { + match encode_subjektiv_cursor("session-search", &state) { + Ok(cursor) => Some(cursor), + Err(error) => { + return subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.error.to_string(), + ); + } + } + } else { + None + }; + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::Search( + server_api::SubjektivSessionSearchResponse { + items, + next_cursor, + has_more, + coverage: if state.partial || has_more { + server_api::SubjektivSessionCoverage::Partial + } else { + server_api::SubjektivSessionCoverage::Complete + }, + issues, + }, + ), + } +} + +fn subjektiv_session_limit( + limit: Option, +) -> std::result::Result { + let limit = limit.unwrap_or(SUBJEKTIV_SESSION_DEFAULT_LIMIT); + if (1..=SUBJEKTIV_SESSION_MAX_LIMIT).contains(&limit) { + Ok(limit) + } else { + Err(subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + "Session history limit must be between 1 and 100", + )) + } +} + +fn normalize_subjektiv_session_selector( + value: Option, + name: &str, +) -> std::result::Result, server_api::SubjektivSessionBackendResponse> { + let Some(value) = value else { + return Ok(None); + }; + let normalized = value.trim(); + if normalized.is_empty() || normalized.len() > 512 || normalized.chars().any(char::is_control) { + return Err(subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + format!("{name} is invalid"), + )); + } + Ok(Some(normalized.to_string())) +} + +fn normalize_subjektiv_session_required_selector( + value: String, + name: &str, +) -> std::result::Result { + normalize_subjektiv_session_selector(Some(value), name)?.ok_or_else(|| { + subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::InvalidInput, + format!("{name} is required"), + ) + }) +} + +fn decode_subjektiv_session_cursor( + kind: &str, + cursor: &str, +) -> std::result::Result { + decode_subjektiv_cursor(kind, cursor).map_err(|_| { + subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::StaleCursor, + "Session history cursor is invalid or stale", + ) + }) +} + +fn subjektiv_session_error( + code: server_api::SubjektivSessionDiagnosticCode, + message: impl Into, +) -> server_api::SubjektivSessionBackendResponse { + server_api::SubjektivSessionBackendResponse::Error { + error: server_api::SubjektivSessionErrorResponse { + code, + message: bounded_subjektiv_session_message(&message.into()), + }, + } +} + +fn bounded_subjektiv_session_message(message: &str) -> String { + truncate_subjektiv_session_utf8(message, 512) +} + +fn truncate_subjektiv_session_utf8(value: &str, max_bytes: usize) -> String { + if value.len() <= max_bytes { + return value.to_string(); + } + let mut end = max_bytes; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + value[..end].to_string() +} + +fn subjektiv_storage_matches( + filter: server_api::SubjektivSessionStorageFilter, + storage: server_api::SubjektivSessionStorage, +) -> bool { + matches!(filter, server_api::SubjektivSessionStorageFilter::All) + || matches!( + (filter, storage), + ( + server_api::SubjektivSessionStorageFilter::Retained, + server_api::SubjektivSessionStorage::Retained + ) | ( + server_api::SubjektivSessionStorageFilter::Archived, + server_api::SubjektivSessionStorage::Archived + ) + ) +} + +fn authorized_subjektiv_session( + api: &WorkspaceApi, + workspace_id: &str, + controller: &RuntimeWorkerRef, + attribution: crate::subjektiv::SubjectSessionAttribution, +) -> std::result::Result, String> { + let target = RuntimeWorkerRef::new(&attribution.runtime_id, &attribution.worker_id); + let archive = api + .config_store + .worker_session_archive(workspace_id, &target, &attribution.session_id) + .map_err(|error| error.to_string())?; + if let Some(archive) = archive { + let authorized = api + .config_store + .worker_session_archive_observable_by( + workspace_id, + &archive.archive_id, + controller, + &target, + ) + .map_err(|error| error.to_string())?; + if !authorized { + return Ok(None); + } + return Ok(Some(AuthorizedSubjectSession { + attribution, + storage: server_api::SubjektivSessionStorage::Archived, + source: runtime_api::SessionPublicSource::Archived { + archive_id: archive.archive_id.clone(), + }, + archive_id: Some(archive.archive_id.clone()), + expired: archive.expires_at.as_deref().is_some_and(is_expired), + archive: Some(archive), + })); + } + + let authorized = api + .store + .get_active_worker_control_grant(workspace_id, controller, &target) + .map_err(|error| error.to_string())? + .is_some_and(|grant| { + grant + .permissions + .iter() + .any(|permission| permission == "observe") + }); + if !authorized { + return Ok(None); + } + let worker_id = worker_runtime::identity::WorkerId::parse(&attribution.worker_id) + .ok_or_else(|| "attributed Worker identity is invalid".to_string())?; + Ok(Some(AuthorizedSubjectSession { + attribution, + storage: server_api::SubjektivSessionStorage::Retained, + source: runtime_api::SessionPublicSource::Retained { worker_id }, + archive_id: None, + archive: None, + expired: false, + })) +} + +fn subjektiv_session_source_matches( + position_storage: server_api::SubjektivSessionStorage, + position_archive_id: Option<&str>, + source: &AuthorizedSubjectSession, +) -> bool { + position_storage == source.storage && position_archive_id == source.archive_id.as_deref() +} + +fn subjektiv_session_archive_manifest_matches( + source: &AuthorizedSubjectSession, + manifest: Option<&runtime_api::WorkerSessionArchiveManifest>, +) -> bool { + match (&source.archive, manifest) { + (None, None) => true, + (Some(expected), Some(actual)) => { + actual.archive_id == expected.archive_id + && actual.workspace_id == expected.workspace_id + && actual.source_runtime_id == expected.worker.runtime_id + && actual.source_worker_id.to_string() == expected.worker.worker_id + && actual.source_session_id == expected.session_id + && actual.content_checksum_sha256 == expected.checksum_sha256 + && actual.content_bytes == expected.content_bytes + && actual.policy_id == expected.policy_id + && actual.policy_revision == expected.policy_revision + && actual.operation_id == expected.operation_id + } + _ => false, + } +} + +fn subjektiv_session_runtime_error( + error: RuntimeRegistryError, +) -> server_api::SubjektivSessionBackendResponse { + subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::RuntimeUnavailable, + bounded_subjektiv_session_message(&error.message()), + ) +} + +fn subjektiv_session_diagnostic_code( + reason: runtime_api::SessionPublicUnavailableReason, +) -> server_api::SubjektivSessionDiagnosticCode { + match reason { + runtime_api::SessionPublicUnavailableReason::NotFound => { + server_api::SubjektivSessionDiagnosticCode::NotFoundOrNotAuthorized + } + runtime_api::SessionPublicUnavailableReason::RetentionMissing => { + server_api::SubjektivSessionDiagnosticCode::RetentionMissing + } + runtime_api::SessionPublicUnavailableReason::RetentionExpired => { + server_api::SubjektivSessionDiagnosticCode::RetentionExpired + } + runtime_api::SessionPublicUnavailableReason::ArchiveIncomplete => { + server_api::SubjektivSessionDiagnosticCode::ArchiveIncomplete + } + runtime_api::SessionPublicUnavailableReason::CorruptLog => { + server_api::SubjektivSessionDiagnosticCode::CorruptLog + } + runtime_api::SessionPublicUnavailableReason::MigrationRequired => { + server_api::SubjektivSessionDiagnosticCode::MigrationRequired + } + runtime_api::SessionPublicUnavailableReason::StorageUnavailable => { + server_api::SubjektivSessionDiagnosticCode::RuntimeUnavailable + } + runtime_api::SessionPublicUnavailableReason::InvalidCursor => { + server_api::SubjektivSessionDiagnosticCode::StaleCursor + } + runtime_api::SessionPublicUnavailableReason::ResourceLimit => { + server_api::SubjektivSessionDiagnosticCode::ResourceLimit + } + } +} + +fn subjektiv_session_entry_kind( + kind: runtime_api::SessionPublicEntryKind, +) -> server_api::SubjektivSessionEntryKind { + match kind { + runtime_api::SessionPublicEntryKind::User => server_api::SubjektivSessionEntryKind::User, + runtime_api::SessionPublicEntryKind::Assistant => { + server_api::SubjektivSessionEntryKind::Assistant + } + runtime_api::SessionPublicEntryKind::Tool => server_api::SubjektivSessionEntryKind::Tool, + } +} + +fn runtime_session_entry_kind( + kind: server_api::SubjektivSessionEntryKind, +) -> runtime_api::SessionPublicEntryKind { + match kind { + server_api::SubjektivSessionEntryKind::User => runtime_api::SessionPublicEntryKind::User, + server_api::SubjektivSessionEntryKind::Assistant => { + runtime_api::SessionPublicEntryKind::Assistant + } + server_api::SubjektivSessionEntryKind::Tool => runtime_api::SessionPublicEntryKind::Tool, + } +} + +fn subjektiv_session_tool_part( + part: runtime_api::SessionPublicToolPart, +) -> server_api::SubjektivSessionToolPart { + match part { + runtime_api::SessionPublicToolPart::Input => server_api::SubjektivSessionToolPart::Input, + runtime_api::SessionPublicToolPart::Output => server_api::SubjektivSessionToolPart::Output, + runtime_api::SessionPublicToolPart::Both => server_api::SubjektivSessionToolPart::Both, + } +} + +fn runtime_session_tool_part( + part: server_api::SubjektivSessionToolPart, +) -> runtime_api::SessionPublicToolPart { + match part { + server_api::SubjektivSessionToolPart::Input => runtime_api::SessionPublicToolPart::Input, + server_api::SubjektivSessionToolPart::Output => runtime_api::SessionPublicToolPart::Output, + server_api::SubjektivSessionToolPart::Both => runtime_api::SessionPublicToolPart::Both, + } +} + +fn subjektiv_session_read_mode( + mode: server_api::SubjektivSessionReadMode, +) -> runtime_api::SessionPublicReadMode { + match mode { + server_api::SubjektivSessionReadMode::Compact => { + runtime_api::SessionPublicReadMode::Compact + } + server_api::SubjektivSessionReadMode::Full => runtime_api::SessionPublicReadMode::Full, + } +} + +fn subjektiv_session_lineage( + lineage: runtime_api::SessionPublicLineage, +) -> server_api::SubjektivSessionLineage { + server_api::SubjektivSessionLineage { + kind: match lineage.kind { + runtime_api::SessionPublicLineageKind::Root => { + server_api::SubjektivSessionLineageKind::Root + } + runtime_api::SessionPublicLineageKind::Fork => { + server_api::SubjektivSessionLineageKind::Fork + } + runtime_api::SessionPublicLineageKind::Compact => { + server_api::SubjektivSessionLineageKind::Compact + } + }, + parent_segment_id: lineage.parent_segment_id, + at_turn_index: lineage.at_turn_index, + } +} + +fn truncate_subjektiv_session_snippet(value: &str) -> (String, bool) { + if value.len() <= server_api::SUBJEKTIV_SESSION_MAX_SNIPPET_BYTES { + (value.to_string(), false) + } else { + ( + truncate_subjektiv_session_utf8(value, server_api::SUBJEKTIV_SESSION_MAX_SNIPPET_BYTES), + true, + ) + } +} + const SUBJEKTIV_QUERY_DEFAULT_LIMIT: usize = 20; const SUBJEKTIV_QUERY_MAX_LIMIT: usize = 100; const SUBJEKTIV_BODY_DEFAULT_LINES: usize = 200; @@ -18362,7 +19639,12 @@ fn decode_subjektiv_cursor( let encoded = cursor .strip_prefix(&prefix) .ok_or_else(|| Error::InvalidInput(format!("invalid subjektiv {kind} cursor type")))?; - if encoded.is_empty() || encoded.len() > 16_384 || encoded.len() % 2 != 0 { + if encoded.is_empty() + || encoded.len() > 16_384 + || encoded.len() % 2 != 0 + || !encoded.is_ascii() + || !encoded.bytes().all(|byte| byte.is_ascii_hexdigit()) + { return Err(Error::InvalidInput(format!("invalid subjektiv {kind} cursor")).into()); } let mut raw = Vec::with_capacity(encoded.len() / 2); @@ -36038,6 +37320,39 @@ mod tests { ); } + #[test] + fn subjektiv_session_cursor_is_bounded_and_rejects_non_ascii_payloads() { + let cursor = SubjektivSessionListCursor { + workspace_id: "workspace-a".to_string(), + subject_id: "subject-a".to_string(), + controller_runtime_id: "runtime-a".to_string(), + controller_worker_id: worker_runtime::identity::WorkerId::now_v7().to_string(), + session_id: Some("session-a".to_string()), + storage: server_api::SubjektivSessionStorageFilter::Retained, + snapshot_at: "2026-10-02T00:00:00Z".to_string(), + after_attributed_at: "2026-10-01T00:00:00Z".to_string(), + after_session_id: "session-a".to_string(), + }; + let encoded = encode_subjektiv_cursor("session-list", &cursor).unwrap(); + let decoded: SubjektivSessionListCursor = + decode_subjektiv_session_cursor("session-list", &encoded).unwrap(); + assert_eq!(decoded.workspace_id, cursor.workspace_id); + assert_eq!(decoded.subject_id, cursor.subject_id); + assert_eq!(decoded.controller_worker_id, cursor.controller_worker_id); + assert!(matches!( + decode_subjektiv_session_cursor::( + "session-list", + "subjektiv.session-list.éé" + ), + Err(server_api::SubjektivSessionBackendResponse::Error { + error: server_api::SubjektivSessionErrorResponse { + code: server_api::SubjektivSessionDiagnosticCode::StaleCursor, + .. + } + }) + )); + } + #[tokio::test] async fn subjektiv_subject_worker_and_staging_derive_scope_from_singleton_authority() { let workspace = tempfile::tempdir().unwrap(); @@ -36162,6 +37477,114 @@ mod tests { assert_eq!(attribution.subject_id, subject.id); assert_eq!(attribution.worker_id, created.worker_id); + let Json(ungranted) = scoped_subjektiv_session_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivSessionBackendRequest { + operation: server_api::SubjektivSessionBackendOperation::List( + server_api::SubjektivSessionListRequest { + session_id: None, + storage: server_api::SubjektivSessionStorageFilter::All, + limit: None, + cursor: None, + }, + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + ungranted, + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::List( + server_api::SubjektivSessionListResponse { ref items, .. } + ) + } if items.is_empty() + )); + + let worker = RuntimeWorkerRef::new(&created.runtime_id, &created.worker_id); + api.store + .create_worker_control_grant(&WorkerControlGrantRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + grant_id: "subjektiv-session-self-observe".to_string(), + controller: worker.clone(), + subject: worker, + relation: "subject_session".to_string(), + origin: "test".to_string(), + permissions: vec!["observe".to_string()], + operation_id: "subjektiv-session-self-observe".to_string(), + created_at: Utc::now().to_rfc3339(), + revoked_at: None, + }) + .unwrap(); + let Json(granted) = scoped_subjektiv_session_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivSessionBackendRequest { + operation: server_api::SubjektivSessionBackendOperation::List( + server_api::SubjektivSessionListRequest { + session_id: Some("session-1".to_string()), + storage: server_api::SubjektivSessionStorageFilter::Retained, + limit: Some(1), + cursor: None, + }, + ), + }), + ) + .await + .unwrap(); + assert!(matches!( + granted, + server_api::SubjektivSessionBackendResponse::Ok { + result: server_api::SubjektivSessionBackendResult::List( + server_api::SubjektivSessionListResponse { ref items, has_more: false, .. } + ) + } if items.len() == 1 + && items[0].session_id == "session-1" + && items[0].storage == server_api::SubjektivSessionStorage::Retained + && items[0].availability == server_api::SubjektivSessionAvailability::Unchecked + )); + let Json(unavailable_search) = scoped_subjektiv_session_backend( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + context.clone(), + Json(server_api::SubjektivSessionBackendRequest { + operation: server_api::SubjektivSessionBackendOperation::Search( + server_api::SubjektivSessionSearchRequest { + query: None, + session_id: Some("session-1".to_string()), + kind: None, + tool_name: None, + tool_part: server_api::SubjektivSessionToolPart::Both, + limit: None, + cursor: None, + }, + ), + }), + ) + .await + .unwrap(); + assert!( + matches!( + &unavailable_search, + server_api::SubjektivSessionBackendResponse::Error { + error: server_api::SubjektivSessionErrorResponse { + code: server_api::SubjektivSessionDiagnosticCode::RetentionMissing, + .. + } + } + ), + "unexpected search response: {unavailable_search:?}" + ); + let target = store .create_memory( &subject.id, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index e0e182ee7..5cb03fd1b 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -27,7 +27,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 75; +const LATEST_SCHEMA_VERSION: i64 = 76; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; @@ -72,6 +72,8 @@ const ORDERED_WORKER_PROJECTION_MIGRATION_NAME: &str = "ordered Worker projection subscriptions without revision reconciliation"; const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str = "Runtime-scoped durable Worker catalog identity"; +const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str = + "preserve explicit observe grants for committed Worker Session archives"; const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7; const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" @@ -244,6 +246,11 @@ const MIGRATIONS: &[Migration] = &[ name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME, apply: migrate_runtime_scoped_worker_identity_v74_to_v75, }, + Migration { + version: 76, + name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME, + apply: migrate_archive_observe_grants_v75_to_v76, + }, ]; #[derive(Clone, Copy)] @@ -14147,6 +14154,47 @@ fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Resul } } +fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> { + let current = current_schema_version(conn)?; + if current != 75 { + return Err(Error::Store(format!( + "expected schema version 75 before {ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME} migration, found {current}" + ))); + } + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + tx.execute_batch( + r#" + CREATE TABLE IF NOT EXISTS worker_session_archive_observe_grants ( + workspace_id TEXT NOT NULL, + archive_id TEXT NOT NULL, + controller_runtime_id TEXT NOT NULL, + controller_worker_id TEXT NOT NULL, + subject_runtime_id TEXT NOT NULL, + subject_worker_id TEXT NOT NULL, + source_grant_id TEXT NOT NULL, + granted_at TEXT NOT NULL, + PRIMARY KEY ( + workspace_id, archive_id, + controller_runtime_id, controller_worker_id, + source_grant_id + ), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE + ); + CREATE INDEX IF NOT EXISTS worker_session_archive_observe_grants_controller + ON worker_session_archive_observe_grants( + workspace_id, controller_runtime_id, controller_worker_id, archive_id + ); + "#, + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![76_i64, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME], + )?; + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch(include_str!("latest_schema.sql"))?; @@ -16617,6 +16665,10 @@ mod tests { version: 75, name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), }, + WorkspaceSchemaMigrationStep { + version: 76, + name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(), + }, ] ); @@ -16699,6 +16751,7 @@ mod tests { 75, RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), ), + (76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -16985,7 +17038,7 @@ mod tests { .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75 + 73, 74, 75, 76 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -16994,7 +17047,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 26); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 27); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); diff --git a/crates/workspace-server/src/subjektiv.rs b/crates/workspace-server/src/subjektiv.rs index 749ea31b1..767f6bef4 100644 --- a/crates/workspace-server/src/subjektiv.rs +++ b/crates/workspace-server/src/subjektiv.rs @@ -175,6 +175,12 @@ impl SubjectSessionAttribution { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SubjectSessionAttributionPage { + pub items: Vec, + pub has_more: bool, +} + #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum RevisionProposalIntent { @@ -1208,6 +1214,93 @@ impl SubjektivStore { }) } + /// Reads one bounded immutable attribution page without materializing the + /// subject's complete history. `snapshot_at` fixes the initial discovery + /// boundary; later Host attributions require a fresh traversal. + pub fn subject_session_attribution_page( + &self, + subject_id: &str, + session_id: Option<&str>, + snapshot_at: &str, + after: Option<(&str, &str)>, + limit: usize, + ) -> Result { + validate_label("subject id", subject_id)?; + validate_label("attribution snapshot", snapshot_at)?; + if let Some(session_id) = session_id { + validate_label("session id", session_id)?; + } + if let Some((attributed_at, session_id)) = after { + validate_label("attribution cursor timestamp", attributed_at)?; + validate_label("attribution cursor session id", session_id)?; + } + if !(1..=100).contains(&limit) { + return Err(SubjektivError::InvalidRecord( + "subject Session attribution page limit must be between 1 and 100".to_string(), + )); + } + let query_limit = limit.saturating_add(1); + self.database.try_with_connection(|connection| { + let raw = match (session_id, after) { + (Some(session_id), _) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND session_id = ?2 AND attributed_at <= ?3 + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?4", + )?; + let rows = statement.query_map( + params![subject_id, session_id, snapshot_at, query_limit as i64], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + (None, Some((after_at, after_session_id))) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND attributed_at <= ?2 + AND (attributed_at < ?3 OR (attributed_at = ?3 AND session_id > ?4)) + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?5", + )?; + let rows = statement.query_map( + params![ + subject_id, + snapshot_at, + after_at, + after_session_id, + query_limit as i64 + ], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + (None, None) => { + let mut statement = connection.prepare( + "SELECT record_json FROM subject_session_attributions + WHERE subject_id = ?1 AND attributed_at <= ?2 + ORDER BY attributed_at DESC, session_id ASC + LIMIT ?3", + )?; + let rows = statement.query_map( + params![subject_id, snapshot_at, query_limit as i64], + |row| row.get::<_, String>(0), + )?; + rows.collect::, _>>()? + } + }; + let mut items = raw + .into_iter() + .map(|raw| parse_session_attribution(&raw)) + .collect::>>()?; + let has_more = items.len() > limit; + if has_more { + items.truncate(limit); + } + Ok(SubjectSessionAttributionPage { items, has_more }) + }) + } + pub fn staging_candidate( &self, subject_id: &str, @@ -3678,6 +3771,88 @@ mod tests { assert!(store.session_attribution("session-2").unwrap().is_none()); } + #[test] + fn attribution_pages_are_bounded_descending_and_snapshot_fixed() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a"); + let subject = store.create_subject(role()).unwrap(); + for (session, at) in [ + ("session-1", "2026-09-28T10:00:00.000Z"), + ("session-2", "2026-09-28T11:00:00.000Z"), + ("session-3", "2026-09-28T11:00:00.000Z"), + ] { + store + .record_session_attribution(attribution( + &subject.id, + "runtime-1", + "worker-1", + session, + at, + )) + .unwrap(); + } + + let first = store + .subject_session_attribution_page( + &subject.id, + None, + "2026-09-28T11:30:00.000Z", + None, + 2, + ) + .unwrap(); + assert_eq!( + first + .items + .iter() + .map(|item| item.session_id.as_str()) + .collect::>(), + vec!["session-2", "session-3"] + ); + assert!(first.has_more); + + store + .record_session_attribution(attribution( + &subject.id, + "runtime-2", + "worker-2", + "session-later", + "2026-09-28T12:00:00.000Z", + )) + .unwrap(); + let last = first.items.last().unwrap(); + let second = store + .subject_session_attribution_page( + &subject.id, + None, + "2026-09-28T11:30:00.000Z", + Some((&last.attributed_at, &last.session_id)), + 2, + ) + .unwrap(); + assert_eq!(second.items.len(), 1); + assert_eq!(second.items[0].session_id, "session-1"); + assert!(!second.has_more); + assert!( + second + .items + .iter() + .all(|item| item.session_id != "session-later") + ); + + let exact = store + .subject_session_attribution_page( + &subject.id, + Some("session-1"), + "2026-09-28T11:30:00.000Z", + None, + 20, + ) + .unwrap(); + assert_eq!(exact.items.len(), 1); + assert!(!exact.has_more); + } + #[test] fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index cfe89abdd..a19f095a4 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -77,6 +77,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "worker_registry_projection_removals", "worker_removal_operations", "worker_retention_audit_events", + "worker_session_archive_observe_grants", "worker_session_archives", "worker_singleton_owners", "worker_tombstones", diff --git a/docs/design/subjektiv-store.md b/docs/design/subjektiv-store.md index 44a0a5554..bd0c77815 100644 --- a/docs/design/subjektiv-store.md +++ b/docs/design/subjektiv-store.md @@ -218,6 +218,46 @@ and exact backend retries return the first candidate rather than creating anothe one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`, or a surface snapshot. + +## Historical Session discovery (T-673) + +An enabled `feature.subjektiv` also installs exactly three read-only tools: +`SubjektivSessionList`, `SubjektivSessionSearch`, and `SubjektivSessionRead`. +The Server binds subject, Workspace, Runtime, Worker, retention catalogue, and +archive authority from the authenticated caller. Model input contains only +Session/segment/entry selectors, search filters, limits, and opaque cursors; +knowing any identifier does not grant access. Reads use Yoi's retained or +committed-archive Session storage and a shared public projection. They do not +copy Session bodies into the subjektiv database, restore Workers, migrate +Session storage, create candidates or Memories, revise a surface, or promote a +re-read tool result to HumanInput. + +The public projection covers committed records in every persisted segment, +including non-active branches and pre-compaction entries, while excluding +system prompts, hidden reasoning, traces/diagnostics, unfinished run tails, and +attachment or pasted-artifact bodies. Results preserve exact Session, segment, +entry, provenance, and lineage identity so inherited entries are not presented +as a current decision. Full entry reads are UTF-8-boundary paged and remain +subject to the same projection as snippets and search. + +The subject's first committed primary-Worker Session is attributed independently +of extraction thresholds or candidate creation. Recording is idempotent and is +retried on later committed-run and pre-request lifecycle points after transient +failure; no attribution is attempted before a committed capture exists. This is +not a distributed transaction with Session persistence. In particular, the +system does **not** backfill older unattributed Sessions from their text, display +name, current singleton ownership, Memory references, or other inference. +Historical Sessions without trustworthy Host-recorded attribution remain outside +subject discovery until a separately authorized provenance mechanism exists. + +Pagination cursors bind scope, operation, filters, stable ordering, and the +public storage generation and are never authorization credentials. Every page +rechecks current subject and observation authority. Deleted-Worker archives are +eligible only when both the committed Server catalogue and Runtime manifest +agree and the archived observation grant remains valid; incomplete, expired, +corrupt, unavailable, or changed sources are reported explicitly rather than as +an empty successful search. + Revision proposals add optional `revision_proposal` metadata to the existing v2 `SubjectStagingRecord` envelope; automatic extraction remains proposal-free and its model schema is unchanged. The metadata is serialized atomically with the diff --git a/openapi/server-api.json b/openapi/server-api.json index 968d40110..a024f5f80 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -2832,6 +2832,19 @@ ], "type": "object" }, + { + "$ref": "#/components/schemas/MemoryResidentSummaryOutput", + "properties": { + "kind": { + "const": "resident_summary", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, { "$ref": "#/components/schemas/MemoryBackendAckOutput", "properties": { @@ -3084,9 +3097,36 @@ }, "type": "object" }, + "MemoryResidentSummaryAvailability": { + "enum": [ + "ready", + "ungenerated", + "stale", + "failed", + "unavailable" + ], + "type": "string" + }, "MemoryResidentSummaryOperation": { "type": "object" }, + "MemoryResidentSummaryOutput": { + "properties": { + "availability": { + "$ref": "#/components/schemas/MemoryResidentSummaryAvailability" + }, + "content": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "availability" + ], + "type": "object" + }, "MemorySettingsAudit": { "properties": { "language": { @@ -14081,7 +14121,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9" + "x-yoi-source-digest": "sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 7e782b48b..5f899c88a 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:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // 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/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 3ca4ddb28..59b5e68a2 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:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // 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 79b0d1bf0..9f33fc5dd 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:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // 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 e687625f0..a1d54d8ed 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:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // 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 0824a0267..f0cec690a 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:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9) +// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0) // 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