fix(wip): page complete workdir catalogs and fence full connection sets

This commit is contained in:
2026-10-05 19:01:13 +09:00
parent 01156f4c78
commit 6feed0f22f
13 changed files with 1187 additions and 57 deletions
+97
View File
@@ -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<WorkingDirectoryRemovalResponse, RepositoryApiError>;
#[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<CurrentWorkerWorkdirCatalogResponse, RepositoryApiError>;
#[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<u32>,
/// 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<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CurrentWorkerWorkdirCatalogResponse {
pub workspace_id: String,
pub items: Vec<WorkingDirectorySummary>,
pub next_cursor: Option<String>,
/// 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<CurrentWorkerWorkdirAttachmentItem>,
pub next_offset: Option<u32>,
/// 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::<CurrentWorkerWorkdirCatalogQuery>(
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::<CurrentWorkerWorkdirCatalogQuery>(
serde_json::json!({"offset": 0})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogQuery>(
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::<CurrentWorkerWorkdirCatalogResponse>(
serde_json::to_value(&response).unwrap()
)
.unwrap(),
response
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirCatalogResponse>(
serde_json::json!({"workspace_id": "workspace", "items": [], "next_cursor": null})
)
.is_err()
);
assert!(
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
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",
@@ -225,8 +225,8 @@ impl Provider {
fn inventory(&self, domain: Domain) -> Result<Vec<Json>, 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<Vec<u8>> {
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,7 +445,9 @@ 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) {
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:")
@@ -436,20 +461,36 @@ impl WipOperationHandler for CollectionHandler {
})
.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())
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")
let id = raw
.get(self.domain.identity_field())
.and_then(Json::as_str)
.ok_or_else(|| {
WipOperationError::OutcomeUnknown(
"attachment identity missing".into(),
"inventory identity missing".into(),
)
})?;
if self.provider.allowed(
@@ -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<String, Value>) -> Result<Json, WipOperati
Ok(Json::Object(values))
}
fn page_limit(input: &Json) -> Result<usize, WipOperationError> {
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::<Vec<_>>()})
}
(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::<usize>().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::<String>();
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::<String>();
if let Some(expected) = query.get("connection_id") {
json!({"items":state.attachments.iter().filter(|a| a["connection_id"] == *expected).collect::<Vec<_>>(), "next_offset":null})
json!({"items":all.into_iter().filter(|a| a["connection_id"] == *expected).collect::<Vec<_>>(), "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::<usize>().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,
..
}))
));
}
}
}
+175
View File
@@ -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;
+465 -1
View File
@@ -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,
&current_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<server_api::CurrentWorkerWorkdirCatalogResponse> {
// 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::<HashSet<_>>();
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::<HashSet<_>>(), 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
+248 -3
View File
@@ -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<String>,
/// None for Repository records and unsupported legacy External grants.
pub external_grant_permissions: Option<workdir::workspace::WorkdirPermissionSummary>,
pub occupied_by: Option<workdir::workspace::WorkingDirectoryOccupancy>,
}
#[derive(Debug, Clone)]
pub struct WorkdirCatalogPage {
pub entries: Vec<WorkdirCatalogEntry>,
pub next_cursor: Option<String>,
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<Vec<WorkdirRegistryRecord>>;
fn workdir_catalog_page(
&self,
workspace_id: &str,
limit: u32,
cursor: Option<&str>,
) -> Result<WorkdirCatalogPage>;
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
fn create_external_workdir_grant(
@@ -2008,6 +2031,14 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
offset: u32,
connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
fn list_worker_workdir_links_page_with_revision(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
limit: u32,
offset: u32,
connection_id: Option<&str>,
) -> Result<(Vec<WorkerWorkdirLinkRecord>, 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<WorkdirCatalogPage> {
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::<String>(),
})
})
}
fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool> {
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<Vec<WorkerWorkdirLinkRecord>> {
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<WorkerWorkdirLinkRecord>, 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::<std::result::Result<Vec<_>, _>>()
.map_err(Error::from)
let links = rows.collect::<std::result::Result<Vec<_>, _>>()?;
Ok((
links,
hasher
.finalize()
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>(),
))
})
}
@@ -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<String>)> {
let record = read_workdir_registry_record(row)?;
let repository_key: Option<String> = 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<String> = 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<String> = 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<String>>(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, \
+15 -4
View File
@@ -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:<n>` 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:<n>` 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
+1 -1
View File
@@ -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": {
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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