feat: discover subject session history
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,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",
|
||||
|
||||
@@ -218,6 +218,46 @@ and exact backend retries return the first candidate rather than creating anothe
|
||||
one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`,
|
||||
or a surface snapshot.
|
||||
|
||||
|
||||
## Historical Session discovery (T-673)
|
||||
|
||||
An enabled `feature.subjektiv` also installs exactly three read-only tools:
|
||||
`SubjektivSessionList`, `SubjektivSessionSearch`, and `SubjektivSessionRead`.
|
||||
The Server binds subject, Workspace, Runtime, Worker, retention catalogue, and
|
||||
archive authority from the authenticated caller. Model input contains only
|
||||
Session/segment/entry selectors, search filters, limits, and opaque cursors;
|
||||
knowing any identifier does not grant access. Reads use Yoi's retained or
|
||||
committed-archive Session storage and a shared public projection. They do not
|
||||
copy Session bodies into the subjektiv database, restore Workers, migrate
|
||||
Session storage, create candidates or Memories, revise a surface, or promote a
|
||||
re-read tool result to HumanInput.
|
||||
|
||||
The public projection covers committed records in every persisted segment,
|
||||
including non-active branches and pre-compaction entries, while excluding
|
||||
system prompts, hidden reasoning, traces/diagnostics, unfinished run tails, and
|
||||
attachment or pasted-artifact bodies. Results preserve exact Session, segment,
|
||||
entry, provenance, and lineage identity so inherited entries are not presented
|
||||
as a current decision. Full entry reads are UTF-8-boundary paged and remain
|
||||
subject to the same projection as snippets and search.
|
||||
|
||||
The subject's first committed primary-Worker Session is attributed independently
|
||||
of extraction thresholds or candidate creation. Recording is idempotent and is
|
||||
retried on later committed-run and pre-request lifecycle points after transient
|
||||
failure; no attribution is attempted before a committed capture exists. This is
|
||||
not a distributed transaction with Session persistence. In particular, the
|
||||
system does **not** backfill older unattributed Sessions from their text, display
|
||||
name, current singleton ownership, Memory references, or other inference.
|
||||
Historical Sessions without trustworthy Host-recorded attribution remain outside
|
||||
subject discovery until a separately authorized provenance mechanism exists.
|
||||
|
||||
Pagination cursors bind scope, operation, filters, stable ordering, and the
|
||||
public storage generation and are never authorization credentials. Every page
|
||||
rechecks current subject and observation authority. Deleted-Worker archives are
|
||||
eligible only when both the committed Server catalogue and Runtime manifest
|
||||
agree and the archived observation grant remains valid; incomplete, expired,
|
||||
corrupt, unavailable, or changed sources are reported explicitly rather than as
|
||||
an empty successful search.
|
||||
|
||||
Revision proposals add optional `revision_proposal` metadata to the existing v2
|
||||
`SubjectStagingRecord` envelope; automatic extraction remains proposal-free and
|
||||
its model schema is unchanged. The metadata is serialized atomically with the
|
||||
|
||||
+41
-1
@@ -2832,6 +2832,19 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/MemoryResidentSummaryOutput",
|
||||
"properties": {
|
||||
"kind": {
|
||||
"const": "resident_summary",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"kind"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/MemoryBackendAckOutput",
|
||||
"properties": {
|
||||
@@ -3084,9 +3097,36 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryResidentSummaryAvailability": {
|
||||
"enum": [
|
||||
"ready",
|
||||
"ungenerated",
|
||||
"stale",
|
||||
"failed",
|
||||
"unavailable"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"MemoryResidentSummaryOperation": {
|
||||
"type": "object"
|
||||
},
|
||||
"MemoryResidentSummaryOutput": {
|
||||
"properties": {
|
||||
"availability": {
|
||||
"$ref": "#/components/schemas/MemoryResidentSummaryAvailability"
|
||||
},
|
||||
"content": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"availability"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"MemorySettingsAudit": {
|
||||
"properties": {
|
||||
"language": {
|
||||
@@ -14081,7 +14121,7 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9"
|
||||
"x-yoi-source-digest": "sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
||||
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
Reference in New Issue
Block a user