diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index b52bddace..bce86de45 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -190,6 +190,8 @@ impl_openapi_schema!( WorkingDirectoryListResponse, WorkingDirectoryDetailResponse, WorkingDirectoryCreateResponse, + CurrentWorkerWorkdirCatalogQuery, + CurrentWorkerWorkdirCatalogResponse, CurrentWorkerWorkdirAttachRequest, CurrentWorkerWorkdirAttachmentResponse, CurrentWorkerWorkdirAttachmentItem, @@ -2553,6 +2555,19 @@ pub trait ServerApi { #[path] working_directory_id: String, #[body] request: WorkingDirectoryRemovalRequest, ) -> Result; + #[get( + "/api/w/{workspace_id}/workers/self/workdir-catalog", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + openapi = false, + )] + async fn current_worker_workdir_catalog( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] query: CurrentWorkerWorkdirCatalogQuery, + ) -> Result; #[get( "/api/w/{workspace_id}/workers/self/workdir-attachments", status = 200, @@ -6772,6 +6787,28 @@ pub struct CurrentWorkerWorkdirAttachRequest { pub working_directory_id: String, } +/// Stable Workdir-ID keyset paging over the authoritative inventory, not a Runtime snapshot. +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirCatalogQuery { + /// Defaults to 50; valid range is 1..=100 scanned registry records. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, + /// Exclusive stable Workdir ID. An empty policy-filtered page can still have a next cursor. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cursor: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct CurrentWorkerWorkdirCatalogResponse { + pub workspace_id: String, + pub items: Vec, + pub next_cursor: Option, + /// Opaque digest of the complete authoritative inventory and its occupancy. + pub revision: String, +} + /// Bounded, alias-ordered paging over the current Worker's active attachments. #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] @@ -6801,6 +6838,8 @@ pub struct CurrentWorkerWorkdirAttachmentListResponse { pub workspace_id: String, pub items: Vec, pub next_offset: Option, + /// Opaque digest of this caller's complete active connection set, independent of query paging/filtering. + pub revision: String, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -12042,6 +12081,7 @@ mod tests { ); let list = CurrentWorkerWorkdirAttachmentListResponse { workspace_id: "workspace".to_string(), + revision: "opaque-revision".to_string(), next_offset: Some(1), items: vec![CurrentWorkerWorkdirAttachmentItem { alias: "checkout".to_string(), @@ -12059,6 +12099,62 @@ mod tests { ); } + #[test] + fn current_worker_workdir_catalog_contract_is_bounded_and_revision_is_required() { + let query = CurrentWorkerWorkdirCatalogQuery { + limit: Some(100), + cursor: Some("stable-id".to_string()), + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&query).unwrap() + ) + .unwrap(), + query + ); + assert_eq!( + serde_json::to_value(CurrentWorkerWorkdirCatalogQuery::default()).unwrap(), + serde_json::json!({}) + ); + assert!( + serde_json::from_value::( + serde_json::json!({"offset": 0}) + ) + .is_err() + ); + assert!( + serde_json::from_value::( + serde_json::json!({"limit": -1}) + ) + .is_err() + ); + let response = CurrentWorkerWorkdirCatalogResponse { + workspace_id: "workspace".to_string(), + items: Vec::new(), + next_cursor: Some("scanned-legacy-row".to_string()), + revision: "opaque".to_string(), + }; + assert_eq!( + serde_json::from_value::( + serde_json::to_value(&response).unwrap() + ) + .unwrap(), + response + ); + assert!( + serde_json::from_value::( + serde_json::json!({"workspace_id": "workspace", "items": [], "next_cursor": null}) + ) + .is_err() + ); + assert!( + serde_json::from_value::( + serde_json::json!({"workspace_id": "workspace", "items": [], "next_offset": null}) + ) + .is_err() + ); + } + #[test] fn workspace_and_repository_response_shapes_round_trip() { let workspace = serde_json::json!({ @@ -13024,6 +13120,7 @@ mod openapi_artifact_tests { // identity, or a one-use Reviewer capability. They remain generated Rust client/Axum // operations, but must not appear as unauthenticated operations in the public OpenAPI. const SIGNED_INTERNAL: &[&str] = &[ + "current_worker_workdir_catalog", "current_worker_workdir_attachment_list", "current_worker_workdir_attach", "current_worker_workdir_detach", diff --git a/crates/worker/src/feature/builtin/manage_workdir/wip.rs b/crates/worker/src/feature/builtin/manage_workdir/wip.rs index 21ebef28a..0d3439578 100644 --- a/crates/worker/src/feature/builtin/manage_workdir/wip.rs +++ b/crates/worker/src/feature/builtin/manage_workdir/wip.rs @@ -225,8 +225,8 @@ impl Provider { fn inventory(&self, domain: Domain) -> Result, WipOperationError> { let response = self.request(match domain { Domain::Repository => "repositories", - Domain::Workdir => "working-directories", - Domain::Attachment => "workers/self/workdir-attachments", + Domain::Workdir => "workers/self/workdir-catalog?limit=1", + Domain::Attachment => "workers/self/workdir-attachments?limit=1", })?; response .get("items") @@ -363,6 +363,29 @@ impl WipOperationHandler for CollectionHandler { fn object_validator(&self) -> Option> { let mut digest = Sha256::new(); digest.update(self.domain.route()); + if matches!(self.domain, Domain::Workdir | Domain::Attachment) { + // The complete-set revision is computed by the existing Backend + // registry/ledger in the same snapshot as a bounded page. Never infer + // collection freshness from just the first page's items. + let path = if matches!(self.domain, Domain::Workdir) { + "workers/self/workdir-catalog?limit=1" + } else { + "workers/self/workdir-attachments?limit=1" + }; + match self + .provider + .request(path) + .ok() + .and_then(|p| p.get("revision").and_then(Json::as_str).map(str::to_owned)) + { + Some(revision) => { + digest.update([0]); + digest.update(revision); + } + None => digest.update(b"inventory-revision-unavailable"), + } + return Some(digest.finalize().to_vec()); + } match self.provider.inventory(self.domain) { Ok(items) => { for raw in items { @@ -422,36 +445,54 @@ impl WipOperationHandler for CollectionHandler { .authorize(self.domain.list_permission(), &input)?; let limit = page_limit(&input)?; let cursor = input.get("cursor").and_then(Json::as_str); - if matches!(self.domain, Domain::Attachment) { - let offset = cursor - .map(|c| { - c.strip_prefix("offset:") - .and_then(|s| s.parse::().ok()) - .ok_or_else(|| { - failure( - ProtocolErrorCode::InvalidArguments, - "invalid attachment page cursor", - ) - }) - }) - .transpose()? - .unwrap_or(0); - let page = self.provider.request(&format!( - "workers/self/workdir-attachments?limit={limit}&offset={offset}" - ))?; - let raws = page.get("items").and_then(Json::as_array).ok_or_else(|| { - WipOperationError::OutcomeUnknown("invalid attachment page".into()) - })?; + if matches!(self.domain, Domain::Workdir | Domain::Attachment) { + let attachment = matches!(self.domain, Domain::Attachment); + let path = if attachment { + let offset = cursor + .map(|c| { + c.strip_prefix("offset:") + .and_then(|s| s.parse::().ok()) + .ok_or_else(|| { + failure( + ProtocolErrorCode::InvalidArguments, + "invalid attachment page cursor", + ) + }) + }) + .transpose()? + .unwrap_or(0); + format!("workers/self/workdir-attachments?limit={limit}&offset={offset}") + } else { + let suffix = cursor + .map(|c| format!("&cursor={}", encode_path_segment(c))) + .unwrap_or_default(); + format!("workers/self/workdir-catalog?limit={limit}{suffix}") + }; + let page = self.provider.request(&path)?; + let raws = page + .get("items") + .and_then(Json::as_array) + .filter(|v| v.len() <= limit) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown( + "invalid bounded inventory page".into(), + ) + })?; + if page.get("revision").and_then(Json::as_str).is_none() { + return Err(WipOperationError::OutcomeUnknown( + "inventory page revision missing".into(), + )); + } let mut items = Vec::new(); for raw in raws { - let id = - raw.get("connection_id") - .and_then(Json::as_str) - .ok_or_else(|| { - WipOperationError::OutcomeUnknown( - "attachment identity missing".into(), - ) - })?; + let id = raw + .get(self.domain.identity_field()) + .and_then(Json::as_str) + .ok_or_else(|| { + WipOperationError::OutcomeUnknown( + "inventory identity missing".into(), + ) + })?; if self.provider.allowed( self.domain.read_permission(), &self.provider.read_input(self.domain, id), @@ -459,10 +500,33 @@ impl WipOperationHandler for CollectionHandler { items.push(self.provider.projected(self.domain, raw)?); } } - let next = page - .get("next_offset") - .and_then(Json::as_u64) - .map(|n| format!("offset:{n}")); + let next = if attachment { + match page.get("next_offset") { + Some(Json::Null) => None, + Some(v) if v.as_u64().is_some_and(|n| n <= u32::MAX as u64) => { + Some(format!("offset:{}", v.as_u64().unwrap())) + } + _ => { + return Err(WipOperationError::OutcomeUnknown( + "invalid attachment next page".into(), + )); + } + } + } else { + match page.get("next_cursor") { + Some(Json::Null) => None, + Some(Json::String(v)) if cursor.is_none_or(|old| v.as_str() > old) => { + Some(v.clone()) + } + _ => { + return Err(WipOperationError::OutcomeUnknown( + "invalid Workdir next page".into(), + )); + } + } + }; + // Empty describes this permission-filtered page, not the + // entire catalog. A sparse page still advances the Backend cursor. return native( json!({"empty":items.is_empty(), "items":items, "has_more":next.is_some(), "next_cursor":next, "limit":limit}), ); @@ -781,7 +845,15 @@ fn argument_json(arguments: &BTreeMap) -> Result Result { - let limit = input.get("limit").and_then(Json::as_u64).unwrap_or(50); + let limit = match input.get("limit") { + None | Some(Json::Null) => 50, + Some(value) => value.as_u64().ok_or_else(|| { + failure( + ProtocolErrorCode::InvalidArguments, + "limit must be an integer in 1..=100", + ) + })?, + }; if !(1..=100).contains(&limit) { return Err(failure( ProtocolErrorCode::InvalidArguments, @@ -995,7 +1067,43 @@ pub(crate) mod tests { json!({"items":state.repositories}) } (WorkspaceRequestMethod::Get, "working-directories") => { - json!({"items":state.workdirs}) + // The legacy browser/Tool endpoint is a latest-200 snapshot, + // not a complete WIP catalog; regressions must not use it. + json!({"items":state.workdirs.iter().rev().take(200).collect::>()}) + } + (WorkspaceRequestMethod::Get, "workers/self/workdir-catalog") => { + let query: BTreeMap<_, _> = path + .split('?') + .nth(1) + .unwrap_or_default() + .split('&') + .filter_map(|p| p.split_once('=')) + .collect(); + let limit = query + .get("limit") + .and_then(|s| s.parse::().ok()) + .unwrap_or(50); + let cursor = query.get("cursor").copied(); + let mut all: Vec<_> = state.workdirs.iter().collect(); + all.sort_by_key(|v| v["working_directory_id"].as_str().unwrap()); + let revision = Sha256::digest(serde_json::to_vec(&all).unwrap()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); + let remaining: Vec<_> = all + .into_iter() + .filter(|v| { + cursor.is_none_or(|c| v["working_directory_id"].as_str().unwrap() > c) + }) + .collect(); + let has_more = remaining.len() > limit; + let items: Vec<_> = remaining.into_iter().take(limit).collect(); + let next = if has_more { + items.last().map(|v| v["working_directory_id"].clone()) + } else { + None + }; + json!({"items":items, "next_cursor":next, "revision":revision}) } (WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => { let query: BTreeMap<_, _> = path @@ -1005,8 +1113,14 @@ pub(crate) mod tests { .split('&') .filter_map(|p| p.split_once('=')) .collect(); + let mut all: Vec<_> = state.attachments.iter().collect(); + all.sort_by_key(|v| v["alias"].as_str().unwrap()); + let revision = Sha256::digest(serde_json::to_vec(&all).unwrap()) + .iter() + .map(|b| format!("{b:02x}")) + .collect::(); if let Some(expected) = query.get("connection_id") { - json!({"items":state.attachments.iter().filter(|a| a["connection_id"] == *expected).collect::>(), "next_offset":null}) + json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::>(), "next_offset":null, "revision":revision}) } else { let limit = query .get("limit") @@ -1016,11 +1130,10 @@ pub(crate) mod tests { .get("offset") .and_then(|s| s.parse::().ok()) .unwrap_or(0); - let items: Vec<_> = - state.attachments.iter().skip(offset).take(limit).collect(); - let next = - (offset + limit < state.attachments.len()).then_some(offset + limit); - json!({"items":items, "next_offset":next}) + let count = all.len(); + let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect(); + let next = (offset + limit < count).then_some(offset + limit); + json!({"items":items, "next_offset":next, "revision":revision}) } } (WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => { @@ -1543,3 +1656,28 @@ mod logical_capability_tests { } } } + +#[cfg(test)] +mod page_contract_tests { + use super::*; + #[test] + fn bounded_page_limit_rejects_negative_and_noninteger_inputs() { + assert_eq!(page_limit(&json!({})).unwrap_or_default(), 50); + assert_eq!(page_limit(&json!({"limit":100})).unwrap_or_default(), 100); + for input in [ + json!({"limit":-1}), + json!({"limit":0}), + json!({"limit":101}), + json!({"limit":"50"}), + json!({"limit":1.5}), + ] { + assert!(matches!( + page_limit(&input), + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::InvalidArguments, + .. + })) + )); + } + } +} diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 4257f3214..46c06c438 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -4019,6 +4019,181 @@ mod tests { runtime } + #[tokio::test] + async fn catalog_native_transport_pages_all_workdirs_beyond_legacy_200_cap() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().workdirs = (0..205).map(|n| json!({ + "working_directory_id":format!("wd-{n:03}"), "source":{"kind":"repository", "repository_key":"registered-key"}, "status":"active" + })).collect(); + let runtime = catalog_transport_runtime(client.clone(), false, None); + let interface = observed_contextual_interface(&runtime, "/workdirs").await; + let mut cursor = Json::Null; + let mut ids = BTreeSet::new(); + let mut pages = 0; + loop { + let page = transport_call_json( + &runtime, + "/workdirs", + &interface, + "list", + if cursor.is_null() { + json!({"limit":100}) + } else { + json!({"limit":100, "cursor":cursor}) + }, + ) + .await; + pages += 1; + let items = page["items"].as_array().unwrap(); + assert!(items.len() <= 100); + for item in items { + assert!(ids.insert(item["working_directory_id"].as_str().unwrap().to_owned())); + } + if !page["has_more"].as_bool().unwrap() { + assert!(page["next_cursor"].is_null()); + break; + } + cursor = page["next_cursor"].clone(); + assert!(pages < 4, "cursor must progress"); + } + assert_eq!(pages, 3); + assert_eq!(ids.len(), 205); + assert!(ids.contains("wd-000")); + let item_interface = observed_contextual_interface(&runtime, "/workdirs/wd-000").await; + let read = transport_call_json( + &runtime, + "/workdirs/wd-000", + &item_interface, + "read", + json!({}), + ) + .await; + assert_eq!(read["working_directory_id"], "wd-000"); + assert!( + client + .state + .lock() + .unwrap() + .requests + .iter() + .all(|r| !r.path.ends_with("/working-directories")), + "WIP must not use a capped legacy inventory" + ); + + let permissions = Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: vec![ + manifest::ToolPermissionRule { + tool: "WorkdirList".into(), + pattern: "*".into(), + action: ToolPermissionAction::Allow, + }, + manifest::ToolPermissionRule { + tool: "WorkdirRead".into(), + pattern: "*wd-204*".into(), + action: ToolPermissionAction::Allow, + }, + ], + }); + let runtime = catalog_transport_runtime(client, false, permissions); + let interface = observed_contextual_interface(&runtime, "/workdirs").await; + let first = transport_call_json( + &runtime, + "/workdirs", + &interface, + "list", + json!({"limit":100}), + ) + .await; + assert_eq!(first["empty"], true); + assert_eq!( + first["has_more"], true, + "empty filtered page is not terminal" + ); + let second = transport_call_json( + &runtime, + "/workdirs", + &interface, + "list", + json!({"limit":100, "cursor":first["next_cursor"]}), + ) + .await; + assert_eq!(second["has_more"], true); + let last = transport_call_json( + &runtime, + "/workdirs", + &interface, + "list", + json!({"limit":100, "cursor":second["next_cursor"]}), + ) + .await; + assert_eq!(last["items"][0]["working_directory_id"], "wd-204"); + assert_eq!(last["has_more"], false); + } + + #[tokio::test] + async fn catalog_native_transport_invalidates_attachment_change_beyond_first_page() { + use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; + for capability_change in [false, true] { + let client = Arc::new(CatalogClient::default()); + client.state.lock().unwrap().attachments = (0..65).map(|n| json!({ + "connection_id":format!("lifetime-{n:03}"), "alias":format!("alias-{n:03}"), "working_directory_id":"wd", "capabilities":{"bits":25} + })).collect(); + let first_fifty = client.state.lock().unwrap().attachments[..50].to_vec(); + let runtime = catalog_transport_runtime(client.clone(), true, None); + let interface = observed_contextual_interface(&runtime, "/workdir-attachments").await; + let old_validator = runtime + .host + .projection("/workdir-attachments") + .unwrap() + .object + .validator; + if capability_change { + client.state.lock().unwrap().attachments[64]["capabilities"] = json!({"bits":63}); + } else { + client.state.lock().unwrap().attachments[64]["connection_id"] = + json!("new-lifetime"); + } + assert_eq!(client.state.lock().unwrap().attachments[..50], first_fifty); + assert_ne!( + runtime + .host + .projection("/workdir-attachments") + .unwrap() + .object + .validator, + old_validator + ); + let error = runtime + .call( + "/workdir-attachments".into(), + interface.clone(), + "list".into(), + json!({"cursor":"offset:50", "limit":100}), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("ValidatorMismatch"), "{error}"); + let interface = observed_contextual_interface(&runtime, "/workdir-attachments").await; + let page = transport_call_json( + &runtime, + "/workdir-attachments", + &interface, + "list", + json!({"cursor":"offset:50"}), + ) + .await; + assert_eq!(page["items"].as_array().unwrap().len(), 15); + if capability_change { + assert_eq!(page["items"][14]["capabilities"]["bits"], 63); + } else { + assert_eq!(page["items"][14]["connection_id"], "new-lifetime"); + } + } + } + #[tokio::test] async fn catalog_native_transport_discovers_creates_attaches_reads_and_detaches() { use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 269b0f248..8ed94a716 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -5885,6 +5885,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::runtime_workdir_cleanup( service.clone(), )) + .merge(server_api::server_api_axum::current_worker_workdir_catalog( + service.clone(), + )) .merge(server_api::server_api_axum::current_worker_workdir_attachment_list(service.clone())) .merge(server_api::server_api_axum::current_worker_workdir_attach( service.clone(), @@ -10417,6 +10420,28 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn current_worker_workdir_catalog( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + query: server_api::CurrentWorkerWorkdirCatalogQuery, + ) -> std::result::Result< + server_api::CurrentWorkerWorkdirCatalogResponse, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?; + validate_workspace_scope(api, &workspace_id) + .map_err(ApiError::into_repository_api_error)?; + current_worker_identity( + api, + &workspace_id, + ¤t_worker_contract_headers(&context)?, + ) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + list_current_worker_workdir_catalog(api, query).map_err(ApiError::into_repository_api_error) + } + async fn current_worker_workdir_attachment_list( &self, context: server_api::ServerRequestContext, @@ -16486,6 +16511,45 @@ async fn scoped_attach_current_worker_workdir( })) } +fn list_current_worker_workdir_catalog( + api: &WorkspaceApi, + query: server_api::CurrentWorkerWorkdirCatalogQuery, +) -> ApiResult { + // No Runtime inventory refresh: both projection inputs and complete digest are authoritative + // Backend records captured by the store in one read transaction. + let page = api.store.workdir_catalog_page( + &api.config.workspace_id, + query.limit.unwrap_or(50), + query.cursor.as_deref(), + )?; + let items = page + .entries + .into_iter() + .filter_map(|entry| { + if matches!( + entry.record.source, + WorkdirRegistrySource::ExternalGrant { .. } + ) && entry.external_grant_permissions.is_none() + { + return None; + } + let mut summary = workdir_summary_from_record( + &entry.record, + entry.repository_key.as_deref(), + entry.external_grant_permissions, + ); + summary.occupied_by = entry.occupied_by; + Some(server_api::WorkingDirectorySummary::from(summary)) + }) + .collect(); + Ok(server_api::CurrentWorkerWorkdirCatalogResponse { + workspace_id: api.config.workspace_id.clone(), + items, + next_cursor: page.next_cursor, + revision: page.revision, + }) +} + fn list_current_worker_workdir_attachments( api: &WorkspaceApi, worker: &RuntimeWorkerRef, @@ -16498,7 +16562,7 @@ fn list_current_worker_workdir_attachments( Error::InvalidInput("attachment page limit must be 1..=100".to_string()).into(), ); } - let mut links = api.store.list_worker_workdir_links_page( + let (mut links, revision) = api.store.list_worker_workdir_links_page_with_revision( &api.config.workspace_id, worker, limit + 1, @@ -16526,6 +16590,7 @@ fn list_current_worker_workdir_attachments( }) .collect(), next_offset, + revision, }) } @@ -50672,6 +50737,260 @@ mod tests { ); } + #[tokio::test] + async fn current_worker_workdir_catalog_enumerates_complete_stable_inventory() { + let mut fixture = manual_coder_assignment_fixture().await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) + .unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let api = &fixture.api; + let template = api + .store + .list_workdir_registry(TEST_WORKSPACE_ID, 10) + .unwrap() + .remove(0); + let mut expected = api + .store + .list_workdir_registry(TEST_WORKSPACE_ID, 10) + .unwrap() + .into_iter() + .map(|record| record.workdir_id) + .collect::>(); + let mut oldest = None; + for index in 0..233 { + let legacy = index < 3; + let mut record = WorkdirRegistryRecord { + workdir_id: format!("000-catalog-{index:03}"), + display_name: Some(format!("Catalog {index}")), + // Timestamp order intentionally differs from stable ID order. + updated_at: format!("{index:05}"), + ..template.clone() + }; + if legacy || index % 2 == 1 { + let grant = ExternalWorkdirGrantRecord { + grant_id: format!("catalog-grant-{index}"), + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: record.workdir_id.clone(), + provider_instance_id: "private-provider-instance".to_string(), + display_name: record.display_name.clone().unwrap(), + permissions: if legacy { "command_only" } else { "read_write" }.to_string(), + created_by: "test-account".to_string(), + created_at: "1".to_string(), + expires_at: None, + generation: 1, + status: "online".to_string(), + updated_at: "1".to_string(), + }; + record.source = WorkdirRegistrySource::ExternalGrant { + grant_id: grant.grant_id.clone(), + }; + api.store + .create_external_workdir_grant(&grant, &record) + .unwrap(); + } else { + api.store.upsert_workdir_registry(&record).unwrap(); + } + if !legacy { + expected.insert(record.workdir_id.clone()); + if oldest.is_none() { + oldest = Some(record.clone()); + } + } + } + let base = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-catalog"); + let request = |path: &str| { + runtime_source_request( + &identity, + Some(&fixture.worker.worker_id), + "GET", + path, + Vec::new(), + ) + }; + let response = build_router(api.clone()) + .oneshot(request(&format!("{base}?limit=3"))) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let empty: server_api::CurrentWorkerWorkdirCatalogResponse = + serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap()) + .unwrap(); + assert!(empty.items.is_empty()); + assert_eq!(empty.next_cursor.as_deref(), Some("000-catalog-002")); + let mut cursor = None; + let mut seen = Vec::new(); + let mut pages = 0; + loop { + let path = match &cursor { + Some(cursor) => format!("{base}?limit=37&cursor={cursor}"), + None => format!("{base}?limit=37"), + }; + let response = build_router(api.clone()) + .oneshot(request(&path)) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert!(!String::from_utf8_lossy(&bytes).contains("private-provider-instance")); + let page: server_api::CurrentWorkerWorkdirCatalogResponse = + serde_json::from_slice(&bytes).unwrap(); + assert!(page.items.len() <= 37); + assert_eq!(page.revision, empty.revision); + seen.extend(page.items.into_iter().map(|item| item.working_directory_id)); + pages += 1; + if let Some(next) = page.next_cursor { + assert!(cursor.as_ref().is_none_or(|previous| previous < &next)); + cursor = Some(next); + } else { + break; + } + assert!(pages < 20); + } + assert!(seen.windows(2).all(|pair| pair[0] < pair[1])); + assert_eq!(seen.into_iter().collect::>(), expected); + assert!(pages > 6); + let oldest = oldest.unwrap(); + // Existing direct lookup agrees for the oldest visible External Workdir. + let Json(detail) = scoped_working_directory_detail( + State(api.clone()), + AxumPath(ScopedWorkingDirectoryPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + working_directory_id: oldest.workdir_id.clone(), + }), + ) + .await + .unwrap(); + let oldest_page = list_current_worker_workdir_catalog( + api, + server_api::CurrentWorkerWorkdirCatalogQuery { + limit: Some(1), + cursor: Some("000-catalog-002".to_string()), + }, + ) + .unwrap(); + assert_eq!(oldest_page.items, vec![detail.item]); + assert!( + !api.store + .list_workdir_registry(TEST_WORKSPACE_ID, 200) + .unwrap() + .iter() + .any(|record| record.workdir_id == oldest.workdir_id) + ); + for query in ["limit=0", "limit=101", "limit=-1", "cursor=", "cursor=%0A"] { + assert_eq!( + build_router(api.clone()) + .oneshot(request(&format!("{base}?{query}"))) + .await + .unwrap() + .status(), + StatusCode::BAD_REQUEST + ); + } + let default = list_current_worker_workdir_catalog(api, Default::default()).unwrap(); + assert_eq!(default.items.len(), 47); + let before = list_current_worker_workdir_catalog( + api, + server_api::CurrentWorkerWorkdirCatalogQuery { + limit: Some(3), + cursor: None, + }, + ) + .unwrap(); + let mut last = api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, "000-catalog-232") + .unwrap() + .unwrap(); + last.display_name = Some("Updated outside the first page".to_string()); + api.store.upsert_workdir_registry(&last).unwrap(); + let changed = list_current_worker_workdir_catalog( + api, + server_api::CurrentWorkerWorkdirCatalogQuery { + limit: Some(3), + cursor: None, + }, + ) + .unwrap(); + assert_eq!(before.items, changed.items); + assert_ne!(before.revision, changed.revision); + let template_link = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .remove(0); + let link = api + .store + .attach_worker_workdir(&WorkerWorkdirLinkRecord { + workdir_id: last.workdir_id.clone(), + alias: "catalog-occupancy".to_string(), + ..template_link + }) + .unwrap(); + let occupied = list_current_worker_workdir_catalog( + api, + server_api::CurrentWorkerWorkdirCatalogQuery { + limit: Some(3), + cursor: None, + }, + ) + .unwrap(); + assert_eq!(changed.items, occupied.items); + assert_ne!(changed.revision, occupied.revision); + api.store + .detach_worker_workdir_connection( + TEST_WORKSPACE_ID, + &fixture.worker, + &link.alias, + &link.connection_id, + "detach", + ) + .unwrap(); + assert_eq!( + changed.revision, + list_current_worker_workdir_catalog(api, Default::default()) + .unwrap() + .revision + ); + let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + assert_eq!( + changed.revision, + restarted + .workdir_catalog_page(TEST_WORKSPACE_ID, 100, None) + .unwrap() + .revision + ); + let no_worker = runtime_source_request(&identity, None, "GET", &base, Vec::new()); + assert_eq!( + build_router(api.clone()) + .oneshot(no_worker) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + let mut forged = request(&base); + forged + .headers_mut() + .insert("x-yoi-worker-id", "foreign-worker".parse().unwrap()); + assert_eq!( + build_router(api.clone()) + .oneshot(forged) + .await + .unwrap() + .status(), + StatusCode::FORBIDDEN + ); + assert_eq!( + build_router(api.clone()) + .oneshot(Request::builder().uri(&base).body(Body::empty()).unwrap()) + .await + .unwrap() + .status(), + StatusCode::UNAUTHORIZED + ); + } + #[tokio::test] async fn current_worker_workdir_routes_require_a_live_runtime_worker_identity() { let workspace = tempfile::tempdir().unwrap(); @@ -50995,6 +51314,10 @@ mod tests { assert_eq!(page.items[0].working_directory_id, target.workdir_id); assert_eq!(page.items[0].connection_id, target.connection_id); assert_eq!(page.next_offset, None); + assert_eq!( + page.revision, first.revision, + "lookup does not narrow the collection revision" + ); let foreign_worker_id = seed_cleanup_worker(api, 902, "normal"); seed_cleanup_workdir(api, "foreign-lookup-workdir", "present", "clean"); @@ -51023,6 +51346,7 @@ mod tests { .unwrap(); assert!(page.items.is_empty()); assert_eq!(page.next_offset, None); + assert_eq!(page.revision, first.revision); } let mut forged = request(&lookup_path); forged @@ -51049,6 +51373,13 @@ mod tests { assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + let unchanged = + list_current_worker_workdir_attachments(api, &fixture.worker, Default::default()) + .unwrap(); + assert_eq!( + unchanged, first, + "another caller's connection cannot change this revision" + ); api.store .detach_worker_workdir_connection( TEST_WORKSPACE_ID, @@ -51060,6 +51391,139 @@ mod tests { .unwrap(); let replacement = api.store.attach_worker_workdir(&target).unwrap(); assert_ne!(replacement.connection_id, target.connection_id); + let reattached = + list_current_worker_workdir_attachments(api, &fixture.worker, Default::default()) + .unwrap(); + assert_eq!(reattached.items, first.items); + assert_eq!(reattached.next_offset, first.next_offset); + assert_ne!( + reattached.revision, first.revision, + "same alias and Workdir, new lifetime beyond page zero" + ); + let downgraded = api + .store + .attach_worker_workdir(&WorkerWorkdirLinkRecord { + capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY, + ..replacement.clone() + }) + .unwrap(); + assert_eq!(downgraded.connection_id, replacement.connection_id); + let capability_changed = + list_current_worker_workdir_attachments(api, &fixture.worker, Default::default()) + .unwrap(); + assert_eq!(capability_changed.items, reattached.items); + assert_ne!(capability_changed.revision, reattached.revision); + let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let (restarted_page, restarted_revision) = restarted + .list_worker_workdir_links_page_with_revision( + TEST_WORKSPACE_ID, + &fixture.worker, + 51, + 0, + None, + ) + .unwrap(); + assert_eq!(restarted_page.len(), 51); + assert_eq!(restarted_revision, capability_changed.revision); + // A SQLite backup restores the same authoritative content/revision, without a sync store. + let source = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let mut snapshot = rusqlite::Connection::open_in_memory().unwrap(); + rusqlite::backup::Backup::new(&source, &mut snapshot) + .unwrap() + .run_to_completion(128, std::time::Duration::from_millis(1), None) + .unwrap(); + let restored = SqliteWorkspaceStore::from_connection(snapshot).unwrap(); + assert_eq!( + restored + .list_worker_workdir_links_page_with_revision( + TEST_WORKSPACE_ID, + &fixture.worker, + 51, + 0, + None + ) + .unwrap(), + (restarted_page, restarted_revision) + ); + // Independent SQLite connections race a last-row capability update against exact lookup. + // The page and complete revision must always describe the same snapshot, not two reads. + let mut read_write_links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap(); + read_write_links + .iter_mut() + .find(|link| link.connection_id == replacement.connection_id) + .unwrap() + .capabilities = workdir::WorkdirSessionCapabilities::READ_WRITE; + let read_only_links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap(); + restarted + .replace_worker_workdir_link_capabilities( + TEST_WORKSPACE_ID, + &fixture.worker, + &read_write_links, + ) + .unwrap(); + let (_, read_write_revision) = restarted + .list_worker_workdir_links_page_with_revision( + TEST_WORKSPACE_ID, + &fixture.worker, + 1, + 0, + Some(&replacement.connection_id), + ) + .unwrap(); + assert_ne!(read_write_revision, capability_changed.revision); + let reader = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let barrier = Arc::new(std::sync::Barrier::new(2)); + std::thread::scope(|scope| { + let worker = &fixture.worker; + let writer_barrier = barrier.clone(); + scope.spawn(move || { + writer_barrier.wait(); + for index in 0..64 { + restarted + .replace_worker_workdir_link_capabilities( + TEST_WORKSPACE_ID, + worker, + if index % 2 == 0 { + &read_write_links + } else { + &read_only_links + }, + ) + .unwrap(); + } + }); + barrier.wait(); + for _ in 0..100 { + let (page, revision) = reader + .list_worker_workdir_links_page_with_revision( + TEST_WORKSPACE_ID, + worker, + 1, + 0, + Some(&replacement.connection_id), + ) + .unwrap(); + assert_eq!(page.len(), 1); + if revision == read_write_revision { + assert_eq!( + page[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_WRITE + ); + } else { + assert_eq!(revision, capability_changed.revision); + assert_eq!( + page[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + } + } + }); let response = build_router(api.clone()) .oneshot(request(&lookup_path)) .await diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 522827f52..27a63c9ba 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -951,6 +951,23 @@ pub struct WorkdirRegistryRecord { pub updated_at: String, } +/// Projection inputs captured together with the catalog revision in one SQLite snapshot. +#[derive(Debug, Clone)] +pub struct WorkdirCatalogEntry { + pub record: WorkdirRegistryRecord, + pub repository_key: Option, + /// None for Repository records and unsupported legacy External grants. + pub external_grant_permissions: Option, + pub occupied_by: Option, +} + +#[derive(Debug, Clone)] +pub struct WorkdirCatalogPage { + pub entries: Vec, + pub next_cursor: Option, + pub revision: String, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct ExternalWorkdirGrantRecord { pub grant_id: String, @@ -1896,6 +1913,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { workspace_id: &str, limit: usize, ) -> Result>; + fn workdir_catalog_page( + &self, + workspace_id: &str, + limit: u32, + cursor: Option<&str>, + ) -> Result; fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result; fn create_external_workdir_grant( @@ -2008,6 +2031,14 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { offset: u32, connection_id: Option<&str>, ) -> Result>; + fn list_worker_workdir_links_page_with_revision( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + limit: u32, + offset: u32, + connection_id: Option<&str>, + ) -> Result<(Vec, String)>; fn worker_workdir_link_history_exists( &self, workspace_id: &str, @@ -8901,6 +8932,72 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn workdir_catalog_page( + &self, + workspace_id: &str, + limit: u32, + cursor: Option<&str>, + ) -> Result { + if limit == 0 || limit > 100 { + return Err(Error::InvalidInput( + "catalog page limit must be 1..=100".to_string(), + )); + } + if cursor.is_some_and(|id| { + id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control) + }) { + return Err(Error::InvalidInput( + "catalog cursor must contain 1..=128 bytes without control characters".to_string(), + )); + } + self.with_conn(|conn| { + // Also fence writers using other SQLite connections, not only this mutex. + let tx = conn.unchecked_transaction()?; + let sql = workdir_catalog_select_sql(); + let mut hasher = Sha256::new(); + update_public_collection_digest(&mut hasher, &("workdir-catalog:v1", workspace_id))?; + { + let mut stmt = tx.prepare(&format!("{sql} WHERE wr.workspace_id = ?1 ORDER BY wr.workdir_id"))?; + let mut rows = stmt.query(params![workspace_id])?; + while let Some(row) = rows.next()? { + let (entry, connection_id) = read_workdir_catalog_entry(row)?; + let record = &entry.record; + // Safe projection fields and timestamps only: no paths, provider identity, + // credentials, Repository UUIDs, or raw internal records. + update_public_collection_digest(&mut hasher, &( + &record.workdir_id, &record.display_name, &entry.repository_key, + record.source.external_grant_id(), &entry.external_grant_permissions, + (&record.creation_selector, &record.creation_ref, &record.creation_tree), + (&record.current_selector, &record.current_ref, &record.current_tree), + record.observed_at_epoch_seconds, + (&record.materialization_status, &record.cleanliness), + (&record.created_at, &record.updated_at), + &entry.occupied_by, connection_id, + ))?; + } + } + let mut stmt = tx.prepare(&format!( + "{sql} WHERE wr.workspace_id = ?1 AND (?2 IS NULL OR wr.workdir_id > ?2) ORDER BY wr.workdir_id ASC LIMIT ?3" + ))?; + let mut rows = stmt.query(params![workspace_id, cursor, limit + 1])?; + let mut entries = Vec::new(); + while let Some(row) = rows.next()? { + entries.push(read_workdir_catalog_entry(row)?.0); + } + let next_cursor = if entries.len() > limit as usize { + entries.truncate(limit as usize); + entries.last().map(|entry| entry.record.workdir_id.clone()) + } else { + None + }; + Ok(WorkdirCatalogPage { + entries, + next_cursor, + revision: hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::(), + }) + }) + } + fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result { self.with_conn(|conn| { let tx = rusqlite::Transaction::new_unchecked( @@ -9815,6 +9912,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore { offset: u32, connection_id: Option<&str>, ) -> Result> { + self.list_worker_workdir_links_page_with_revision( + workspace_id, + worker, + limit, + offset, + connection_id, + ) + .map(|(links, _)| links) + } + + fn list_worker_workdir_links_page_with_revision( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + limit: u32, + offset: u32, + connection_id: Option<&str>, + ) -> Result<(Vec, String)> { if limit == 0 || limit > 101 { return Err(Error::InvalidInput( "attachment page limit must be 1..=101".to_string(), @@ -9829,7 +9944,40 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )); } self.with_conn(|conn| { - let mut stmt = conn.prepare( + let tx = conn.unchecked_transaction()?; + let mut hasher = Sha256::new(); + update_public_collection_digest( + &mut hasher, + &( + "workdir-connections:v1", + workspace_id, + &worker.runtime_id, + &worker.worker_id, + ), + )?; + { + let mut stmt = tx.prepare( + "SELECT alias, connection_id, workdir_id, capabilities FROM worker_workdir_links + WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 + AND unlinked_at IS NULL ORDER BY alias" + )?; + let mut rows = + stmt.query(params![workspace_id, worker.runtime_id, worker.worker_id])?; + while let Some(row) = rows.next()? { + let alias: String = row.get(0)?; + let id: String = row.get(1)?; + let workdir: String = row.get(2)?; + let capabilities = + decode_workdir_link_capabilities(&row.get::<_, String>(3)?, 3).map_err( + |_| Error::Store("invalid public connection capabilities".to_string()), + )?; + update_public_collection_digest( + &mut hasher, + &(alias, id, workdir, capabilities), + )?; + } + } + let mut stmt = tx.prepare( r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id FROM worker_workdir_links @@ -9849,8 +9997,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ], read_worker_workdir_link_record, )?; - rows.collect::, _>>() - .map_err(Error::from) + let links = rows.collect::, _>>()?; + Ok(( + links, + hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(), + )) }) } @@ -11614,6 +11769,96 @@ fn require_expected_ticket_assignment( ))) } +// Length-framed canonical JSON prevents ambiguous concatenations and never hashes raw ledgers. +fn update_public_collection_digest(hasher: &mut Sha256, value: &impl Serialize) -> Result<()> { + let bytes = serde_json::to_vec(value) + .map_err(|_| Error::Store("unable to encode public collection metadata".to_string()))?; + hasher.update((bytes.len() as u64).to_be_bytes()); + hasher.update(bytes); + Ok(()) +} + +fn workdir_catalog_select_sql() -> String { + format!( + "SELECT wr.*, r.repository_key, g.permissions, + l.runtime_id, l.worker_id, w.display_name, l.linked_at, l.connection_id + FROM ({}) wr + LEFT JOIN repositories r ON r.workspace_id = wr.workspace_id AND r.repository_id = wr.repository_id + LEFT JOIN external_workdir_grants g ON g.workspace_id = wr.workspace_id AND g.grant_id = wr.external_grant_id + LEFT JOIN worker_workdir_links l ON l.rowid = ( + SELECT rowid FROM worker_workdir_links + WHERE workspace_id = wr.workspace_id AND workdir_id = wr.workdir_id AND unlinked_at IS NULL + ORDER BY linked_at DESC, rowid DESC LIMIT 1 + ) + LEFT JOIN worker_registry w ON w.workspace_id = l.workspace_id AND w.runtime_id = l.runtime_id AND w.worker_id = l.worker_id", + workdir_registry_select_sql("") + ) +} + +fn read_workdir_catalog_entry( + row: &rusqlite::Row<'_>, +) -> Result<(WorkdirCatalogEntry, Option)> { + let record = read_workdir_registry_record(row)?; + let repository_key: Option = row.get(18)?; + let external_grant_permissions = match &record.source { + WorkdirRegistrySource::Repository { .. } => { + if repository_key.is_none() { + return Err(Error::Store( + "Workdir catalog Repository is unavailable".to_string(), + )); + } + None + } + WorkdirRegistrySource::ExternalGrant { .. } => { + let permissions: Option = row.get(19)?; + match permissions.as_deref() { + Some("read_only") => Some(workdir::workspace::WorkdirPermissionSummary { + read: true, + write: false, + command: false, + }), + Some("read_write") => Some(workdir::workspace::WorkdirPermissionSummary { + read: true, + write: true, + command: false, + }), + Some("read_write_command") => Some(workdir::workspace::WorkdirPermissionSummary { + read: true, + write: true, + command: true, + }), + Some("command_only" | "read_command") => None, + _ => { + return Err(Error::Store( + "Workdir catalog External grant permissions are unavailable".to_string(), + )); + } + } + } + }; + let runtime_id: Option = row.get(20)?; + let occupied_by = match runtime_id { + Some(runtime_id) => Some(workdir::workspace::WorkingDirectoryOccupancy { + runtime_id, + worker_id: row.get(21)?, + display_name: row.get::<_, Option>(22)?.ok_or_else(|| { + Error::Store("Workdir catalog occupant is unavailable".to_string()) + })?, + linked_at: row.get(23)?, + }), + None => None, + }; + Ok(( + WorkdirCatalogEntry { + record, + repository_key, + external_grant_permissions, + occupied_by, + }, + row.get(24)?, + )) +} + fn workdir_registry_select_sql(where_clause: &str) -> String { format!( "SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \ diff --git a/docs/design/workdir-wip-projection.md b/docs/design/workdir-wip-projection.md index df07146b5..bace39405 100644 --- a/docs/design/workdir-wip-projection.md +++ b/docs/design/workdir-wip-projection.md @@ -111,10 +111,21 @@ Historic session text remains append-only and is not rewritten or replayed. Catalogs are request-time projections of existing Backend authority, not another store or synchronization job. WIP list pages default to 50 and accept 1–100 -items. Repository/Workdir pages sort by stable identity and use the last identity -as `next_cursor`; they project the existing Backend inventory response rather -than maintaining a catalog copy. Attachment pages use the Backend's SQL-bounded, -alias-ordered limit/offset query and opaque `offset:` cursors. A cursor is not +items. Repository pages sort the existing complete Repository catalog by stable +key. Workdir pages use the signed caller-bound Backend +`workers/self/workdir-catalog` operation, with SQL keyset paging over the existing +Workdir registry in stable ID order. This is not the legacy bounded +`working-directories` browser/Tool snapshot: Workdirs older than its 200-record +cap remain reachable. `next_cursor` advances over scanned rows even when the +current read policy or unsupported legacy source makes a page sparse or empty; +`empty` describes that page, and `has_more` remains authoritative for progress. +Attachment pages use the Backend's SQL-bounded, alias-ordered limit/offset query +and opaque `offset:` cursors. Both Workdir and attachment responses supply an +opaque complete-set `revision` derived from existing authoritative records in +the same database snapshot as the page, not by hashing the first page. The Host +uses this complete-set revision for the collection Object validator. A lifetime +or capability change beyond row 50 therefore invalidates old collection +observations without adding a ledger or revision store. A cursor is not a snapshot or authority grant: restart listing after concurrent collection changes. Filtering by exact `connection_id` is applied in the caller-scoped Backend ledger before paging, so direct item lookup also works beyond the first diff --git a/openapi/server-api.json b/openapi/server-api.json index 30be582b1..50a9dfda1 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -14794,7 +14794,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778" + "x-yoi-source-digest": "sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece" }, "openapi": "3.1.0", "paths": { diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 36d091d7d..77e1d9810 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) +// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index ce89d157a..ad5e02b04 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) +// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 163b263a5..7f3b992b7 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) +// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 061fdc848..dc57dabda 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) +// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 89612a759..e20c00541 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) +// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 7f56fba96..4df9141f3 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) +// Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check