feat: discover subject session history

This commit is contained in:
2026-10-02 16:41:29 +09:00
parent 43e3b42055
commit ee6aa9de41
34 changed files with 5167 additions and 12 deletions
+205 -1
View File
@@ -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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_turn_index: Option<usize>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub query: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<SessionPublicEntryKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
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<String>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_part: Option<SessionPublicToolPart>,
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<SessionPublicSearchItem>,
pub next_offset: Option<usize>,
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<WorkerSessionArchiveManifest>,
}
#[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<usize>,
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<WorkerSessionArchiveManifest>,
}
#[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)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct RetainedSessionIdentity { pub struct RetainedSessionIdentity {
pub session_id: String, pub session_id: String,
@@ -766,6 +938,18 @@ pub trait RuntimeApi {
#[query] request: WorkerSessionHistoryRequest, #[query] request: WorkerSessionHistoryRequest,
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>; ) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
#[post("/v1/session-public/search", status = 200, error_status = 400)]
async fn session_public_search(
&self,
#[body] request: SessionPublicSearchRequest,
) -> Result<SessionPublicSearchAvailability, RuntimeApiError>;
#[post("/v1/session-public/read", status = 200, error_status = 400)]
async fn session_public_read(
&self,
#[body] request: SessionPublicReadRequest,
) -> Result<SessionPublicReadAvailability, RuntimeApiError>;
#[post("/v1/workers", status = 200, error_status = 400)] #[post("/v1/workers", status = 200, error_status = 400)]
async fn create_worker( async fn create_worker(
&self, &self,
@@ -1112,6 +1296,26 @@ mod tests {
}) })
} }
async fn session_public_search(
&self,
_request: SessionPublicSearchRequest,
) -> Result<SessionPublicSearchAvailability, RuntimeApiError> {
Ok(SessionPublicSearchAvailability::Unavailable {
reason: SessionPublicUnavailableReason::StorageUnavailable,
message: "unsupported".to_string(),
})
}
async fn session_public_read(
&self,
_request: SessionPublicReadRequest,
) -> Result<SessionPublicReadAvailability, RuntimeApiError> {
Ok(SessionPublicReadAvailability::Unavailable {
reason: SessionPublicUnavailableReason::StorageUnavailable,
message: "unsupported".to_string(),
})
}
async fn create_worker( async fn create_worker(
&self, &self,
_request: CreateWorkerRequest, _request: CreateWorkerRequest,
@@ -1248,7 +1452,7 @@ mod tests {
#[test] #[test]
fn contract_inventory_is_complete_and_unique() { fn contract_inventory_is_complete_and_unique() {
let operations = RuntimeApiMetadata::OPERATIONS; let operations = RuntimeApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 17); assert_eq!(operations.len(), 19);
let mut routes = operations let mut routes = operations
.iter() .iter()
.map(|operation| (format!("{:?}", operation.method), operation.path)) .map(|operation| (format!("{:?}", operation.method), operation.path))
+289
View File
@@ -1191,6 +1191,20 @@ pub trait ServerApi {
#[path] workspace_id: String, #[path] workspace_id: String,
#[body] request: SubjektivMemoryBackendRequest, #[body] request: SubjektivMemoryBackendRequest,
) -> Result<SubjektivMemoryBackendResponse, RepositoryApiError>; ) -> Result<SubjektivMemoryBackendResponse, RepositoryApiError>;
#[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<SubjektivSessionBackendResponse, RepositoryApiError>;
#[post( #[post(
"/api/w/{workspace_id}/subjektiv/consolidation", "/api/w/{workspace_id}/subjektiv/consolidation",
status = 200, status = 200,
@@ -4534,6 +4548,280 @@ pub struct SubjektivRecordSessionResponse {
pub session_id: String, 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<String>,
#[serde(default)]
pub storage: SubjektivSessionStorageFilter,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 100))]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<SubjektivSessionEntryKind>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_name: Option<String>,
#[serde(default)]
pub tool_part: SubjektivSessionToolPart,
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(range(min = 1, max = 100))]
pub limit: Option<usize>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cursor: Option<String>,
}
#[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<String>,
}
#[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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSessionListResponse {
pub items: Vec<SubjektivSessionListItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub at_turn_index: Option<usize>,
}
#[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<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tool_part: Option<SubjektivSessionToolPart>,
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<SubjektivSessionSearchItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub has_more: bool,
pub coverage: SubjektivSessionCoverage,
pub issues: Vec<SubjektivSessionIssue>,
}
#[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<String>,
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. /// Current lifecycle state of one confirmed subject Memory revision.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -12273,6 +12561,7 @@ mod openapi_artifact_tests {
"subjektiv_stage_candidate", "subjektiv_stage_candidate",
"subjektiv_record_session", "subjektiv_record_session",
"subjektiv_memory_backend", "subjektiv_memory_backend",
"subjektiv_session_backend",
"subjektiv_memory_consolidation", "subjektiv_memory_consolidation",
"workspace_worker_discovery", "workspace_worker_discovery",
"workspace_worker_remove", "workspace_worker_remove",
+9
View File
@@ -75,6 +75,7 @@ pub mod history;
mod legacy_session_log; mod legacy_session_log;
pub mod logged_item; pub mod logged_item;
mod paste_artifact; mod paste_artifact;
pub mod public_index;
pub mod public_snapshot; pub mod public_snapshot;
pub mod segment; pub mod segment;
pub mod segment_log; pub mod segment_log;
@@ -95,6 +96,14 @@ pub use history::{
}; };
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged}; pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use paste_artifact::PasteArtifactLimits; 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::{ pub use public_snapshot::{
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES, DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS, DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -978,7 +978,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
project_session_snapshot_for_segment(session_id, None, log) 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, session_id: SessionId,
segment_id: Option<SegmentId>, segment_id: Option<SegmentId>,
log: &[LogEntry], log: &[LogEntry],
@@ -468,6 +468,23 @@ impl WorkerSessionStore {
Ok(()) 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<Vec<SegmentId>, 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<u64, StoreError> { pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
Ok(fs::metadata(self.log_path(segment_id))?.len()) Ok(fs::metadata(self.log_path(segment_id))?.len())
} }
+3
View File
@@ -2312,6 +2312,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
if path.starts_with("/v1/workers/") && *method == Method::DELETE { if path.starts_with("/v1/workers/") && *method == Method::DELETE {
return Some("workers: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 { if path.starts_with("/v1/workers/") && *method == Method::GET {
return Some("workers:read"); return Some("workers:read");
} }
@@ -289,6 +289,80 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
.map_err(api_error) .map_err(api_error)
} }
async fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, runtime_api::RuntimeApiError> {
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<runtime_api::SessionPublicReadAvailability, runtime_api::RuntimeApiError> {
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( async fn create_worker(
&self, &self,
value: runtime_api::CreateWorkerRequest, value: runtime_api::CreateWorkerRequest,
+2
View File
@@ -25,6 +25,8 @@ pub mod resource;
#[cfg(feature = "fs-store")] #[cfg(feature = "fs-store")]
pub mod retention; pub mod retention;
mod runtime; mod runtime;
#[cfg(feature = "fs-store")]
mod session_public;
pub mod ssh_host_key_probe; pub mod ssh_host_key_probe;
pub mod worker_backend; pub mod worker_backend;
pub mod worker_source; pub mod worker_source;
+20
View File
@@ -356,6 +356,26 @@ impl FsWorkerRetentionProvider {
.join(archive_id)) .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<PathBuf, RuntimeError> { fn diagnostics_dir(&self, operation_id: &str) -> Result<PathBuf, RuntimeError> {
validate_id("operation_id", operation_id)?; validate_id("operation_id", operation_id)?;
Ok(self Ok(self
+152
View File
@@ -77,6 +77,41 @@ impl RuntimeWorkspaceScope {
} }
const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256; 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_CODE: &str = "worker_delete_persistence_failed";
const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str = const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str =
"Worker metadata deletion failed; the persisted Worker identity was retained for retry"; "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<runtime_api::SessionPublicSearchAvailability, RuntimeError> {
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<runtime_api::SessionPublicReadAvailability, RuntimeError> {
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<crate::retention::WorkerSessionArchiveManifest>,
),
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 /// Enumerate host-authoritative Runtime inventory for Backend orphan
/// reconciliation. Runtime identity and Workspace scope are derived here, /// reconciliation. Runtime identity and Workspace scope are derived here,
/// not accepted in a diagnostic payload. /// not accepted in a diagnostic payload.
+357
View File
@@ -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<SessionPublicIndex, (SessionPublicUnavailableReason, &'static str)> {
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<String>,
) -> SessionPublicSearchAvailability {
SessionPublicSearchAvailability::Unavailable {
reason,
message: message.into(),
}
}
fn read_unavailable(
reason: SessionPublicUnavailableReason,
message: impl Into<String>,
) -> 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(),
}
}
@@ -581,6 +581,13 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
client.subjektiv_memory_backend(workspace_id, request).await, client.subjektiv_memory_backend(workspace_id, request).await,
) )
} }
WorkspaceServerOperation::SubjektivSession(request) => {
generated_workspace_response(
client
.subjektiv_session_backend(workspace_id, request)
.await,
)
}
} }
}) })
}) })
+9
View File
@@ -1307,6 +1307,15 @@ where
{ {
feature_registry.add_module(subjektiv_memory); 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 if let Some(memory_config) = memory_lifecycle_config
&& let Some(memory_lifecycle) = && let Some(memory_lifecycle) =
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config( crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
+1
View File
@@ -19,6 +19,7 @@ pub mod orchestration;
mod resource_projection; mod resource_projection;
pub mod session_explore; pub mod session_explore;
pub(crate) mod subjektiv_memory; pub(crate) mod subjektiv_memory;
pub(crate) mod subjektiv_session;
pub mod task; pub mod task;
pub mod ticket; pub mod ticket;
pub mod worker_observation; pub mod worker_observation;
@@ -2078,6 +2078,7 @@ permission = "write"
segment_id: "segment-1".to_string(), segment_id: "segment-1".to_string(),
session_revision: history_len.try_into().unwrap(), session_revision: history_len.try_into().unwrap(),
entry_count: history_len, entry_count: history_len,
has_committed_run: true,
run_exit: CommittedRunExit::Finished, run_exit: CommittedRunExit::Finished,
history: (0..history_len) history: (0..history_len)
.map(|index| HistoryEntry { .map(|index| HistoryEntry {
@@ -1101,6 +1101,7 @@ mod tests {
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
session_revision: 1, session_revision: 1,
entry_count: 0, entry_count: 0,
has_committed_run: true,
run_exit: CommittedRunExit::Finished, run_exit: CommittedRunExit::Finished,
history: Vec::new(), history: Vec::new(),
usage_history: Vec::new(), usage_history: Vec::new(),
@@ -1351,6 +1352,7 @@ mod tests {
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
session_revision: 1, session_revision: 1,
entry_count: 3, entry_count: 3,
has_committed_run: true,
run_exit: CommittedRunExit::Interrupted, run_exit: CommittedRunExit::Interrupted,
history: vec![ history: vec![
crate::session_history::history_entry( crate::session_history::history_entry(
@@ -1410,6 +1412,7 @@ mod tests {
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
session_revision: 2, session_revision: 2,
entry_count: 2, entry_count: 2,
has_committed_run: true,
run_exit: CommittedRunExit::Finished, run_exit: CommittedRunExit::Finished,
history: vec![ history: vec![
crate::session_history::history_entry( crate::session_history::history_entry(
@@ -1487,6 +1490,7 @@ mod tests {
segment_id: "segment-1".into(), segment_id: "segment-1".into(),
session_revision: 2, session_revision: 2,
entry_count: 2, entry_count: 2,
has_committed_run: true,
run_exit: CommittedRunExit::Finished, run_exit: CommittedRunExit::Finished,
history: vec![ history: vec![
crate::session_history::history_entry( crate::session_history::history_entry(
@@ -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<dyn WorkspaceClient>,
capture: CommittedSessionCaptureHandle,
}
impl SubjektivSessionFeature {
pub(crate) fn from_resolved_config(
config: &manifest::ResolvedSubjektivFeatureConfig,
capture: CommittedSessionCaptureHandle,
client: Arc<dyn WorkspaceClient>,
) -> std::io::Result<Option<Self>> {
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::<SubjektivSessionListRequest>(),
SessionOperation::Search => schema_for::<SubjektivSessionSearchRequest>(),
SessionOperation::Read => schema_for::<SubjektivSessionReadRequest>(),
};
let tool: Arc<dyn Tool> = 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<ToolOutput, ToolError> {
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<SubjektivSessionBackendResponse, ToolError> {
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::<SubjektivSessionBackendResponse>(&response.body)
{
return Ok(response);
}
let detail = serde_json::from_str::<server_api::RepositoryApiError>(&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<RunCommitted> for RecordCommittedSessionHook {
async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> {
self.state.record_current_committed_session()
}
}
struct RetryCommittedSessionHook {
state: SubjektivSessionState,
}
#[async_trait]
impl Hook<PreLlmRequest> for RetryCommittedSessionHook {
async fn call(&self, _input: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
self.state.retry_current_committed_session()?;
Ok(HookPreRequestAction::Continue)
}
}
fn parse<T: serde::de::DeserializeOwned>(input: &str, tool: &str) -> Result<T, ToolError> {
serde_json::from_str(input)
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
}
fn schema_for<T: JsonSchema>() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({}))
}
fn model_visible_response(
response: SubjektivSessionBackendResponse,
) -> Result<ToolOutput, ToolError> {
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<SubjektivSessionBackendResponse>,
requests: Mutex<Vec<WorkspaceRequest>>,
}
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<WorkspaceResponse, WorkspaceClientError> {
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<Vec<WorkspaceRequest>>,
recorded: Mutex<HashSet<String>>,
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<WorkspaceResponse, WorkspaceClientError> {
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<dyn WorkspaceClient>, has_committed_run: bool) -> SubjektivSessionState {
SubjektivSessionState {
client,
capture: capture(has_committed_run),
}
}
fn feature(client: Arc<dyn WorkspaceClient>) -> 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<_>>(),
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<_>>(),
vec![
(COMMIT_HOOK, FeatureHookPoint::RunCommitted),
(RETRY_HOOK, FeatureHookPoint::PreLlmRequest),
]
);
for (name, description, operation, expected_schema) in [
(
LIST_TOOL,
LIST_DESCRIPTION,
SessionOperation::List,
schema_for::<SubjektivSessionListRequest>(),
),
(
SEARCH_TOOL,
SEARCH_DESCRIPTION,
SessionOperation::Search,
schema_for::<SubjektivSessionSearchRequest>(),
),
(
READ_TOOL,
READ_DESCRIPTION,
SessionOperation::Read,
schema_for::<SubjektivSessionReadRequest>(),
),
] {
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);
}
}
+3
View File
@@ -25,6 +25,9 @@ pub(crate) struct CommittedSessionCapture {
/// Monotonic committed-log revision for the captured Segment. /// Monotonic committed-log revision for the captured Segment.
pub(crate) session_revision: u64, pub(crate) session_revision: u64,
pub(crate) entry_count: usize, 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) run_exit: CommittedRunExit,
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>, pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
pub(crate) usage_history: Vec<UsageRecord>, pub(crate) usage_history: Vec<UsageRecord>,
+11
View File
@@ -629,6 +629,7 @@ pub enum WorkspaceServerOperation {
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest), SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest), SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
SubjektivMemory(server_api::SubjektivMemoryBackendRequest), SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
SubjektivSession(server_api::SubjektivSessionBackendRequest),
} }
fn workspace_server_json_request<T: serde::Serialize>( fn workspace_server_json_request<T: serde::Serialize>(
@@ -716,6 +717,9 @@ fn workspace_server_operation_request(
WorkspaceServerOperation::SubjektivMemory(request) => { WorkspaceServerOperation::SubjektivMemory(request) => {
workspace_server_json_request(format!("{base}/subjektiv/memory"), &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<C: LlmClient + 'static, St: Store> Worker<C, St> {
let entries = store let entries = store
.read_all(location.session_id, location.segment_id) .read_all(location.session_id, location.segment_id)
.map_err(|error| FeatureSessionError::Capture(error.to_string()))?; .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 let run_exit = entries
.iter() .iter()
.rev() .rev()
@@ -3514,6 +3524,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
segment_id: location.segment_id.to_string(), segment_id: location.segment_id.to_string(),
session_revision: entries.len().try_into().unwrap_or(u64::MAX), session_revision: entries.len().try_into().unwrap_or(u64::MAX),
entry_count: entries.len(), entry_count: entries.len(),
has_committed_run,
run_exit, run_exit,
history, history,
usage_history: restored.usage_history, usage_history: restored.usage_history,
+107
View File
@@ -1217,6 +1217,26 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
}) })
} }
fn session_public_search(
&self,
_request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
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<runtime_api::SessionPublicReadAvailability, String> {
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( fn observation_source(
&self, &self,
_worker_id: &str, _worker_id: &str,
@@ -2073,6 +2093,36 @@ impl RuntimeRegistry {
}) })
} }
pub fn session_public_search(
&self,
runtime_id: &str,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeRegistryError> {
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<runtime_api::SessionPublicReadAvailability, RuntimeRegistryError> {
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( pub fn observation_source(
&self, &self,
worker: &RuntimeWorkerRef, worker: &RuntimeWorkerRef,
@@ -2964,6 +3014,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.map_err(|error| error.to_string()) .map_err(|error| error.to_string())
} }
fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
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<runtime_api::SessionPublicReadAvailability, String> {
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( fn observation_source(
&self, &self,
worker_id: &str, 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") { if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
return "workers:delete"; return "workers:delete";
} }
if path.starts_with("/v1/session-public/") && method == "POST" {
return "workers:read";
}
if path.starts_with("/v1/workers/") && method == "GET" { if path.starts_with("/v1/workers/") && method == "GET" {
return "workers:read"; return "workers:read";
} }
@@ -5007,6 +5090,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.map_err(|diagnostic| diagnostic.message) .map_err(|diagnostic| diagnostic.message)
} }
fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
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<runtime_api::SessionPublicReadAvailability, String> {
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( fn observation_source(
&self, &self,
worker_id: &str, worker_id: &str,
@@ -957,6 +957,17 @@ CREATE TABLE worker_session_archives (
committed_at TEXT NOT NULL, expires_at TEXT, committed_at TEXT NOT NULL, expires_at TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id)); 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 ( CREATE TABLE worker_tombstones (
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, 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, display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
+131
View File
@@ -132,6 +132,21 @@ pub struct WorkerTombstone {
pub operation_id: String, 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<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerOrphanDiagnostic { pub struct WorkerOrphanDiagnostic {
pub diagnostic_id: String, 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()));} 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())} }; 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])?; 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::<std::result::Result<Vec<_>, _>>()?
};
for (grant_id, controller_runtime_id, controller_worker_id, permissions, created_at) in observe_grants {
let permissions: Vec<String> = 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()));} }else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));}
match plan.diagnostics_disposition { match plan.diagnostics_disposition {
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())), 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<Option<WorkerTombstone>> { ) -> crate::Result<Option<WorkerTombstone>> {
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)) 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<Option<WorkerSessionArchiveRecord>> {
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<bool> {
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( fn orphan_diagnostic(
File diff suppressed because it is too large Load Diff
+56 -3
View File
@@ -27,7 +27,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result}; use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50; 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 SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit"; 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"; "ordered Worker projection subscriptions without revision reconciliation";
const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str = const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str =
"Runtime-scoped durable Worker catalog identity"; "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_VERSION_WITH_TARGETS: i64 = 7;
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline"; const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#" const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
@@ -244,6 +246,11 @@ const MIGRATIONS: &[Migration] = &[
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME, name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME,
apply: migrate_runtime_scoped_worker_identity_v74_to_v75, 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)] #[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<()> { fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
conn.execute_batch(include_str!("latest_schema.sql"))?; conn.execute_batch(include_str!("latest_schema.sql"))?;
@@ -16617,6 +16665,10 @@ mod tests {
version: 75, version: 75,
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), 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, 75,
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(), RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
), ),
(76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()),
] ]
); );
assert!(!table_exists(conn, "trusted_runtime_records")?); assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -16985,7 +17038,7 @@ mod tests {
.collect::<Vec<_>>(), .collect::<Vec<_>>(),
vec![ vec![
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 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(); SqliteWorkspaceStore::migrate_database(&path).unwrap();
@@ -16994,7 +17047,7 @@ mod tests {
current_schema_version(&conn).unwrap(), current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION 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", "alias").unwrap());
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
+175
View File
@@ -175,6 +175,12 @@ impl SubjectSessionAttribution {
} }
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SubjectSessionAttributionPage {
pub items: Vec<SubjectSessionAttribution>,
pub has_more: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum RevisionProposalIntent { 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<SubjectSessionAttributionPage> {
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::<std::result::Result<Vec<_>, _>>()?
}
(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::<std::result::Result<Vec<_>, _>>()?
}
(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::<std::result::Result<Vec<_>, _>>()?
}
};
let mut items = raw
.into_iter()
.map(|raw| parse_session_attribution(&raw))
.collect::<Result<Vec<_>>>()?;
let has_more = items.len() > limit;
if has_more {
items.truncate(limit);
}
Ok(SubjectSessionAttributionPage { items, has_more })
})
}
pub fn staging_candidate( pub fn staging_candidate(
&self, &self,
subject_id: &str, subject_id: &str,
@@ -3678,6 +3771,88 @@ mod tests {
assert!(store.session_attribution("session-2").unwrap().is_none()); 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<_>>(),
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] #[test]
fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() { fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() {
let temp = tempfile::tempdir().unwrap(); let temp = tempfile::tempdir().unwrap();
@@ -77,6 +77,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"worker_registry_projection_removals", "worker_registry_projection_removals",
"worker_removal_operations", "worker_removal_operations",
"worker_retention_audit_events", "worker_retention_audit_events",
"worker_session_archive_observe_grants",
"worker_session_archives", "worker_session_archives",
"worker_singleton_owners", "worker_singleton_owners",
"worker_tombstones", "worker_tombstones",
+40
View File
@@ -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`, one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`,
or a surface snapshot. 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 Revision proposals add optional `revision_proposal` metadata to the existing v2
`SubjectStagingRecord` envelope; automatic extraction remains proposal-free and `SubjectStagingRecord` envelope; automatic extraction remains proposal-free and
its model schema is unchanged. The metadata is serialized atomically with the its model schema is unchanged. The metadata is serialized atomically with the
+41 -1
View File
@@ -2832,6 +2832,19 @@
], ],
"type": "object" "type": "object"
}, },
{
"$ref": "#/components/schemas/MemoryResidentSummaryOutput",
"properties": {
"kind": {
"const": "resident_summary",
"type": "string"
}
},
"required": [
"kind"
],
"type": "object"
},
{ {
"$ref": "#/components/schemas/MemoryBackendAckOutput", "$ref": "#/components/schemas/MemoryBackendAckOutput",
"properties": { "properties": {
@@ -3084,9 +3097,36 @@
}, },
"type": "object" "type": "object"
}, },
"MemoryResidentSummaryAvailability": {
"enum": [
"ready",
"ungenerated",
"stale",
"failed",
"unavailable"
],
"type": "string"
},
"MemoryResidentSummaryOperation": { "MemoryResidentSummaryOperation": {
"type": "object" "type": "object"
}, },
"MemoryResidentSummaryOutput": {
"properties": {
"availability": {
"$ref": "#/components/schemas/MemoryResidentSummaryAvailability"
},
"content": {
"type": [
"string",
"null"
]
}
},
"required": [
"availability"
],
"type": "object"
},
"MemorySettingsAudit": { "MemorySettingsAudit": {
"properties": { "properties": {
"language": { "language": {
@@ -14081,7 +14121,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9" "x-yoi-source-digest": "sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // 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 // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check