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)]
pub struct RetainedSessionIdentity {
pub session_id: String,
@@ -766,6 +938,18 @@ pub trait RuntimeApi {
#[query] request: WorkerSessionHistoryRequest,
) -> 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)]
async fn create_worker(
&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(
&self,
_request: CreateWorkerRequest,
@@ -1248,7 +1452,7 @@ mod tests {
#[test]
fn contract_inventory_is_complete_and_unique() {
let operations = RuntimeApiMetadata::OPERATIONS;
assert_eq!(operations.len(), 17);
assert_eq!(operations.len(), 19);
let mut routes = operations
.iter()
.map(|operation| (format!("{:?}", operation.method), operation.path))
+289
View File
@@ -1191,6 +1191,20 @@ pub trait ServerApi {
#[path] workspace_id: String,
#[body] request: SubjektivMemoryBackendRequest,
) -> 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(
"/api/w/{workspace_id}/subjektiv/consolidation",
status = 200,
@@ -4534,6 +4548,280 @@ pub struct SubjektivRecordSessionResponse {
pub session_id: String,
}
/// Model-visible storage selector for subject-attributed Sessions. Runtime,
/// Worker, archive and filesystem identities remain Host-bound.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SubjektivSessionStorageFilter {
All,
Retained,
Archived,
}
impl Default for SubjektivSessionStorageFilter {
fn default() -> Self {
Self::All
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SubjektivSessionStorage {
Retained,
Archived,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum SubjektivSessionAvailability {
Available,
Unchecked,
Unavailable,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct SubjektivSessionListRequest {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub session_id: Option<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.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
@@ -12273,6 +12561,7 @@ mod openapi_artifact_tests {
"subjektiv_stage_candidate",
"subjektiv_record_session",
"subjektiv_memory_backend",
"subjektiv_session_backend",
"subjektiv_memory_consolidation",
"workspace_worker_discovery",
"workspace_worker_remove",
+9
View File
@@ -75,6 +75,7 @@ pub mod history;
mod legacy_session_log;
pub mod logged_item;
mod paste_artifact;
pub mod public_index;
pub mod public_snapshot;
pub mod segment;
pub mod segment_log;
@@ -95,6 +96,14 @@ pub use history::{
};
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
pub use paste_artifact::PasteArtifactLimits;
pub use public_index::{
DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES,
DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES,
SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind,
SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind,
SessionPublicIndexReadError, SessionPublicIndexSegment, SessionPublicIndexToolPart,
read_session_public_index,
};
pub use public_snapshot::{
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
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)
}
fn project_session_snapshot_for_segment(
pub(crate) fn project_session_snapshot_for_segment(
session_id: SessionId,
segment_id: Option<SegmentId>,
log: &[LogEntry],
@@ -468,6 +468,23 @@ impl WorkerSessionStore {
Ok(())
}
/// Enumerate canonical Segment logs in ascending identity order without
/// creating or migrating state. Unlike the general [`Store`] listing, this
/// observation boundary rejects malformed names and non-regular files so a
/// persisted Segment cannot be silently omitted from a public index.
pub(crate) fn list_segments_read_only(
&self,
session_id: SessionId,
) -> Result<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> {
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 {
return Some("workers:delete");
}
if path.starts_with("/v1/session-public/") && *method == Method::POST {
return Some("workers:read");
}
if path.starts_with("/v1/workers/") && *method == Method::GET {
return Some("workers:read");
}
@@ -289,6 +289,80 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
.map_err(api_error)
}
async fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<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(
&self,
value: runtime_api::CreateWorkerRequest,
+2
View File
@@ -25,6 +25,8 @@ pub mod resource;
#[cfg(feature = "fs-store")]
pub mod retention;
mod runtime;
#[cfg(feature = "fs-store")]
mod session_public;
pub mod ssh_host_key_probe;
pub mod worker_backend;
pub mod worker_source;
+20
View File
@@ -356,6 +356,26 @@ impl FsWorkerRetentionProvider {
.join(archive_id))
}
pub(crate) fn session_archive(
&self,
archive_id: &str,
) -> Result<(WorkerSessionArchiveManifest, PathBuf), RuntimeError> {
let archive_dir = self.archive_dir(archive_id)?;
let manifest: WorkerSessionArchiveManifest = read_json(
&archive_dir.join("manifest.json"),
"read Worker Session archive",
)?;
if manifest.schema_version != ARCHIVE_SCHEMA_VERSION || manifest.archive_id != archive_id {
return Err(RuntimeError::StoreCorrupt {
operation: "read Worker Session archive",
path: archive_dir.join("manifest.json"),
message: "archive manifest identity mismatch".to_string(),
});
}
let manifest = validate_existing_archive(&archive_dir, &manifest)?;
Ok((manifest, archive_dir.join("session")))
}
fn diagnostics_dir(&self, operation_id: &str) -> Result<PathBuf, RuntimeError> {
validate_id("operation_id", operation_id)?;
Ok(self
+152
View File
@@ -77,6 +77,41 @@ impl RuntimeWorkspaceScope {
}
const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256;
#[cfg(feature = "fs-store")]
fn session_public_source_unavailable(
source: &runtime_api::SessionPublicSource,
error: &RuntimeError,
) -> Option<(runtime_api::SessionPublicUnavailableReason, &'static str)> {
use runtime_api::SessionPublicUnavailableReason as Reason;
match (source, error) {
(
runtime_api::SessionPublicSource::Retained { .. },
RuntimeError::WorkerNotFound { .. },
) => Some((
Reason::RetentionMissing,
"retained Session storage is missing",
)),
(runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreMissing { .. }) => {
Some((
Reason::ArchiveIncomplete,
"committed Session archive is incomplete",
))
}
(runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreCorrupt { .. }) => {
Some((
Reason::ArchiveIncomplete,
"committed Session archive failed integrity validation",
))
}
(_, RuntimeError::StoreIo { .. } | RuntimeError::StoreCommitOutcomeUnknown { .. }) => {
Some((Reason::StorageUnavailable, "Session storage is unavailable"))
}
_ => None,
}
}
const WORKER_DELETE_FAILURE_DIAGNOSTIC_CODE: &str = "worker_delete_persistence_failed";
const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str =
"Worker metadata deletion failed; the persisted Worker identity was retained for retry";
@@ -3543,6 +3578,123 @@ impl Runtime {
)
}
/// Search the committed public projection of one Backend-selected retained
/// Session or verified archive without restoring a Worker.
#[cfg(feature = "fs-store")]
pub fn session_public_search_scoped(
&self,
scope: &RuntimeWorkspaceScope,
request: &runtime_api::SessionPublicSearchRequest,
) -> Result<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
/// reconciliation. Runtime identity and Workspace scope are derived here,
/// 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,
)
}
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);
}
if let Some(subjektiv_sessions) =
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
&worker.manifest().feature.subjektiv,
worker.committed_session_capture_handle(),
worker.workspace_client_handle(),
)?
{
feature_registry.add_module(subjektiv_sessions);
}
if let Some(memory_config) = memory_lifecycle_config
&& let Some(memory_lifecycle) =
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
+1
View File
@@ -19,6 +19,7 @@ pub mod orchestration;
mod resource_projection;
pub mod session_explore;
pub(crate) mod subjektiv_memory;
pub(crate) mod subjektiv_session;
pub mod task;
pub mod ticket;
pub mod worker_observation;
@@ -2078,6 +2078,7 @@ permission = "write"
segment_id: "segment-1".to_string(),
session_revision: history_len.try_into().unwrap(),
entry_count: history_len,
has_committed_run: true,
run_exit: CommittedRunExit::Finished,
history: (0..history_len)
.map(|index| HistoryEntry {
@@ -1101,6 +1101,7 @@ mod tests {
segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 0,
has_committed_run: true,
run_exit: CommittedRunExit::Finished,
history: Vec::new(),
usage_history: Vec::new(),
@@ -1351,6 +1352,7 @@ mod tests {
segment_id: "segment-1".into(),
session_revision: 1,
entry_count: 3,
has_committed_run: true,
run_exit: CommittedRunExit::Interrupted,
history: vec![
crate::session_history::history_entry(
@@ -1410,6 +1412,7 @@ mod tests {
segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2,
has_committed_run: true,
run_exit: CommittedRunExit::Finished,
history: vec![
crate::session_history::history_entry(
@@ -1487,6 +1490,7 @@ mod tests {
segment_id: "segment-1".into(),
session_revision: 2,
entry_count: 2,
has_committed_run: true,
run_exit: CommittedRunExit::Finished,
history: vec![
crate::session_history::history_entry(
@@ -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.
pub(crate) session_revision: u64,
pub(crate) entry_count: usize,
/// Whether the durable capture contains a committed run boundary. This is
/// false for a newly materialized Session before its first Run commits.
pub(crate) has_committed_run: bool,
pub(crate) run_exit: CommittedRunExit,
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
pub(crate) usage_history: Vec<UsageRecord>,
+11
View File
@@ -629,6 +629,7 @@ pub enum WorkspaceServerOperation {
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
SubjektivSession(server_api::SubjektivSessionBackendRequest),
}
fn workspace_server_json_request<T: serde::Serialize>(
@@ -716,6 +717,9 @@ fn workspace_server_operation_request(
WorkspaceServerOperation::SubjektivMemory(request) => {
workspace_server_json_request(format!("{base}/subjektiv/memory"), &request)
}
WorkspaceServerOperation::SubjektivSession(request) => {
workspace_server_json_request(format!("{base}/subjektiv/session-history"), &request)
}
}
}
@@ -3488,6 +3492,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
let entries = store
.read_all(location.session_id, location.segment_id)
.map_err(|error| FeatureSessionError::Capture(error.to_string()))?;
let has_committed_run = entries.iter().any(|entry| {
matches!(
entry,
LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. }
)
});
let run_exit = entries
.iter()
.rev()
@@ -3514,6 +3524,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
segment_id: location.segment_id.to_string(),
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
entry_count: entries.len(),
has_committed_run,
run_exit,
history,
usage_history: restored.usage_history,
+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(
&self,
_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(
&self,
worker: &RuntimeWorkerRef,
@@ -2964,6 +3014,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
.map_err(|error| error.to_string())
}
fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<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(
&self,
worker_id: &str,
@@ -3669,6 +3749,9 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
return "workers:delete";
}
if path.starts_with("/v1/session-public/") && method == "POST" {
return "workers:read";
}
if path.starts_with("/v1/workers/") && method == "GET" {
return "workers:read";
}
@@ -5007,6 +5090,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
.map_err(|diagnostic| diagnostic.message)
}
fn session_public_search(
&self,
request: runtime_api::SessionPublicSearchRequest,
) -> Result<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(
&self,
worker_id: &str,
@@ -957,6 +957,17 @@ CREATE TABLE worker_session_archives (
committed_at TEXT NOT NULL, expires_at TEXT,
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
CREATE TABLE worker_session_archive_observe_grants (
workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL,
controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL,
subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL,
source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id),
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE);
CREATE INDEX worker_session_archive_observe_grants_controller
ON worker_session_archive_observe_grants(
workspace_id,controller_runtime_id,controller_worker_id,archive_id);
CREATE TABLE worker_tombstones (
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
+131
View File
@@ -132,6 +132,21 @@ pub struct WorkerTombstone {
pub operation_id: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerSessionArchiveRecord {
pub archive_id: String,
pub workspace_id: String,
pub worker: RuntimeWorkerRef,
pub session_id: String,
pub checksum_sha256: String,
pub content_bytes: u64,
pub policy_id: String,
pub policy_revision: u64,
pub operation_id: String,
pub committed_at: String,
pub expires_at: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerOrphanDiagnostic {
pub diagnostic_id: String,
@@ -454,6 +469,51 @@ impl SqliteWorkspaceStore {
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
let observe_grants = {
let mut statement = tx.prepare(
"SELECT grant_id, controller_runtime_id, controller_worker_id,
permissions_json, created_at
FROM worker_control_grants
WHERE workspace_id = ?1 AND subject_runtime_id = ?2
AND subject_worker_id = ?3 AND revoked_at IS NULL",
)?;
statement
.query_map(
params![workspace_id, plan.worker.runtime_id, plan.worker.worker_id],
|row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, String>(4)?,
))
},
)?
.collect::<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()));}
match plan.diagnostics_disposition {
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())),
@@ -592,6 +652,77 @@ impl SqliteWorkspaceStore {
) -> crate::Result<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))
}
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(
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};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 75;
const LATEST_SCHEMA_VERSION: i64 = 76;
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
@@ -72,6 +72,8 @@ const ORDERED_WORKER_PROJECTION_MIGRATION_NAME: &str =
"ordered Worker projection subscriptions without revision reconciliation";
const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str =
"Runtime-scoped durable Worker catalog identity";
const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
"preserve explicit observe grants for committed Worker Session archives";
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
@@ -244,6 +246,11 @@ const MIGRATIONS: &[Migration] = &[
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME,
apply: migrate_runtime_scoped_worker_identity_v74_to_v75,
},
Migration {
version: 76,
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME,
apply: migrate_archive_observe_grants_v75_to_v76,
},
];
#[derive(Clone, Copy)]
@@ -14147,6 +14154,47 @@ fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Resul
}
}
fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
let current = current_schema_version(conn)?;
if current != 75 {
return Err(Error::Store(format!(
"expected schema version 75 before {ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME} migration, found {current}"
)));
}
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
tx.execute_batch(
r#"
CREATE TABLE IF NOT EXISTS worker_session_archive_observe_grants (
workspace_id TEXT NOT NULL,
archive_id TEXT NOT NULL,
controller_runtime_id TEXT NOT NULL,
controller_worker_id TEXT NOT NULL,
subject_runtime_id TEXT NOT NULL,
subject_worker_id TEXT NOT NULL,
source_grant_id TEXT NOT NULL,
granted_at TEXT NOT NULL,
PRIMARY KEY (
workspace_id, archive_id,
controller_runtime_id, controller_worker_id,
source_grant_id
),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
FOREIGN KEY (archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS worker_session_archive_observe_grants_controller
ON worker_session_archive_observe_grants(
workspace_id, controller_runtime_id, controller_worker_id, archive_id
);
"#,
)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
params![76_i64, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME],
)?;
tx.commit()?;
Ok(())
}
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
conn.execute_batch(include_str!("latest_schema.sql"))?;
@@ -16617,6 +16665,10 @@ mod tests {
version: 75,
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
},
WorkspaceSchemaMigrationStep {
version: 76,
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(),
},
]
);
@@ -16699,6 +16751,7 @@ mod tests {
75,
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
),
(76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -16985,7 +17038,7 @@ mod tests {
.collect::<Vec<_>>(),
vec![
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
73, 74, 75
73, 74, 75, 76
]
);
SqliteWorkspaceStore::migrate_database(&path).unwrap();
@@ -16994,7 +17047,7 @@ mod tests {
current_schema_version(&conn).unwrap(),
LATEST_SCHEMA_VERSION
);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 26);
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 27);
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
+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)]
#[serde(rename_all = "snake_case")]
pub enum RevisionProposalIntent {
@@ -1208,6 +1214,93 @@ impl SubjektivStore {
})
}
/// Reads one bounded immutable attribution page without materializing the
/// subject's complete history. `snapshot_at` fixes the initial discovery
/// boundary; later Host attributions require a fresh traversal.
pub fn subject_session_attribution_page(
&self,
subject_id: &str,
session_id: Option<&str>,
snapshot_at: &str,
after: Option<(&str, &str)>,
limit: usize,
) -> Result<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(
&self,
subject_id: &str,
@@ -3678,6 +3771,88 @@ mod tests {
assert!(store.session_attribution("session-2").unwrap().is_none());
}
#[test]
fn attribution_pages_are_bounded_descending_and_snapshot_fixed() {
let temp = tempfile::tempdir().unwrap();
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
let subject = store.create_subject(role()).unwrap();
for (session, at) in [
("session-1", "2026-09-28T10:00:00.000Z"),
("session-2", "2026-09-28T11:00:00.000Z"),
("session-3", "2026-09-28T11:00:00.000Z"),
] {
store
.record_session_attribution(attribution(
&subject.id,
"runtime-1",
"worker-1",
session,
at,
))
.unwrap();
}
let first = store
.subject_session_attribution_page(
&subject.id,
None,
"2026-09-28T11:30:00.000Z",
None,
2,
)
.unwrap();
assert_eq!(
first
.items
.iter()
.map(|item| item.session_id.as_str())
.collect::<Vec<_>>(),
vec!["session-2", "session-3"]
);
assert!(first.has_more);
store
.record_session_attribution(attribution(
&subject.id,
"runtime-2",
"worker-2",
"session-later",
"2026-09-28T12:00:00.000Z",
))
.unwrap();
let last = first.items.last().unwrap();
let second = store
.subject_session_attribution_page(
&subject.id,
None,
"2026-09-28T11:30:00.000Z",
Some((&last.attributed_at, &last.session_id)),
2,
)
.unwrap();
assert_eq!(second.items.len(), 1);
assert_eq!(second.items[0].session_id, "session-1");
assert!(!second.has_more);
assert!(
second
.items
.iter()
.all(|item| item.session_id != "session-later")
);
let exact = store
.subject_session_attribution_page(
&subject.id,
Some("session-1"),
"2026-09-28T11:30:00.000Z",
None,
20,
)
.unwrap();
assert_eq!(exact.items.len(), 1);
assert!(!exact.has_more);
}
#[test]
fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() {
let temp = tempfile::tempdir().unwrap();
@@ -77,6 +77,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"worker_registry_projection_removals",
"worker_removal_operations",
"worker_retention_audit_events",
"worker_session_archive_observe_grants",
"worker_session_archives",
"worker_singleton_owners",
"worker_tombstones",