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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct RetainedSessionIdentity {
|
pub struct RetainedSessionIdentity {
|
||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
@@ -766,6 +938,18 @@ pub trait RuntimeApi {
|
|||||||
#[query] request: WorkerSessionHistoryRequest,
|
#[query] request: WorkerSessionHistoryRequest,
|
||||||
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
|
) -> Result<WorkerSessionHistoryAvailability, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post("/v1/session-public/search", status = 200, error_status = 400)]
|
||||||
|
async fn session_public_search(
|
||||||
|
&self,
|
||||||
|
#[body] request: SessionPublicSearchRequest,
|
||||||
|
) -> Result<SessionPublicSearchAvailability, RuntimeApiError>;
|
||||||
|
|
||||||
|
#[post("/v1/session-public/read", status = 200, error_status = 400)]
|
||||||
|
async fn session_public_read(
|
||||||
|
&self,
|
||||||
|
#[body] request: SessionPublicReadRequest,
|
||||||
|
) -> Result<SessionPublicReadAvailability, RuntimeApiError>;
|
||||||
|
|
||||||
#[post("/v1/workers", status = 200, error_status = 400)]
|
#[post("/v1/workers", status = 200, error_status = 400)]
|
||||||
async fn create_worker(
|
async fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
@@ -1112,6 +1296,26 @@ mod tests {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn session_public_search(
|
||||||
|
&self,
|
||||||
|
_request: SessionPublicSearchRequest,
|
||||||
|
) -> Result<SessionPublicSearchAvailability, RuntimeApiError> {
|
||||||
|
Ok(SessionPublicSearchAvailability::Unavailable {
|
||||||
|
reason: SessionPublicUnavailableReason::StorageUnavailable,
|
||||||
|
message: "unsupported".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session_public_read(
|
||||||
|
&self,
|
||||||
|
_request: SessionPublicReadRequest,
|
||||||
|
) -> Result<SessionPublicReadAvailability, RuntimeApiError> {
|
||||||
|
Ok(SessionPublicReadAvailability::Unavailable {
|
||||||
|
reason: SessionPublicUnavailableReason::StorageUnavailable,
|
||||||
|
message: "unsupported".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_worker(
|
async fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
_request: CreateWorkerRequest,
|
_request: CreateWorkerRequest,
|
||||||
@@ -1248,7 +1452,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn contract_inventory_is_complete_and_unique() {
|
fn contract_inventory_is_complete_and_unique() {
|
||||||
let operations = RuntimeApiMetadata::OPERATIONS;
|
let operations = RuntimeApiMetadata::OPERATIONS;
|
||||||
assert_eq!(operations.len(), 17);
|
assert_eq!(operations.len(), 19);
|
||||||
let mut routes = operations
|
let mut routes = operations
|
||||||
.iter()
|
.iter()
|
||||||
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
.map(|operation| (format!("{:?}", operation.method), operation.path))
|
||||||
|
|||||||
@@ -1191,6 +1191,20 @@ pub trait ServerApi {
|
|||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[body] request: SubjektivMemoryBackendRequest,
|
#[body] request: SubjektivMemoryBackendRequest,
|
||||||
) -> Result<SubjektivMemoryBackendResponse, RepositoryApiError>;
|
) -> Result<SubjektivMemoryBackendResponse, RepositoryApiError>;
|
||||||
|
#[post(
|
||||||
|
"/api/w/{workspace_id}/subjektiv/session-history",
|
||||||
|
operation_id = "subjektiv_session_backend",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401, 403, 404, 409, 500],
|
||||||
|
openapi = false,
|
||||||
|
)]
|
||||||
|
async fn subjektiv_session_backend(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[body] request: SubjektivSessionBackendRequest,
|
||||||
|
) -> Result<SubjektivSessionBackendResponse, RepositoryApiError>;
|
||||||
#[post(
|
#[post(
|
||||||
"/api/w/{workspace_id}/subjektiv/consolidation",
|
"/api/w/{workspace_id}/subjektiv/consolidation",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -4534,6 +4548,280 @@ pub struct SubjektivRecordSessionResponse {
|
|||||||
pub session_id: String,
|
pub session_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Model-visible storage selector for subject-attributed Sessions. Runtime,
|
||||||
|
/// Worker, archive and filesystem identities remain Host-bound.
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionStorageFilter {
|
||||||
|
All,
|
||||||
|
Retained,
|
||||||
|
Archived,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubjektivSessionStorageFilter {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::All
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionStorage {
|
||||||
|
Retained,
|
||||||
|
Archived,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionAvailability {
|
||||||
|
Available,
|
||||||
|
Unchecked,
|
||||||
|
Unavailable,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionListRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub storage: SubjektivSessionStorageFilter,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[schemars(range(min = 1, max = 100))]
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionEntryKind {
|
||||||
|
User,
|
||||||
|
Assistant,
|
||||||
|
Tool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionToolPart {
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
Both,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubjektivSessionToolPart {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Both
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionSearchRequest {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub query: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub kind: Option<SubjektivSessionEntryKind>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub tool_part: SubjektivSessionToolPart,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
#[schemars(range(min = 1, max = 100))]
|
||||||
|
pub limit: Option<usize>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionReadMode {
|
||||||
|
Compact,
|
||||||
|
Full,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SubjektivSessionReadMode {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::Compact
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionReadRequest {
|
||||||
|
pub session_id: String,
|
||||||
|
pub segment_id: String,
|
||||||
|
pub entry_ref: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub mode: SubjektivSessionReadMode,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub cursor: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(tag = "operation", content = "input", rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionBackendOperation {
|
||||||
|
List(SubjektivSessionListRequest),
|
||||||
|
Search(SubjektivSessionSearchRequest),
|
||||||
|
Read(SubjektivSessionReadRequest),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionBackendRequest {
|
||||||
|
pub operation: SubjektivSessionBackendOperation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionListItem {
|
||||||
|
pub session_id: String,
|
||||||
|
/// Host attribution time. This is not represented as experience time.
|
||||||
|
pub attributed_at: String,
|
||||||
|
pub storage: SubjektivSessionStorage,
|
||||||
|
pub availability: SubjektivSessionAvailability,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionListResponse {
|
||||||
|
pub items: Vec<SubjektivSessionListItem>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionLineageKind {
|
||||||
|
Root,
|
||||||
|
Fork,
|
||||||
|
Compact,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionLineage {
|
||||||
|
pub kind: SubjektivSessionLineageKind,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub parent_segment_id: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub at_turn_index: Option<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionSearchItem {
|
||||||
|
pub session_id: String,
|
||||||
|
pub segment_id: String,
|
||||||
|
pub entry_ref: String,
|
||||||
|
pub kind: SubjektivSessionEntryKind,
|
||||||
|
pub origin: protocol::SessionEntryProvenance,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_name: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub tool_part: Option<SubjektivSessionToolPart>,
|
||||||
|
pub snippet: String,
|
||||||
|
pub snippet_truncated: bool,
|
||||||
|
pub lineage: SubjektivSessionLineage,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionCoverage {
|
||||||
|
Complete,
|
||||||
|
Partial,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionDiagnosticCode {
|
||||||
|
InvalidInput,
|
||||||
|
SubjectUnavailable,
|
||||||
|
StaleCursor,
|
||||||
|
NotFoundOrNotAuthorized,
|
||||||
|
RetentionMissing,
|
||||||
|
RetentionExpired,
|
||||||
|
ArchiveIncomplete,
|
||||||
|
CorruptLog,
|
||||||
|
RuntimeUnavailable,
|
||||||
|
MigrationRequired,
|
||||||
|
ResourceLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionIssue {
|
||||||
|
pub session_id: String,
|
||||||
|
pub code: SubjektivSessionDiagnosticCode,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionSearchResponse {
|
||||||
|
pub items: Vec<SubjektivSessionSearchItem>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
pub has_more: bool,
|
||||||
|
pub coverage: SubjektivSessionCoverage,
|
||||||
|
pub issues: Vec<SubjektivSessionIssue>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionReadResponse {
|
||||||
|
pub session_id: String,
|
||||||
|
pub segment_id: String,
|
||||||
|
pub entry_ref: String,
|
||||||
|
pub kind: SubjektivSessionEntryKind,
|
||||||
|
pub origin: protocol::SessionEntryProvenance,
|
||||||
|
pub lineage: SubjektivSessionLineage,
|
||||||
|
pub mode: SubjektivSessionReadMode,
|
||||||
|
pub content: String,
|
||||||
|
pub truncated: bool,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub next_cursor: Option<String>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct SubjektivSessionErrorResponse {
|
||||||
|
pub code: SubjektivSessionDiagnosticCode,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(tag = "result", content = "data", rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionBackendResult {
|
||||||
|
List(SubjektivSessionListResponse),
|
||||||
|
Search(SubjektivSessionSearchResponse),
|
||||||
|
Read(SubjektivSessionReadResponse),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expected discovery/read failures remain typed model-visible data instead of
|
||||||
|
/// being collapsed into an empty success or an unstructured transport error.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(tag = "status", rename_all = "snake_case")]
|
||||||
|
pub enum SubjektivSessionBackendResponse {
|
||||||
|
Ok {
|
||||||
|
result: SubjektivSessionBackendResult,
|
||||||
|
},
|
||||||
|
Error {
|
||||||
|
error: SubjektivSessionErrorResponse,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maximum serialized model-facing JSON content for every subject Session tool.
|
||||||
|
pub const SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES: usize = 56 * 1024;
|
||||||
|
pub const SUBJEKTIV_SESSION_MAX_READ_CONTENT_BYTES: usize = 16 * 1024;
|
||||||
|
pub const SUBJEKTIV_SESSION_MAX_SNIPPET_BYTES: usize = 512;
|
||||||
|
|
||||||
/// Current lifecycle state of one confirmed subject Memory revision.
|
/// Current lifecycle state of one confirmed subject Memory revision.
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -12273,6 +12561,7 @@ mod openapi_artifact_tests {
|
|||||||
"subjektiv_stage_candidate",
|
"subjektiv_stage_candidate",
|
||||||
"subjektiv_record_session",
|
"subjektiv_record_session",
|
||||||
"subjektiv_memory_backend",
|
"subjektiv_memory_backend",
|
||||||
|
"subjektiv_session_backend",
|
||||||
"subjektiv_memory_consolidation",
|
"subjektiv_memory_consolidation",
|
||||||
"workspace_worker_discovery",
|
"workspace_worker_discovery",
|
||||||
"workspace_worker_remove",
|
"workspace_worker_remove",
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ pub mod history;
|
|||||||
mod legacy_session_log;
|
mod legacy_session_log;
|
||||||
pub mod logged_item;
|
pub mod logged_item;
|
||||||
mod paste_artifact;
|
mod paste_artifact;
|
||||||
|
pub mod public_index;
|
||||||
pub mod public_snapshot;
|
pub mod public_snapshot;
|
||||||
pub mod segment;
|
pub mod segment;
|
||||||
pub mod segment_log;
|
pub mod segment_log;
|
||||||
@@ -95,6 +96,14 @@ pub use history::{
|
|||||||
};
|
};
|
||||||
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
pub use logged_item::{LoggedContentPart, LoggedItem, LoggedRole, from_logged, to_logged};
|
||||||
pub use paste_artifact::PasteArtifactLimits;
|
pub use paste_artifact::PasteArtifactLimits;
|
||||||
|
pub use public_index::{
|
||||||
|
DEFAULT_SESSION_PUBLIC_INDEX_MAX_BYTES, DEFAULT_SESSION_PUBLIC_INDEX_MAX_ENTRIES,
|
||||||
|
DEFAULT_SESSION_PUBLIC_INDEX_MAX_SEGMENTS, SESSION_PUBLIC_INDEX_COMPACT_TEXT_MAX_BYTES,
|
||||||
|
SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind,
|
||||||
|
SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind,
|
||||||
|
SessionPublicIndexReadError, SessionPublicIndexSegment, SessionPublicIndexToolPart,
|
||||||
|
read_session_public_index,
|
||||||
|
};
|
||||||
pub use public_snapshot::{
|
pub use public_snapshot::{
|
||||||
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
DEFAULT_RETAINED_HISTORY_MAX_ENTRIES, DEFAULT_RETAINED_HISTORY_MAX_RESPONSE_BYTES,
|
||||||
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
DEFAULT_RETAINED_HISTORY_MAX_SCAN_BYTES, DEFAULT_RETAINED_HISTORY_MAX_SEGMENTS,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -978,7 +978,7 @@ pub fn project_session_snapshot(session_id: SessionId, log: &[LogEntry]) -> Sess
|
|||||||
project_session_snapshot_for_segment(session_id, None, log)
|
project_session_snapshot_for_segment(session_id, None, log)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn project_session_snapshot_for_segment(
|
pub(crate) fn project_session_snapshot_for_segment(
|
||||||
session_id: SessionId,
|
session_id: SessionId,
|
||||||
segment_id: Option<SegmentId>,
|
segment_id: Option<SegmentId>,
|
||||||
log: &[LogEntry],
|
log: &[LogEntry],
|
||||||
|
|||||||
@@ -468,6 +468,23 @@ impl WorkerSessionStore {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enumerate canonical Segment logs in ascending identity order without
|
||||||
|
/// creating or migrating state. Unlike the general [`Store`] listing, this
|
||||||
|
/// observation boundary rejects malformed names and non-regular files so a
|
||||||
|
/// persisted Segment cannot be silently omitted from a public index.
|
||||||
|
pub(crate) fn list_segments_read_only(
|
||||||
|
&self,
|
||||||
|
session_id: SessionId,
|
||||||
|
) -> Result<Vec<SegmentId>, StoreError> {
|
||||||
|
self.validate_retained_session(session_id)?;
|
||||||
|
segment_log_paths(&self.root).map(|paths| {
|
||||||
|
paths
|
||||||
|
.into_iter()
|
||||||
|
.map(|(segment_id, _)| segment_id)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
|
pub fn segment_log_len(&self, segment_id: SegmentId) -> Result<u64, StoreError> {
|
||||||
Ok(fs::metadata(self.log_path(segment_id))?.len())
|
Ok(fs::metadata(self.log_path(segment_id))?.len())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2312,6 +2312,9 @@ fn required_runtime_permission(method: &Method, path: &str) -> Option<&'static s
|
|||||||
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
|
if path.starts_with("/v1/workers/") && *method == Method::DELETE {
|
||||||
return Some("workers:delete");
|
return Some("workers:delete");
|
||||||
}
|
}
|
||||||
|
if path.starts_with("/v1/session-public/") && *method == Method::POST {
|
||||||
|
return Some("workers:read");
|
||||||
|
}
|
||||||
if path.starts_with("/v1/workers/") && *method == Method::GET {
|
if path.starts_with("/v1/workers/") && *method == Method::GET {
|
||||||
return Some("workers:read");
|
return Some("workers:read");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,6 +289,80 @@ impl runtime_api::RuntimeApi for RuntimeManagementApi {
|
|||||||
.map_err(api_error)
|
.map_err(api_error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn session_public_search(
|
||||||
|
&self,
|
||||||
|
request: runtime_api::SessionPublicSearchRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicSearchAvailability, runtime_api::RuntimeApiError> {
|
||||||
|
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||||
|
.map_err(api_error)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::FORBIDDEN.as_u16(),
|
||||||
|
"runtime_session_public_scope_required",
|
||||||
|
"Session public search requires a Workspace-scoped capability",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if scope.workspace_id != request.workspace_id {
|
||||||
|
return Err(runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::FORBIDDEN.as_u16(),
|
||||||
|
"runtime_session_public_workspace_scope_mismatch",
|
||||||
|
"Session public search Workspace scope does not match the authenticated capability",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
{
|
||||||
|
self.state
|
||||||
|
.runtime
|
||||||
|
.session_public_search_scoped(&scope, &request)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)
|
||||||
|
.map_err(api_error)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "fs-store"))]
|
||||||
|
{
|
||||||
|
Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
|
||||||
|
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||||
|
message: "Session public storage is unavailable".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn session_public_read(
|
||||||
|
&self,
|
||||||
|
request: runtime_api::SessionPublicReadRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicReadAvailability, runtime_api::RuntimeApiError> {
|
||||||
|
let scope = auth_workspace_scope(&self.state, auth_extension().as_ref())
|
||||||
|
.map_err(api_error)?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::FORBIDDEN.as_u16(),
|
||||||
|
"runtime_session_public_scope_required",
|
||||||
|
"Session public read requires a Workspace-scoped capability",
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if scope.workspace_id != request.workspace_id {
|
||||||
|
return Err(runtime_api::RuntimeApiError::new(
|
||||||
|
StatusCode::FORBIDDEN.as_u16(),
|
||||||
|
"runtime_session_public_workspace_scope_mismatch",
|
||||||
|
"Session public read Workspace scope does not match the authenticated capability",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
{
|
||||||
|
self.state
|
||||||
|
.runtime
|
||||||
|
.session_public_read_scoped(&scope, &request)
|
||||||
|
.map_err(RuntimeHttpRestError::runtime)
|
||||||
|
.map_err(api_error)
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "fs-store"))]
|
||||||
|
{
|
||||||
|
Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
|
||||||
|
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||||
|
message: "Session public storage is unavailable".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn create_worker(
|
async fn create_worker(
|
||||||
&self,
|
&self,
|
||||||
value: runtime_api::CreateWorkerRequest,
|
value: runtime_api::CreateWorkerRequest,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ pub mod resource;
|
|||||||
#[cfg(feature = "fs-store")]
|
#[cfg(feature = "fs-store")]
|
||||||
pub mod retention;
|
pub mod retention;
|
||||||
mod runtime;
|
mod runtime;
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
mod session_public;
|
||||||
pub mod ssh_host_key_probe;
|
pub mod ssh_host_key_probe;
|
||||||
pub mod worker_backend;
|
pub mod worker_backend;
|
||||||
pub mod worker_source;
|
pub mod worker_source;
|
||||||
|
|||||||
@@ -356,6 +356,26 @@ impl FsWorkerRetentionProvider {
|
|||||||
.join(archive_id))
|
.join(archive_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn session_archive(
|
||||||
|
&self,
|
||||||
|
archive_id: &str,
|
||||||
|
) -> Result<(WorkerSessionArchiveManifest, PathBuf), RuntimeError> {
|
||||||
|
let archive_dir = self.archive_dir(archive_id)?;
|
||||||
|
let manifest: WorkerSessionArchiveManifest = read_json(
|
||||||
|
&archive_dir.join("manifest.json"),
|
||||||
|
"read Worker Session archive",
|
||||||
|
)?;
|
||||||
|
if manifest.schema_version != ARCHIVE_SCHEMA_VERSION || manifest.archive_id != archive_id {
|
||||||
|
return Err(RuntimeError::StoreCorrupt {
|
||||||
|
operation: "read Worker Session archive",
|
||||||
|
path: archive_dir.join("manifest.json"),
|
||||||
|
message: "archive manifest identity mismatch".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let manifest = validate_existing_archive(&archive_dir, &manifest)?;
|
||||||
|
Ok((manifest, archive_dir.join("session")))
|
||||||
|
}
|
||||||
|
|
||||||
fn diagnostics_dir(&self, operation_id: &str) -> Result<PathBuf, RuntimeError> {
|
fn diagnostics_dir(&self, operation_id: &str) -> Result<PathBuf, RuntimeError> {
|
||||||
validate_id("operation_id", operation_id)?;
|
validate_id("operation_id", operation_id)?;
|
||||||
Ok(self
|
Ok(self
|
||||||
|
|||||||
@@ -77,6 +77,41 @@ impl RuntimeWorkspaceScope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256;
|
const SUBSCRIPTION_QUEUE_CAPACITY: usize = 256;
|
||||||
|
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
fn session_public_source_unavailable(
|
||||||
|
source: &runtime_api::SessionPublicSource,
|
||||||
|
error: &RuntimeError,
|
||||||
|
) -> Option<(runtime_api::SessionPublicUnavailableReason, &'static str)> {
|
||||||
|
use runtime_api::SessionPublicUnavailableReason as Reason;
|
||||||
|
|
||||||
|
match (source, error) {
|
||||||
|
(
|
||||||
|
runtime_api::SessionPublicSource::Retained { .. },
|
||||||
|
RuntimeError::WorkerNotFound { .. },
|
||||||
|
) => Some((
|
||||||
|
Reason::RetentionMissing,
|
||||||
|
"retained Session storage is missing",
|
||||||
|
)),
|
||||||
|
(runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreMissing { .. }) => {
|
||||||
|
Some((
|
||||||
|
Reason::ArchiveIncomplete,
|
||||||
|
"committed Session archive is incomplete",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
(runtime_api::SessionPublicSource::Archived { .. }, RuntimeError::StoreCorrupt { .. }) => {
|
||||||
|
Some((
|
||||||
|
Reason::ArchiveIncomplete,
|
||||||
|
"committed Session archive failed integrity validation",
|
||||||
|
))
|
||||||
|
}
|
||||||
|
(_, RuntimeError::StoreIo { .. } | RuntimeError::StoreCommitOutcomeUnknown { .. }) => {
|
||||||
|
Some((Reason::StorageUnavailable, "Session storage is unavailable"))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const WORKER_DELETE_FAILURE_DIAGNOSTIC_CODE: &str = "worker_delete_persistence_failed";
|
const WORKER_DELETE_FAILURE_DIAGNOSTIC_CODE: &str = "worker_delete_persistence_failed";
|
||||||
const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str =
|
const WORKER_DELETE_FAILURE_DIAGNOSTIC_MESSAGE: &str =
|
||||||
"Worker metadata deletion failed; the persisted Worker identity was retained for retry";
|
"Worker metadata deletion failed; the persisted Worker identity was retained for retry";
|
||||||
@@ -3543,6 +3578,123 @@ impl Runtime {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Search the committed public projection of one Backend-selected retained
|
||||||
|
/// Session or verified archive without restoring a Worker.
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
pub fn session_public_search_scoped(
|
||||||
|
&self,
|
||||||
|
scope: &RuntimeWorkspaceScope,
|
||||||
|
request: &runtime_api::SessionPublicSearchRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeError> {
|
||||||
|
let (session_root, archive) =
|
||||||
|
match self.resolve_session_public_source(scope, &request.source) {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(error) => {
|
||||||
|
if let Some((reason, message)) =
|
||||||
|
session_public_source_unavailable(&request.source, &error)
|
||||||
|
{
|
||||||
|
return Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
|
||||||
|
reason,
|
||||||
|
message: message.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(crate::session_public::search(
|
||||||
|
&session_root,
|
||||||
|
request,
|
||||||
|
archive.as_ref(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read one exact public entry from a Backend-selected retained Session or
|
||||||
|
/// verified archive. No fallback Worker, Segment, or entry is selected.
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
pub fn session_public_read_scoped(
|
||||||
|
&self,
|
||||||
|
scope: &RuntimeWorkspaceScope,
|
||||||
|
request: &runtime_api::SessionPublicReadRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicReadAvailability, RuntimeError> {
|
||||||
|
let (session_root, archive) =
|
||||||
|
match self.resolve_session_public_source(scope, &request.source) {
|
||||||
|
Ok(source) => source,
|
||||||
|
Err(error) => {
|
||||||
|
if let Some((reason, message)) =
|
||||||
|
session_public_source_unavailable(&request.source, &error)
|
||||||
|
{
|
||||||
|
return Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
|
||||||
|
reason,
|
||||||
|
message: message.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Err(error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(crate::session_public::read(
|
||||||
|
&session_root,
|
||||||
|
request,
|
||||||
|
archive.as_ref(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "fs-store")]
|
||||||
|
fn resolve_session_public_source(
|
||||||
|
&self,
|
||||||
|
scope: &RuntimeWorkspaceScope,
|
||||||
|
source: &runtime_api::SessionPublicSource,
|
||||||
|
) -> Result<
|
||||||
|
(
|
||||||
|
std::path::PathBuf,
|
||||||
|
Option<crate::retention::WorkerSessionArchiveManifest>,
|
||||||
|
),
|
||||||
|
RuntimeError,
|
||||||
|
> {
|
||||||
|
let state = self.lock()?;
|
||||||
|
let store = state.fs_store().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Session public reads require an fs-backed Runtime".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
match source {
|
||||||
|
runtime_api::SessionPublicSource::Retained { worker_id } => {
|
||||||
|
let worker_ref = WorkerRef::new(*worker_id);
|
||||||
|
let worker = state.worker(&worker_ref)?;
|
||||||
|
if worker.workspace_id.as_deref() != Some(scope.workspace_id.as_str()) {
|
||||||
|
return Err(RuntimeError::WorkerNotFound {
|
||||||
|
worker_id: *worker_id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok((
|
||||||
|
store
|
||||||
|
.runtime_dir()
|
||||||
|
.join("workers")
|
||||||
|
.join(worker_id.to_string())
|
||||||
|
.join("session"),
|
||||||
|
None,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
runtime_api::SessionPublicSource::Archived { archive_id } => {
|
||||||
|
let runtime_id = state.runtime_identity.as_deref().ok_or_else(|| {
|
||||||
|
RuntimeError::InvalidRequest(
|
||||||
|
"Runtime identity is not bound for Session archive reads".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let provider = FsWorkerRetentionProvider::new(store.runtime_dir());
|
||||||
|
let (manifest, session_root) = provider.session_archive(archive_id)?;
|
||||||
|
if manifest.workspace_id != scope.workspace_id
|
||||||
|
|| manifest.source_runtime_id != runtime_id
|
||||||
|
{
|
||||||
|
return Err(RuntimeError::InvalidRequest(
|
||||||
|
"Session archive scope does not match the authenticated Runtime capability"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok((session_root, Some(manifest)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Enumerate host-authoritative Runtime inventory for Backend orphan
|
/// Enumerate host-authoritative Runtime inventory for Backend orphan
|
||||||
/// reconciliation. Runtime identity and Workspace scope are derived here,
|
/// reconciliation. Runtime identity and Workspace scope are derived here,
|
||||||
/// not accepted in a diagnostic payload.
|
/// not accepted in a diagnostic payload.
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use runtime_api::{
|
||||||
|
SessionPublicEntryKind, SessionPublicLineage, SessionPublicLineageKind,
|
||||||
|
SessionPublicReadAvailability, SessionPublicReadMode, SessionPublicReadPage,
|
||||||
|
SessionPublicReadRequest, SessionPublicSearchAvailability, SessionPublicSearchItem,
|
||||||
|
SessionPublicSearchPage, SessionPublicSearchRequest, SessionPublicToolPart,
|
||||||
|
SessionPublicUnavailableReason, WorkerSessionArchiveManifest,
|
||||||
|
};
|
||||||
|
use session_store::{
|
||||||
|
SessionPublicIndex, SessionPublicIndexEntry, SessionPublicIndexEntryKind,
|
||||||
|
SessionPublicIndexLimits, SessionPublicIndexLineage, SessionPublicIndexOriginKind,
|
||||||
|
SessionPublicIndexReadError, SessionPublicIndexToolPart, read_session_public_index,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::retention::WorkerSessionArchiveManifest as DomainArchiveManifest;
|
||||||
|
|
||||||
|
pub(crate) fn search(
|
||||||
|
session_root: &Path,
|
||||||
|
request: &SessionPublicSearchRequest,
|
||||||
|
archive_manifest: Option<&DomainArchiveManifest>,
|
||||||
|
) -> SessionPublicSearchAvailability {
|
||||||
|
if request.limit == 0 || request.limit > 100 {
|
||||||
|
return unavailable(
|
||||||
|
SessionPublicUnavailableReason::ResourceLimit,
|
||||||
|
"Session public search limit must be between 1 and 100",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let index = match load_index(
|
||||||
|
session_root,
|
||||||
|
request.max_scan_bytes,
|
||||||
|
request.max_segments,
|
||||||
|
request.max_entries,
|
||||||
|
) {
|
||||||
|
Ok(index) => index,
|
||||||
|
Err((reason, message)) => return unavailable(reason, message),
|
||||||
|
};
|
||||||
|
if index.session_id != request.expected_session_id {
|
||||||
|
return unavailable(
|
||||||
|
SessionPublicUnavailableReason::NotFound,
|
||||||
|
"Session identity does not match the authorized source",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if request
|
||||||
|
.expected_generation
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|expected| expected != index.generation)
|
||||||
|
{
|
||||||
|
return unavailable(
|
||||||
|
SessionPublicUnavailableReason::InvalidCursor,
|
||||||
|
"Session public search generation changed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let query = request
|
||||||
|
.query
|
||||||
|
.as_deref()
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|value| !value.is_empty())
|
||||||
|
.map(str::to_lowercase);
|
||||||
|
let mut matches = Vec::new();
|
||||||
|
for segment in &index.segments {
|
||||||
|
for entry in &segment.entries {
|
||||||
|
if !matches_entry(entry, request, query.as_deref()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
matches.push(SessionPublicSearchItem {
|
||||||
|
segment_id: segment.segment_id.clone(),
|
||||||
|
entry_ref: entry.entry_ref.clone(),
|
||||||
|
kind: entry_kind(entry.kind),
|
||||||
|
origin: entry.provenance.clone(),
|
||||||
|
tool_name: entry.tool_name.clone(),
|
||||||
|
tool_part: entry.tool_part.map(tool_part),
|
||||||
|
compact: entry.compact_text.clone(),
|
||||||
|
lineage: lineage(&segment.lineage),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request.offset > matches.len() {
|
||||||
|
return unavailable(
|
||||||
|
SessionPublicUnavailableReason::InvalidCursor,
|
||||||
|
"Session public search position is stale",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let end = request
|
||||||
|
.offset
|
||||||
|
.saturating_add(request.limit)
|
||||||
|
.min(matches.len());
|
||||||
|
let items = matches[request.offset..end].to_vec();
|
||||||
|
let has_more = end < matches.len();
|
||||||
|
SessionPublicSearchAvailability::Page {
|
||||||
|
page: SessionPublicSearchPage {
|
||||||
|
session_id: index.session_id,
|
||||||
|
generation: index.generation,
|
||||||
|
items,
|
||||||
|
next_offset: has_more.then_some(end),
|
||||||
|
has_more,
|
||||||
|
scanned_bytes: index.scanned_bytes,
|
||||||
|
scanned_segments: index.segments.len(),
|
||||||
|
scanned_entries: index.scanned_entries,
|
||||||
|
archive_manifest: archive_manifest.map(api_archive_manifest),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn read(
|
||||||
|
session_root: &Path,
|
||||||
|
request: &SessionPublicReadRequest,
|
||||||
|
archive_manifest: Option<&DomainArchiveManifest>,
|
||||||
|
) -> SessionPublicReadAvailability {
|
||||||
|
if request.max_content_bytes == 0 {
|
||||||
|
return read_unavailable(
|
||||||
|
SessionPublicUnavailableReason::ResourceLimit,
|
||||||
|
"Session public read content limit must be positive",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let index = match load_index(
|
||||||
|
session_root,
|
||||||
|
request.max_scan_bytes,
|
||||||
|
request.max_segments,
|
||||||
|
request.max_entries,
|
||||||
|
) {
|
||||||
|
Ok(index) => index,
|
||||||
|
Err((reason, message)) => return read_unavailable(reason, message),
|
||||||
|
};
|
||||||
|
if index.session_id != request.expected_session_id {
|
||||||
|
return read_unavailable(
|
||||||
|
SessionPublicUnavailableReason::NotFound,
|
||||||
|
"Session identity does not match the authorized source",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if request
|
||||||
|
.expected_generation
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|expected| expected != index.generation)
|
||||||
|
{
|
||||||
|
return read_unavailable(
|
||||||
|
SessionPublicUnavailableReason::InvalidCursor,
|
||||||
|
"Session public read generation changed",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let Some(segment) = index
|
||||||
|
.segments
|
||||||
|
.iter()
|
||||||
|
.find(|segment| segment.segment_id == request.segment_id)
|
||||||
|
else {
|
||||||
|
return read_unavailable(
|
||||||
|
SessionPublicUnavailableReason::NotFound,
|
||||||
|
"Session entry was not found in the authorized source",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
let mut entries = segment
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|entry| entry.entry_ref == request.entry_ref);
|
||||||
|
let Some(entry) = entries.next() else {
|
||||||
|
return read_unavailable(
|
||||||
|
SessionPublicUnavailableReason::NotFound,
|
||||||
|
"Session entry was not found in the authorized source",
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if entries.next().is_some() {
|
||||||
|
return read_unavailable(
|
||||||
|
SessionPublicUnavailableReason::CorruptLog,
|
||||||
|
"Session entry identity is ambiguous in its Segment",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let content = match request.mode {
|
||||||
|
SessionPublicReadMode::Compact => &entry.compact_text,
|
||||||
|
SessionPublicReadMode::Full => &entry.full_text,
|
||||||
|
};
|
||||||
|
if request.byte_offset > content.len() || !content.is_char_boundary(request.byte_offset) {
|
||||||
|
return read_unavailable(
|
||||||
|
SessionPublicUnavailableReason::InvalidCursor,
|
||||||
|
"Session public read position is stale",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut end = request
|
||||||
|
.byte_offset
|
||||||
|
.saturating_add(request.max_content_bytes)
|
||||||
|
.min(content.len());
|
||||||
|
while end > request.byte_offset && !content.is_char_boundary(end) {
|
||||||
|
end -= 1;
|
||||||
|
}
|
||||||
|
if end == request.byte_offset && request.byte_offset < content.len() {
|
||||||
|
return read_unavailable(
|
||||||
|
SessionPublicUnavailableReason::ResourceLimit,
|
||||||
|
"Session public read cannot fit one UTF-8 scalar in the content limit",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let has_more = end < content.len();
|
||||||
|
SessionPublicReadAvailability::Page {
|
||||||
|
page: SessionPublicReadPage {
|
||||||
|
session_id: index.session_id,
|
||||||
|
generation: index.generation,
|
||||||
|
segment_id: segment.segment_id.clone(),
|
||||||
|
entry_ref: entry.entry_ref.clone(),
|
||||||
|
kind: entry_kind(entry.kind),
|
||||||
|
origin: entry.provenance.clone(),
|
||||||
|
lineage: lineage(&segment.lineage),
|
||||||
|
mode: request.mode,
|
||||||
|
content: content[request.byte_offset..end].to_string(),
|
||||||
|
next_byte_offset: has_more.then_some(end),
|
||||||
|
has_more,
|
||||||
|
scanned_bytes: index.scanned_bytes,
|
||||||
|
scanned_segments: index.segments.len(),
|
||||||
|
scanned_entries: index.scanned_entries,
|
||||||
|
archive_manifest: archive_manifest.map(api_archive_manifest),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn load_index(
|
||||||
|
session_root: &Path,
|
||||||
|
max_bytes: u64,
|
||||||
|
max_segments: usize,
|
||||||
|
max_entries: usize,
|
||||||
|
) -> Result<SessionPublicIndex, (SessionPublicUnavailableReason, &'static str)> {
|
||||||
|
read_session_public_index(
|
||||||
|
session_root,
|
||||||
|
SessionPublicIndexLimits {
|
||||||
|
max_bytes,
|
||||||
|
max_segments,
|
||||||
|
max_entries,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(map_index_error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn matches_entry(
|
||||||
|
entry: &SessionPublicIndexEntry,
|
||||||
|
request: &SessionPublicSearchRequest,
|
||||||
|
query: Option<&str>,
|
||||||
|
) -> bool {
|
||||||
|
if request
|
||||||
|
.kind
|
||||||
|
.is_some_and(|kind| kind != entry_kind(entry.kind))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if request.tool_name.is_some() || request.tool_part != SessionPublicToolPart::Both {
|
||||||
|
if entry.kind != SessionPublicIndexEntryKind::Tool {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if request
|
||||||
|
.tool_name
|
||||||
|
.as_deref()
|
||||||
|
.is_some_and(|name| entry.tool_name.as_deref() != Some(name))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if request.tool_part != SessionPublicToolPart::Both
|
||||||
|
&& entry.tool_part.map(tool_part) != Some(request.tool_part)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
query.is_none_or(|query| entry.full_text.to_lowercase().contains(query))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn entry_kind(kind: SessionPublicIndexEntryKind) -> SessionPublicEntryKind {
|
||||||
|
match kind {
|
||||||
|
SessionPublicIndexEntryKind::User => SessionPublicEntryKind::User,
|
||||||
|
SessionPublicIndexEntryKind::Assistant => SessionPublicEntryKind::Assistant,
|
||||||
|
SessionPublicIndexEntryKind::Tool => SessionPublicEntryKind::Tool,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_part(part: SessionPublicIndexToolPart) -> SessionPublicToolPart {
|
||||||
|
match part {
|
||||||
|
SessionPublicIndexToolPart::Input => SessionPublicToolPart::Input,
|
||||||
|
SessionPublicIndexToolPart::Output => SessionPublicToolPart::Output,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lineage(value: &SessionPublicIndexLineage) -> SessionPublicLineage {
|
||||||
|
SessionPublicLineage {
|
||||||
|
kind: match value.origin_kind {
|
||||||
|
SessionPublicIndexOriginKind::Root => SessionPublicLineageKind::Root,
|
||||||
|
SessionPublicIndexOriginKind::Fork => SessionPublicLineageKind::Fork,
|
||||||
|
SessionPublicIndexOriginKind::Compact => SessionPublicLineageKind::Compact,
|
||||||
|
},
|
||||||
|
parent_segment_id: value.parent_segment_id.clone(),
|
||||||
|
at_turn_index: value.parent_turn_index,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn map_index_error(
|
||||||
|
error: SessionPublicIndexReadError,
|
||||||
|
) -> (SessionPublicUnavailableReason, &'static str) {
|
||||||
|
match error {
|
||||||
|
SessionPublicIndexReadError::MigrationRequired => (
|
||||||
|
SessionPublicUnavailableReason::MigrationRequired,
|
||||||
|
"Session storage requires migration",
|
||||||
|
),
|
||||||
|
SessionPublicIndexReadError::Missing => (
|
||||||
|
SessionPublicUnavailableReason::RetentionMissing,
|
||||||
|
"Session storage is missing",
|
||||||
|
),
|
||||||
|
SessionPublicIndexReadError::Corrupt => (
|
||||||
|
SessionPublicUnavailableReason::CorruptLog,
|
||||||
|
"Session log is corrupt",
|
||||||
|
),
|
||||||
|
SessionPublicIndexReadError::Storage => (
|
||||||
|
SessionPublicUnavailableReason::StorageUnavailable,
|
||||||
|
"Session storage is unavailable",
|
||||||
|
),
|
||||||
|
SessionPublicIndexReadError::ResourceLimit => (
|
||||||
|
SessionPublicUnavailableReason::ResourceLimit,
|
||||||
|
"Session scan exceeds a resource limit",
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unavailable(
|
||||||
|
reason: SessionPublicUnavailableReason,
|
||||||
|
message: impl Into<String>,
|
||||||
|
) -> SessionPublicSearchAvailability {
|
||||||
|
SessionPublicSearchAvailability::Unavailable {
|
||||||
|
reason,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_unavailable(
|
||||||
|
reason: SessionPublicUnavailableReason,
|
||||||
|
message: impl Into<String>,
|
||||||
|
) -> SessionPublicReadAvailability {
|
||||||
|
SessionPublicReadAvailability::Unavailable {
|
||||||
|
reason,
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn api_archive_manifest(manifest: &DomainArchiveManifest) -> WorkerSessionArchiveManifest {
|
||||||
|
WorkerSessionArchiveManifest {
|
||||||
|
schema_version: manifest.schema_version,
|
||||||
|
archive_id: manifest.archive_id.clone(),
|
||||||
|
workspace_id: manifest.workspace_id.clone(),
|
||||||
|
source_runtime_id: manifest.source_runtime_id.clone(),
|
||||||
|
source_worker_id: manifest.source_worker_id,
|
||||||
|
source_session_id: manifest.source_session_id.clone(),
|
||||||
|
segment_ids: manifest.segment_ids.clone(),
|
||||||
|
source_created_at: manifest.source_created_at.clone(),
|
||||||
|
removed_at: manifest.removed_at.clone(),
|
||||||
|
archived_at_unix_seconds: manifest.archived_at_unix_seconds,
|
||||||
|
effective_profile: manifest.effective_profile.clone(),
|
||||||
|
retention_class: manifest.retention_class.clone(),
|
||||||
|
content_checksum_sha256: manifest.content_checksum_sha256.clone(),
|
||||||
|
content_bytes: manifest.content_bytes,
|
||||||
|
content_file_count: manifest.content_file_count,
|
||||||
|
policy_id: manifest.policy_id.clone(),
|
||||||
|
policy_revision: manifest.policy_revision,
|
||||||
|
operation_id: manifest.operation_id.clone(),
|
||||||
|
input_fingerprint: manifest.input_fingerprint.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -581,6 +581,13 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient {
|
|||||||
client.subjektiv_memory_backend(workspace_id, request).await,
|
client.subjektiv_memory_backend(workspace_id, request).await,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
WorkspaceServerOperation::SubjektivSession(request) => {
|
||||||
|
generated_workspace_response(
|
||||||
|
client
|
||||||
|
.subjektiv_session_backend(workspace_id, request)
|
||||||
|
.await,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1307,6 +1307,15 @@ where
|
|||||||
{
|
{
|
||||||
feature_registry.add_module(subjektiv_memory);
|
feature_registry.add_module(subjektiv_memory);
|
||||||
}
|
}
|
||||||
|
if let Some(subjektiv_sessions) =
|
||||||
|
crate::feature::builtin::subjektiv_session::SubjektivSessionFeature::from_resolved_config(
|
||||||
|
&worker.manifest().feature.subjektiv,
|
||||||
|
worker.committed_session_capture_handle(),
|
||||||
|
worker.workspace_client_handle(),
|
||||||
|
)?
|
||||||
|
{
|
||||||
|
feature_registry.add_module(subjektiv_sessions);
|
||||||
|
}
|
||||||
if let Some(memory_config) = memory_lifecycle_config
|
if let Some(memory_config) = memory_lifecycle_config
|
||||||
&& let Some(memory_lifecycle) =
|
&& let Some(memory_lifecycle) =
|
||||||
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
crate::feature::builtin::memory_lifecycle::MemoryLifecycleFeature::from_resolved_config(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ pub mod orchestration;
|
|||||||
mod resource_projection;
|
mod resource_projection;
|
||||||
pub mod session_explore;
|
pub mod session_explore;
|
||||||
pub(crate) mod subjektiv_memory;
|
pub(crate) mod subjektiv_memory;
|
||||||
|
pub(crate) mod subjektiv_session;
|
||||||
pub mod task;
|
pub mod task;
|
||||||
pub mod ticket;
|
pub mod ticket;
|
||||||
pub mod worker_observation;
|
pub mod worker_observation;
|
||||||
|
|||||||
@@ -2078,6 +2078,7 @@ permission = "write"
|
|||||||
segment_id: "segment-1".to_string(),
|
segment_id: "segment-1".to_string(),
|
||||||
session_revision: history_len.try_into().unwrap(),
|
session_revision: history_len.try_into().unwrap(),
|
||||||
entry_count: history_len,
|
entry_count: history_len,
|
||||||
|
has_committed_run: true,
|
||||||
run_exit: CommittedRunExit::Finished,
|
run_exit: CommittedRunExit::Finished,
|
||||||
history: (0..history_len)
|
history: (0..history_len)
|
||||||
.map(|index| HistoryEntry {
|
.map(|index| HistoryEntry {
|
||||||
|
|||||||
@@ -1101,6 +1101,7 @@ mod tests {
|
|||||||
segment_id: "segment-1".into(),
|
segment_id: "segment-1".into(),
|
||||||
session_revision: 1,
|
session_revision: 1,
|
||||||
entry_count: 0,
|
entry_count: 0,
|
||||||
|
has_committed_run: true,
|
||||||
run_exit: CommittedRunExit::Finished,
|
run_exit: CommittedRunExit::Finished,
|
||||||
history: Vec::new(),
|
history: Vec::new(),
|
||||||
usage_history: Vec::new(),
|
usage_history: Vec::new(),
|
||||||
@@ -1351,6 +1352,7 @@ mod tests {
|
|||||||
segment_id: "segment-1".into(),
|
segment_id: "segment-1".into(),
|
||||||
session_revision: 1,
|
session_revision: 1,
|
||||||
entry_count: 3,
|
entry_count: 3,
|
||||||
|
has_committed_run: true,
|
||||||
run_exit: CommittedRunExit::Interrupted,
|
run_exit: CommittedRunExit::Interrupted,
|
||||||
history: vec![
|
history: vec![
|
||||||
crate::session_history::history_entry(
|
crate::session_history::history_entry(
|
||||||
@@ -1410,6 +1412,7 @@ mod tests {
|
|||||||
segment_id: "segment-1".into(),
|
segment_id: "segment-1".into(),
|
||||||
session_revision: 2,
|
session_revision: 2,
|
||||||
entry_count: 2,
|
entry_count: 2,
|
||||||
|
has_committed_run: true,
|
||||||
run_exit: CommittedRunExit::Finished,
|
run_exit: CommittedRunExit::Finished,
|
||||||
history: vec![
|
history: vec![
|
||||||
crate::session_history::history_entry(
|
crate::session_history::history_entry(
|
||||||
@@ -1487,6 +1490,7 @@ mod tests {
|
|||||||
segment_id: "segment-1".into(),
|
segment_id: "segment-1".into(),
|
||||||
session_revision: 2,
|
session_revision: 2,
|
||||||
entry_count: 2,
|
entry_count: 2,
|
||||||
|
has_committed_run: true,
|
||||||
run_exit: CommittedRunExit::Finished,
|
run_exit: CommittedRunExit::Finished,
|
||||||
history: vec![
|
history: vec![
|
||||||
crate::session_history::history_entry(
|
crate::session_history::history_entry(
|
||||||
|
|||||||
@@ -0,0 +1,817 @@
|
|||||||
|
//! Host-scoped tools for exploring subject-attributed committed Sessions.
|
||||||
|
//!
|
||||||
|
//! Subject, Runtime, Worker, archive, and filesystem authority stays bound by
|
||||||
|
//! the Workspace host. Model input contains only the selectors and filters from
|
||||||
|
//! the public `server_api` request types.
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta, ToolOutput};
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use schemars::JsonSchema;
|
||||||
|
use server_api::{
|
||||||
|
SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES, SubjektivRecordSessionRequest,
|
||||||
|
SubjektivRecordSessionResponse, SubjektivSessionBackendOperation,
|
||||||
|
SubjektivSessionBackendRequest, SubjektivSessionBackendResponse, SubjektivSessionBackendResult,
|
||||||
|
SubjektivSessionDiagnosticCode, SubjektivSessionErrorResponse, SubjektivSessionListRequest,
|
||||||
|
SubjektivSessionReadRequest, SubjektivSessionSearchRequest,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::feature::session::CommittedSessionCaptureHandle;
|
||||||
|
use crate::feature::{
|
||||||
|
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
|
||||||
|
HookDeclaration, ToolContribution, ToolDeclaration,
|
||||||
|
};
|
||||||
|
use crate::hook::{
|
||||||
|
Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookFailurePolicy,
|
||||||
|
HookPreRequestAction, PreLlmRequest, PreRequestContext, RunCommitted, RunCommittedContext,
|
||||||
|
};
|
||||||
|
use crate::worker::{WorkspaceClient, WorkspaceServerOperation};
|
||||||
|
|
||||||
|
const LIST_TOOL: &str = "SubjektivSessionList";
|
||||||
|
const SEARCH_TOOL: &str = "SubjektivSessionSearch";
|
||||||
|
const READ_TOOL: &str = "SubjektivSessionRead";
|
||||||
|
const COMMIT_HOOK: &str = "record-subjektiv-session-after-commit";
|
||||||
|
const RETRY_HOOK: &str = "retry-subjektiv-session-attribution";
|
||||||
|
|
||||||
|
const LIST_DESCRIPTION: &str = "List committed retained or archived Sessions attributed by the Host to the connected subject. Filter by exact session_id or storage and continue with the returned opaque cursor; subject, Runtime, Worker, archive, and filesystem authority are never model inputs.";
|
||||||
|
const SEARCH_DESCRIPTION: &str = "Search committed public entries across Host-authorized Sessions attributed to the connected subject, or list one Session's public entries when session_id is supplied and query is omitted. Search is case-insensitive literal matching; system prompts, hidden reasoning, traces, and attachment bodies are never exposed.";
|
||||||
|
const READ_DESCRIPTION: &str = "Read one exact committed public Session entry selected by session_id, segment_id, and entry_ref. Compact mode is the default; full mode remains bounded, and partial content must be continued with the returned opaque cursor.";
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub(crate) struct SubjektivSessionFeature {
|
||||||
|
state: SubjektivSessionState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SubjektivSessionState {
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
capture: CommittedSessionCaptureHandle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubjektivSessionFeature {
|
||||||
|
pub(crate) fn from_resolved_config(
|
||||||
|
config: &manifest::ResolvedSubjektivFeatureConfig,
|
||||||
|
capture: CommittedSessionCaptureHandle,
|
||||||
|
client: Arc<dyn WorkspaceClient>,
|
||||||
|
) -> std::io::Result<Option<Self>> {
|
||||||
|
if !config.profile.enabled {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
config
|
||||||
|
.validate_execution()
|
||||||
|
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||||
|
if !client.is_available() || client.workspace_id().is_none() {
|
||||||
|
return Err(std::io::Error::new(
|
||||||
|
std::io::ErrorKind::InvalidInput,
|
||||||
|
"subjektiv Session tools require Backend Workspace API authority",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(Some(Self {
|
||||||
|
state: SubjektivSessionState { client, capture },
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FeatureModule for SubjektivSessionFeature {
|
||||||
|
fn descriptor(&self) -> FeatureDescriptor {
|
||||||
|
FeatureDescriptor::builtin("subjektiv-session-tools", "subjektiv Session Tools")
|
||||||
|
.with_description(
|
||||||
|
"Read-only discovery, search, and reading of Host-authorized subject Sessions.",
|
||||||
|
)
|
||||||
|
.with_tool(ToolDeclaration::new(LIST_TOOL, LIST_DESCRIPTION))
|
||||||
|
.with_tool(ToolDeclaration::new(SEARCH_TOOL, SEARCH_DESCRIPTION))
|
||||||
|
.with_tool(ToolDeclaration::new(READ_TOOL, READ_DESCRIPTION))
|
||||||
|
.with_hook(HookDeclaration::new(
|
||||||
|
COMMIT_HOOK,
|
||||||
|
FeatureHookPoint::RunCommitted,
|
||||||
|
))
|
||||||
|
.with_hook(HookDeclaration::new(
|
||||||
|
RETRY_HOOK,
|
||||||
|
FeatureHookPoint::PreLlmRequest,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
|
||||||
|
for (name, description, operation) in [
|
||||||
|
(LIST_TOOL, LIST_DESCRIPTION, SessionOperation::List),
|
||||||
|
(SEARCH_TOOL, SEARCH_DESCRIPTION, SessionOperation::Search),
|
||||||
|
(READ_TOOL, READ_DESCRIPTION, SessionOperation::Read),
|
||||||
|
] {
|
||||||
|
context.tools().register(ToolContribution::new(
|
||||||
|
name,
|
||||||
|
tool_definition(name, description, self.state.clone(), operation),
|
||||||
|
))?;
|
||||||
|
}
|
||||||
|
context.hooks().add_run_committed(
|
||||||
|
COMMIT_HOOK,
|
||||||
|
HookExecutionPolicy::new(HookFailurePolicy::AttentionRequired, 30_000),
|
||||||
|
RecordCommittedSessionHook {
|
||||||
|
state: self.state.clone(),
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
context.hooks().add_pre_request(
|
||||||
|
RETRY_HOOK,
|
||||||
|
RetryCommittedSessionHook {
|
||||||
|
state: self.state.clone(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
enum SessionOperation {
|
||||||
|
List,
|
||||||
|
Search,
|
||||||
|
Read,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_definition(
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
state: SubjektivSessionState,
|
||||||
|
operation: SessionOperation,
|
||||||
|
) -> ToolDefinition {
|
||||||
|
Arc::new(move || {
|
||||||
|
let schema = match operation {
|
||||||
|
SessionOperation::List => schema_for::<SubjektivSessionListRequest>(),
|
||||||
|
SessionOperation::Search => schema_for::<SubjektivSessionSearchRequest>(),
|
||||||
|
SessionOperation::Read => schema_for::<SubjektivSessionReadRequest>(),
|
||||||
|
};
|
||||||
|
let tool: Arc<dyn Tool> = Arc::new(SubjektivSessionTool {
|
||||||
|
state: state.clone(),
|
||||||
|
operation,
|
||||||
|
});
|
||||||
|
(
|
||||||
|
ToolMeta::new(name)
|
||||||
|
.description(description)
|
||||||
|
.input_schema(schema),
|
||||||
|
tool,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SubjektivSessionTool {
|
||||||
|
state: SubjektivSessionState,
|
||||||
|
operation: SessionOperation,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Tool for SubjektivSessionTool {
|
||||||
|
async fn execute(
|
||||||
|
&self,
|
||||||
|
input_json: &str,
|
||||||
|
_context: ToolExecutionContext,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let operation = match self.operation {
|
||||||
|
SessionOperation::List => {
|
||||||
|
SubjektivSessionBackendOperation::List(parse(input_json, LIST_TOOL)?)
|
||||||
|
}
|
||||||
|
SessionOperation::Search => {
|
||||||
|
SubjektivSessionBackendOperation::Search(parse(input_json, SEARCH_TOOL)?)
|
||||||
|
}
|
||||||
|
SessionOperation::Read => {
|
||||||
|
SubjektivSessionBackendOperation::Read(parse(input_json, READ_TOOL)?)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let response = self.state.execute(operation)?;
|
||||||
|
model_visible_response(response)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SubjektivSessionState {
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
operation: SubjektivSessionBackendOperation,
|
||||||
|
) -> Result<SubjektivSessionBackendResponse, ToolError> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.execute_server_operation(WorkspaceServerOperation::SubjektivSession(
|
||||||
|
SubjektivSessionBackendRequest { operation },
|
||||||
|
))
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
|
||||||
|
if let Ok(response) =
|
||||||
|
serde_json::from_str::<SubjektivSessionBackendResponse>(&response.body)
|
||||||
|
{
|
||||||
|
return Ok(response);
|
||||||
|
}
|
||||||
|
let detail = serde_json::from_str::<server_api::RepositoryApiError>(&response.body)
|
||||||
|
.map(|error| error.message)
|
||||||
|
.unwrap_or(response.body);
|
||||||
|
Err(ToolError::ExecutionFailed(format!(
|
||||||
|
"subjektiv Session backend returned HTTP {}: {detail}",
|
||||||
|
response.status
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_current_committed_session(&self) -> Result<(), HookError> {
|
||||||
|
let capture = self
|
||||||
|
.capture
|
||||||
|
.capture()
|
||||||
|
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
|
||||||
|
if !capture.has_committed_run {
|
||||||
|
return Err(HookError::new(
|
||||||
|
HookErrorCategory::Dependency,
|
||||||
|
"committed Session capture is not available yet",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.record_session(&capture.session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn retry_current_committed_session(&self) -> Result<(), HookError> {
|
||||||
|
let capture = self
|
||||||
|
.capture
|
||||||
|
.capture()
|
||||||
|
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
|
||||||
|
if !capture.has_committed_run {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
self.record_session(&capture.session_id)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn record_session(&self, session_id: &str) -> Result<(), HookError> {
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.execute_server_operation(WorkspaceServerOperation::SubjektivRecordSession(
|
||||||
|
SubjektivRecordSessionRequest {
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error.to_string()))?;
|
||||||
|
if !response.is_success() {
|
||||||
|
return Err(HookError::new(
|
||||||
|
HookErrorCategory::Dependency,
|
||||||
|
format!(
|
||||||
|
"record subjektiv Session attribution returned HTTP {}",
|
||||||
|
response.status
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let output: SubjektivRecordSessionResponse =
|
||||||
|
serde_json::from_str(&response.body).map_err(|error| {
|
||||||
|
HookError::new(
|
||||||
|
HookErrorCategory::Dependency,
|
||||||
|
format!("decode subjektiv Session attribution response: {error}"),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
if output.session_id != session_id {
|
||||||
|
return Err(HookError::new(
|
||||||
|
HookErrorCategory::Dependency,
|
||||||
|
"subjektiv Session attribution response changed session identity",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RecordCommittedSessionHook {
|
||||||
|
state: SubjektivSessionState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<RunCommitted> for RecordCommittedSessionHook {
|
||||||
|
async fn call(&self, _input: &RunCommittedContext) -> Result<(), HookError> {
|
||||||
|
self.state.record_current_committed_session()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct RetryCommittedSessionHook {
|
||||||
|
state: SubjektivSessionState,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Hook<PreLlmRequest> for RetryCommittedSessionHook {
|
||||||
|
async fn call(&self, _input: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
|
||||||
|
self.state.retry_current_committed_session()?;
|
||||||
|
Ok(HookPreRequestAction::Continue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse<T: serde::de::DeserializeOwned>(input: &str, tool: &str) -> Result<T, ToolError> {
|
||||||
|
serde_json::from_str(input)
|
||||||
|
.map_err(|error| ToolError::InvalidArgument(format!("invalid {tool} input: {error}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schema_for<T: JsonSchema>() -> serde_json::Value {
|
||||||
|
serde_json::to_value(schemars::schema_for!(T)).unwrap_or_else(|_| serde_json::json!({}))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn model_visible_response(
|
||||||
|
response: SubjektivSessionBackendResponse,
|
||||||
|
) -> Result<ToolOutput, ToolError> {
|
||||||
|
let summary = response_summary(&response);
|
||||||
|
let content = serde_json::to_string(&response)
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
if content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES {
|
||||||
|
return Ok(ToolOutput {
|
||||||
|
summary,
|
||||||
|
content: Some(content),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let bounded = SubjektivSessionBackendResponse::Error {
|
||||||
|
error: SubjektivSessionErrorResponse {
|
||||||
|
code: SubjektivSessionDiagnosticCode::ResourceLimit,
|
||||||
|
message: "subjektiv Session response exceeded the 56 KiB model-visible JSON budget"
|
||||||
|
.to_string(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let content = serde_json::to_string(&bounded)
|
||||||
|
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
|
||||||
|
debug_assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES);
|
||||||
|
Ok(ToolOutput {
|
||||||
|
summary: "Subjektiv Session operation reached its output limit.".to_string(),
|
||||||
|
content: Some(content),
|
||||||
|
attachments: Vec::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_summary(response: &SubjektivSessionBackendResponse) -> String {
|
||||||
|
match response {
|
||||||
|
SubjektivSessionBackendResponse::Ok {
|
||||||
|
result: SubjektivSessionBackendResult::List(value),
|
||||||
|
} => format!("Listed {} subject Session(s).", value.items.len()),
|
||||||
|
SubjektivSessionBackendResponse::Ok {
|
||||||
|
result: SubjektivSessionBackendResult::Search(value),
|
||||||
|
} => format!("Found {} subject Session entries.", value.items.len()),
|
||||||
|
SubjektivSessionBackendResponse::Ok {
|
||||||
|
result: SubjektivSessionBackendResult::Read(value),
|
||||||
|
} => format!("Read subject Session entry {}.", value.entry_ref),
|
||||||
|
SubjektivSessionBackendResponse::Error { error } => {
|
||||||
|
format!("Subjektiv Session operation returned {:?}.", error.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
use crate::feature::session::{CommittedRunExit, CommittedSessionCapture};
|
||||||
|
use crate::hook::{HookHistoryRange, HookInvocationContext, PreRequestInfo, RunCommittedExit};
|
||||||
|
use crate::worker::{
|
||||||
|
WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
struct SessionToolClient {
|
||||||
|
response: Mutex<SubjektivSessionBackendResponse>,
|
||||||
|
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionToolClient {
|
||||||
|
fn new(response: SubjektivSessionBackendResponse) -> Self {
|
||||||
|
Self {
|
||||||
|
response: Mutex::new(response),
|
||||||
|
requests: Mutex::new(Vec::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceClient for SessionToolClient {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
Some("workspace-test")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"subjektiv-session-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
self.requests.lock().unwrap().push(request);
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(&*self.response.lock().unwrap()).unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
struct AttributionClient {
|
||||||
|
requests: Mutex<Vec<WorkspaceRequest>>,
|
||||||
|
recorded: Mutex<HashSet<String>>,
|
||||||
|
insertions: AtomicUsize,
|
||||||
|
failures: AtomicUsize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl WorkspaceClient for AttributionClient {
|
||||||
|
fn workspace_id(&self) -> Option<&str> {
|
||||||
|
Some("workspace-test")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn kind(&self) -> &str {
|
||||||
|
"subjektiv-attribution-test"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_available(&self) -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute(
|
||||||
|
&self,
|
||||||
|
request: WorkspaceRequest,
|
||||||
|
) -> Result<WorkspaceResponse, WorkspaceClientError> {
|
||||||
|
self.requests.lock().unwrap().push(request.clone());
|
||||||
|
if self
|
||||||
|
.failures
|
||||||
|
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
|
||||||
|
remaining.checked_sub(1)
|
||||||
|
})
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Ok(WorkspaceResponse {
|
||||||
|
status: 503,
|
||||||
|
body: "temporarily unavailable".into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
let input: SubjektivRecordSessionRequest =
|
||||||
|
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
|
||||||
|
if self
|
||||||
|
.recorded
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(input.session_id.clone())
|
||||||
|
{
|
||||||
|
self.insertions.fetch_add(1, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
Ok(WorkspaceResponse {
|
||||||
|
status: 200,
|
||||||
|
body: serde_json::to_string(&SubjektivRecordSessionResponse {
|
||||||
|
subject_id: "subject-1".into(),
|
||||||
|
session_id: input.session_id,
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capture(has_committed_run: bool) -> CommittedSessionCaptureHandle {
|
||||||
|
CommittedSessionCaptureHandle::new(move || {
|
||||||
|
Ok(CommittedSessionCapture {
|
||||||
|
session_id: "session-1".into(),
|
||||||
|
segment_id: "segment-1".into(),
|
||||||
|
session_revision: usize::from(has_committed_run) as u64,
|
||||||
|
entry_count: usize::from(has_committed_run),
|
||||||
|
has_committed_run,
|
||||||
|
run_exit: CommittedRunExit::Finished,
|
||||||
|
history: Vec::new(),
|
||||||
|
usage_history: Vec::new(),
|
||||||
|
extensions: Vec::new(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_response() -> SubjektivSessionBackendResponse {
|
||||||
|
SubjektivSessionBackendResponse::Ok {
|
||||||
|
result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse {
|
||||||
|
items: Vec::new(),
|
||||||
|
next_cursor: None,
|
||||||
|
has_more: false,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn state(client: Arc<dyn WorkspaceClient>, has_committed_run: bool) -> SubjektivSessionState {
|
||||||
|
SubjektivSessionState {
|
||||||
|
client,
|
||||||
|
capture: capture(has_committed_run),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feature(client: Arc<dyn WorkspaceClient>) -> SubjektivSessionFeature {
|
||||||
|
SubjektivSessionFeature {
|
||||||
|
state: state(client, true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_committed_context() -> RunCommittedContext {
|
||||||
|
RunCommittedContext {
|
||||||
|
invocation: HookInvocationContext {
|
||||||
|
workspace_id: Some("workspace-test".into()),
|
||||||
|
worker_id: "worker-1".into(),
|
||||||
|
session_id: "session-1".into(),
|
||||||
|
session_revision: 1,
|
||||||
|
run_id: Some("run-1".into()),
|
||||||
|
turn_index: Some(0),
|
||||||
|
call_id: None,
|
||||||
|
},
|
||||||
|
exit: RunCommittedExit::Finished,
|
||||||
|
committed_history: HookHistoryRange::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pre_request_context() -> PreRequestContext {
|
||||||
|
PreRequestContext::new(
|
||||||
|
PreRequestInfo {
|
||||||
|
item_count: 0,
|
||||||
|
estimated_tokens: None,
|
||||||
|
turn_index: 0,
|
||||||
|
tool_calls_this_turn: 0,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn descriptor_and_definitions_register_exact_session_tools_and_typed_schemas() {
|
||||||
|
let feature = feature(Arc::new(SessionToolClient::new(list_response())));
|
||||||
|
let descriptor = feature.descriptor();
|
||||||
|
assert_eq!(
|
||||||
|
descriptor
|
||||||
|
.tools
|
||||||
|
.iter()
|
||||||
|
.map(|tool| (tool.name.as_str(), tool.description.as_str()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
(LIST_TOOL, LIST_DESCRIPTION),
|
||||||
|
(SEARCH_TOOL, SEARCH_DESCRIPTION),
|
||||||
|
(READ_TOOL, READ_DESCRIPTION),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
descriptor
|
||||||
|
.hooks
|
||||||
|
.iter()
|
||||||
|
.map(|hook| (hook.name.as_str(), hook.point.clone()))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec![
|
||||||
|
(COMMIT_HOOK, FeatureHookPoint::RunCommitted),
|
||||||
|
(RETRY_HOOK, FeatureHookPoint::PreLlmRequest),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
for (name, description, operation, expected_schema) in [
|
||||||
|
(
|
||||||
|
LIST_TOOL,
|
||||||
|
LIST_DESCRIPTION,
|
||||||
|
SessionOperation::List,
|
||||||
|
schema_for::<SubjektivSessionListRequest>(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SEARCH_TOOL,
|
||||||
|
SEARCH_DESCRIPTION,
|
||||||
|
SessionOperation::Search,
|
||||||
|
schema_for::<SubjektivSessionSearchRequest>(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
READ_TOOL,
|
||||||
|
READ_DESCRIPTION,
|
||||||
|
SessionOperation::Read,
|
||||||
|
schema_for::<SubjektivSessionReadRequest>(),
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let (meta, _) = tool_definition(name, description, feature.state.clone(), operation)();
|
||||||
|
assert_eq!(meta.name, name);
|
||||||
|
assert_eq!(meta.description, description);
|
||||||
|
assert_eq!(meta.input_schema, expected_schema);
|
||||||
|
let properties = meta.input_schema["properties"].as_object().unwrap();
|
||||||
|
for forbidden in [
|
||||||
|
"subject_id",
|
||||||
|
"runtime_id",
|
||||||
|
"worker_id",
|
||||||
|
"archive_id",
|
||||||
|
"archive_path",
|
||||||
|
"path",
|
||||||
|
] {
|
||||||
|
assert!(!properties.contains_key(forbidden));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_subjektiv_feature_registers_nothing() {
|
||||||
|
let config = manifest::ResolvedSubjektivFeatureConfig::default();
|
||||||
|
let result = SubjektivSessionFeature::from_resolved_config(
|
||||||
|
&config,
|
||||||
|
capture(false),
|
||||||
|
Arc::new(SessionToolClient::new(list_response())),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(result.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tools_reject_model_supplied_host_scope_and_storage_paths() {
|
||||||
|
let client = Arc::new(SessionToolClient::new(list_response()));
|
||||||
|
for (operation, input) in [
|
||||||
|
(
|
||||||
|
SessionOperation::List,
|
||||||
|
r#"{"subject_id":"subject-1","archive_path":"/tmp/archive"}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SessionOperation::Search,
|
||||||
|
r#"{"query":"needle","runtime_id":"runtime-1","worker_id":"worker-1"}"#,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
SessionOperation::Read,
|
||||||
|
r#"{"session_id":"session-1","segment_id":"segment-1","entry_ref":"E1","path":"session.jsonl"}"#,
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let error = SubjektivSessionTool {
|
||||||
|
state: state(client.clone(), true),
|
||||||
|
operation,
|
||||||
|
}
|
||||||
|
.execute(input, ToolExecutionContext::new("call", "batch", 0))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(matches!(error, ToolError::InvalidArgument(_)));
|
||||||
|
}
|
||||||
|
assert!(client.requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn tool_routes_one_host_bound_operation_to_session_history() {
|
||||||
|
let client = Arc::new(SessionToolClient::new(list_response()));
|
||||||
|
let tool = SubjektivSessionTool {
|
||||||
|
state: state(client.clone(), true),
|
||||||
|
operation: SessionOperation::List,
|
||||||
|
};
|
||||||
|
let output = tool
|
||||||
|
.execute(
|
||||||
|
r#"{"storage":"retained","limit":20}"#,
|
||||||
|
ToolExecutionContext::new("call-1", "batch-1", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(requests[0].method, WorkspaceRequestMethod::Post);
|
||||||
|
assert_eq!(
|
||||||
|
requests[0].path,
|
||||||
|
"/api/w/workspace-test/subjektiv/session-history"
|
||||||
|
);
|
||||||
|
let body: SubjektivSessionBackendRequest =
|
||||||
|
serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
body.operation,
|
||||||
|
SubjektivSessionBackendOperation::List(SubjektivSessionListRequest {
|
||||||
|
session_id: None,
|
||||||
|
storage: server_api::SubjektivSessionStorageFilter::Retained,
|
||||||
|
limit: Some(20),
|
||||||
|
cursor: None,
|
||||||
|
})
|
||||||
|
));
|
||||||
|
let visible: SubjektivSessionBackendResponse =
|
||||||
|
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
visible,
|
||||||
|
SubjektivSessionBackendResponse::Ok {
|
||||||
|
result: SubjektivSessionBackendResult::List(_)
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn typed_errors_are_visible_and_oversized_json_becomes_bounded_resource_limit() {
|
||||||
|
let typed_error = SubjektivSessionBackendResponse::Error {
|
||||||
|
error: SubjektivSessionErrorResponse {
|
||||||
|
code: SubjektivSessionDiagnosticCode::StaleCursor,
|
||||||
|
message: "restart the search".into(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let error_tool = SubjektivSessionTool {
|
||||||
|
state: state(Arc::new(SessionToolClient::new(typed_error.clone())), true),
|
||||||
|
operation: SessionOperation::Search,
|
||||||
|
};
|
||||||
|
let output = error_tool
|
||||||
|
.execute(
|
||||||
|
r#"{"query":"needle"}"#,
|
||||||
|
ToolExecutionContext::new("call-error", "batch", 0),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let visible: SubjektivSessionBackendResponse =
|
||||||
|
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
visible,
|
||||||
|
SubjektivSessionBackendResponse::Error {
|
||||||
|
error: SubjektivSessionErrorResponse {
|
||||||
|
code: SubjektivSessionDiagnosticCode::StaleCursor,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
|
||||||
|
let oversized = SubjektivSessionBackendResponse::Ok {
|
||||||
|
result: SubjektivSessionBackendResult::List(server_api::SubjektivSessionListResponse {
|
||||||
|
items: vec![server_api::SubjektivSessionListItem {
|
||||||
|
session_id: "session-1".into(),
|
||||||
|
attributed_at: "2026-01-01T00:00:00Z".into(),
|
||||||
|
storage: server_api::SubjektivSessionStorage::Retained,
|
||||||
|
availability: server_api::SubjektivSessionAvailability::Unavailable,
|
||||||
|
reason: Some("\\\u{0000}".repeat(SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES)),
|
||||||
|
}],
|
||||||
|
next_cursor: Some("cursor".into()),
|
||||||
|
has_more: true,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
let output = model_visible_response(oversized).unwrap();
|
||||||
|
let content = output.content.unwrap();
|
||||||
|
assert!(content.len() <= SUBJEKTIV_SESSION_MAX_TOOL_CONTENT_BYTES);
|
||||||
|
let visible: SubjektivSessionBackendResponse = serde_json::from_str(&content).unwrap();
|
||||||
|
assert!(matches!(
|
||||||
|
visible,
|
||||||
|
SubjektivSessionBackendResponse::Error {
|
||||||
|
error: SubjektivSessionErrorResponse {
|
||||||
|
code: SubjektivSessionDiagnosticCode::ResourceLimit,
|
||||||
|
..
|
||||||
|
}
|
||||||
|
}
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn candidate_free_commit_records_session_and_pre_request_waits_for_committed_capture() {
|
||||||
|
let client = Arc::new(AttributionClient::default());
|
||||||
|
let commit_hook = RecordCommittedSessionHook {
|
||||||
|
state: state(client.clone(), true),
|
||||||
|
};
|
||||||
|
commit_hook.call(&run_committed_context()).await.unwrap();
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 1);
|
||||||
|
assert_eq!(requests[0].path, "/api/w/workspace-test/subjektiv/sessions");
|
||||||
|
assert!(!requests[0].path.contains("staging"));
|
||||||
|
drop(requests);
|
||||||
|
|
||||||
|
let before_commit = Arc::new(AttributionClient::default());
|
||||||
|
let premature_commit_hook = RecordCommittedSessionHook {
|
||||||
|
state: state(before_commit.clone(), false),
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
premature_commit_hook
|
||||||
|
.call(&run_committed_context())
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let retry_hook = RetryCommittedSessionHook {
|
||||||
|
state: state(before_commit.clone(), false),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
retry_hook.call(&pre_request_context()).await.unwrap(),
|
||||||
|
HookPreRequestAction::Continue
|
||||||
|
);
|
||||||
|
assert!(before_commit.requests.lock().unwrap().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn transient_attribution_failure_is_a_hook_failure_then_retries_exactly() {
|
||||||
|
let client = Arc::new(AttributionClient::default());
|
||||||
|
client.failures.store(1, Ordering::SeqCst);
|
||||||
|
let state = state(client.clone(), true);
|
||||||
|
let commit_hook = RecordCommittedSessionHook {
|
||||||
|
state: state.clone(),
|
||||||
|
};
|
||||||
|
let retry_hook = RetryCommittedSessionHook { state };
|
||||||
|
|
||||||
|
let error = commit_hook
|
||||||
|
.call(&run_committed_context())
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert_eq!(error.category, HookErrorCategory::Dependency);
|
||||||
|
assert_eq!(
|
||||||
|
retry_hook.call(&pre_request_context()).await.unwrap(),
|
||||||
|
HookPreRequestAction::Continue
|
||||||
|
);
|
||||||
|
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 2);
|
||||||
|
assert_eq!(requests[0].body, requests[1].body);
|
||||||
|
assert_eq!(client.insertions.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn repeated_commit_and_pre_request_use_exact_request_and_backend_dedupes() {
|
||||||
|
let client = Arc::new(AttributionClient::default());
|
||||||
|
let state = state(client.clone(), true);
|
||||||
|
let commit_hook = RecordCommittedSessionHook {
|
||||||
|
state: state.clone(),
|
||||||
|
};
|
||||||
|
let retry_hook = RetryCommittedSessionHook { state };
|
||||||
|
|
||||||
|
commit_hook.call(&run_committed_context()).await.unwrap();
|
||||||
|
commit_hook.call(&run_committed_context()).await.unwrap();
|
||||||
|
retry_hook.call(&pre_request_context()).await.unwrap();
|
||||||
|
|
||||||
|
let requests = client.requests.lock().unwrap();
|
||||||
|
assert_eq!(requests.len(), 3);
|
||||||
|
assert!(
|
||||||
|
requests
|
||||||
|
.iter()
|
||||||
|
.all(|request| request.body == requests[0].body)
|
||||||
|
);
|
||||||
|
assert_eq!(client.recorded.lock().unwrap().len(), 1);
|
||||||
|
assert_eq!(client.insertions.load(Ordering::SeqCst), 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,9 @@ pub(crate) struct CommittedSessionCapture {
|
|||||||
/// Monotonic committed-log revision for the captured Segment.
|
/// Monotonic committed-log revision for the captured Segment.
|
||||||
pub(crate) session_revision: u64,
|
pub(crate) session_revision: u64,
|
||||||
pub(crate) entry_count: usize,
|
pub(crate) entry_count: usize,
|
||||||
|
/// Whether the durable capture contains a committed run boundary. This is
|
||||||
|
/// false for a newly materialized Session before its first Run commits.
|
||||||
|
pub(crate) has_committed_run: bool,
|
||||||
pub(crate) run_exit: CommittedRunExit,
|
pub(crate) run_exit: CommittedRunExit,
|
||||||
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
pub(crate) history: Vec<HistoryEntry<SessionHistoryMetadata>>,
|
||||||
pub(crate) usage_history: Vec<UsageRecord>,
|
pub(crate) usage_history: Vec<UsageRecord>,
|
||||||
|
|||||||
@@ -629,6 +629,7 @@ pub enum WorkspaceServerOperation {
|
|||||||
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
|
SubjektivStageCandidate(server_api::SubjektivStageCandidateRequest),
|
||||||
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
|
SubjektivRecordSession(server_api::SubjektivRecordSessionRequest),
|
||||||
SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
|
SubjektivMemory(server_api::SubjektivMemoryBackendRequest),
|
||||||
|
SubjektivSession(server_api::SubjektivSessionBackendRequest),
|
||||||
}
|
}
|
||||||
|
|
||||||
fn workspace_server_json_request<T: serde::Serialize>(
|
fn workspace_server_json_request<T: serde::Serialize>(
|
||||||
@@ -716,6 +717,9 @@ fn workspace_server_operation_request(
|
|||||||
WorkspaceServerOperation::SubjektivMemory(request) => {
|
WorkspaceServerOperation::SubjektivMemory(request) => {
|
||||||
workspace_server_json_request(format!("{base}/subjektiv/memory"), &request)
|
workspace_server_json_request(format!("{base}/subjektiv/memory"), &request)
|
||||||
}
|
}
|
||||||
|
WorkspaceServerOperation::SubjektivSession(request) => {
|
||||||
|
workspace_server_json_request(format!("{base}/subjektiv/session-history"), &request)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3488,6 +3492,12 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
let entries = store
|
let entries = store
|
||||||
.read_all(location.session_id, location.segment_id)
|
.read_all(location.session_id, location.segment_id)
|
||||||
.map_err(|error| FeatureSessionError::Capture(error.to_string()))?;
|
.map_err(|error| FeatureSessionError::Capture(error.to_string()))?;
|
||||||
|
let has_committed_run = entries.iter().any(|entry| {
|
||||||
|
matches!(
|
||||||
|
entry,
|
||||||
|
LogEntry::RunCompleted { .. } | LogEntry::RunYielded { .. }
|
||||||
|
)
|
||||||
|
});
|
||||||
let run_exit = entries
|
let run_exit = entries
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
@@ -3514,6 +3524,7 @@ impl<C: LlmClient + 'static, St: Store> Worker<C, St> {
|
|||||||
segment_id: location.segment_id.to_string(),
|
segment_id: location.segment_id.to_string(),
|
||||||
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
|
session_revision: entries.len().try_into().unwrap_or(u64::MAX),
|
||||||
entry_count: entries.len(),
|
entry_count: entries.len(),
|
||||||
|
has_committed_run,
|
||||||
run_exit,
|
run_exit,
|
||||||
history,
|
history,
|
||||||
usage_history: restored.usage_history,
|
usage_history: restored.usage_history,
|
||||||
|
|||||||
@@ -1217,6 +1217,26 @@ pub trait WorkspaceWorkerRuntime: Send + Sync {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_public_search(
|
||||||
|
&self,
|
||||||
|
_request: runtime_api::SessionPublicSearchRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||||
|
Ok(runtime_api::SessionPublicSearchAvailability::Unavailable {
|
||||||
|
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||||
|
message: "Session public search is not supported by this Runtime".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_public_read(
|
||||||
|
&self,
|
||||||
|
_request: runtime_api::SessionPublicReadRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||||
|
Ok(runtime_api::SessionPublicReadAvailability::Unavailable {
|
||||||
|
reason: runtime_api::SessionPublicUnavailableReason::StorageUnavailable,
|
||||||
|
message: "Session public read is not supported by this Runtime".to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
_worker_id: &str,
|
_worker_id: &str,
|
||||||
@@ -2073,6 +2093,36 @@ impl RuntimeRegistry {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn session_public_search(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: runtime_api::SessionPublicSearchRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicSearchAvailability, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
|
self.runtime(runtime_id)?
|
||||||
|
.session_public_search(request)
|
||||||
|
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "session_public_search_failed".to_string(),
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn session_public_read(
|
||||||
|
&self,
|
||||||
|
runtime_id: &str,
|
||||||
|
request: runtime_api::SessionPublicReadRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicReadAvailability, RuntimeRegistryError> {
|
||||||
|
validate_backend_identifier("runtime_id", runtime_id)?;
|
||||||
|
self.runtime(runtime_id)?
|
||||||
|
.session_public_read(request)
|
||||||
|
.map_err(|message| RuntimeRegistryError::RuntimeOperationFailed {
|
||||||
|
runtime_id: runtime_id.to_string(),
|
||||||
|
code: "session_public_read_failed".to_string(),
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn observation_source(
|
pub fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker: &RuntimeWorkerRef,
|
worker: &RuntimeWorkerRef,
|
||||||
@@ -2964,6 +3014,36 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime {
|
|||||||
.map_err(|error| error.to_string())
|
.map_err(|error| error.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_public_search(
|
||||||
|
&self,
|
||||||
|
request: runtime_api::SessionPublicSearchRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||||
|
if request.workspace_id != self.workspace_id {
|
||||||
|
return Err(
|
||||||
|
"Session public search Workspace does not match embedded Runtime".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
|
||||||
|
self.runtime
|
||||||
|
.session_public_search_scoped(&scope, &request)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_public_read(
|
||||||
|
&self,
|
||||||
|
request: runtime_api::SessionPublicReadRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||||
|
if request.workspace_id != self.workspace_id {
|
||||||
|
return Err(
|
||||||
|
"Session public read Workspace does not match embedded Runtime".to_string(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let scope = RuntimeWorkspaceScope::new(&request.workspace_id, "embedded-backend");
|
||||||
|
self.runtime
|
||||||
|
.session_public_read_scoped(&scope, &request)
|
||||||
|
.map_err(|error| error.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
@@ -3669,6 +3749,9 @@ fn workspace_runtime_operation(method: &str, path_and_query: &str) -> &'static s
|
|||||||
if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
|
if path.contains("/retention/") || (path.starts_with("/v1/workers/") && method == "DELETE") {
|
||||||
return "workers:delete";
|
return "workers:delete";
|
||||||
}
|
}
|
||||||
|
if path.starts_with("/v1/session-public/") && method == "POST" {
|
||||||
|
return "workers:read";
|
||||||
|
}
|
||||||
if path.starts_with("/v1/workers/") && method == "GET" {
|
if path.starts_with("/v1/workers/") && method == "GET" {
|
||||||
return "workers:read";
|
return "workers:read";
|
||||||
}
|
}
|
||||||
@@ -5007,6 +5090,30 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime {
|
|||||||
.map_err(|diagnostic| diagnostic.message)
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn session_public_search(
|
||||||
|
&self,
|
||||||
|
request: runtime_api::SessionPublicSearchRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicSearchAvailability, String> {
|
||||||
|
self.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.session_public_search(request).await },
|
||||||
|
)
|
||||||
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn session_public_read(
|
||||||
|
&self,
|
||||||
|
request: runtime_api::SessionPublicReadRequest,
|
||||||
|
) -> Result<runtime_api::SessionPublicReadAvailability, String> {
|
||||||
|
self.run_runtime_api(
|
||||||
|
self.request_timeout,
|
||||||
|
MAX_REMOTE_RUNTIME_RESPONSE_BYTES,
|
||||||
|
move |client| async move { client.session_public_read(request).await },
|
||||||
|
)
|
||||||
|
.map_err(|diagnostic| diagnostic.message)
|
||||||
|
}
|
||||||
|
|
||||||
fn observation_source(
|
fn observation_source(
|
||||||
&self,
|
&self,
|
||||||
worker_id: &str,
|
worker_id: &str,
|
||||||
|
|||||||
@@ -957,6 +957,17 @@ CREATE TABLE worker_session_archives (
|
|||||||
committed_at TEXT NOT NULL, expires_at TEXT,
|
committed_at TEXT NOT NULL, expires_at TEXT,
|
||||||
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
|
FOREIGN KEY(operation_id) REFERENCES worker_removal_operations(operation_id));
|
||||||
|
CREATE TABLE worker_session_archive_observe_grants (
|
||||||
|
workspace_id TEXT NOT NULL, archive_id TEXT NOT NULL,
|
||||||
|
controller_runtime_id TEXT NOT NULL, controller_worker_id TEXT NOT NULL,
|
||||||
|
subject_runtime_id TEXT NOT NULL, subject_worker_id TEXT NOT NULL,
|
||||||
|
source_grant_id TEXT NOT NULL, granted_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(workspace_id,archive_id,controller_runtime_id,controller_worker_id,source_grant_id),
|
||||||
|
FOREIGN KEY(workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY(archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE);
|
||||||
|
CREATE INDEX worker_session_archive_observe_grants_controller
|
||||||
|
ON worker_session_archive_observe_grants(
|
||||||
|
workspace_id,controller_runtime_id,controller_worker_id,archive_id);
|
||||||
CREATE TABLE worker_tombstones (
|
CREATE TABLE worker_tombstones (
|
||||||
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL,
|
||||||
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
|
display_name TEXT NOT NULL, profile TEXT, worker_created_at TEXT NOT NULL, removed_at TEXT NOT NULL,
|
||||||
|
|||||||
@@ -132,6 +132,21 @@ pub struct WorkerTombstone {
|
|||||||
pub operation_id: String,
|
pub operation_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct WorkerSessionArchiveRecord {
|
||||||
|
pub archive_id: String,
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub worker: RuntimeWorkerRef,
|
||||||
|
pub session_id: String,
|
||||||
|
pub checksum_sha256: String,
|
||||||
|
pub content_bytes: u64,
|
||||||
|
pub policy_id: String,
|
||||||
|
pub policy_revision: u64,
|
||||||
|
pub operation_id: String,
|
||||||
|
pub committed_at: String,
|
||||||
|
pub expires_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct WorkerOrphanDiagnostic {
|
pub struct WorkerOrphanDiagnostic {
|
||||||
pub diagnostic_id: String,
|
pub diagnostic_id: String,
|
||||||
@@ -454,6 +469,51 @@ impl SqliteWorkspaceStore {
|
|||||||
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
|
if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));}
|
||||||
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
|
let expires_at=match plan.archive_retention { ArchiveRetention::Forever=>None, ArchiveRetention::ForSeconds{seconds}=>{let seconds=i64::try_from(seconds).map_err(|_|StoreError::InvalidInput("archive retention deadline overflow".into()))?;Some((Utc::now()+chrono::Duration::seconds(seconds)).to_rfc3339())} };
|
||||||
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
|
tx.execute("INSERT OR IGNORE INTO worker_session_archives(archive_id,workspace_id,runtime_id,worker_id,session_id,checksum_sha256,content_bytes,policy_id,policy_revision,operation_id,committed_at,expires_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12)",params![a.archive_id,workspace_id,plan.worker.runtime_id,plan.worker.worker_id,a.source_session_id,a.content_checksum_sha256,a.content_bytes,plan.policy_id,plan.policy_revision,operation_id,now,expires_at])?;
|
||||||
|
let observe_grants = {
|
||||||
|
let mut statement = tx.prepare(
|
||||||
|
"SELECT grant_id, controller_runtime_id, controller_worker_id,
|
||||||
|
permissions_json, created_at
|
||||||
|
FROM worker_control_grants
|
||||||
|
WHERE workspace_id = ?1 AND subject_runtime_id = ?2
|
||||||
|
AND subject_worker_id = ?3 AND revoked_at IS NULL",
|
||||||
|
)?;
|
||||||
|
statement
|
||||||
|
.query_map(
|
||||||
|
params![workspace_id, plan.worker.runtime_id, plan.worker.worker_id],
|
||||||
|
|row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, String>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
row.get::<_, String>(4)?,
|
||||||
|
))
|
||||||
|
},
|
||||||
|
)?
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()?
|
||||||
|
};
|
||||||
|
for (grant_id, controller_runtime_id, controller_worker_id, permissions, created_at) in observe_grants {
|
||||||
|
let permissions: Vec<String> = serde_json::from_str(&permissions)
|
||||||
|
.map_err(|error| StoreError::Store(format!("invalid Worker control grant permissions: {error}")))?;
|
||||||
|
if permissions.iter().any(|permission| permission == "observe") {
|
||||||
|
tx.execute(
|
||||||
|
"INSERT OR IGNORE INTO worker_session_archive_observe_grants
|
||||||
|
(workspace_id,archive_id,controller_runtime_id,controller_worker_id,
|
||||||
|
subject_runtime_id,subject_worker_id,source_grant_id,granted_at)
|
||||||
|
VALUES(?1,?2,?3,?4,?5,?6,?7,?8)",
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
a.archive_id,
|
||||||
|
controller_runtime_id,
|
||||||
|
controller_worker_id,
|
||||||
|
plan.worker.runtime_id,
|
||||||
|
plan.worker.worker_id,
|
||||||
|
grant_id,
|
||||||
|
created_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
}else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));}
|
}else if plan.session_disposition==SessionDisposition::Archive{return Err(StoreError::InvalidInput("archive manifest missing".into()));}
|
||||||
match plan.diagnostics_disposition {
|
match plan.diagnostics_disposition {
|
||||||
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())),
|
DiagnosticsDisposition::Purge if result.diagnostics_retained => return Err(StoreError::InvalidInput("Runtime retained diagnostics for purge disposition".into())),
|
||||||
@@ -592,6 +652,77 @@ impl SqliteWorkspaceStore {
|
|||||||
) -> crate::Result<Option<WorkerTombstone>> {
|
) -> crate::Result<Option<WorkerTombstone>> {
|
||||||
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
|
self.with_conn(|conn|conn.query_row("SELECT display_name,profile,worker_created_at,removed_at,archive_id,policy_id,policy_revision,operation_id FROM worker_tombstones WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3",params![workspace_id,worker.runtime_id,worker.worker_id],|r|Ok(WorkerTombstone{workspace_id:workspace_id.into(),worker:worker.clone(),display_name:r.get(0)?,profile:r.get(1)?,created_at:r.get(2)?,removed_at:r.get(3)?,archive_id:r.get(4)?,policy_id:r.get(5)?,policy_revision:r.get::<_,i64>(6)? as u64,operation_id:r.get(7)?})).optional().map_err(StoreError::from))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn worker_session_archive(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
session_id: &str,
|
||||||
|
) -> crate::Result<Option<WorkerSessionArchiveRecord>> {
|
||||||
|
self.with_conn(|connection| {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT archive_id, checksum_sha256, content_bytes, policy_id,
|
||||||
|
policy_revision, operation_id, committed_at, expires_at
|
||||||
|
FROM worker_session_archives
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
|
AND session_id = ?4",
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
worker.runtime_id,
|
||||||
|
worker.worker_id,
|
||||||
|
session_id
|
||||||
|
],
|
||||||
|
|row| {
|
||||||
|
Ok(WorkerSessionArchiveRecord {
|
||||||
|
archive_id: row.get(0)?,
|
||||||
|
workspace_id: workspace_id.to_string(),
|
||||||
|
worker: worker.clone(),
|
||||||
|
session_id: session_id.to_string(),
|
||||||
|
checksum_sha256: row.get(1)?,
|
||||||
|
content_bytes: row.get::<_, i64>(2)? as u64,
|
||||||
|
policy_id: row.get(3)?,
|
||||||
|
policy_revision: row.get::<_, i64>(4)? as u64,
|
||||||
|
operation_id: row.get(5)?,
|
||||||
|
committed_at: row.get(6)?,
|
||||||
|
expires_at: row.get(7)?,
|
||||||
|
})
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.optional()
|
||||||
|
.map_err(StoreError::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn worker_session_archive_observable_by(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
archive_id: &str,
|
||||||
|
controller: &RuntimeWorkerRef,
|
||||||
|
subject: &RuntimeWorkerRef,
|
||||||
|
) -> crate::Result<bool> {
|
||||||
|
self.with_conn(|connection| {
|
||||||
|
connection
|
||||||
|
.query_row(
|
||||||
|
"SELECT EXISTS(
|
||||||
|
SELECT 1 FROM worker_session_archive_observe_grants
|
||||||
|
WHERE workspace_id = ?1 AND archive_id = ?2
|
||||||
|
AND controller_runtime_id = ?3 AND controller_worker_id = ?4
|
||||||
|
AND subject_runtime_id = ?5 AND subject_worker_id = ?6
|
||||||
|
)",
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
archive_id,
|
||||||
|
controller.runtime_id,
|
||||||
|
controller.worker_id,
|
||||||
|
subject.runtime_id,
|
||||||
|
subject.worker_id
|
||||||
|
],
|
||||||
|
|row| row.get(0),
|
||||||
|
)
|
||||||
|
.map_err(StoreError::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn orphan_diagnostic(
|
fn orphan_diagnostic(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -27,7 +27,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
|||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||||
const LATEST_SCHEMA_VERSION: i64 = 75;
|
const LATEST_SCHEMA_VERSION: i64 = 76;
|
||||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||||
@@ -72,6 +72,8 @@ const ORDERED_WORKER_PROJECTION_MIGRATION_NAME: &str =
|
|||||||
"ordered Worker projection subscriptions without revision reconciliation";
|
"ordered Worker projection subscriptions without revision reconciliation";
|
||||||
const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str =
|
const RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME: &str =
|
||||||
"Runtime-scoped durable Worker catalog identity";
|
"Runtime-scoped durable Worker catalog identity";
|
||||||
|
const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
|
||||||
|
"preserve explicit observe grants for committed Worker Session archives";
|
||||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||||
@@ -244,6 +246,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME,
|
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME,
|
||||||
apply: migrate_runtime_scoped_worker_identity_v74_to_v75,
|
apply: migrate_runtime_scoped_worker_identity_v74_to_v75,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 76,
|
||||||
|
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME,
|
||||||
|
apply: migrate_archive_observe_grants_v75_to_v76,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -14147,6 +14154,47 @@ fn migrate_runtime_scoped_worker_identity_v74_to_v75(conn: &Connection) -> Resul
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> {
|
||||||
|
let current = current_schema_version(conn)?;
|
||||||
|
if current != 75 {
|
||||||
|
return Err(Error::Store(format!(
|
||||||
|
"expected schema version 75 before {ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME} migration, found {current}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||||
|
tx.execute_batch(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS worker_session_archive_observe_grants (
|
||||||
|
workspace_id TEXT NOT NULL,
|
||||||
|
archive_id TEXT NOT NULL,
|
||||||
|
controller_runtime_id TEXT NOT NULL,
|
||||||
|
controller_worker_id TEXT NOT NULL,
|
||||||
|
subject_runtime_id TEXT NOT NULL,
|
||||||
|
subject_worker_id TEXT NOT NULL,
|
||||||
|
source_grant_id TEXT NOT NULL,
|
||||||
|
granted_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (
|
||||||
|
workspace_id, archive_id,
|
||||||
|
controller_runtime_id, controller_worker_id,
|
||||||
|
source_grant_id
|
||||||
|
),
|
||||||
|
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (archive_id) REFERENCES worker_session_archives(archive_id) ON DELETE CASCADE
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS worker_session_archive_observe_grants_controller
|
||||||
|
ON worker_session_archive_observe_grants(
|
||||||
|
workspace_id, controller_runtime_id, controller_worker_id, archive_id
|
||||||
|
);
|
||||||
|
"#,
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||||
|
params![76_i64, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
||||||
@@ -16617,6 +16665,10 @@ mod tests {
|
|||||||
version: 75,
|
version: 75,
|
||||||
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
name: RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
||||||
},
|
},
|
||||||
|
WorkspaceSchemaMigrationStep {
|
||||||
|
version: 76,
|
||||||
|
name: ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -16699,6 +16751,7 @@ mod tests {
|
|||||||
75,
|
75,
|
||||||
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
RUNTIME_SCOPED_WORKER_IDENTITY_MIGRATION_NAME.to_string(),
|
||||||
),
|
),
|
||||||
|
(76, ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME.to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -16985,7 +17038,7 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![
|
vec![
|
||||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||||
73, 74, 75
|
73, 74, 75, 76
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||||
@@ -16994,7 +17047,7 @@ mod tests {
|
|||||||
current_schema_version(&conn).unwrap(),
|
current_schema_version(&conn).unwrap(),
|
||||||
LATEST_SCHEMA_VERSION
|
LATEST_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 26);
|
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 27);
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||||
|
|||||||
@@ -175,6 +175,12 @@ impl SubjectSessionAttribution {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SubjectSessionAttributionPage {
|
||||||
|
pub items: Vec<SubjectSessionAttribution>,
|
||||||
|
pub has_more: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum RevisionProposalIntent {
|
pub enum RevisionProposalIntent {
|
||||||
@@ -1208,6 +1214,93 @@ impl SubjektivStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reads one bounded immutable attribution page without materializing the
|
||||||
|
/// subject's complete history. `snapshot_at` fixes the initial discovery
|
||||||
|
/// boundary; later Host attributions require a fresh traversal.
|
||||||
|
pub fn subject_session_attribution_page(
|
||||||
|
&self,
|
||||||
|
subject_id: &str,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
snapshot_at: &str,
|
||||||
|
after: Option<(&str, &str)>,
|
||||||
|
limit: usize,
|
||||||
|
) -> Result<SubjectSessionAttributionPage> {
|
||||||
|
validate_label("subject id", subject_id)?;
|
||||||
|
validate_label("attribution snapshot", snapshot_at)?;
|
||||||
|
if let Some(session_id) = session_id {
|
||||||
|
validate_label("session id", session_id)?;
|
||||||
|
}
|
||||||
|
if let Some((attributed_at, session_id)) = after {
|
||||||
|
validate_label("attribution cursor timestamp", attributed_at)?;
|
||||||
|
validate_label("attribution cursor session id", session_id)?;
|
||||||
|
}
|
||||||
|
if !(1..=100).contains(&limit) {
|
||||||
|
return Err(SubjektivError::InvalidRecord(
|
||||||
|
"subject Session attribution page limit must be between 1 and 100".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let query_limit = limit.saturating_add(1);
|
||||||
|
self.database.try_with_connection(|connection| {
|
||||||
|
let raw = match (session_id, after) {
|
||||||
|
(Some(session_id), _) => {
|
||||||
|
let mut statement = connection.prepare(
|
||||||
|
"SELECT record_json FROM subject_session_attributions
|
||||||
|
WHERE subject_id = ?1 AND session_id = ?2 AND attributed_at <= ?3
|
||||||
|
ORDER BY attributed_at DESC, session_id ASC
|
||||||
|
LIMIT ?4",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(
|
||||||
|
params![subject_id, session_id, snapshot_at, query_limit as i64],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||||
|
}
|
||||||
|
(None, Some((after_at, after_session_id))) => {
|
||||||
|
let mut statement = connection.prepare(
|
||||||
|
"SELECT record_json FROM subject_session_attributions
|
||||||
|
WHERE subject_id = ?1 AND attributed_at <= ?2
|
||||||
|
AND (attributed_at < ?3 OR (attributed_at = ?3 AND session_id > ?4))
|
||||||
|
ORDER BY attributed_at DESC, session_id ASC
|
||||||
|
LIMIT ?5",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(
|
||||||
|
params![
|
||||||
|
subject_id,
|
||||||
|
snapshot_at,
|
||||||
|
after_at,
|
||||||
|
after_session_id,
|
||||||
|
query_limit as i64
|
||||||
|
],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||||
|
}
|
||||||
|
(None, None) => {
|
||||||
|
let mut statement = connection.prepare(
|
||||||
|
"SELECT record_json FROM subject_session_attributions
|
||||||
|
WHERE subject_id = ?1 AND attributed_at <= ?2
|
||||||
|
ORDER BY attributed_at DESC, session_id ASC
|
||||||
|
LIMIT ?3",
|
||||||
|
)?;
|
||||||
|
let rows = statement.query_map(
|
||||||
|
params![subject_id, snapshot_at, query_limit as i64],
|
||||||
|
|row| row.get::<_, String>(0),
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut items = raw
|
||||||
|
.into_iter()
|
||||||
|
.map(|raw| parse_session_attribution(&raw))
|
||||||
|
.collect::<Result<Vec<_>>>()?;
|
||||||
|
let has_more = items.len() > limit;
|
||||||
|
if has_more {
|
||||||
|
items.truncate(limit);
|
||||||
|
}
|
||||||
|
Ok(SubjectSessionAttributionPage { items, has_more })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
pub fn staging_candidate(
|
pub fn staging_candidate(
|
||||||
&self,
|
&self,
|
||||||
subject_id: &str,
|
subject_id: &str,
|
||||||
@@ -3678,6 +3771,88 @@ mod tests {
|
|||||||
assert!(store.session_attribution("session-2").unwrap().is_none());
|
assert!(store.session_attribution("session-2").unwrap().is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn attribution_pages_are_bounded_descending_and_snapshot_fixed() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let (_manager, _workspace, store) = open_store(&temp.path().join("storage"), "workspace-a");
|
||||||
|
let subject = store.create_subject(role()).unwrap();
|
||||||
|
for (session, at) in [
|
||||||
|
("session-1", "2026-09-28T10:00:00.000Z"),
|
||||||
|
("session-2", "2026-09-28T11:00:00.000Z"),
|
||||||
|
("session-3", "2026-09-28T11:00:00.000Z"),
|
||||||
|
] {
|
||||||
|
store
|
||||||
|
.record_session_attribution(attribution(
|
||||||
|
&subject.id,
|
||||||
|
"runtime-1",
|
||||||
|
"worker-1",
|
||||||
|
session,
|
||||||
|
at,
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
let first = store
|
||||||
|
.subject_session_attribution_page(
|
||||||
|
&subject.id,
|
||||||
|
None,
|
||||||
|
"2026-09-28T11:30:00.000Z",
|
||||||
|
None,
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.map(|item| item.session_id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["session-2", "session-3"]
|
||||||
|
);
|
||||||
|
assert!(first.has_more);
|
||||||
|
|
||||||
|
store
|
||||||
|
.record_session_attribution(attribution(
|
||||||
|
&subject.id,
|
||||||
|
"runtime-2",
|
||||||
|
"worker-2",
|
||||||
|
"session-later",
|
||||||
|
"2026-09-28T12:00:00.000Z",
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let last = first.items.last().unwrap();
|
||||||
|
let second = store
|
||||||
|
.subject_session_attribution_page(
|
||||||
|
&subject.id,
|
||||||
|
None,
|
||||||
|
"2026-09-28T11:30:00.000Z",
|
||||||
|
Some((&last.attributed_at, &last.session_id)),
|
||||||
|
2,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(second.items.len(), 1);
|
||||||
|
assert_eq!(second.items[0].session_id, "session-1");
|
||||||
|
assert!(!second.has_more);
|
||||||
|
assert!(
|
||||||
|
second
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.all(|item| item.session_id != "session-later")
|
||||||
|
);
|
||||||
|
|
||||||
|
let exact = store
|
||||||
|
.subject_session_attribution_page(
|
||||||
|
&subject.id,
|
||||||
|
Some("session-1"),
|
||||||
|
"2026-09-28T11:30:00.000Z",
|
||||||
|
None,
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(exact.items.len(), 1);
|
||||||
|
assert!(!exact.has_more);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() {
|
fn worker_replacement_preserves_subject_history_and_rejects_cross_subject_session() {
|
||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
|
|||||||
"worker_registry_projection_removals",
|
"worker_registry_projection_removals",
|
||||||
"worker_removal_operations",
|
"worker_removal_operations",
|
||||||
"worker_retention_audit_events",
|
"worker_retention_audit_events",
|
||||||
|
"worker_session_archive_observe_grants",
|
||||||
"worker_session_archives",
|
"worker_session_archives",
|
||||||
"worker_singleton_owners",
|
"worker_singleton_owners",
|
||||||
"worker_tombstones",
|
"worker_tombstones",
|
||||||
|
|||||||
@@ -218,6 +218,46 @@ and exact backend retries return the first candidate rather than creating anothe
|
|||||||
one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`,
|
one. Neither path changes `memory_records`, `memory_revisions`, `store_revision`,
|
||||||
or a surface snapshot.
|
or a surface snapshot.
|
||||||
|
|
||||||
|
|
||||||
|
## Historical Session discovery (T-673)
|
||||||
|
|
||||||
|
An enabled `feature.subjektiv` also installs exactly three read-only tools:
|
||||||
|
`SubjektivSessionList`, `SubjektivSessionSearch`, and `SubjektivSessionRead`.
|
||||||
|
The Server binds subject, Workspace, Runtime, Worker, retention catalogue, and
|
||||||
|
archive authority from the authenticated caller. Model input contains only
|
||||||
|
Session/segment/entry selectors, search filters, limits, and opaque cursors;
|
||||||
|
knowing any identifier does not grant access. Reads use Yoi's retained or
|
||||||
|
committed-archive Session storage and a shared public projection. They do not
|
||||||
|
copy Session bodies into the subjektiv database, restore Workers, migrate
|
||||||
|
Session storage, create candidates or Memories, revise a surface, or promote a
|
||||||
|
re-read tool result to HumanInput.
|
||||||
|
|
||||||
|
The public projection covers committed records in every persisted segment,
|
||||||
|
including non-active branches and pre-compaction entries, while excluding
|
||||||
|
system prompts, hidden reasoning, traces/diagnostics, unfinished run tails, and
|
||||||
|
attachment or pasted-artifact bodies. Results preserve exact Session, segment,
|
||||||
|
entry, provenance, and lineage identity so inherited entries are not presented
|
||||||
|
as a current decision. Full entry reads are UTF-8-boundary paged and remain
|
||||||
|
subject to the same projection as snippets and search.
|
||||||
|
|
||||||
|
The subject's first committed primary-Worker Session is attributed independently
|
||||||
|
of extraction thresholds or candidate creation. Recording is idempotent and is
|
||||||
|
retried on later committed-run and pre-request lifecycle points after transient
|
||||||
|
failure; no attribution is attempted before a committed capture exists. This is
|
||||||
|
not a distributed transaction with Session persistence. In particular, the
|
||||||
|
system does **not** backfill older unattributed Sessions from their text, display
|
||||||
|
name, current singleton ownership, Memory references, or other inference.
|
||||||
|
Historical Sessions without trustworthy Host-recorded attribution remain outside
|
||||||
|
subject discovery until a separately authorized provenance mechanism exists.
|
||||||
|
|
||||||
|
Pagination cursors bind scope, operation, filters, stable ordering, and the
|
||||||
|
public storage generation and are never authorization credentials. Every page
|
||||||
|
rechecks current subject and observation authority. Deleted-Worker archives are
|
||||||
|
eligible only when both the committed Server catalogue and Runtime manifest
|
||||||
|
agree and the archived observation grant remains valid; incomplete, expired,
|
||||||
|
corrupt, unavailable, or changed sources are reported explicitly rather than as
|
||||||
|
an empty successful search.
|
||||||
|
|
||||||
Revision proposals add optional `revision_proposal` metadata to the existing v2
|
Revision proposals add optional `revision_proposal` metadata to the existing v2
|
||||||
`SubjectStagingRecord` envelope; automatic extraction remains proposal-free and
|
`SubjectStagingRecord` envelope; automatic extraction remains proposal-free and
|
||||||
its model schema is unchanged. The metadata is serialized atomically with the
|
its model schema is unchanged. The metadata is serialized atomically with the
|
||||||
|
|||||||
+41
-1
@@ -2832,6 +2832,19 @@
|
|||||||
],
|
],
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/MemoryResidentSummaryOutput",
|
||||||
|
"properties": {
|
||||||
|
"kind": {
|
||||||
|
"const": "resident_summary",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"kind"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"$ref": "#/components/schemas/MemoryBackendAckOutput",
|
"$ref": "#/components/schemas/MemoryBackendAckOutput",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -3084,9 +3097,36 @@
|
|||||||
},
|
},
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MemoryResidentSummaryAvailability": {
|
||||||
|
"enum": [
|
||||||
|
"ready",
|
||||||
|
"ungenerated",
|
||||||
|
"stale",
|
||||||
|
"failed",
|
||||||
|
"unavailable"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
"MemoryResidentSummaryOperation": {
|
"MemoryResidentSummaryOperation": {
|
||||||
"type": "object"
|
"type": "object"
|
||||||
},
|
},
|
||||||
|
"MemoryResidentSummaryOutput": {
|
||||||
|
"properties": {
|
||||||
|
"availability": {
|
||||||
|
"$ref": "#/components/schemas/MemoryResidentSummaryAvailability"
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"type": [
|
||||||
|
"string",
|
||||||
|
"null"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"availability"
|
||||||
|
],
|
||||||
|
"type": "object"
|
||||||
|
},
|
||||||
"MemorySettingsAudit": {
|
"MemorySettingsAudit": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"language": {
|
"language": {
|
||||||
@@ -14081,7 +14121,7 @@
|
|||||||
"info": {
|
"info": {
|
||||||
"title": "Yoi Server API",
|
"title": "Yoi Server API",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"x-yoi-source-digest": "sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9"
|
"x-yoi-source-digest": "sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0"
|
||||||
},
|
},
|
||||||
"openapi": "3.1.0",
|
"openapi": "3.1.0",
|
||||||
"paths": {
|
"paths": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||||
// Input: openapi/server-api.json
|
// Input: openapi/server-api.json
|
||||||
// Input contract: 0.1.0 (sha256:c77c3a923b6eb888bc80137894313f668ed14e11248695ca5fb07cb00281fef9)
|
// Input contract: 0.1.0 (sha256:85a55ead75ec0b0ddebc4dd45905db935c59520656b8490df9fb928daac58dd0)
|
||||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||||
|
|||||||
Reference in New Issue
Block a user