feat: bind Workdir attachments to durable connection lifetimes
Add caller-scoped bounded connection inventory and conditional detach on the existing ledger/session lifecycle. Preserve lifetime identity during attenuation and compensation, migrate persisted rows, and bind self operations to verified Runtime/Worker proof. Validation: workspace-root cargo check; Backend 561 unit + 11 CLI tests; server-api 62 default + 69 typescript tests. Canonical OpenAPI and generated TypeScript source digests regenerated; browser declarations unchanged.
This commit is contained in:
@@ -192,6 +192,10 @@ impl_openapi_schema!(
|
|||||||
WorkingDirectoryCreateResponse,
|
WorkingDirectoryCreateResponse,
|
||||||
CurrentWorkerWorkdirAttachRequest,
|
CurrentWorkerWorkdirAttachRequest,
|
||||||
CurrentWorkerWorkdirAttachmentResponse,
|
CurrentWorkerWorkdirAttachmentResponse,
|
||||||
|
CurrentWorkerWorkdirAttachmentItem,
|
||||||
|
CurrentWorkerWorkdirAttachmentListQuery,
|
||||||
|
CurrentWorkerWorkdirAttachmentListResponse,
|
||||||
|
CurrentWorkerWorkdirDetachQuery,
|
||||||
CurrentWorkerWorkdirOperationRequest,
|
CurrentWorkerWorkdirOperationRequest,
|
||||||
CurrentWorkerWorkdirOperationResponse,
|
CurrentWorkerWorkdirOperationResponse,
|
||||||
ExternalWorkdirGrantCreateRequest,
|
ExternalWorkdirGrantCreateRequest,
|
||||||
@@ -2549,6 +2553,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-attachments",
|
||||||
|
status = 200,
|
||||||
|
error_status = 400,
|
||||||
|
additional_error_statuses = [401, 403, 404, 500],
|
||||||
|
openapi = false,
|
||||||
|
)]
|
||||||
|
async fn current_worker_workdir_attachment_list(
|
||||||
|
&self,
|
||||||
|
#[extension] context: ServerRequestContext,
|
||||||
|
#[path] workspace_id: String,
|
||||||
|
#[query] query: CurrentWorkerWorkdirAttachmentListQuery,
|
||||||
|
) -> Result<CurrentWorkerWorkdirAttachmentListResponse, RepositoryApiError>;
|
||||||
#[post(
|
#[post(
|
||||||
"/api/w/{workspace_id}/workers/self/workdir-attachments",
|
"/api/w/{workspace_id}/workers/self/workdir-attachments",
|
||||||
status = 200,
|
status = 200,
|
||||||
@@ -2574,6 +2591,7 @@ pub trait ServerApi {
|
|||||||
#[extension] context: ServerRequestContext,
|
#[extension] context: ServerRequestContext,
|
||||||
#[path] workspace_id: String,
|
#[path] workspace_id: String,
|
||||||
#[path] alias: String,
|
#[path] alias: String,
|
||||||
|
#[query] query: CurrentWorkerWorkdirDetachQuery,
|
||||||
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
|
) -> Result<CurrentWorkerWorkdirAttachmentResponse, RepositoryApiError>;
|
||||||
#[post(
|
#[post(
|
||||||
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
|
"/api/w/{workspace_id}/workers/self/workdir-session/operations",
|
||||||
@@ -6754,6 +6772,45 @@ pub struct CurrentWorkerWorkdirAttachRequest {
|
|||||||
pub working_directory_id: String,
|
pub working_directory_id: 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)]
|
||||||
|
pub struct CurrentWorkerWorkdirAttachmentListQuery {
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub limit: Option<u32>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub offset: Option<u32>,
|
||||||
|
/// Exact caller-scoped connection lookup, applied before paging; 1..=128 bytes.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub connection_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CurrentWorkerWorkdirAttachmentItem {
|
||||||
|
pub alias: String,
|
||||||
|
pub working_directory_id: String,
|
||||||
|
pub capabilities: workdir::WorkdirSessionCapabilities,
|
||||||
|
/// Opaque durable identity of this attachment lifetime, not a Workdir ID.
|
||||||
|
pub connection_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CurrentWorkerWorkdirAttachmentListResponse {
|
||||||
|
pub workspace_id: String,
|
||||||
|
pub items: Vec<CurrentWorkerWorkdirAttachmentItem>,
|
||||||
|
pub next_offset: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct CurrentWorkerWorkdirDetachQuery {
|
||||||
|
/// When omitted, retain the normal Tool's unconditional detach contract.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub expected_connection_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||||
#[serde(deny_unknown_fields)]
|
#[serde(deny_unknown_fields)]
|
||||||
pub struct CurrentWorkerWorkdirAttachmentResponse {
|
pub struct CurrentWorkerWorkdirAttachmentResponse {
|
||||||
@@ -11930,6 +11987,78 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn current_worker_attachment_contract_keeps_normal_tool_response_closed() {
|
||||||
|
let lookup = CurrentWorkerWorkdirAttachmentListQuery {
|
||||||
|
limit: Some(1),
|
||||||
|
offset: None,
|
||||||
|
connection_id: Some("opaque-connection".to_string()),
|
||||||
|
};
|
||||||
|
let value = serde_json::json!({"limit": 1, "connection_id": "opaque-connection"});
|
||||||
|
assert_eq!(serde_json::to_value(&lookup).unwrap(), value);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(value).unwrap(),
|
||||||
|
lookup
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::to_value(CurrentWorkerWorkdirAttachmentListQuery::default()).unwrap(),
|
||||||
|
serde_json::json!({})
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
|
||||||
|
serde_json::json!({"connection_id": 123})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListQuery>(
|
||||||
|
serde_json::json!({"expected_connection_id": "wrong-field"})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let query: CurrentWorkerWorkdirDetachQuery =
|
||||||
|
serde_json::from_value(serde_json::json!({})).unwrap();
|
||||||
|
assert_eq!(query.expected_connection_id, None);
|
||||||
|
assert_eq!(serde_json::to_value(query).unwrap(), serde_json::json!({}));
|
||||||
|
assert!(
|
||||||
|
serde_json::from_value::<CurrentWorkerWorkdirDetachQuery>(
|
||||||
|
serde_json::json!({"connection_id":"wrong-field"})
|
||||||
|
)
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
let response = CurrentWorkerWorkdirAttachmentResponse {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
alias: "checkout".to_string(),
|
||||||
|
working_directory_id: "workdir".to_string(),
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
|
||||||
|
attached: false,
|
||||||
|
};
|
||||||
|
let value = serde_json::to_value(&response).unwrap();
|
||||||
|
assert_eq!(value.as_object().unwrap().len(), 5);
|
||||||
|
assert!(value.get("connection_id").is_none());
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<CurrentWorkerWorkdirAttachmentResponse>(value).unwrap(),
|
||||||
|
response
|
||||||
|
);
|
||||||
|
let list = CurrentWorkerWorkdirAttachmentListResponse {
|
||||||
|
workspace_id: "workspace".to_string(),
|
||||||
|
next_offset: Some(1),
|
||||||
|
items: vec![CurrentWorkerWorkdirAttachmentItem {
|
||||||
|
alias: "checkout".to_string(),
|
||||||
|
working_directory_id: "workdir".to_string(),
|
||||||
|
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
|
connection_id: "opaque".to_string(),
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
serde_json::from_value::<CurrentWorkerWorkdirAttachmentListResponse>(
|
||||||
|
serde_json::to_value(&list).unwrap()
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
list
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[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!({
|
||||||
@@ -12895,6 +13024,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_attachment_list",
|
||||||
"current_worker_workdir_attach",
|
"current_worker_workdir_attach",
|
||||||
"current_worker_workdir_detach",
|
"current_worker_workdir_detach",
|
||||||
"current_worker_workdir_operation",
|
"current_worker_workdir_operation",
|
||||||
|
|||||||
@@ -4739,6 +4739,7 @@ impl WorkspaceApi {
|
|||||||
|
|
||||||
for (alias, workdir_id, reservation_id, capabilities) in &reserved_attachments {
|
for (alias, workdir_id, reservation_id, capabilities) in &reserved_attachments {
|
||||||
let attachment = WorkerWorkdirLinkRecord {
|
let attachment = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: self.config.workspace_id.clone(),
|
workspace_id: self.config.workspace_id.clone(),
|
||||||
worker: worker_ref.clone(),
|
worker: worker_ref.clone(),
|
||||||
workdir_id: workdir_id.clone(),
|
workdir_id: workdir_id.clone(),
|
||||||
@@ -5555,6 +5556,49 @@ fn contract_request_headers(
|
|||||||
Ok(headers)
|
Ok(headers)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Self APIs derive identity from authenticated middleware, never caller-supplied hints.
|
||||||
|
fn current_worker_contract_headers(
|
||||||
|
context: &server_api::ServerRequestContext,
|
||||||
|
) -> std::result::Result<HeaderMap, server_api::RepositoryApiError> {
|
||||||
|
let source = context.runtime_source.as_ref().ok_or_else(|| {
|
||||||
|
ApiError::from(Error::WorkspacePermissionDenied(
|
||||||
|
"self Workdir requests require a verified Runtime Worker source".to_string(),
|
||||||
|
))
|
||||||
|
.into_repository_api_error()
|
||||||
|
})?;
|
||||||
|
let worker_id = source.worker_id.as_deref().ok_or_else(|| {
|
||||||
|
ApiError::from(Error::WorkspacePermissionDenied(
|
||||||
|
"self Workdir requests require a Runtime-bound Worker identity".to_string(),
|
||||||
|
))
|
||||||
|
.into_repository_api_error()
|
||||||
|
})?;
|
||||||
|
let mut headers = contract_request_headers(context)?;
|
||||||
|
for (name, expected) in [
|
||||||
|
("x-yoi-runtime-id", source.runtime_id.as_str()),
|
||||||
|
("x-yoi-worker-id", worker_id),
|
||||||
|
] {
|
||||||
|
if headers
|
||||||
|
.get(name)
|
||||||
|
.is_some_and(|value| value.as_bytes() != expected.as_bytes())
|
||||||
|
{
|
||||||
|
return Err(ApiError::from(Error::WorkspacePermissionDenied(
|
||||||
|
"self Workdir request identity does not match its verified source".to_string(),
|
||||||
|
))
|
||||||
|
.into_repository_api_error());
|
||||||
|
}
|
||||||
|
headers.insert(
|
||||||
|
name,
|
||||||
|
HeaderValue::from_str(expected).map_err(|_| {
|
||||||
|
ApiError::from(Error::WorkerSourceIdentity(
|
||||||
|
"invalid verified Worker identity".to_string(),
|
||||||
|
))
|
||||||
|
.into_repository_api_error()
|
||||||
|
})?,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(headers)
|
||||||
|
}
|
||||||
|
|
||||||
async fn attach_server_origin_context(mut request: Request, next: Next) -> Response {
|
async fn attach_server_origin_context(mut request: Request, next: Next) -> Response {
|
||||||
let actor = request.extensions().get::<RequestActor>().cloned();
|
let actor = request.extensions().get::<RequestActor>().cloned();
|
||||||
let origin = request_origin(request.headers());
|
let origin = request_origin(request.headers());
|
||||||
@@ -5841,6 +5885,7 @@ 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_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(),
|
||||||
))
|
))
|
||||||
@@ -10372,6 +10417,31 @@ 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_attachment_list(
|
||||||
|
&self,
|
||||||
|
context: server_api::ServerRequestContext,
|
||||||
|
workspace_id: String,
|
||||||
|
query: server_api::CurrentWorkerWorkdirAttachmentListQuery,
|
||||||
|
) -> std::result::Result<
|
||||||
|
server_api::CurrentWorkerWorkdirAttachmentListResponse,
|
||||||
|
server_api::RepositoryApiError,
|
||||||
|
> {
|
||||||
|
let api = self.workspace_api()?;
|
||||||
|
validate_workspace_scope(api, &workspace_id)
|
||||||
|
.map_err(ApiError::into_repository_api_error)?;
|
||||||
|
let worker = current_worker_identity(
|
||||||
|
api,
|
||||||
|
&workspace_id,
|
||||||
|
¤t_worker_contract_headers(&context)?,
|
||||||
|
)
|
||||||
|
.map_err(ApiError::from)
|
||||||
|
.map_err(ApiError::into_repository_api_error)?;
|
||||||
|
let session_lock = current_worker_session_lock(api, &worker);
|
||||||
|
let _session_guard = session_lock.lock().await;
|
||||||
|
list_current_worker_workdir_attachments(api, &worker, query)
|
||||||
|
.map_err(ApiError::into_repository_api_error)
|
||||||
|
}
|
||||||
|
|
||||||
async fn current_worker_workdir_attach(
|
async fn current_worker_workdir_attach(
|
||||||
&self,
|
&self,
|
||||||
context: server_api::ServerRequestContext,
|
context: server_api::ServerRequestContext,
|
||||||
@@ -10384,7 +10454,7 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
let Json(response) = scoped_attach_current_worker_workdir(
|
let Json(response) = scoped_attach_current_worker_workdir(
|
||||||
State(self.workspace_api()?.clone()),
|
State(self.workspace_api()?.clone()),
|
||||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||||
contract_request_headers(&context)?,
|
current_worker_contract_headers(&context)?,
|
||||||
Json(AttachCurrentWorkerWorkdirRequest {
|
Json(AttachCurrentWorkerWorkdirRequest {
|
||||||
alias: request.alias,
|
alias: request.alias,
|
||||||
working_directory_id: request.working_directory_id,
|
working_directory_id: request.working_directory_id,
|
||||||
@@ -10400,6 +10470,7 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
context: server_api::ServerRequestContext,
|
context: server_api::ServerRequestContext,
|
||||||
workspace_id: String,
|
workspace_id: String,
|
||||||
alias: String,
|
alias: String,
|
||||||
|
query: server_api::CurrentWorkerWorkdirDetachQuery,
|
||||||
) -> std::result::Result<
|
) -> std::result::Result<
|
||||||
server_api::CurrentWorkerWorkdirAttachmentResponse,
|
server_api::CurrentWorkerWorkdirAttachmentResponse,
|
||||||
server_api::RepositoryApiError,
|
server_api::RepositoryApiError,
|
||||||
@@ -10407,7 +10478,8 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
let Json(response) = scoped_detach_current_worker_workdir(
|
let Json(response) = scoped_detach_current_worker_workdir(
|
||||||
State(self.workspace_api()?.clone()),
|
State(self.workspace_api()?.clone()),
|
||||||
AxumPath((workspace_id, alias)),
|
AxumPath((workspace_id, alias)),
|
||||||
contract_request_headers(&context)?,
|
current_worker_contract_headers(&context)?,
|
||||||
|
query,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(ApiError::into_repository_api_error)?;
|
.map_err(ApiError::into_repository_api_error)?;
|
||||||
@@ -10430,7 +10502,7 @@ impl server_api::ServerApi for ServerApiContractService {
|
|||||||
.clone(),
|
.clone(),
|
||||||
),
|
),
|
||||||
AxumPath(ScopedWorkspacePath { workspace_id }),
|
AxumPath(ScopedWorkspacePath { workspace_id }),
|
||||||
contract_request_headers(&context)
|
current_worker_contract_headers(&context)
|
||||||
.map_err(server_api::WorkdirOperationApiError::api)?,
|
.map_err(server_api::WorkdirOperationApiError::api)?,
|
||||||
Json(WorkspaceWorkdirSessionOperationRequest {
|
Json(WorkspaceWorkdirSessionOperationRequest {
|
||||||
target_workdir: request.target_workdir,
|
target_workdir: request.target_workdir,
|
||||||
@@ -16354,6 +16426,7 @@ async fn scoped_attach_current_worker_workdir(
|
|||||||
}
|
}
|
||||||
close_current_worker_attachment_session_locked(&api, &worker).await?;
|
close_current_worker_attachment_session_locked(&api, &worker).await?;
|
||||||
let link = api.store.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
let link = api.store.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
workdir_id: workdir_id.to_string(),
|
workdir_id: workdir_id.to_string(),
|
||||||
@@ -16413,56 +16486,121 @@ async fn scoped_attach_current_worker_workdir(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn list_current_worker_workdir_attachments(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
query: server_api::CurrentWorkerWorkdirAttachmentListQuery,
|
||||||
|
) -> ApiResult<server_api::CurrentWorkerWorkdirAttachmentListResponse> {
|
||||||
|
let limit = query.limit.unwrap_or(50);
|
||||||
|
let offset = query.offset.unwrap_or(0);
|
||||||
|
if limit == 0 || limit > 100 {
|
||||||
|
return Err(
|
||||||
|
Error::InvalidInput("attachment page limit must be 1..=100".to_string()).into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let mut links = api.store.list_worker_workdir_links_page(
|
||||||
|
&api.config.workspace_id,
|
||||||
|
worker,
|
||||||
|
limit + 1,
|
||||||
|
offset,
|
||||||
|
query.connection_id.as_deref(),
|
||||||
|
)?;
|
||||||
|
let next_offset =
|
||||||
|
if links.len() > limit as usize {
|
||||||
|
Some(offset.checked_add(limit).ok_or_else(|| {
|
||||||
|
Error::InvalidInput("attachment page offset overflow".to_string())
|
||||||
|
})?)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
links.truncate(limit as usize);
|
||||||
|
Ok(server_api::CurrentWorkerWorkdirAttachmentListResponse {
|
||||||
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
|
items: links
|
||||||
|
.into_iter()
|
||||||
|
.map(|link| server_api::CurrentWorkerWorkdirAttachmentItem {
|
||||||
|
alias: link.alias,
|
||||||
|
working_directory_id: link.workdir_id,
|
||||||
|
capabilities: link.capabilities,
|
||||||
|
connection_id: link.connection_id,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
next_offset,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
async fn scoped_detach_current_worker_workdir(
|
async fn scoped_detach_current_worker_workdir(
|
||||||
State(api): State<WorkspaceApi>,
|
State(api): State<WorkspaceApi>,
|
||||||
AxumPath((workspace_id, alias)): AxumPath<(String, String)>,
|
AxumPath((workspace_id, alias)): AxumPath<(String, String)>,
|
||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
|
query: server_api::CurrentWorkerWorkdirDetachQuery,
|
||||||
) -> ApiResult<Json<server_api::CurrentWorkerWorkdirAttachmentResponse>> {
|
) -> ApiResult<Json<server_api::CurrentWorkerWorkdirAttachmentResponse>> {
|
||||||
validate_workspace_scope(&api, &workspace_id)?;
|
validate_workspace_scope(&api, &workspace_id)?;
|
||||||
let alias = workdir::WorkdirAttachmentAlias::new(alias)
|
let alias = workdir::WorkdirAttachmentAlias::new(alias)
|
||||||
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
.map_err(|error| Error::InvalidInput(error.to_string()))?;
|
||||||
let worker = current_worker_identity(&api, &workspace_id, &headers)?;
|
let worker = current_worker_identity(&api, &workspace_id, &headers)?;
|
||||||
let session_lock = current_worker_session_lock(&api, &worker);
|
detach_current_worker_workdir(
|
||||||
|
&api,
|
||||||
|
&worker,
|
||||||
|
alias.as_str(),
|
||||||
|
query.expected_connection_id.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map(Json)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn detach_current_worker_workdir(
|
||||||
|
api: &WorkspaceApi,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
alias: &str,
|
||||||
|
expected_connection_id: Option<&str>,
|
||||||
|
) -> ApiResult<server_api::CurrentWorkerWorkdirAttachmentResponse> {
|
||||||
|
let session_lock = current_worker_session_lock(api, worker);
|
||||||
let _session_guard = session_lock.lock().await;
|
let _session_guard = session_lock.lock().await;
|
||||||
let link = api
|
let link = api
|
||||||
.store
|
.store
|
||||||
.list_worker_workdir_links(&api.config.workspace_id, &worker)?
|
.list_worker_workdir_links(&api.config.workspace_id, worker)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.find(|link| link.unlinked_at.is_none() && link.alias == alias.as_str())
|
.find(|link| link.unlinked_at.is_none() && link.alias == alias)
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
Error::WorkdirAttachmentConflict(format!(
|
Error::WorkdirAttachmentConflict(format!(
|
||||||
"Worker {}:{} has no Workdir attachment `{alias}`",
|
"Worker {}:{} has no Workdir attachment `{alias}`",
|
||||||
worker.runtime_id, worker.worker_id
|
worker.runtime_id, worker.worker_id
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
close_current_worker_attachment_session_locked(&api, &worker).await?;
|
if expected_connection_id.is_some_and(|expected| expected != link.connection_id) {
|
||||||
close_current_worker_command_sessions_for_alias_locked(&api, &worker, alias.as_str()).await?;
|
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||||
api.store.detach_worker_workdir(
|
"Workdir attachment `{alias}` connection changed during detach"
|
||||||
|
))
|
||||||
|
.into());
|
||||||
|
}
|
||||||
|
close_current_worker_attachment_session_locked(api, worker).await?;
|
||||||
|
close_current_worker_command_sessions_for_alias_locked(api, worker, alias).await?;
|
||||||
|
// Even unconditional HTTP requests condition the mutation on the lifetime just read
|
||||||
|
// under the session lock, so a concurrent ledger replacement cannot be removed.
|
||||||
|
api.store.detach_worker_workdir_connection(
|
||||||
&api.config.workspace_id,
|
&api.config.workspace_id,
|
||||||
&worker,
|
worker,
|
||||||
Some(&link.workdir_id),
|
alias,
|
||||||
|
&link.connection_id,
|
||||||
&Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
&Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true),
|
||||||
)?;
|
)?;
|
||||||
if let Err(error) = sync_runtime_worker_workdir_attachments(&api, &worker) {
|
if let Err(error) = sync_runtime_worker_workdir_attachments(api, worker) {
|
||||||
let mut restored = link.clone();
|
api.store.restore_worker_workdir_connection(&link)?;
|
||||||
restored.unlinked_at = None;
|
|
||||||
api.store.attach_worker_workdir(&restored)?;
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
let remaining = api
|
let remaining = api
|
||||||
.store
|
.store
|
||||||
.list_worker_workdir_links(&api.config.workspace_id, &worker)?
|
.list_worker_workdir_links(&api.config.workspace_id, worker)?
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|link| link.unlinked_at.is_none())
|
.filter(|link| link.unlinked_at.is_none())
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
if remaining.len() == 1
|
if remaining.len() == 1
|
||||||
&& let Err(error) =
|
&& let Err(error) =
|
||||||
open_current_worker_workdir_session_locked(&api, &worker, &remaining[0]).await
|
open_current_worker_workdir_session_locked(api, worker, &remaining[0]).await
|
||||||
{
|
{
|
||||||
let mut restored = link.clone();
|
api.store.restore_worker_workdir_connection(&link)?;
|
||||||
restored.unlinked_at = None;
|
let _ = sync_runtime_worker_workdir_attachments(api, worker);
|
||||||
api.store.attach_worker_workdir(&restored)?;
|
|
||||||
let _ = sync_runtime_worker_workdir_attachments(&api, &worker);
|
|
||||||
return Err(error.into());
|
return Err(error.into());
|
||||||
}
|
}
|
||||||
if let Err(error) = api
|
if let Err(error) = api
|
||||||
@@ -16470,20 +16608,18 @@ async fn scoped_detach_current_worker_workdir(
|
|||||||
.refresh(&worker)
|
.refresh(&worker)
|
||||||
.map_err(ApiError::from)
|
.map_err(ApiError::from)
|
||||||
{
|
{
|
||||||
let mut restored = link.clone();
|
api.store.restore_worker_workdir_connection(&link)?;
|
||||||
restored.unlinked_at = None;
|
sync_runtime_worker_workdir_attachments(api, worker)?;
|
||||||
api.store.attach_worker_workdir(&restored)?;
|
refresh_current_worker_session_locked(api, worker).await?;
|
||||||
sync_runtime_worker_workdir_attachments(&api, &worker)?;
|
|
||||||
refresh_current_worker_session_locked(&api, &worker).await?;
|
|
||||||
return Err(error);
|
return Err(error);
|
||||||
}
|
}
|
||||||
Ok(Json(server_api::CurrentWorkerWorkdirAttachmentResponse {
|
Ok(server_api::CurrentWorkerWorkdirAttachmentResponse {
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
alias: alias.to_string(),
|
alias: alias.to_string(),
|
||||||
working_directory_id: link.workdir_id,
|
working_directory_id: link.workdir_id,
|
||||||
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
|
capabilities: workdir::WorkdirSessionCapabilities::EMPTY,
|
||||||
attached: false,
|
attached: false,
|
||||||
}))
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn validated_current_worker_attachment(
|
fn validated_current_worker_attachment(
|
||||||
@@ -31079,6 +31215,7 @@ fn link_worker_to_workdir(
|
|||||||
) -> ApiResult<()> {
|
) -> ApiResult<()> {
|
||||||
let timestamp = now_registry_timestamp();
|
let timestamp = now_registry_timestamp();
|
||||||
let record = WorkerWorkdirLinkRecord {
|
let record = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
worker: worker_record.worker.clone(),
|
worker: worker_record.worker.clone(),
|
||||||
workdir_id: workdir_id.to_string(),
|
workdir_id: workdir_id.to_string(),
|
||||||
@@ -33218,6 +33355,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
schedule_external_workdir_expiry(&api, &grant).unwrap();
|
schedule_external_workdir_expiry(&api, &grant).unwrap();
|
||||||
let link = WorkerWorkdirLinkRecord {
|
let link = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
workdir_id: grant.workdir_id.clone(),
|
workdir_id: grant.workdir_id.clone(),
|
||||||
@@ -34186,6 +34324,7 @@ mod tests {
|
|||||||
})
|
})
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let repository_link = WorkerWorkdirLinkRecord {
|
let repository_link = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
workdir_id: "analysis-repository".to_string(),
|
workdir_id: "analysis-repository".to_string(),
|
||||||
@@ -34195,6 +34334,7 @@ mod tests {
|
|||||||
unlinked_at: None,
|
unlinked_at: None,
|
||||||
};
|
};
|
||||||
let external_link = WorkerWorkdirLinkRecord {
|
let external_link = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
workdir_id: grant.working_directory_id.clone(),
|
workdir_id: grant.working_directory_id.clone(),
|
||||||
@@ -35524,7 +35664,7 @@ mod tests {
|
|||||||
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
updated_at: "2026-01-01T00:00:00Z".to_owned(),
|
||||||
revoked_at: None,
|
revoked_at: None,
|
||||||
},
|
},
|
||||||
false,
|
true,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
}
|
}
|
||||||
@@ -35939,6 +36079,7 @@ mod tests {
|
|||||||
};
|
};
|
||||||
api.store.upsert_workdir_registry(&workdir).unwrap();
|
api.store.upsert_workdir_registry(&workdir).unwrap();
|
||||||
let link = WorkerWorkdirLinkRecord {
|
let link = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: worker.worker.clone(),
|
worker: worker.worker.clone(),
|
||||||
workdir_id: workdir.workdir_id.clone(),
|
workdir_id: workdir.workdir_id.clone(),
|
||||||
@@ -36043,6 +36184,7 @@ mod tests {
|
|||||||
.create_external_workdir_grant(&grant, &workdir)
|
.create_external_workdir_grant(&grant, &workdir)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let link = WorkerWorkdirLinkRecord {
|
let link = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: worker.worker.clone(),
|
worker: worker.worker.clone(),
|
||||||
workdir_id: workdir.workdir_id.clone(),
|
workdir_id: workdir.workdir_id.clone(),
|
||||||
@@ -36943,6 +37085,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
api.store
|
api.store
|
||||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
workdir_id: workdir_id.to_string(),
|
workdir_id: workdir_id.to_string(),
|
||||||
@@ -41307,6 +41450,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
api.store
|
api.store
|
||||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "7"),
|
worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, "7"),
|
||||||
workdir_id: "managed".to_string(),
|
workdir_id: "managed".to_string(),
|
||||||
@@ -46566,6 +46710,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
api.store
|
api.store
|
||||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: worker.clone(),
|
worker: worker.clone(),
|
||||||
workdir_id: workdir_id.clone(),
|
workdir_id: workdir_id.clone(),
|
||||||
@@ -50104,6 +50249,7 @@ mod tests {
|
|||||||
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
let runtime_worker_id = runtime_worker_id.parse::<u64>().unwrap();
|
||||||
api.store
|
api.store
|
||||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: api.config.workspace_id.clone(),
|
workspace_id: api.config.workspace_id.clone(),
|
||||||
worker: RuntimeWorkerRef::new("runtime-test", runtime_worker_id.to_string()),
|
worker: RuntimeWorkerRef::new("runtime-test", runtime_worker_id.to_string()),
|
||||||
workdir_id: workdir_id.to_string(),
|
workdir_id: workdir_id.to_string(),
|
||||||
@@ -50546,7 +50692,426 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn current_worker_attachment_list_and_stale_detach_use_signed_identity() {
|
||||||
|
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 base = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-attachments");
|
||||||
|
let request = |method: &str, path: &str| {
|
||||||
|
runtime_source_request(
|
||||||
|
&identity,
|
||||||
|
Some(&fixture.worker.worker_id),
|
||||||
|
method,
|
||||||
|
path,
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
|
};
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request("GET", &format!("{base}?limit=1")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let page: server_api::CurrentWorkerWorkdirAttachmentListResponse =
|
||||||
|
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(page.workspace_id, TEST_WORKSPACE_ID);
|
||||||
|
assert_eq!(page.items.len(), 1);
|
||||||
|
assert_eq!(page.items[0].alias, "checkout");
|
||||||
|
assert_eq!(page.next_offset, Some(1));
|
||||||
|
let old = api
|
||||||
|
.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker)
|
||||||
|
.unwrap()
|
||||||
|
.into_iter()
|
||||||
|
.find(|link| link.alias == "checkout")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(page.items[0].connection_id, old.connection_id);
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request("GET", &format!("{base}?limit=1&offset=1")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let page: serde_json::Value =
|
||||||
|
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(page["items"][0]["alias"], "docs");
|
||||||
|
assert!(page["next_offset"].is_null());
|
||||||
|
assert_eq!(
|
||||||
|
page["items"][0].as_object().unwrap().len(),
|
||||||
|
4,
|
||||||
|
"no paths, credentials or materializer data"
|
||||||
|
);
|
||||||
|
for query in ["limit=0", "limit=101", "offset=-1"] {
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request("GET", &format!("{base}?{query}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
let mut forged = request("GET", &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
|
||||||
|
);
|
||||||
|
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
|
||||||
|
);
|
||||||
|
|
||||||
|
api.store
|
||||||
|
.detach_worker_workdir_connection(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&fixture.worker,
|
||||||
|
"checkout",
|
||||||
|
&old.connection_id,
|
||||||
|
"first-detach",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let replacement = api.store.attach_worker_workdir(&old).unwrap();
|
||||||
|
assert_ne!(replacement.connection_id, old.connection_id);
|
||||||
|
// Leave one attachment so successful detach does not need a fixture provider session.
|
||||||
|
api.store
|
||||||
|
.detach_worker_workdir(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&fixture.worker,
|
||||||
|
Some(&fixture.docs_workdir_id),
|
||||||
|
"remove-docs",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let session: WorkdirSessionHandle = Arc::new(workdir::LocalWorkdirSession::new(
|
||||||
|
manifest::Scope::writable(root.path()).unwrap(),
|
||||||
|
root.path().to_path_buf(),
|
||||||
|
));
|
||||||
|
let provider_handle = session
|
||||||
|
.start_command(workdir::CommandRequest {
|
||||||
|
command: "sleep 30".to_string(),
|
||||||
|
timeout_secs: 60,
|
||||||
|
output_limit: 4096,
|
||||||
|
cwd: workdir::WorkdirPath::root(),
|
||||||
|
spill_dir: None,
|
||||||
|
tool_call_id: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let external = api.workdir_sessions.lock().unwrap().register_command(
|
||||||
|
fixture.worker.clone(),
|
||||||
|
"checkout".to_string(),
|
||||||
|
session.clone(),
|
||||||
|
provider_handle.clone(),
|
||||||
|
);
|
||||||
|
let stale_path = format!(
|
||||||
|
"{base}/checkout?expected_connection_id={}",
|
||||||
|
old.connection_id
|
||||||
|
);
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request("DELETE", &stale_path))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::CONFLICT);
|
||||||
|
assert!(
|
||||||
|
api.workdir_sessions
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.command(&fixture.worker, "checkout", &external)
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.command_status(provider_handle.clone())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
workdir::CommandStatus::Running
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
api.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker)
|
||||||
|
.unwrap(),
|
||||||
|
vec![replacement.clone()]
|
||||||
|
);
|
||||||
|
|
||||||
|
// The same lock excludes command/SubWorker session activity before checking identity.
|
||||||
|
let lock = current_worker_session_lock(api, &fixture.worker);
|
||||||
|
let guard = lock.lock().await;
|
||||||
|
let mut detach = Box::pin(detach_current_worker_workdir(
|
||||||
|
api,
|
||||||
|
&fixture.worker,
|
||||||
|
"checkout",
|
||||||
|
Some(&old.connection_id),
|
||||||
|
));
|
||||||
|
assert!(
|
||||||
|
tokio::time::timeout(std::time::Duration::from_millis(20), &mut detach)
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
session
|
||||||
|
.command_status(provider_handle.clone())
|
||||||
|
.await
|
||||||
|
.unwrap(),
|
||||||
|
workdir::CommandStatus::Running
|
||||||
|
);
|
||||||
|
drop(guard);
|
||||||
|
assert!(matches!(
|
||||||
|
detach.await.unwrap_err().error,
|
||||||
|
Error::WorkdirAttachmentConflict(_)
|
||||||
|
));
|
||||||
|
|
||||||
|
let current_path = format!(
|
||||||
|
"{base}/checkout?expected_connection_id={}",
|
||||||
|
replacement.connection_id
|
||||||
|
);
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request("DELETE", ¤t_path))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let response: server_api::CurrentWorkerWorkdirAttachmentResponse =
|
||||||
|
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert!(!response.attached);
|
||||||
|
assert!(
|
||||||
|
session.command_status(provider_handle).await.is_err(),
|
||||||
|
"matching detach cancels and closes command authority"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
api.workdir_sessions
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.command(&fixture.worker, "checkout", &external)
|
||||||
|
.is_none()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
api.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
let new_lifetime = api.store.attach_worker_workdir(&old).unwrap();
|
||||||
|
assert_ne!(new_lifetime.connection_id, replacement.connection_id);
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request("DELETE", &format!("{base}/checkout")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
response.status(),
|
||||||
|
StatusCode::OK,
|
||||||
|
"normal Tool's query-free detach stays unconditional"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn current_worker_attachment_connection_lookup_precedes_paging_and_is_scoped() {
|
||||||
|
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_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker)
|
||||||
|
.unwrap()
|
||||||
|
.remove(0);
|
||||||
|
let workdir = api
|
||||||
|
.store
|
||||||
|
.get_workdir_registry(TEST_WORKSPACE_ID, &template.workdir_id)
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
let mut target = None;
|
||||||
|
for index in 0..50 {
|
||||||
|
let workdir = WorkdirRegistryRecord {
|
||||||
|
workdir_id: format!("lookup-workdir-{index:03}"),
|
||||||
|
..workdir.clone()
|
||||||
|
};
|
||||||
|
api.store.upsert_workdir_registry(&workdir).unwrap();
|
||||||
|
target = Some(
|
||||||
|
api.store
|
||||||
|
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
workdir_id: workdir.workdir_id,
|
||||||
|
alias: format!("zz-lookup-{index:03}"),
|
||||||
|
..template.clone()
|
||||||
|
})
|
||||||
|
.unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let target = target.unwrap();
|
||||||
|
let base = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-attachments");
|
||||||
|
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(&base))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let first: server_api::CurrentWorkerWorkdirAttachmentListResponse =
|
||||||
|
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first.items.len(), 50);
|
||||||
|
assert_eq!(first.next_offset, Some(50));
|
||||||
|
assert!(
|
||||||
|
!first
|
||||||
|
.items
|
||||||
|
.iter()
|
||||||
|
.any(|item| item.connection_id == target.connection_id)
|
||||||
|
);
|
||||||
|
let lookup_path = format!("{base}?limit=1&connection_id={}", target.connection_id);
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request(&lookup_path))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let page: server_api::CurrentWorkerWorkdirAttachmentListResponse =
|
||||||
|
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(page.items.len(), 1);
|
||||||
|
assert_eq!(page.items[0].alias, target.alias);
|
||||||
|
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);
|
||||||
|
|
||||||
|
let foreign_worker_id = seed_cleanup_worker(api, 902, "normal");
|
||||||
|
seed_cleanup_workdir(api, "foreign-lookup-workdir", "present", "clean");
|
||||||
|
seed_cleanup_link(api, &foreign_worker_id, "foreign-lookup-workdir");
|
||||||
|
let foreign = api
|
||||||
|
.store
|
||||||
|
.list_worker_workdir_links(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&RuntimeWorkerRef::new("runtime-test", &foreign_worker_id),
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.remove(0);
|
||||||
|
for query in [
|
||||||
|
format!("limit=1&connection_id={}&offset=1", target.connection_id),
|
||||||
|
format!("limit=1&connection_id={}", foreign.connection_id),
|
||||||
|
"limit=1&connection_id=nonexistent".to_string(),
|
||||||
|
format!("limit=1&connection_id={}", "x".repeat(128)),
|
||||||
|
] {
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request(&format!("{base}?{query}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let page: server_api::CurrentWorkerWorkdirAttachmentListResponse =
|
||||||
|
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert!(page.items.is_empty());
|
||||||
|
assert_eq!(page.next_offset, None);
|
||||||
|
}
|
||||||
|
let mut forged = request(&lookup_path);
|
||||||
|
forged
|
||||||
|
.headers_mut()
|
||||||
|
.insert("x-yoi-worker-id", foreign_worker_id.parse().unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
build_router(api.clone())
|
||||||
|
.oneshot(forged)
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.status(),
|
||||||
|
StatusCode::FORBIDDEN
|
||||||
|
);
|
||||||
|
for invalid in [
|
||||||
|
String::new(),
|
||||||
|
"%20".to_string(),
|
||||||
|
"%0A".to_string(),
|
||||||
|
"x".repeat(129),
|
||||||
|
] {
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request(&format!("{base}?limit=1&connection_id={invalid}")))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
api.store
|
||||||
|
.detach_worker_workdir_connection(
|
||||||
|
TEST_WORKSPACE_ID,
|
||||||
|
&fixture.worker,
|
||||||
|
&target.alias,
|
||||||
|
&target.connection_id,
|
||||||
|
"lookup-detach",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
let replacement = api.store.attach_worker_workdir(&target).unwrap();
|
||||||
|
assert_ne!(replacement.connection_id, target.connection_id);
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request(&lookup_path))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let stale: server_api::CurrentWorkerWorkdirAttachmentListResponse =
|
||||||
|
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert!(stale.items.is_empty());
|
||||||
|
assert_eq!(stale.next_offset, None);
|
||||||
|
let response = build_router(api.clone())
|
||||||
|
.oneshot(request(&format!(
|
||||||
|
"{base}?limit=1&connection_id={}",
|
||||||
|
replacement.connection_id,
|
||||||
|
)))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let current: server_api::CurrentWorkerWorkdirAttachmentListResponse =
|
||||||
|
serde_json::from_slice(&to_bytes(response.into_body(), usize::MAX).await.unwrap())
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(current.items.len(), 1);
|
||||||
|
assert_eq!(current.items[0].connection_id, replacement.connection_id);
|
||||||
|
assert_eq!(current.next_offset, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn current_worker_attachment_failed_detach_preserves_connection_identity() {
|
||||||
|
let workspace = tempfile::tempdir().unwrap();
|
||||||
|
init_clean_git_workspace(workspace.path());
|
||||||
|
let api = test_api(workspace.path()).await;
|
||||||
|
let worker_id = seed_cleanup_worker(&api, 901, "normal");
|
||||||
|
seed_cleanup_workdir(&api, "compensation-workdir", "present", "clean");
|
||||||
|
seed_cleanup_link(&api, &worker_id, "compensation-workdir");
|
||||||
|
let worker = RuntimeWorkerRef::new("runtime-test", worker_id);
|
||||||
|
let link = api
|
||||||
|
.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||||
|
.unwrap()
|
||||||
|
.remove(0);
|
||||||
|
// No live Runtime provider: runtime synchronization rejects the detach after mutation.
|
||||||
|
assert!(
|
||||||
|
detach_current_worker_workdir(&api, &worker, "attachment", Some(&link.connection_id))
|
||||||
|
.await
|
||||||
|
.is_err()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
api.store
|
||||||
|
.list_worker_workdir_links(TEST_WORKSPACE_ID, &worker)
|
||||||
|
.unwrap(),
|
||||||
|
vec![link]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -54773,6 +55338,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
api.store
|
api.store
|
||||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id),
|
worker: RuntimeWorkerRef::new("embedded-worker-runtime", &worker_id),
|
||||||
workdir_id: workdir_id.to_string(),
|
workdir_id: workdir_id.to_string(),
|
||||||
@@ -55168,6 +55734,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
api.store
|
api.store
|
||||||
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
.attach_worker_workdir(&WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||||
worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, &worker_id),
|
worker: RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, &worker_id),
|
||||||
workdir_id: workdir_id.to_string(),
|
workdir_id: workdir_id.to_string(),
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
|
|||||||
use crate::{Error, Result};
|
use crate::{Error, Result};
|
||||||
|
|
||||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||||
const LATEST_SCHEMA_VERSION: i64 = 77;
|
const LATEST_SCHEMA_VERSION: i64 = 78;
|
||||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||||
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings";
|
||||||
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
const RUNTIME_BINDING_AUDIT_MIGRATION_NAME: &str = "workspace Runtime binding revision and audit";
|
||||||
@@ -77,6 +77,7 @@ const ARCHIVE_OBSERVE_GRANTS_MIGRATION_NAME: &str =
|
|||||||
"preserve explicit observe grants for committed Worker Session archives";
|
"preserve explicit observe grants for committed Worker Session archives";
|
||||||
const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str =
|
const BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME: &str =
|
||||||
"durable terminal Backend Job Worker cleanup";
|
"durable terminal Backend Job Worker cleanup";
|
||||||
|
const WORKDIR_CONNECTION_ID_MIGRATION_NAME: &str = "durable Workdir attachment connection identity";
|
||||||
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
const TICKET_SCHEMA_VERSION_WITH_TARGETS: i64 = 7;
|
||||||
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
const TICKET_SCHEMA_BASELINE_NAME: &str = "ticket schema baseline";
|
||||||
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
const WORKER_REGISTRY_PROJECTION_SCHEMA: &str = r#"
|
||||||
@@ -259,6 +260,11 @@ const MIGRATIONS: &[Migration] = &[
|
|||||||
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME,
|
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME,
|
||||||
apply: migrate_backend_job_worker_cleanup_v76_to_v77,
|
apply: migrate_backend_job_worker_cleanup_v76_to_v77,
|
||||||
},
|
},
|
||||||
|
Migration {
|
||||||
|
version: 78,
|
||||||
|
name: WORKDIR_CONNECTION_ID_MIGRATION_NAME,
|
||||||
|
apply: migrate_workdir_connection_id_v77_to_v78,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
#[derive(Clone, Copy)]
|
#[derive(Clone, Copy)]
|
||||||
@@ -963,6 +969,8 @@ pub struct ExternalWorkdirGrantRecord {
|
|||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
pub struct WorkerWorkdirLinkRecord {
|
pub struct WorkerWorkdirLinkRecord {
|
||||||
|
/// Assigned by the ledger on attach; preserved only by explicit compensation.
|
||||||
|
pub connection_id: String,
|
||||||
pub workspace_id: String,
|
pub workspace_id: String,
|
||||||
pub worker: RuntimeWorkerRef,
|
pub worker: RuntimeWorkerRef,
|
||||||
pub workdir_id: String,
|
pub workdir_id: String,
|
||||||
@@ -1983,6 +1991,23 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
|||||||
expected_workdir_id: Option<&str>,
|
expected_workdir_id: Option<&str>,
|
||||||
unlinked_at: &str,
|
unlinked_at: &str,
|
||||||
) -> Result<Option<WorkerWorkdirLinkRecord>>;
|
) -> Result<Option<WorkerWorkdirLinkRecord>>;
|
||||||
|
fn detach_worker_workdir_connection(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
alias: &str,
|
||||||
|
expected_connection_id: &str,
|
||||||
|
unlinked_at: &str,
|
||||||
|
) -> Result<WorkerWorkdirLinkRecord>;
|
||||||
|
fn restore_worker_workdir_connection(&self, record: &WorkerWorkdirLinkRecord) -> Result<()>;
|
||||||
|
fn list_worker_workdir_links_page(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
connection_id: Option<&str>,
|
||||||
|
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
|
||||||
fn worker_workdir_link_history_exists(
|
fn worker_workdir_link_history_exists(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -9349,6 +9374,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
"reserved attachment finalization requires an active attachment".to_string(),
|
"reserved attachment finalization requires an active attachment".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let mut record = record.clone();
|
||||||
|
record.connection_id = uuid::Uuid::now_v7().to_string();
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let tx = rusqlite::Transaction::new_unchecked(
|
let tx = rusqlite::Transaction::new_unchecked(
|
||||||
conn,
|
conn,
|
||||||
@@ -9370,11 +9397,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
tx.execute(
|
tx.execute(
|
||||||
r#"INSERT INTO worker_workdir_links (
|
r#"INSERT INTO worker_workdir_links (
|
||||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
|
||||||
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
|
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
|
||||||
capabilities = excluded.capabilities,
|
capabilities = excluded.capabilities,
|
||||||
linked_at = excluded.linked_at,
|
linked_at = excluded.linked_at,
|
||||||
|
connection_id = excluded.connection_id,
|
||||||
unlinked_at = NULL"#,
|
unlinked_at = NULL"#,
|
||||||
params![
|
params![
|
||||||
record.workspace_id,
|
record.workspace_id,
|
||||||
@@ -9384,6 +9412,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
record.alias,
|
record.alias,
|
||||||
encode_workdir_link_capabilities(record.capabilities)?,
|
encode_workdir_link_capabilities(record.capabilities)?,
|
||||||
record.linked_at,
|
record.linked_at,
|
||||||
|
record.connection_id,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
.map_err(|error| {
|
.map_err(|error| {
|
||||||
@@ -9421,6 +9450,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
"a new attachment cannot already be unlinked".to_string(),
|
"a new attachment cannot already be unlinked".to_string(),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
let mut record = record.clone();
|
||||||
|
record.connection_id = uuid::Uuid::now_v7().to_string();
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let tx = rusqlite::Transaction::new_unchecked(
|
let tx = rusqlite::Transaction::new_unchecked(
|
||||||
conn,
|
conn,
|
||||||
@@ -9456,7 +9487,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
let active_for_alias = tx
|
let active_for_alias = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
AND alias = ?4 AND unlinked_at IS NULL"#,
|
AND alias = ?4 AND unlinked_at IS NULL"#,
|
||||||
@@ -9499,7 +9530,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
let active_for_workdir = tx
|
let active_for_workdir = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#,
|
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL"#,
|
||||||
params![record.workspace_id, record.workdir_id],
|
params![record.workspace_id, record.workdir_id],
|
||||||
@@ -9514,11 +9545,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
}
|
}
|
||||||
let write = tx.execute(
|
let write = tx.execute(
|
||||||
r#"INSERT INTO worker_workdir_links (
|
r#"INSERT INTO worker_workdir_links (
|
||||||
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL)
|
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, ?8)
|
||||||
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
|
ON CONFLICT(workspace_id, runtime_id, worker_id, workdir_id, alias) DO UPDATE SET
|
||||||
capabilities = excluded.capabilities,
|
capabilities = excluded.capabilities,
|
||||||
linked_at = excluded.linked_at,
|
linked_at = excluded.linked_at,
|
||||||
|
connection_id = excluded.connection_id,
|
||||||
unlinked_at = NULL"#,
|
unlinked_at = NULL"#,
|
||||||
params![
|
params![
|
||||||
record.workspace_id,
|
record.workspace_id,
|
||||||
@@ -9528,6 +9560,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
record.alias,
|
record.alias,
|
||||||
encode_workdir_link_capabilities(record.capabilities)?,
|
encode_workdir_link_capabilities(record.capabilities)?,
|
||||||
record.linked_at,
|
record.linked_at,
|
||||||
|
record.connection_id,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
if let Err(error) = write {
|
if let Err(error) = write {
|
||||||
@@ -9556,7 +9589,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
rusqlite::TransactionBehavior::Immediate,
|
rusqlite::TransactionBehavior::Immediate,
|
||||||
)?;
|
)?;
|
||||||
let mut stmt = tx.prepare(
|
let mut stmt = tx.prepare(
|
||||||
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
AND unlinked_at IS NULL"#,
|
AND unlinked_at IS NULL"#,
|
||||||
@@ -9571,7 +9604,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
|
|
||||||
let mut persisted_identity = persisted
|
let mut persisted_identity = persisted
|
||||||
.iter()
|
.iter()
|
||||||
.map(|link| (link.alias.as_str(), link.workdir_id.as_str()))
|
.map(|link| (link.alias.as_str(), link.workdir_id.as_str(), link.connection_id.as_str()))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
persisted_identity.sort_unstable();
|
persisted_identity.sort_unstable();
|
||||||
let mut requested_identity = active_links
|
let mut requested_identity = active_links
|
||||||
@@ -9592,7 +9625,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
link.alias
|
link.alias
|
||||||
))
|
))
|
||||||
})?;
|
})?;
|
||||||
Ok((link.alias.as_str(), link.workdir_id.as_str()))
|
Ok((link.alias.as_str(), link.workdir_id.as_str(), link.connection_id.as_str()))
|
||||||
})
|
})
|
||||||
.collect::<Result<Vec<_>>>()?;
|
.collect::<Result<Vec<_>>>()?;
|
||||||
requested_identity.sort_unstable();
|
requested_identity.sort_unstable();
|
||||||
@@ -9644,7 +9677,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
)?;
|
)?;
|
||||||
let active = tx
|
let active = tx
|
||||||
.query_row(
|
.query_row(
|
||||||
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
AND (?4 IS NULL OR workdir_id = ?4) AND unlinked_at IS NULL"#,
|
AND (?4 IS NULL OR workdir_id = ?4) AND unlinked_at IS NULL"#,
|
||||||
@@ -9683,6 +9716,144 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn detach_worker_workdir_connection(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
alias: &str,
|
||||||
|
expected_connection_id: &str,
|
||||||
|
unlinked_at: &str,
|
||||||
|
) -> Result<WorkerWorkdirLinkRecord> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let tx = rusqlite::Transaction::new_unchecked(
|
||||||
|
conn,
|
||||||
|
rusqlite::TransactionBehavior::Immediate,
|
||||||
|
)?;
|
||||||
|
let active = tx
|
||||||
|
.query_row(
|
||||||
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
|
||||||
|
linked_at, unlinked_at, connection_id
|
||||||
|
FROM worker_workdir_links
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
|
AND alias = ?4 AND connection_id = ?5 AND unlinked_at IS NULL"#,
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
worker.runtime_id,
|
||||||
|
worker.worker_id,
|
||||||
|
alias,
|
||||||
|
expected_connection_id
|
||||||
|
],
|
||||||
|
read_worker_workdir_link_record,
|
||||||
|
)
|
||||||
|
.optional()?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
Error::WorkdirAttachmentConflict(format!(
|
||||||
|
"Workdir attachment `{alias}` connection changed during detach"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let changed = tx.execute(
|
||||||
|
r#"UPDATE worker_workdir_links SET unlinked_at = ?6
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
|
AND alias = ?4 AND connection_id = ?5 AND unlinked_at IS NULL"#,
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
worker.runtime_id,
|
||||||
|
worker.worker_id,
|
||||||
|
alias,
|
||||||
|
expected_connection_id,
|
||||||
|
unlinked_at
|
||||||
|
],
|
||||||
|
)?;
|
||||||
|
if changed != 1 {
|
||||||
|
return Err(Error::WorkdirAttachmentConflict(format!(
|
||||||
|
"Workdir attachment `{alias}` connection changed during detach"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(WorkerWorkdirLinkRecord {
|
||||||
|
unlinked_at: Some(unlinked_at.to_string()),
|
||||||
|
..active
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_worker_workdir_connection(&self, record: &WorkerWorkdirLinkRecord) -> Result<()> {
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
// Compensation reactivates only the same lifetime, never an intervening attach.
|
||||||
|
let changed = conn.execute(
|
||||||
|
r#"UPDATE worker_workdir_links SET unlinked_at = NULL
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
|
AND workdir_id = ?4 AND alias = ?5 AND connection_id = ?6
|
||||||
|
AND unlinked_at IS NOT NULL
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM worker_workdir_attachment_reservations
|
||||||
|
WHERE workspace_id = ?1 AND workdir_id = ?4
|
||||||
|
)"#,
|
||||||
|
params![record.workspace_id, record.worker.runtime_id, record.worker.worker_id,
|
||||||
|
record.workdir_id, record.alias, record.connection_id],
|
||||||
|
).map_err(|error| {
|
||||||
|
if matches!(error, rusqlite::Error::SqliteFailure(ref code, _) if code.extended_code == rusqlite::ffi::SQLITE_CONSTRAINT_UNIQUE) {
|
||||||
|
Error::WorkdirAttachmentConflict("Workdir acquired another active attachment during compensation".to_string())
|
||||||
|
} else {
|
||||||
|
error.into()
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
if changed != 1 {
|
||||||
|
return Err(Error::WorkdirAttachmentConflict(
|
||||||
|
"Workdir attachment connection changed during compensation".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_worker_workdir_links_page(
|
||||||
|
&self,
|
||||||
|
workspace_id: &str,
|
||||||
|
worker: &RuntimeWorkerRef,
|
||||||
|
limit: u32,
|
||||||
|
offset: u32,
|
||||||
|
connection_id: Option<&str>,
|
||||||
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
|
if limit == 0 || limit > 101 {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"attachment page limit must be 1..=101".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if connection_id.is_some_and(|id| {
|
||||||
|
id.trim().is_empty() || id.len() > 128 || id.chars().any(char::is_control)
|
||||||
|
}) {
|
||||||
|
return Err(Error::InvalidInput(
|
||||||
|
"attachment connection_id must contain 1..=128 bytes without control characters"
|
||||||
|
.to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
self.with_conn(|conn| {
|
||||||
|
let mut stmt = conn.prepare(
|
||||||
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities,
|
||||||
|
linked_at, unlinked_at, connection_id
|
||||||
|
FROM worker_workdir_links
|
||||||
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3
|
||||||
|
AND unlinked_at IS NULL
|
||||||
|
AND (?6 IS NULL OR connection_id = ?6)
|
||||||
|
ORDER BY alias LIMIT ?4 OFFSET ?5"#,
|
||||||
|
)?;
|
||||||
|
let rows = stmt.query_map(
|
||||||
|
params![
|
||||||
|
workspace_id,
|
||||||
|
worker.runtime_id,
|
||||||
|
worker.worker_id,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
connection_id
|
||||||
|
],
|
||||||
|
read_worker_workdir_link_record,
|
||||||
|
)?;
|
||||||
|
rows.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.map_err(Error::from)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn worker_workdir_link_history_exists(
|
fn worker_workdir_link_history_exists(
|
||||||
&self,
|
&self,
|
||||||
workspace_id: &str,
|
workspace_id: &str,
|
||||||
@@ -9708,7 +9879,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL
|
WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3 AND unlinked_at IS NULL
|
||||||
ORDER BY linked_at DESC"#,
|
ORDER BY linked_at DESC"#,
|
||||||
@@ -9729,7 +9900,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
let mut stmt = conn.prepare(
|
let mut stmt = conn.prepare(
|
||||||
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
|
WHERE workspace_id = ?1 AND workdir_id = ?2 AND unlinked_at IS NULL
|
||||||
ORDER BY linked_at DESC"#,
|
ORDER BY linked_at DESC"#,
|
||||||
@@ -9750,7 +9921,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
|||||||
) -> Result<Option<WorkerWorkdirLinkRecord>> {
|
) -> Result<Option<WorkerWorkdirLinkRecord>> {
|
||||||
self.with_conn(|conn| {
|
self.with_conn(|conn| {
|
||||||
conn.query_row(
|
conn.query_row(
|
||||||
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at
|
r#"SELECT workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at, unlinked_at, connection_id
|
||||||
FROM worker_workdir_links
|
FROM worker_workdir_links
|
||||||
WHERE workspace_id = ?1 AND workdir_id = ?2
|
WHERE workspace_id = ?1 AND workdir_id = ?2
|
||||||
ORDER BY linked_at DESC, rowid DESC
|
ORDER BY linked_at DESC, rowid DESC
|
||||||
@@ -10497,6 +10668,7 @@ fn read_worker_workdir_link_record(
|
|||||||
capabilities,
|
capabilities,
|
||||||
linked_at: row.get(6)?,
|
linked_at: row.get(6)?,
|
||||||
unlinked_at: row.get(7)?,
|
unlinked_at: row.get(7)?,
|
||||||
|
connection_id: row.get(8)?,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14486,6 +14658,27 @@ fn migrate_backend_job_worker_cleanup_v76_to_v77(conn: &Connection) -> Result<()
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn migrate_workdir_connection_id_v77_to_v78(conn: &Connection) -> Result<()> {
|
||||||
|
use rusqlite::TransactionBehavior;
|
||||||
|
let current = current_schema_version(conn)?;
|
||||||
|
if current != 77 {
|
||||||
|
return Err(Error::Store(format!(
|
||||||
|
"expected schema version 77 before {WORKDIR_CONNECTION_ID_MIGRATION_NAME} migration, found {current}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?;
|
||||||
|
tx.execute_batch(
|
||||||
|
"ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT '';
|
||||||
|
UPDATE worker_workdir_links SET connection_id = lower(hex(randomblob(16)));",
|
||||||
|
)?;
|
||||||
|
tx.execute(
|
||||||
|
"INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)",
|
||||||
|
params![78_i64, WORKDIR_CONNECTION_ID_MIGRATION_NAME],
|
||||||
|
)?;
|
||||||
|
tx.commit()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||||
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?;
|
||||||
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
conn.execute_batch(include_str!("latest_schema.sql"))?;
|
||||||
@@ -14546,6 +14739,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
|||||||
);
|
);
|
||||||
CREATE INDEX typed_ticket_targets_workspace_repository
|
CREATE INDEX typed_ticket_targets_workspace_repository
|
||||||
ON typed_ticket_targets(workspace_id, repository_key, ticket_id);
|
ON typed_ticket_targets(workspace_id, repository_key, ticket_id);
|
||||||
|
ALTER TABLE worker_workdir_links ADD COLUMN connection_id TEXT NOT NULL DEFAULT '';
|
||||||
ALTER TABLE worker_workdir_links
|
ALTER TABLE worker_workdir_links
|
||||||
ADD COLUMN capabilities TEXT NOT NULL
|
ADD COLUMN capabilities TEXT NOT NULL
|
||||||
CHECK (capabilities IN (
|
CHECK (capabilities IN (
|
||||||
@@ -15610,6 +15804,46 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn workdir_connection_migration_backfills_distinct_durable_lifetimes() {
|
||||||
|
let conn = Connection::open_in_memory().unwrap();
|
||||||
|
conn.execute_batch(
|
||||||
|
"CREATE TABLE __yoi_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL);
|
||||||
|
INSERT INTO __yoi_schema_migrations VALUES (77, 'workspace schema baseline');
|
||||||
|
CREATE TABLE worker_workdir_links (alias TEXT, linked_at TEXT, unlinked_at TEXT);
|
||||||
|
INSERT INTO worker_workdir_links VALUES ('checkout', 'same-time', NULL), ('docs', 'same-time', 'removed');",
|
||||||
|
).unwrap();
|
||||||
|
migrate_workdir_connection_id_v77_to_v78(&conn).unwrap();
|
||||||
|
assert_eq!(current_schema_version(&conn).unwrap(), 78);
|
||||||
|
let rows = || {
|
||||||
|
let mut stmt = conn.prepare("SELECT alias, linked_at, unlinked_at, connection_id FROM worker_workdir_links ORDER BY alias").unwrap();
|
||||||
|
stmt.query_map([], |row| {
|
||||||
|
Ok((
|
||||||
|
row.get::<_, String>(0)?,
|
||||||
|
row.get::<_, String>(1)?,
|
||||||
|
row.get::<_, Option<String>>(2)?,
|
||||||
|
row.get::<_, String>(3)?,
|
||||||
|
))
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.collect::<std::result::Result<Vec<_>, _>>()
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
let migrated = rows();
|
||||||
|
assert_eq!(migrated[0].0, "checkout");
|
||||||
|
assert_eq!(migrated[0].1, "same-time");
|
||||||
|
assert_eq!(migrated[0].2, None);
|
||||||
|
assert_eq!(migrated[1].2.as_deref(), Some("removed"));
|
||||||
|
assert!(!migrated[0].3.is_empty());
|
||||||
|
assert_ne!(migrated[0].3, migrated[1].3);
|
||||||
|
assert!(migrate_workdir_connection_id_v77_to_v78(&conn).is_err());
|
||||||
|
assert_eq!(
|
||||||
|
rows(),
|
||||||
|
migrated,
|
||||||
|
"a retry cannot replace an already durable connection ID"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() {
|
fn worker_workdir_link_capability_codec_round_trips_only_canonical_sets() {
|
||||||
for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS {
|
for (capabilities, encoded) in WORKDIR_LINK_CAPABILITY_ENCODINGS {
|
||||||
@@ -17052,6 +17286,10 @@ mod tests {
|
|||||||
version: 77,
|
version: 77,
|
||||||
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
name: BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
||||||
},
|
},
|
||||||
|
WorkspaceSchemaMigrationStep {
|
||||||
|
version: 78,
|
||||||
|
name: WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string(),
|
||||||
|
},
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -17139,6 +17377,7 @@ mod tests {
|
|||||||
77,
|
77,
|
||||||
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
BACKEND_JOB_WORKER_CLEANUP_MIGRATION_NAME.to_string(),
|
||||||
),
|
),
|
||||||
|
(78, WORKDIR_CONNECTION_ID_MIGRATION_NAME.to_string()),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||||
@@ -17490,7 +17729,7 @@ mod tests {
|
|||||||
.collect::<Vec<_>>(),
|
.collect::<Vec<_>>(),
|
||||||
vec![
|
vec![
|
||||||
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72,
|
||||||
73, 74, 75, 76, 77
|
73, 74, 75, 76, 77, 78
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
SqliteWorkspaceStore::migrate_database(&path).unwrap();
|
||||||
@@ -17499,7 +17738,7 @@ mod tests {
|
|||||||
current_schema_version(&conn).unwrap(),
|
current_schema_version(&conn).unwrap(),
|
||||||
LATEST_SCHEMA_VERSION
|
LATEST_SCHEMA_VERSION
|
||||||
);
|
);
|
||||||
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 28);
|
assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 29);
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap());
|
||||||
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap());
|
||||||
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap());
|
||||||
@@ -20855,6 +21094,7 @@ INSERT INTO worker_registry (
|
|||||||
store.upsert_workdir_registry(&unmanaged_workdir).unwrap();
|
store.upsert_workdir_registry(&unmanaged_workdir).unwrap();
|
||||||
|
|
||||||
let link = WorkerWorkdirLinkRecord {
|
let link = WorkerWorkdirLinkRecord {
|
||||||
|
connection_id: String::new(),
|
||||||
workspace_id: "local-dev".to_string(),
|
workspace_id: "local-dev".to_string(),
|
||||||
worker: worker.worker.clone(),
|
worker: worker.worker.clone(),
|
||||||
workdir_id: workdir.workdir_id.clone(),
|
workdir_id: workdir.workdir_id.clone(),
|
||||||
@@ -20883,12 +21123,10 @@ INSERT INTO worker_registry (
|
|||||||
store.attach_worker_workdir(&link),
|
store.attach_worker_workdir(&link),
|
||||||
Err(Error::WorkdirAttachmentConflict(_))
|
Err(Error::WorkdirAttachmentConflict(_))
|
||||||
));
|
));
|
||||||
assert_eq!(
|
let link = store
|
||||||
store
|
.finalize_reserved_worker_workdir_attachment(&link, "spawn-1")
|
||||||
.finalize_reserved_worker_workdir_attachment(&link, "spawn-1")
|
.unwrap();
|
||||||
.unwrap(),
|
assert!(!link.connection_id.is_empty());
|
||||||
link
|
|
||||||
);
|
|
||||||
assert_eq!(store.attach_worker_workdir(&link).unwrap(), link);
|
assert_eq!(store.attach_worker_workdir(&link).unwrap(), link);
|
||||||
let downgraded_link = WorkerWorkdirLinkRecord {
|
let downgraded_link = WorkerWorkdirLinkRecord {
|
||||||
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
capabilities: workdir::WorkdirSessionCapabilities::READ_ONLY,
|
||||||
@@ -20958,10 +21196,7 @@ INSERT INTO worker_registry (
|
|||||||
store.attach_worker_workdir(&invalid_capabilities),
|
store.attach_worker_workdir(&invalid_capabilities),
|
||||||
Err(Error::InvalidInput(_))
|
Err(Error::InvalidInput(_))
|
||||||
));
|
));
|
||||||
assert_eq!(
|
let second_attachment = store.attach_worker_workdir(&second_attachment).unwrap();
|
||||||
store.attach_worker_workdir(&second_attachment).unwrap(),
|
|
||||||
second_attachment
|
|
||||||
);
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
store
|
store
|
||||||
.list_worker_workdir_links("local-dev", &worker.worker)
|
.list_worker_workdir_links("local-dev", &worker.worker)
|
||||||
@@ -20991,6 +21226,157 @@ INSERT INTO worker_registry (
|
|||||||
store.detach_worker_workdir("local-dev", &worker.worker, Some("wrong-workdir"), "6",),
|
store.detach_worker_workdir("local-dev", &worker.worker, Some("wrong-workdir"), "6",),
|
||||||
Err(Error::WorkdirAttachmentConflict(_))
|
Err(Error::WorkdirAttachmentConflict(_))
|
||||||
));
|
));
|
||||||
|
let old_connection = downgraded_link.connection_id.clone();
|
||||||
|
let removed = store
|
||||||
|
.detach_worker_workdir_connection(
|
||||||
|
"local-dev",
|
||||||
|
&worker.worker,
|
||||||
|
"checkout",
|
||||||
|
&old_connection,
|
||||||
|
"5",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
store.restore_worker_workdir_connection(&removed).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
|
||||||
|
.unwrap()[0],
|
||||||
|
downgraded_link
|
||||||
|
);
|
||||||
|
store
|
||||||
|
.detach_worker_workdir_connection(
|
||||||
|
"local-dev",
|
||||||
|
&worker.worker,
|
||||||
|
"checkout",
|
||||||
|
&old_connection,
|
||||||
|
"5",
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// Reusing the exact record, including alias, Workdir ID and timestamp, starts a fresh lifetime.
|
||||||
|
let replacement = store.attach_worker_workdir(&downgraded_link).unwrap();
|
||||||
|
assert_ne!(replacement.connection_id, old_connection);
|
||||||
|
assert!(matches!(
|
||||||
|
store.detach_worker_workdir_connection(
|
||||||
|
"local-dev",
|
||||||
|
&worker.worker,
|
||||||
|
"checkout",
|
||||||
|
&old_connection,
|
||||||
|
"6",
|
||||||
|
),
|
||||||
|
Err(Error::WorkdirAttachmentConflict(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.restore_worker_workdir_connection(&removed),
|
||||||
|
Err(Error::WorkdirAttachmentConflict(_))
|
||||||
|
));
|
||||||
|
assert!(matches!(
|
||||||
|
store.replace_worker_workdir_link_capabilities(
|
||||||
|
"local-dev",
|
||||||
|
&worker.worker,
|
||||||
|
&[downgraded_link.clone(), second_attachment.clone()],
|
||||||
|
),
|
||||||
|
Err(Error::WorkdirAttachmentConflict(_))
|
||||||
|
));
|
||||||
|
assert_eq!(store.attach_worker_workdir(&link).unwrap(), replacement);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
|
||||||
|
.unwrap(),
|
||||||
|
vec![replacement.clone()]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 1, None)
|
||||||
|
.unwrap(),
|
||||||
|
vec![second_attachment.clone()]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 2, None)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page("foreign", &worker.worker, 1, 0, None)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page(
|
||||||
|
"local-dev",
|
||||||
|
&RuntimeWorkerRef::new("foreign", worker.worker.worker_id.clone()),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
None
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page(
|
||||||
|
"local-dev",
|
||||||
|
&worker.worker,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
Some(&second_attachment.connection_id)
|
||||||
|
)
|
||||||
|
.unwrap(),
|
||||||
|
vec![second_attachment.clone()],
|
||||||
|
"connection filtering must precede paging"
|
||||||
|
);
|
||||||
|
for (workspace_id, identity, connection_id) in [
|
||||||
|
(
|
||||||
|
"foreign",
|
||||||
|
worker.worker.clone(),
|
||||||
|
second_attachment.connection_id.as_str(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"local-dev",
|
||||||
|
second_worker.worker.clone(),
|
||||||
|
second_attachment.connection_id.as_str(),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"local-dev",
|
||||||
|
RuntimeWorkerRef::new("foreign", worker.worker.worker_id.clone()),
|
||||||
|
second_attachment.connection_id.as_str(),
|
||||||
|
),
|
||||||
|
("local-dev", worker.worker.clone(), old_connection.as_str()),
|
||||||
|
("local-dev", worker.worker.clone(), "nonexistent"),
|
||||||
|
] {
|
||||||
|
assert!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page(
|
||||||
|
workspace_id,
|
||||||
|
&identity,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
Some(connection_id)
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
.is_empty()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
drop(store);
|
||||||
|
let store = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
store
|
||||||
|
.list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None)
|
||||||
|
.unwrap(),
|
||||||
|
vec![replacement]
|
||||||
|
);
|
||||||
|
assert!(matches!(
|
||||||
|
store.detach_worker_workdir_connection(
|
||||||
|
"local-dev",
|
||||||
|
&worker.worker,
|
||||||
|
"checkout",
|
||||||
|
&old_connection,
|
||||||
|
"6",
|
||||||
|
),
|
||||||
|
Err(Error::WorkdirAttachmentConflict(_))
|
||||||
|
));
|
||||||
let detached = store
|
let detached = store
|
||||||
.detach_worker_workdir("local-dev", &worker.worker, Some(&workdir.workdir_id), "6")
|
.detach_worker_workdir("local-dev", &worker.worker, Some(&workdir.workdir_id), "6")
|
||||||
.unwrap()
|
.unwrap()
|
||||||
@@ -21001,10 +21387,7 @@ INSERT INTO worker_registry (
|
|||||||
.worker_workdir_link_history_exists("local-dev", &worker.worker)
|
.worker_workdir_link_history_exists("local-dev", &worker.worker)
|
||||||
.unwrap()
|
.unwrap()
|
||||||
);
|
);
|
||||||
assert_eq!(
|
let workdir_conflict = store.attach_worker_workdir(&workdir_conflict).unwrap();
|
||||||
store.attach_worker_workdir(&workdir_conflict).unwrap(),
|
|
||||||
workdir_conflict
|
|
||||||
);
|
|
||||||
|
|
||||||
drop(store);
|
drop(store);
|
||||||
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
let reopened = SqliteWorkspaceStore::open(&db).unwrap();
|
||||||
|
|||||||
@@ -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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870"
|
"x-yoi-source-digest": "sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778"
|
||||||
},
|
},
|
||||||
"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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778)
|
||||||
// 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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778)
|
||||||
// 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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778)
|
||||||
// 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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778)
|
||||||
// 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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778)
|
||||||
// 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:f9f0f8918dbcce386e94d5368fa938f02565ca98f9f67f07537bd88ed8c49870)
|
// Input contract: 0.1.0 (sha256:dc18992a7d28201966c9f5d4e60681bbbf664f1aa8bd76535cd239f62c7fe778)
|
||||||
// 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
|
||||||
|
|||||||
Reference in New Issue
Block a user