fix(wip): page complete workdir catalogs and fence full connection sets
This commit is contained in:
@@ -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,36 +445,54 @@ impl WipOperationHandler for CollectionHandler {
|
||||
.authorize(self.domain.list_permission(), &input)?;
|
||||
let limit = page_limit(&input)?;
|
||||
let cursor = input.get("cursor").and_then(Json::as_str);
|
||||
if matches!(self.domain, Domain::Attachment) {
|
||||
let offset = cursor
|
||||
.map(|c| {
|
||||
c.strip_prefix("offset:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid attachment page cursor",
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(0);
|
||||
let page = self.provider.request(&format!(
|
||||
"workers/self/workdir-attachments?limit={limit}&offset={offset}"
|
||||
))?;
|
||||
let raws = page.get("items").and_then(Json::as_array).ok_or_else(|| {
|
||||
WipOperationError::OutcomeUnknown("invalid attachment page".into())
|
||||
})?;
|
||||
if matches!(self.domain, Domain::Workdir | Domain::Attachment) {
|
||||
let attachment = matches!(self.domain, Domain::Attachment);
|
||||
let path = if attachment {
|
||||
let offset = cursor
|
||||
.map(|c| {
|
||||
c.strip_prefix("offset:")
|
||||
.and_then(|s| s.parse::<u32>().ok())
|
||||
.ok_or_else(|| {
|
||||
failure(
|
||||
ProtocolErrorCode::InvalidArguments,
|
||||
"invalid attachment page cursor",
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(0);
|
||||
format!("workers/self/workdir-attachments?limit={limit}&offset={offset}")
|
||||
} else {
|
||||
let suffix = cursor
|
||||
.map(|c| format!("&cursor={}", encode_path_segment(c)))
|
||||
.unwrap_or_default();
|
||||
format!("workers/self/workdir-catalog?limit={limit}{suffix}")
|
||||
};
|
||||
let page = self.provider.request(&path)?;
|
||||
let raws = page
|
||||
.get("items")
|
||||
.and_then(Json::as_array)
|
||||
.filter(|v| v.len() <= limit)
|
||||
.ok_or_else(|| {
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"invalid bounded inventory page".into(),
|
||||
)
|
||||
})?;
|
||||
if page.get("revision").and_then(Json::as_str).is_none() {
|
||||
return Err(WipOperationError::OutcomeUnknown(
|
||||
"inventory page revision missing".into(),
|
||||
));
|
||||
}
|
||||
let mut items = Vec::new();
|
||||
for raw in raws {
|
||||
let id =
|
||||
raw.get("connection_id")
|
||||
.and_then(Json::as_str)
|
||||
.ok_or_else(|| {
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"attachment identity missing".into(),
|
||||
)
|
||||
})?;
|
||||
let id = raw
|
||||
.get(self.domain.identity_field())
|
||||
.and_then(Json::as_str)
|
||||
.ok_or_else(|| {
|
||||
WipOperationError::OutcomeUnknown(
|
||||
"inventory identity missing".into(),
|
||||
)
|
||||
})?;
|
||||
if self.provider.allowed(
|
||||
self.domain.read_permission(),
|
||||
&self.provider.read_input(self.domain, id),
|
||||
@@ -459,10 +500,33 @@ impl WipOperationHandler for CollectionHandler {
|
||||
items.push(self.provider.projected(self.domain, raw)?);
|
||||
}
|
||||
}
|
||||
let next = page
|
||||
.get("next_offset")
|
||||
.and_then(Json::as_u64)
|
||||
.map(|n| format!("offset:{n}"));
|
||||
let next = if attachment {
|
||||
match page.get("next_offset") {
|
||||
Some(Json::Null) => None,
|
||||
Some(v) if v.as_u64().is_some_and(|n| n <= u32::MAX as u64) => {
|
||||
Some(format!("offset:{}", v.as_u64().unwrap()))
|
||||
}
|
||||
_ => {
|
||||
return Err(WipOperationError::OutcomeUnknown(
|
||||
"invalid attachment next page".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
match page.get("next_cursor") {
|
||||
Some(Json::Null) => None,
|
||||
Some(Json::String(v)) if cursor.is_none_or(|old| v.as_str() > old) => {
|
||||
Some(v.clone())
|
||||
}
|
||||
_ => {
|
||||
return Err(WipOperationError::OutcomeUnknown(
|
||||
"invalid Workdir next page".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
};
|
||||
// Empty describes this permission-filtered page, not the
|
||||
// entire catalog. A sparse page still advances the Backend cursor.
|
||||
return native(
|
||||
json!({"empty":items.is_empty(), "items":items, "has_more":next.is_some(), "next_cursor":next, "limit":limit}),
|
||||
);
|
||||
@@ -781,7 +845,15 @@ fn argument_json(arguments: &BTreeMap<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,
|
||||
..
|
||||
}))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -5885,6 +5885,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou
|
||||
.merge(server_api::server_api_axum::runtime_workdir_cleanup(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::current_worker_workdir_catalog(
|
||||
service.clone(),
|
||||
))
|
||||
.merge(server_api::server_api_axum::current_worker_workdir_attachment_list(service.clone()))
|
||||
.merge(server_api::server_api_axum::current_worker_workdir_attach(
|
||||
service.clone(),
|
||||
@@ -10417,6 +10420,28 @@ impl server_api::ServerApi for ServerApiContractService {
|
||||
.map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn current_worker_workdir_catalog(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
workspace_id: String,
|
||||
query: server_api::CurrentWorkerWorkdirCatalogQuery,
|
||||
) -> std::result::Result<
|
||||
server_api::CurrentWorkerWorkdirCatalogResponse,
|
||||
server_api::RepositoryApiError,
|
||||
> {
|
||||
let api = self.workspace_api()?;
|
||||
validate_workspace_scope(api, &workspace_id)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
current_worker_identity(
|
||||
api,
|
||||
&workspace_id,
|
||||
¤t_worker_contract_headers(&context)?,
|
||||
)
|
||||
.map_err(ApiError::from)
|
||||
.map_err(ApiError::into_repository_api_error)?;
|
||||
list_current_worker_workdir_catalog(api, query).map_err(ApiError::into_repository_api_error)
|
||||
}
|
||||
|
||||
async fn current_worker_workdir_attachment_list(
|
||||
&self,
|
||||
context: server_api::ServerRequestContext,
|
||||
@@ -16486,6 +16511,45 @@ async fn scoped_attach_current_worker_workdir(
|
||||
}))
|
||||
}
|
||||
|
||||
fn list_current_worker_workdir_catalog(
|
||||
api: &WorkspaceApi,
|
||||
query: server_api::CurrentWorkerWorkdirCatalogQuery,
|
||||
) -> ApiResult<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
|
||||
|
||||
@@ -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, \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user