fix(wip): page complete workdir catalogs and fence full connection sets
This commit is contained in:
@@ -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, \
|
||||
|
||||
Reference in New Issue
Block a user