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, WorkingDirectoryListResponse,
WorkingDirectoryDetailResponse, WorkingDirectoryDetailResponse,
WorkingDirectoryCreateResponse, WorkingDirectoryCreateResponse,
CurrentWorkerWorkdirCatalogQuery,
CurrentWorkerWorkdirCatalogResponse,
CurrentWorkerWorkdirAttachRequest, CurrentWorkerWorkdirAttachRequest,
CurrentWorkerWorkdirAttachmentResponse, CurrentWorkerWorkdirAttachmentResponse,
CurrentWorkerWorkdirAttachmentItem, CurrentWorkerWorkdirAttachmentItem,
@@ -2553,6 +2555,19 @@ pub trait ServerApi {
#[path] working_directory_id: String, #[path] working_directory_id: String,
#[body] request: WorkingDirectoryRemovalRequest, #[body] request: WorkingDirectoryRemovalRequest,
) -> Result<WorkingDirectoryRemovalResponse, RepositoryApiError>; ) -> 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( #[get(
"/api/w/{workspace_id}/workers/self/workdir-attachments", "/api/w/{workspace_id}/workers/self/workdir-attachments",
status = 200, status = 200,
@@ -6772,6 +6787,28 @@ pub struct CurrentWorkerWorkdirAttachRequest {
pub working_directory_id: String, 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. /// Bounded, alias-ordered paging over the current Worker's active attachments.
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
@@ -6801,6 +6838,8 @@ pub struct CurrentWorkerWorkdirAttachmentListResponse {
pub workspace_id: String, pub workspace_id: String,
pub items: Vec<CurrentWorkerWorkdirAttachmentItem>, pub items: Vec<CurrentWorkerWorkdirAttachmentItem>,
pub next_offset: Option<u32>, 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)] #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -12042,6 +12081,7 @@ mod tests {
); );
let list = CurrentWorkerWorkdirAttachmentListResponse { let list = CurrentWorkerWorkdirAttachmentListResponse {
workspace_id: "workspace".to_string(), workspace_id: "workspace".to_string(),
revision: "opaque-revision".to_string(),
next_offset: Some(1), next_offset: Some(1),
items: vec![CurrentWorkerWorkdirAttachmentItem { items: vec![CurrentWorkerWorkdirAttachmentItem {
alias: "checkout".to_string(), 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] #[test]
fn workspace_and_repository_response_shapes_round_trip() { fn workspace_and_repository_response_shapes_round_trip() {
let workspace = serde_json::json!({ 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 // 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. // operations, but must not appear as unauthenticated operations in the public OpenAPI.
const SIGNED_INTERNAL: &[&str] = &[ const SIGNED_INTERNAL: &[&str] = &[
"current_worker_workdir_catalog",
"current_worker_workdir_attachment_list", "current_worker_workdir_attachment_list",
"current_worker_workdir_attach", "current_worker_workdir_attach",
"current_worker_workdir_detach", "current_worker_workdir_detach",
@@ -225,8 +225,8 @@ impl Provider {
fn inventory(&self, domain: Domain) -> Result<Vec<Json>, WipOperationError> { fn inventory(&self, domain: Domain) -> Result<Vec<Json>, WipOperationError> {
let response = self.request(match domain { let response = self.request(match domain {
Domain::Repository => "repositories", Domain::Repository => "repositories",
Domain::Workdir => "working-directories", Domain::Workdir => "workers/self/workdir-catalog?limit=1",
Domain::Attachment => "workers/self/workdir-attachments", Domain::Attachment => "workers/self/workdir-attachments?limit=1",
})?; })?;
response response
.get("items") .get("items")
@@ -363,6 +363,29 @@ impl WipOperationHandler for CollectionHandler {
fn object_validator(&self) -> Option<Vec<u8>> { fn object_validator(&self) -> Option<Vec<u8>> {
let mut digest = Sha256::new(); let mut digest = Sha256::new();
digest.update(self.domain.route()); 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) { match self.provider.inventory(self.domain) {
Ok(items) => { Ok(items) => {
for raw in items { for raw in items {
@@ -422,36 +445,54 @@ impl WipOperationHandler for CollectionHandler {
.authorize(self.domain.list_permission(), &input)?; .authorize(self.domain.list_permission(), &input)?;
let limit = page_limit(&input)?; let limit = page_limit(&input)?;
let cursor = input.get("cursor").and_then(Json::as_str); 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 offset = cursor let attachment = matches!(self.domain, Domain::Attachment);
.map(|c| { let path = if attachment {
c.strip_prefix("offset:") let offset = cursor
.and_then(|s| s.parse::<u32>().ok()) .map(|c| {
.ok_or_else(|| { c.strip_prefix("offset:")
failure( .and_then(|s| s.parse::<u32>().ok())
ProtocolErrorCode::InvalidArguments, .ok_or_else(|| {
"invalid attachment page cursor", failure(
) ProtocolErrorCode::InvalidArguments,
}) "invalid attachment page cursor",
}) )
.transpose()? })
.unwrap_or(0); })
let page = self.provider.request(&format!( .transpose()?
"workers/self/workdir-attachments?limit={limit}&offset={offset}" .unwrap_or(0);
))?; format!("workers/self/workdir-attachments?limit={limit}&offset={offset}")
let raws = page.get("items").and_then(Json::as_array).ok_or_else(|| { } else {
WipOperationError::OutcomeUnknown("invalid attachment page".into()) 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(); let mut items = Vec::new();
for raw in raws { for raw in raws {
let id = let id = raw
raw.get("connection_id") .get(self.domain.identity_field())
.and_then(Json::as_str) .and_then(Json::as_str)
.ok_or_else(|| { .ok_or_else(|| {
WipOperationError::OutcomeUnknown( WipOperationError::OutcomeUnknown(
"attachment identity missing".into(), "inventory identity missing".into(),
) )
})?; })?;
if self.provider.allowed( if self.provider.allowed(
self.domain.read_permission(), self.domain.read_permission(),
&self.provider.read_input(self.domain, id), &self.provider.read_input(self.domain, id),
@@ -459,10 +500,33 @@ impl WipOperationHandler for CollectionHandler {
items.push(self.provider.projected(self.domain, raw)?); items.push(self.provider.projected(self.domain, raw)?);
} }
} }
let next = page let next = if attachment {
.get("next_offset") match page.get("next_offset") {
.and_then(Json::as_u64) Some(Json::Null) => None,
.map(|n| format!("offset:{n}")); 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( return native(
json!({"empty":items.is_empty(), "items":items, "has_more":next.is_some(), "next_cursor":next, "limit":limit}), 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)) Ok(Json::Object(values))
} }
fn page_limit(input: &Json) -> Result<usize, WipOperationError> { 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) { if !(1..=100).contains(&limit) {
return Err(failure( return Err(failure(
ProtocolErrorCode::InvalidArguments, ProtocolErrorCode::InvalidArguments,
@@ -995,7 +1067,43 @@ pub(crate) mod tests {
json!({"items":state.repositories}) json!({"items":state.repositories})
} }
(WorkspaceRequestMethod::Get, "working-directories") => { (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") => { (WorkspaceRequestMethod::Get, "workers/self/workdir-attachments") => {
let query: BTreeMap<_, _> = path let query: BTreeMap<_, _> = path
@@ -1005,8 +1113,14 @@ pub(crate) mod tests {
.split('&') .split('&')
.filter_map(|p| p.split_once('=')) .filter_map(|p| p.split_once('='))
.collect(); .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") { 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 { } else {
let limit = query let limit = query
.get("limit") .get("limit")
@@ -1016,11 +1130,10 @@ pub(crate) mod tests {
.get("offset") .get("offset")
.and_then(|s| s.parse::<usize>().ok()) .and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0); .unwrap_or(0);
let items: Vec<_> = let count = all.len();
state.attachments.iter().skip(offset).take(limit).collect(); let items: Vec<_> = all.into_iter().skip(offset).take(limit).collect();
let next = let next = (offset + limit < count).then_some(offset + limit);
(offset + limit < state.attachments.len()).then_some(offset + limit); json!({"items":items, "next_offset":next, "revision":revision})
json!({"items":items, "next_offset":next})
} }
} }
(WorkspaceRequestMethod::Get, p) if p.starts_with("repositories/") => { (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 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] #[tokio::test]
async fn catalog_native_transport_discovers_creates_attaches_reads_and_detaches() { async fn catalog_native_transport_discovers_creates_attaches_reads_and_detaches() {
use crate::feature::builtin::manage_workdir::wip::tests::CatalogClient; 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( .merge(server_api::server_api_axum::runtime_workdir_cleanup(
service.clone(), 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_attachment_list(service.clone()))
.merge(server_api::server_api_axum::current_worker_workdir_attach( .merge(server_api::server_api_axum::current_worker_workdir_attach(
service.clone(), service.clone(),
@@ -10417,6 +10420,28 @@ impl server_api::ServerApi for ServerApiContractService {
.map_err(ApiError::into_repository_api_error) .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( async fn current_worker_workdir_attachment_list(
&self, &self,
context: server_api::ServerRequestContext, 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( fn list_current_worker_workdir_attachments(
api: &WorkspaceApi, api: &WorkspaceApi,
worker: &RuntimeWorkerRef, worker: &RuntimeWorkerRef,
@@ -16498,7 +16562,7 @@ fn list_current_worker_workdir_attachments(
Error::InvalidInput("attachment page limit must be 1..=100".to_string()).into(), 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, &api.config.workspace_id,
worker, worker,
limit + 1, limit + 1,
@@ -16526,6 +16590,7 @@ fn list_current_worker_workdir_attachments(
}) })
.collect(), .collect(),
next_offset, 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] #[tokio::test]
async fn current_worker_workdir_routes_require_a_live_runtime_worker_identity() { async fn current_worker_workdir_routes_require_a_live_runtime_worker_identity() {
let workspace = tempfile::tempdir().unwrap(); 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].working_directory_id, target.workdir_id);
assert_eq!(page.items[0].connection_id, target.connection_id); assert_eq!(page.items[0].connection_id, target.connection_id);
assert_eq!(page.next_offset, None); 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"); let foreign_worker_id = seed_cleanup_worker(api, 902, "normal");
seed_cleanup_workdir(api, "foreign-lookup-workdir", "present", "clean"); seed_cleanup_workdir(api, "foreign-lookup-workdir", "present", "clean");
@@ -51023,6 +51346,7 @@ mod tests {
.unwrap(); .unwrap();
assert!(page.items.is_empty()); assert!(page.items.is_empty());
assert_eq!(page.next_offset, None); assert_eq!(page.next_offset, None);
assert_eq!(page.revision, first.revision);
} }
let mut forged = request(&lookup_path); let mut forged = request(&lookup_path);
forged forged
@@ -51049,6 +51373,13 @@ mod tests {
assert_eq!(response.status(), StatusCode::BAD_REQUEST); 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 api.store
.detach_worker_workdir_connection( .detach_worker_workdir_connection(
TEST_WORKSPACE_ID, TEST_WORKSPACE_ID,
@@ -51060,6 +51391,139 @@ mod tests {
.unwrap(); .unwrap();
let replacement = api.store.attach_worker_workdir(&target).unwrap(); let replacement = api.store.attach_worker_workdir(&target).unwrap();
assert_ne!(replacement.connection_id, target.connection_id); 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()) let response = build_router(api.clone())
.oneshot(request(&lookup_path)) .oneshot(request(&lookup_path))
.await .await
+248 -3
View File
@@ -951,6 +951,23 @@ pub struct WorkdirRegistryRecord {
pub updated_at: String, 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)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct ExternalWorkdirGrantRecord { pub struct ExternalWorkdirGrantRecord {
pub grant_id: String, pub grant_id: String,
@@ -1896,6 +1913,12 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
workspace_id: &str, workspace_id: &str,
limit: usize, limit: usize,
) -> Result<Vec<WorkdirRegistryRecord>>; ) -> 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 delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool>;
fn create_external_workdir_grant( fn create_external_workdir_grant(
@@ -2008,6 +2031,14 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
offset: u32, offset: u32,
connection_id: Option<&str>, connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>>; ) -> 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( fn worker_workdir_link_history_exists(
&self, &self,
workspace_id: &str, 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> { fn delete_workdir_registry(&self, workspace_id: &str, workdir_id: &str) -> Result<bool> {
self.with_conn(|conn| { self.with_conn(|conn| {
let tx = rusqlite::Transaction::new_unchecked( let tx = rusqlite::Transaction::new_unchecked(
@@ -9815,6 +9912,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
offset: u32, offset: u32,
connection_id: Option<&str>, connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>> { ) -> 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 { if limit == 0 || limit > 101 {
return Err(Error::InvalidInput( return Err(Error::InvalidInput(
"attachment page limit must be 1..=101".to_string(), "attachment page limit must be 1..=101".to_string(),
@@ -9829,7 +9944,40 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
)); ));
} }
self.with_conn(|conn| { 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, r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
linked_at, unlinked_at, connection_id linked_at, unlinked_at, connection_id
FROM worker_workdir_links FROM worker_workdir_links
@@ -9849,8 +9997,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
], ],
read_worker_workdir_link_record, read_worker_workdir_link_record,
)?; )?;
rows.collect::<std::result::Result<Vec<_>, _>>() let links = rows.collect::<std::result::Result<Vec<_>, _>>()?;
.map_err(Error::from) 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 { fn workdir_registry_select_sql(where_clause: &str) -> String {
format!( format!(
"SELECT workspace_id, workdir_id, display_name, source_kind, runtime_id, repository_id, external_grant_id, \ "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 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 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 items. Repository pages sort the existing complete Repository catalog by stable
as `next_cursor`; they project the existing Backend inventory response rather key. Workdir pages use the signed caller-bound Backend
than maintaining a catalog copy. Attachment pages use the Backend's SQL-bounded, `workers/self/workdir-catalog` operation, with SQL keyset paging over the existing
alias-ordered limit/offset query and opaque `offset:<n>` cursors. A cursor is not 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 a snapshot or authority grant: restart listing after concurrent collection
changes. Filtering by exact `connection_id` is applied in the caller-scoped 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 Backend ledger before paging, so direct item lookup also works beyond the first
+1 -1
View File
@@ -14794,7 +14794,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778" "x-yoi-source-digest": "sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) // Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256: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 // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) // Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256: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 // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit. // @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts // Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check // 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. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778) // Input contract: 0.1.0 (sha256:4dc4cdeb9c6f3830b9797af0dc286a1baa6b07d9ed0bf1e576712cd155582ece)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check